From 6fa86b594114353347d69331c01c438bd2b9d188 Mon Sep 17 00:00:00 2001 From: Thomas Adamek Date: Mon, 19 Oct 2026 10:13:56 +0200 Subject: [PATCH] Bugfixes, Security und Umbau auf einheitliche Namen --- .env.example | 2 +- AGENTEN-GUIDE.md | 2 +- AUSBAUSTUFEN.md | 170 +- BETRIEB.md | 2 +- LASTTEST.md | 6 +- README.md | 22 +- REPOMAP.md | 119 +- docker-compose.dev.yml | 2 +- internal/auth/auth_test.go | 121 +- internal/auth/csrf.go | 17 + internal/auth/ratelimit.go | 136 +- internal/auth/session.go | 66 +- .../dubletten.go => duplicates/duplicates.go} | 108 +- .../duplicates_test.go} | 22 +- internal/entry/diff.go | 22 +- internal/entry/diff_test.go | 22 +- internal/entry/entry.go | 112 +- internal/entry/entry_test.go | 58 +- internal/export/export.go | 236 +-- internal/export/export_test.go | 190 +-- internal/export/zip.go | 66 +- internal/help/help.go | 141 ++ internal/help/help_test.go | 188 +++ .../{hilfe/seiten => help/pages}/begriffe.md | 0 .../seiten => help/pages}/mcp-einrichten.md | 0 .../pages}/mit-agenten-arbeiten.md | 0 .../seiten => help/pages}/plattform-admin.md | 5 + .../{hilfe/seiten => help/pages}/start.md | 0 .../seiten => help/pages}/weboberflaeche.md | 0 .../seiten => help/pages}/workspace-admin.md | 0 .../{hilfe/seiten => help/pages}/zugaenge.md | 0 internal/hilfe/hilfe.go | 141 -- internal/hilfe/hilfe_test.go | 188 --- internal/importer/{archiv.go => archive.go} | 82 +- internal/importer/importer.go | 200 +-- internal/importer/importer_test.go | 182 +- internal/importer/parse.go | 174 +- internal/importer/postgres_test.go | 38 +- internal/markdown/markdown.go | 68 +- internal/markdown/markdown_test.go | 52 +- ...eichzeitig_test.go => concurrency_test.go} | 84 +- .../mcpserver/{kuratiert.go => curated.go} | 14 +- internal/mcpserver/mcpserver_test.go | 690 ++++---- internal/mcpserver/server.go | 10 +- internal/mcpserver/tools.go | 198 +-- internal/search/bench_test.go | 20 +- internal/search/index.go | 34 +- internal/search/registry.go | 54 +- internal/search/registry_test.go | 144 +- internal/search/search_test.go | 162 +- internal/search/{varianten.go => variants.go} | 248 +-- .../{varianten_test.go => variants_test.go} | 196 +-- internal/statistik/statistik.go | 290 ---- internal/statistik/statistik_test.go | 124 -- internal/stats/stats.go | 290 ++++ internal/stats/stats_test.go | 124 ++ internal/store/audit.go | 114 +- internal/store/{speicher.go => backend.go} | 44 +- ...eicher_postgres.go => backend_postgres.go} | 208 +-- internal/store/backend_sqlite.go | 81 + internal/store/control.go | 92 +- internal/store/control_test.go | 32 +- internal/store/{dialekt.go => dialect.go} | 82 +- .../{dialekt_test.go => dialect_test.go} | 70 +- internal/store/entries.go | 246 +-- internal/store/keys.go | 32 +- internal/store/links.go | 164 +- ...en.sql => 002_project_in_child_tables.sql} | 4 +- ...t.sql => 003_write_budget_per_project.sql} | 0 ...ngshistorie.sql => 004_change_history.sql} | 0 .../005_trash.sql} | 2 +- ...6_suchprotokoll.sql => 006_search_log.sql} | 0 ...{007_key_ablauf.sql => 007_key_expiry.sql} | 0 ...en.sql => 002_project_in_child_tables.sql} | 4 +- ...t.sql => 003_write_budget_per_project.sql} | 0 ...ngshistorie.sql => 004_change_history.sql} | 0 .../005_trash.sql} | 2 +- ...6_suchprotokoll.sql => 006_search_log.sql} | 0 ...{007_key_ablauf.sql => 007_key_expiry.sql} | 0 internal/store/models.go | 54 +- internal/store/pool.go | 90 + internal/store/postgres_test.go | 196 +-- internal/store/projects.go | 40 +- internal/store/related.go | 158 +- internal/store/related_test.go | 104 +- .../store/{suchprotokoll.go => searchlog.go} | 40 +- ...uchprotokoll_test.go => searchlog_test.go} | 58 +- internal/store/speicher_sqlite.go | 81 - internal/store/store.go | 120 +- internal/store/store_test.go | 462 +++--- internal/store/{systemstand.go => sysinfo.go} | 208 +-- .../{systemstand_test.go => sysinfo_test.go} | 110 +- internal/store/{papierkorb.go => trash.go} | 94 +- .../{papierkorb_test.go => trash_test.go} | 132 +- internal/store/{nutzung.go => usage.go} | 106 +- .../store/{nutzung_test.go => usage_test.go} | 128 +- internal/store/verbindung.go | 90 - internal/store/workspace_admins.go | 24 +- internal/web/chart.go | 191 +++ internal/web/chart_test.go | 82 + internal/web/context.go | 26 +- internal/web/diagramm.go | 191 --- internal/web/diagramm_test.go | 82 - internal/web/download_test.go | 118 +- internal/web/handlers_admin.go | 323 ++-- internal/web/handlers_auth.go | 52 +- internal/web/handlers_download.go | 46 +- internal/web/handlers_entries.go | 376 +++-- .../{handlers_hilfe.go => handlers_help.go} | 52 +- internal/web/handlers_import.go | 84 +- internal/web/handlers_overview.go | 286 ++++ ...lers_bereitschaft.go => handlers_ready.go} | 20 +- .../{handlers_suche.go => handlers_search.go} | 58 +- internal/web/handlers_system.go | 354 ++-- internal/web/handlers_tags.go | 76 +- ...ndlers_papierkorb.go => handlers_trash.go} | 77 +- internal/web/handlers_ueberblick.go | 286 ---- ...{handlers_nutzung.go => handlers_usage.go} | 50 +- internal/web/handlers_workspace_admins.go | 10 +- internal/web/handlers_workspaces.go | 84 +- internal/web/import_test.go | 106 +- internal/web/middleware.go | 87 +- internal/web/oberflaeche_test.go | 388 ----- internal/web/server.go | 206 +-- internal/web/tags_test.go | 94 +- internal/web/ui_test.go | 388 +++++ internal/web/web_test.go | 1458 +++++++++-------- internal/web/workspace_admin_test.go | 232 +-- internal/workspace/workspace.go | 48 +- last_test.go | 250 +-- main.go | 170 +- main_test.go | 74 +- static/aehnlich.js | 96 -- static/autocomplete.js | 140 +- static/confirm.js | 4 +- static/graph.js | 540 +++--- static/preview.js | 46 +- static/{tastenkuerzel.js => shortcuts.js} | 26 +- static/similar.js | 96 ++ static/style.css | 506 +++--- static/tagfilter.js | 50 +- static/{ungespeichert.js => unsaved.js} | 24 +- templates/audit.html | 44 +- templates/edit.html | 70 +- templates/entries.html | 72 +- templates/entry-diff.html | 38 +- templates/entry-versions.html | 44 +- templates/entry.html | 42 +- templates/graph.html | 20 +- templates/help.html | 25 + templates/hilfe.html | 25 - templates/import.html | 56 +- templates/keys.html | 86 +- templates/login.html | 22 +- templates/overview.html | 124 ++ templates/partials.html | 168 +- templates/project-edit.html | 62 +- templates/projects.html | 80 +- templates/search.html | 51 + templates/suche.html | 51 - templates/system.html | 272 +-- templates/tags.html | 40 +- templates/{papierkorb.html => trash.html} | 58 +- templates/ueberblick.html | 124 -- templates/{nutzung.html => usage.html} | 168 +- templates/workspace-edit.html | 90 +- templates/workspaces.html | 68 +- 167 files changed, 9606 insertions(+), 9361 deletions(-) rename internal/{dubletten/dubletten.go => duplicates/duplicates.go} (65%) rename internal/{dubletten/dubletten_test.go => duplicates/duplicates_test.go} (77%) create mode 100644 internal/help/help.go create mode 100644 internal/help/help_test.go rename internal/{hilfe/seiten => help/pages}/begriffe.md (100%) rename internal/{hilfe/seiten => help/pages}/mcp-einrichten.md (100%) rename internal/{hilfe/seiten => help/pages}/mit-agenten-arbeiten.md (100%) rename internal/{hilfe/seiten => help/pages}/plattform-admin.md (96%) rename internal/{hilfe/seiten => help/pages}/start.md (100%) rename internal/{hilfe/seiten => help/pages}/weboberflaeche.md (100%) rename internal/{hilfe/seiten => help/pages}/workspace-admin.md (100%) rename internal/{hilfe/seiten => help/pages}/zugaenge.md (100%) delete mode 100644 internal/hilfe/hilfe.go delete mode 100644 internal/hilfe/hilfe_test.go rename internal/importer/{archiv.go => archive.go} (50%) rename internal/mcpserver/{gleichzeitig_test.go => concurrency_test.go} (60%) rename internal/mcpserver/{kuratiert.go => curated.go} (82%) rename internal/search/{varianten.go => variants.go} (63%) rename internal/search/{varianten_test.go => variants_test.go} (57%) delete mode 100644 internal/statistik/statistik.go delete mode 100644 internal/statistik/statistik_test.go create mode 100644 internal/stats/stats.go create mode 100644 internal/stats/stats_test.go rename internal/store/{speicher.go => backend.go} (62%) rename internal/store/{speicher_postgres.go => backend_postgres.go} (64%) create mode 100644 internal/store/backend_sqlite.go rename internal/store/{dialekt.go => dialect.go} (58%) rename internal/store/{dialekt_test.go => dialect_test.go} (65%) rename internal/store/migrations/postgres/{002_projekt_in_nebentabellen.sql => 002_project_in_child_tables.sql} (95%) rename internal/store/migrations/postgres/{003_schreibbudget_je_projekt.sql => 003_write_budget_per_project.sql} (100%) rename internal/store/migrations/postgres/{004_aenderungshistorie.sql => 004_change_history.sql} (100%) rename internal/store/migrations/{sqlite/005_papierkorb.sql => postgres/005_trash.sql} (95%) rename internal/store/migrations/postgres/{006_suchprotokoll.sql => 006_search_log.sql} (100%) rename internal/store/migrations/postgres/{007_key_ablauf.sql => 007_key_expiry.sql} (100%) rename internal/store/migrations/sqlite/{002_projekt_in_nebentabellen.sql => 002_project_in_child_tables.sql} (95%) rename internal/store/migrations/sqlite/{003_schreibbudget_je_projekt.sql => 003_write_budget_per_project.sql} (100%) rename internal/store/migrations/sqlite/{004_aenderungshistorie.sql => 004_change_history.sql} (100%) rename internal/store/migrations/{postgres/005_papierkorb.sql => sqlite/005_trash.sql} (95%) rename internal/store/migrations/sqlite/{006_suchprotokoll.sql => 006_search_log.sql} (100%) rename internal/store/migrations/sqlite/{007_key_ablauf.sql => 007_key_expiry.sql} (100%) create mode 100644 internal/store/pool.go rename internal/store/{suchprotokoll.go => searchlog.go} (84%) rename internal/store/{suchprotokoll_test.go => searchlog_test.go} (67%) delete mode 100644 internal/store/speicher_sqlite.go rename internal/store/{systemstand.go => sysinfo.go} (52%) rename internal/store/{systemstand_test.go => sysinfo_test.go} (56%) rename internal/store/{papierkorb.go => trash.go} (78%) rename internal/store/{papierkorb_test.go => trash_test.go} (67%) rename internal/store/{nutzung.go => usage.go} (85%) rename internal/store/{nutzung_test.go => usage_test.go} (63%) delete mode 100644 internal/store/verbindung.go create mode 100644 internal/web/chart.go create mode 100644 internal/web/chart_test.go delete mode 100644 internal/web/diagramm.go delete mode 100644 internal/web/diagramm_test.go rename internal/web/{handlers_hilfe.go => handlers_help.go} (71%) create mode 100644 internal/web/handlers_overview.go rename internal/web/{handlers_bereitschaft.go => handlers_ready.go} (78%) rename internal/web/{handlers_suche.go => handlers_search.go} (63%) rename internal/web/{handlers_papierkorb.go => handlers_trash.go} (69%) delete mode 100644 internal/web/handlers_ueberblick.go rename internal/web/{handlers_nutzung.go => handlers_usage.go} (67%) delete mode 100644 internal/web/oberflaeche_test.go create mode 100644 internal/web/ui_test.go delete mode 100644 static/aehnlich.js rename static/{tastenkuerzel.js => shortcuts.js} (72%) create mode 100644 static/similar.js rename static/{ungespeichert.js => unsaved.js} (66%) create mode 100644 templates/help.html delete mode 100644 templates/hilfe.html create mode 100644 templates/overview.html create mode 100644 templates/search.html delete mode 100644 templates/suche.html rename templates/{papierkorb.html => trash.html} (73%) delete mode 100644 templates/ueberblick.html rename templates/{nutzung.html => usage.html} (55%) diff --git a/.env.example b/.env.example index e3e68c2..be47840 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ SESSION_SECRET= # Zugang des Plattform-Admins: legt Projekte an und löscht sie, kommt an jedes -# Projekt. Mindestens 16 Zeichen. +# Projekt. Mindestens 32 Zeichen. # openssl rand -hex 24 ADMIN_TOKEN= diff --git a/AGENTEN-GUIDE.md b/AGENTEN-GUIDE.md index 70610f1..5ab07ac 100644 --- a/AGENTEN-GUIDE.md +++ b/AGENTEN-GUIDE.md @@ -4,7 +4,7 @@ Wie man KI-Werkzeuge so einrichtet und anspricht, dass sie den Munin tatsächlich benutzen. > Dieselben Inhalte stehen für Nutzer auch in der Weboberfläche unter -> `/hilfe/mit-agenten-arbeiten` (Quelle: `internal/hilfe/seiten/`). Wer hier +> `/hilfe/mit-agenten-arbeiten` (Quelle: `internal/help/pages/`). Wer hier > etwas ändert, sollte dort nachziehen. --- diff --git a/AUSBAUSTUFEN.md b/AUSBAUSTUFEN.md index 210e335..9d95419 100644 --- a/AUSBAUSTUFEN.md +++ b/AUSBAUSTUFEN.md @@ -156,7 +156,7 @@ sucht. ### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt `tokenize` bildet `ä→a`, `ö→o`, `ü→u` und `ß→ss` ab, beim Indizieren wie beim -Suchen (siehe `faltUmlaut` in `internal/search/index.go`). +Suchen (siehe `foldUmlauts` in `internal/search/index.go`). ``` prüfung ↔ prufung ✓ @@ -241,7 +241,7 @@ Problem. ### 2.4 Wortvarianten, Stoppwörter, Tippfehler — ✅ umgesetzt (2026-10-14) -Umgesetzt in `internal/search/varianten.go`. Zu jedem Suchwort sucht der Index +Umgesetzt in `internal/search/variants.go`. Zu jedem Suchwort sucht der Index mehrere Varianten, jede schwächer gewichtet als ein exakter Treffer (1): | Variante | Beispiel | Gewicht | ab Länge | @@ -345,11 +345,11 @@ Erstbefüllung aus vorhandener Dokumentation. ### 3.3 Dubletten über Ähnlichkeit erkennen — ✅ umgesetzt (2026-10-14) -**Umsetzung:** `internal/dubletten` (bis 2026-10-16 `internal/mcpserver/dubletten.go`; +**Umsetzung:** `internal/duplicates` (bis 2026-10-16 `internal/mcpserver/dubletten.go`; seitdem eigenes Paket, weil auch die Weboberfläche es nutzt, siehe 5.7). Bei `remember` mit neuem Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich), -sondern der Anteil gemeinsamer Titelwörter (`search.Ueberdeckung`, in beide +sondern der Anteil gemeinsamer Titelwörter (`search.Coverage`, in beide Richtungen, samt Wortanfang, Endung und Teilwort). Die Zeile „Auch gesucht als: …" zählt zum Titel — so findet „Release-Ablauf" auch den „Deployment-Prozess", wenn dort „release" steht. @@ -400,12 +400,12 @@ Titeln nach dem Muster „Projekt – Thema“ wurde fast jeder neue Eintrag gemeldet: „Munin – Architektur“ gegen „Munin – Überblick“ ergibt ein gemeinsames Wort von zweien, also 50 %, genau die Schwelle. Jetzt fällt ein Präfix vor einem Trenner (` – `, ` - `, `: `, ` | `) aus dem Vergleich, wenn -**beide** Titel dasselbe haben (`ohneGemeinsamesPraefix` in -`internal/dubletten`). Verschiedene Präfixe bleiben im +**beide** Titel dasselbe haben (`withoutCommonPrefix` in +`internal/duplicates`). Verschiedene Präfixe bleiben im Vergleich, denn dann können sie etwas unterscheiden. Ein Bindestrich ohne Leerzeichen („Deployment-Ablauf“) ist kein Trenner. Eine echte Dublette unter -gleichem Präfix fällt weiter auf. Tests: `TestOhneGemeinsamesPraefix`, -`TestRememberGemeinsamesPraefixKeineDublette`. +gleichem Präfix fällt weiter auf. Tests: `TestWithoutCommonPrefix`, +`TestRememberCommonPrefixNoDuplicate`. Verworfen: Wörter nach ihrer Häufigkeit im Projekt gewichten. In einem jungen Projekt mit zwei Einträgen drückt das auch „Deployment-Prozess“ gegen @@ -421,7 +421,7 @@ Fehlalarme mit je 50 %: Suchbegriff `postgres-migration`. „postgres“ ist der Wortanfang von „postgresql“. **Behoben:** Ein Suchbegriff aus „Auch gesucht als“ zählt jetzt nur wörtlich, Titelwörter weiter mit allen Varianten - (`search.UeberdeckungMitSuchbegriffen`). Der Kernfall „Release-Ablauf“ + (`search.CoverageWithSearchTerms`). Der Kernfall „Release-Ablauf“ gegen den Suchbegriff `release` bleibt erkannt. 2. „Munin – Entscheidungen zur PostgreSQL-Anbindung“ gegen „Munin – Betrieb mit PostgreSQL“: Beide Titel enthalten „PostgreSQL“. **Bleibt** gemeldet — @@ -432,8 +432,8 @@ Fehlalarme mit je 50 %: allgemeines Wort, sieht der Agent sofort, dass `force` richtig ist, ohne den Kandidaten erst zu laden. -Tests: `TestUeberdeckungMitSuchbegriffen`, `TestRememberSuchbegriffNurWoertlich`, -`matched_words` in `TestRememberMeldetDublette`. +Tests: `TestCoverageWithSearchTerms`, `TestRememberSearchTermOnlyLiteral`, +`matched_words` in `TestRememberReportsDuplicate`. ### 3.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14) @@ -442,7 +442,7 @@ Haken fiel still weg. Weder der Agent noch ein Mensch bekam es mit, und eine Wiederherstellung holte den Haken nicht zurück (sie lässt ihn bewusst, wie er aktuell ist). -**Umsetzung** (`internal/mcpserver/kuratiert.go`), Muster wie bei 3.3: +**Umsetzung** (`internal/mcpserver/curated.go`), Muster wie bei 3.3: - `remember` auf einen kuratierten Eintrag schreibt nicht, sondern meldet `judgment_required` mit Handlungsanweisung. @@ -524,7 +524,7 @@ ausgebauter als das, was wir bräuchten — aber die Richtung stimmt. Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg. **Umgesetzt:** Tabelle `entry_versions` -(`migrations/004_aenderungshistorie.sql`). `entries` bleibt die aktuelle +(`migrations/004_change_history.sql`). `entries` bleibt die aktuelle Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird. @@ -627,17 +627,17 @@ WP12). **✅ Umgesetzt 2026-09-24:** -- **Schema:** `migrations/005_papierkorb.sql`: `deleted_at` (NULL = normal), +- **Schema:** `migrations/005_trash.sql`: `deleted_at` (NULL = normal), `deleted_by` und ein Teilindex nur über den Papierkorb. -- **Store:** `internal/store/papierkorb.go`. `DeleteEntry` gibt es nicht mehr, +- **Store:** `internal/store/trash.go`. `DeleteEntry` gibt es nicht mehr, an seine Stelle tritt `MoveEntryToTrash`. Der neue Name sorgt dafür, dass jeder alte Aufrufer beim Kompilieren auffällt. Endgültig löschen können nur `PurgeFromTrash` und `PurgeTrash`, und beide fassen nur an, was schon im Papierkorb liegt. -- **Lesewege:** Alle tragen `nichtImPapierkorb`. `ListTags` schließt die +- **Lesewege:** Alle tragen `notInTrash`. `ListTags` schließt die Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen. - `TestPapierkorbUnsichtbar` prüft jeden Weg einzeln, `TestForgetMovesToTrash` + `TestTrashInvisible` prüft jeden Weg einzeln, `TestForgetMovesToTrash` dasselbe aus Sicht eines Agenten (`get`, `recall`, `list`). - **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf @@ -656,7 +656,7 @@ WP12). löschendem Zugang mit *Alle … wiederherstellen* für den Fall „Agent hat zu gründlich aufgeräumt“. `confirm.js` liest die Rückfrage jetzt auch am gedrückten Knopf (`event.submitter`). -- **Frist:** `store.TrashRetention` = 30 Tage. `papierkorbLeerenAlle` in +- **Frist:** `store.TrashRetention` = 30 Tage. `purgeTrashAll` in `main.go` läuft im selben täglichen Takt wie das Protokoll-Aufräumen und schreibt je Eintrag eine Protokollzeile mit Herkunft `system`. Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie @@ -693,7 +693,7 @@ kein Betriebsdatum. 2. Der Zugang stand nicht in der Zeile. Das Protokoll liegt ganz außen, damit es auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des - Requests. Gelöst über einen veränderbaren Halter im Context (`aktorHalter`), + Requests. Gelöst über einen veränderbaren Halter im Context (`actorHolder`), den die Anmelde-Middleware befüllt. ### 4.2 Tests bei jeder Änderung — ✅ umgesetzt (manuell) @@ -800,7 +800,7 @@ genannten Schwelle, ab der Verbindungsverwaltung mit Verdrängung nötig würde (nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die Workspace-Umschalter von Slack/Linear/Notion (`templates/partials.html`, - `.arbeitsbereich*` in `static/style.css`). + `.workspace*` in `static/style.css`). 9. ✅ `scripts/backup.sh`: sichert Kontroll-DB und jede Workspace-Datei einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner, optionale Aufräumfrist über `RETENTION_DAYS`. README verlinkt es @@ -826,7 +826,7 @@ und am selben Tag behoben): `pruneAuditRegularly` und `exportRegularly` nur den ältesten Workspace. Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1) griff dort nicht. Jetzt gehen beide in jedem Durchgang über - `fuerJedenWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus + `forEachWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus der Kontroll-Datenbank, also sind auch nach dem Start angelegte Workspaces dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`. @@ -840,8 +840,8 @@ und am selben Tag behoben): `EXPORT_DIR///`, dazu `_workspace.md` mit dem Namen. Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner erzeugt. `./munin -export` exportiert alle Workspaces und endet mit - Fehlercode, wenn einer scheitert. Tests: `TestExportTrenntWorkspaces`, - `TestExportKnopfImZweitenWorkspace`, `main_test.go`. + Fehlercode, wenn einer scheitert. Tests: `TestExportSeparatesWorkspaces`, + `TestExportButtonInSecondWorkspace`, `main_test.go`. Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden. @@ -900,10 +900,10 @@ unterscheiden. **✅ Umgesetzt 2026-09-24:** -- **Seite:** `/p/{project}/usage` (`handlers_nutzung.go`), für jede Rolle. +- **Seite:** `/p/{project}/usage` (`handlers_usage.go`), für jede Rolle. Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle sieht. -- **Zahlen:** `store.UsageSummaries` (`internal/store/nutzung.go`) liefert +- **Zahlen:** `store.UsageSummaries` (`internal/store/usage.go`) liefert Schreibvorgänge der letzten 30 Tage (`UsageWindow`) aus dem Protokoll und den Bestand aus `entries`, für ein Projekt oder alle, in zwei Abfragen. Es gibt keine eigene Zähltabelle, die auseinanderlaufen könnte. @@ -953,7 +953,7 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang. **✅ Stufe 2 umgesetzt 2026-10-16: Ablaufdatum.** -- **Migration 007** (`007_key_ablauf.sql`, in beiden Ordnern gleich): +- **Migration 007** (`007_key_expiry.sql`, in beiden Ordnern gleich): `api_keys.expires_at TEXT NOT NULL DEFAULT ''`. Leer = läuft nie ab, wie bei `revoked_at`. Vorhandene Keys behalten ihr Verhalten, die Migration fasst keine Daten an. @@ -970,8 +970,8 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang. - **Abgelaufene Keys sind nie „ungenutzt“** — sie öffnen ohnehin nichts mehr. - **Nicht dabei:** Ablaufdatum für Workspace-Admin-Tokens (`wa_…`). Bei Bedarf derselbe Weg in der Kontroll-Datenbank. -- **Tests:** `TestKeyAblauf` (Store, auch gegen PostgreSQL), - `TestKeyMitAblaufdatum` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung). +- **Tests:** `TestKeyExpiry` (Store, auch gegen PostgreSQL), + `TestKeyWithExpiryDate` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung). ### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30) @@ -1237,14 +1237,14 @@ Stattdessen bleibt es bei **einem** `Store`. Umgesetzt in drei Teilen: | Teil | Datei | Aufgabe | |---|---|---| -| `Speicher` | `speicher.go`, `speicher_sqlite.go`, `speicher_postgres.go` | Wo liegen die Daten? Öffnet Kontroll-Datenbank und Workspaces, löscht Workspaces, prüft das Verbindungsbudget. `main.go` wählt ihn anhand von `DB_DRIVER`, danach weiß nur noch er davon. | -| `dialekt` | `dialekt.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. | -| `pool`, `transaktion` | `verbindung.go` | Jede Abfrage läuft hier durch `rebind`. An `*sql.DB` kommt außerhalb dieser Datei niemand heran, eine Abfrage am Dialekt vorbei lässt sich also gar nicht schreiben. | +| `Backend` | `backend.go`, `backend_sqlite.go`, `backend_postgres.go` | Wo liegen die Daten? Öffnet Kontroll-Datenbank und Workspaces, löscht Workspaces, prüft das Verbindungsbudget. `main.go` wählt ihn anhand von `DB_DRIVER`, danach weiß nur noch er davon. | +| `dialect` | `dialect.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. | +| `pool`, `transaction` | `pool.go` | Jede Abfrage läuft hier durch `rebind`. An `*sql.DB` kommt außerhalb dieser Datei niemand heran, eine Abfrage am Dialekt vorbei lässt sich also gar nicht schreiben. | ```go type dialekt interface { rebind(abfrage string) string // ? → $1, $2 … (SQLite: unverändert) - migrationsUnterordner() string // "sqlite" bzw. "postgres" + migrationsSubdir() string // "sqlite" bzw. "postgres" } ``` @@ -1254,10 +1254,10 @@ Dialekt dafür gezielt eine eigene Methode. Der Rest bleibt gemeinsam. deshalb, wo sie ist. **Zwei Abfragen mussten umgeschrieben werden:** `CuratedOverrides` -(`nutzung.go`) und `EmptySearches` (`suchprotokoll.go`) nutzten `MAX()` mit +(`usage.go`) und `EmptySearches` (`searchlog.go`) nutzten `MAX()` mit Spalten ohne Aggregat. Das kann nur SQLite, PostgreSQL lehnt es ab. Beide nutzen jetzt `ROW_NUMBER() OVER (…)`, das können beide Datenbanken (SQLite ab -3.25). Neuer Test: `TestCuratedOverridesJuengsterVorgang`. +3.25). Neuer Test: `TestCuratedOverridesLatestEvent`. #### Entscheidung: bewusst schlichtes SQL @@ -1281,8 +1281,8 @@ Später denkbar, erst bei konkretem Bedarf: `TIMESTAMPTZ` statt Text, #### Entscheidung: Migrationen je Datenbank getrennt ``` -internal/store/migrations/sqlite/001_init.sql … 006_suchprotokoll.sql -internal/store/migrations/postgres/001_init.sql … 006_suchprotokoll.sql +internal/store/migrations/sqlite/001_init.sql … 006_search_log.sql +internal/store/migrations/postgres/001_init.sql … 006_search_log.sql internal/store/migrations_control/sqlite/… internal/store/migrations_control/postgres/… ``` @@ -1415,7 +1415,7 @@ Leseverbindung. `ab`). Deshalb die Datenbank mit `--locale=C` anlegen, dann verhält sie sich wie SQLite. Die Startprüfung (siehe oben) kann das gleich mit prüfen. - **`LIKE`** unterscheidet in PostgreSQL Groß- und Kleinschreibung, in SQLite - (bei ASCII) nicht. Die heutigen Stellen in `audit.go` und `nutzung.go` + (bei ASCII) nicht. Die heutigen Stellen in `audit.go` und `usage.go` vergleichen feste, kleingeschriebene Texte. Bei neuen Abfragen beachten. - **`NULL` beim Sortieren:** PostgreSQL sortiert `NULL` bei `DESC` nach vorn, SQLite nach hinten. Betrifft heute nichts (nur `deleted_at` ist nullbar). @@ -1510,7 +1510,7 @@ diesem Ablauf: | Wann | Was | |---|---| | Bei jeder Änderung | `go test ./...` (nur SQLite) | -| Nach einer neuen Migration oder geänderten Abfrage | zusätzlich `MUNIN_TEST_PG=… go test ./...` mit laufendem Container. Dabei laufen Store-Suite, Schema-Vergleich und Export/Import (`TestRundreisePostgres`) gegen PostgreSQL | +| Nach einer neuen Migration oder geänderten Abfrage | zusätzlich `MUNIN_TEST_PG=… go test ./...` mit laufendem Container. Dabei laufen Store-Suite, Schema-Vergleich und Export/Import (`TestRoundTripPostgres`) gegen PostgreSQL | | Nach Änderungen am Schreibweg | Lasttest (Auslöser wie in `LASTTEST.md`). Läuft vorerst nur gegen SQLite; die PostgreSQL-Fassung kommt mit Schritt 4 | | Vor einer Auslieferung | einmal alles gegen beide Datenbanken | @@ -1535,7 +1535,7 @@ der eigentliche Gewinn, aber auch die heikelste Stelle. - Die ganze Store-Testsuite lief ohne einen Fehler gegen PostgreSQL. Gefunden hat sie nur einen Fehler in einer Test-Hilfsfunktion (aufräumen nach gelöschtem Workspace). -- Export und Import laufen unverändert, `TestRundreisePostgres` belegt es. +- Export und Import laufen unverändert, `TestRoundTripPostgres` belegt es. - Abfragepläne mit 5.000 Einträgen, Schlagworten, Verweisen und Protokoll (PostgreSQL 18 im Container, MacBook): @@ -1543,9 +1543,9 @@ der eigentliche Gewinn, aber auch die heikelste Stelle. |---|---|---| | `get` (Eintrag nach Bezeichner) | Index `entries_project_id_slug_key` | 0,03 ms | | Liste, neueste 50 | Index `idx_entries_project_updated` rückwärts | 0,03 ms | - | Rückverweise, verwandte Einträge | `idx_entry_links_projekt`, Primärschlüssel | 0,01 ms | + | Rückverweise, verwandte Einträge | `idx_entry_links_project`, Primärschlüssel | 0,01 ms | | Protokoll je Projekt | Index `idx_audit_ts` | 0,04 ms | - | Liste nach Schlagwort | `idx_entry_tags_projekt`, dann Hash Join | 1,0 ms | + | Liste nach Schlagwort | `idx_entry_tags_project`, dann Hash Join | 1,0 ms | | Schlagwortmenü | ganze Tabelle `entry_tags` | 1,3 ms | | Suchen ohne Treffer | ganze Tabelle `search_log`, Fensterfunktion | 1,4 ms | | Suchindex aufbauen | ganze Tabelle `entries` (braucht alle Zeilen) | 0,4 ms | @@ -1587,7 +1587,7 @@ Zwei Lücken, die mit PostgreSQL sichtbar wurden: Die Datenbank ist dort ein eigener Dienst, der wegfallen kann, und die Probe der Sicherung bestand aus vielen Handgriffen. -**`/live` und `/ready`** (`internal/web/handlers_bereitschaft.go`): +**`/live` und `/ready`** (`internal/web/handlers_ready.go`): | Pfad | Prüft | Daran hängt | |---|---|---| @@ -1600,7 +1600,7 @@ vielen Handgriffen. Log. Dort steht nur der **Wechsel** (`Bereitschaft: Datenbank antwortet nicht …` / `… wieder`), sonst flutete eine Abfrage alle paar Sekunden das Log. - Alle drei Pfade erscheinen nicht im Zugriffsprotokoll. -- Test: `TestLiveUndReady` (Kontroll-Datenbank geschlossen → `/ready` 503, +- Test: `TestLiveAndReady` (Kontroll-Datenbank geschlossen → `/ready` 503, `/live` weiter 200, keine Einzelheiten in der Antwort). **`scripts/sicherung-pruefen.sh`**: spielt eine Sicherung zurück (SQLite mit @@ -1655,21 +1655,21 @@ Agenten bekamen bei `remember` eine Rückfrage mit Kandidaten (3.3), Menschen im Formular nicht. Das war die letzte Lücke im Schutz gegen doppeltes Wissen. - Die Prüfung zog dafür aus `internal/mcpserver` in ein eigenes Paket - `internal/dubletten` (`Pruefe`). MCP und Weboberfläche urteilen so mit + `internal/duplicates` (`Check`). MCP und Weboberfläche urteilen so mit derselben Logik; zwei Fassungen liefen mit der Zeit auseinander. - `GET /p/{projekt}/entries/similar?title=…&slug=…` (Schreibrecht) liefert die - Kandidaten als JSON, samt gemeinsamer Wörter. `static/aehnlich.js` fragt + Kandidaten als JSON, samt gemeinsamer Wörter. `static/similar.js` fragt nach einer Tipp-Pause (400 ms) und zeigt unter dem Titel „Gibt es schon?“. - **Nur ein Hinweis, keine Sperre:** Ein Mensch sieht die Liste und entscheidet; speichern geht immer. Die Links öffnen einen neuen Tab, damit der angefangene Text bleibt. - Nur beim Anlegen, nicht beim Bearbeiten. Ist der Bezeichner schon vergeben, ist es eine Aktualisierung — dann gibt es keinen Hinweis. -- Tests: `TestAehnlicheEintraege`, `TestFormularUndKuerzel`. +- Tests: `TestSimilarEntries`, `TestFormAndShortcuts`. ### 5.8 Rückfrage bei ungespeicherten Änderungen — ✅ umgesetzt (2026-10-16) -`static/ungespeichert.js` an Formularen mit `data-ungespeichert` (das +`static/unsaved.js` an Formularen mit `data-unsaved` (das Bearbeitungsformular): Verlässt jemand die Seite mit Änderungen, fragt der Browser nach (`beforeunload`). „Geändert“ heißt: anders als beim Laden — wer tippt und wieder löscht, wird nicht gefragt. Beim Speichern keine Rückfrage. @@ -1683,7 +1683,7 @@ Unter einem Eintrag steht jetzt neben *Verweist hierher* der Kasten *Auch verwandt*: die **zweite Reihe** aus `store.RelatedEntries` (dieselbe Funktion wie MCP `related`), jeweils mit dem Zwischeneintrag. Die erste Reihe fehlt dort absichtlich: Eigene Verweise stehen im Text, eingehende unter *Verweist -hierher*. Test: `TestAuchVerwandt`. +hierher*. Test: `TestAlsoRelated`. ### 5.10 Veraltete Einträge sichtbar machen — ✅ umgesetzt (2026-10-16) @@ -1695,8 +1695,8 @@ Einzelansicht. Jetzt: `?veraltet=1` zeigt genau diese. - Kennzahl auf der Nutzungsseite, verlinkt auf die gefilterte Liste. - Store: `EntryFilter.ChangedBefore`, `CountStaleEntries`, `StaleCutoff`. - Tests: `TestVeralteteEintraegeFilternUndZaehlen` (auch PostgreSQL), - `TestVeralteteEintraege`. + Tests: `TestStaleEntriesFilterAndCount` (auch PostgreSQL), + `TestStaleEntries`. ### 5.11 Protokoll filtern — ✅ umgesetzt (2026-10-16) @@ -1706,23 +1706,23 @@ Zugang (Liste aus `store.AuditActors`), Zeitraum (ganze Tage), im Protokoll gefilterte Ansicht als Adresse weitergeben lässt. Kaputte Werte in der Adresse ergeben das ungefilterte Protokoll statt einer Fehlerseite. Höchstens 200 Vorgänge wie bisher. Tests: `TestAuditFilter` (auch PostgreSQL), -`TestProtokollFiltern`. +`TestAuditFilterForm`. ### 5.12 Systemseite — ✅ umgesetzt (2026-10-16) `/admin/system`, nur Plattform-Admin: -- Datenbank (`Speicher.Beschreibung`, ohne Passwort) und Bereitschaft wie +- Datenbank (`Backend.Description`, ohne Passwort) und Bereitschaft wie `/ready`. - Offene Verbindungen gegen das Budget: mit PostgreSQL `max_connections` und - die daraus folgende Höchstzahl an Workspaces (`Speicher.Kapazitaet`, - `Kapazitaet.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in + die daraus folgende Höchstzahl an Workspaces (`Backend.Capacity`, + `Capacity.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in Benutzung / lesen-schreiben / gewartet (`sql.DBStats` über - `Store.Verbindungen`). + `Store.PoolStats`). - Fassung aus den Build-Informationen von Go (`vcs.revision`, `vcs.time`, `vcs.modified`), Go-Fassung, Start und Laufzeit. -Test: `TestSystemseite`. +Test: `TestSystemPage`. ### 5.13 Suche über alle Projekte — ✅ umgesetzt (2026-10-16) @@ -1734,32 +1734,32 @@ nicht ehrlich: Jedes Projekt hat seinen eigenen BM25-Index, die Werte sind nicht vergleichbar. Bewusst getrennt von 4.9: Am Zugriff der Keys ändert sich nichts, ein Key und -ein Agent sehen weiter genau ein Projekt. Test: `TestSucheInAllenProjekten` +ein Agent sehen weiter genau ein Projekt. Test: `TestSearchAllProjects` (auch: ein Projektzugang kommt nicht dran). ### 5.14 Tastenkürzel — ✅ umgesetzt (2026-10-16) -`static/tastenkuerzel.js`: `/` Suchfeld, `n` neuer Eintrag, `e` bearbeiten, +`static/shortcuts.js`: `/` Suchfeld, `n` neuer Eintrag, `e` bearbeiten, `Strg+Enter`/`Cmd+Enter` speichern. Das Ziel einer Taste steht im Template -(`data-kuerzel="n"` an genau dem Link, den die Rolle sehen darf) — die Rechte +(`data-shortcut="n"` an genau dem Link, den die Rolle sehen darf) — die Rechte bleiben so an einer Stelle. Buchstaben gelten nur außerhalb von Eingabefeldern, mit Strg/Alt/Cmd bleiben sie dem Browser. ### 5.15 Hilfe für Workspace-Admins, Hilfe in der Kopfleiste, Formulare — ✅ umgesetzt (2026-10-16) -- **Neue Hilfeseite „Für Workspace-Admins“** (`internal/hilfe/seiten/workspace-admin.md`): +- **Neue Hilfeseite „Für Workspace-Admins“** (`internal/help/pages/workspace-admin.md`): Anmelden, was sie dürfen und was nicht, ein Projekt einrichten, Überblick behalten (Nutzung, Protokoll, Suche über alle Projekte), regelmäßige Pflege, Projekt löschen. Sichtbar für Workspace- und Plattform-Admin. Dafür ersetzt - eine **Zielgruppe** (`FuerAlle`, `FuerAdmins`, `FuerPlattformAdmin`) das + eine **Zielgruppe** (`ForEveryone`, `ForAdmins`, `ForPlatformAdmin`) das bisherige `NurAdmin`. Tests: Sichtbarkeit je Lesergruppe, keine Links auf Seiten, die der Leser nicht sieht, und neu: **jeder `#Abschnitt`-Link innerhalb der Hilfe trifft eine vorhandene Überschrift** - (`TestAnkerInLinksGibtEs`). + (`TestLinkAnchorsExist`). - **„Hilfe & Einstieg“ steht in der Kopfleiste** links neben „Abmelden“ statt unten in der Seitenleiste, wo sie bei vielen Projekten und Schlagworten aus dem Blick rutschte. Auf schmalen Bildschirmen nur „Hilfe“. -- **Formulare:** Die Breitenklassen (`kurz`, `mittel`, `lang`) begrenzen jetzt +- **Formulare:** Die Breitenklassen (`short`, `medium`, `long`) begrenzen jetzt nur das Eingabefeld, nicht mehr Beschriftung und Hinweis — lange Hinweise brachen vorher in eine Spalte von knapp 290 Pixeln um. Mehr Abstand zwischen Feldern, Hinweise mit Zeilenabstand 1,5 und bis zur Lesebreite. Zahlen-, @@ -1782,7 +1782,7 @@ kommen drei Abschnitte: 1. **Zustand** — ein Kasten mit allem, was zu tun ist, leer heißt „Alles in Ordnung“. *Warnungen:* Kapazität ab 80 % der höchstens möglichen - Workspaces; Hinweise des Speichers (`Speicher.Hinweise`, heute: PostgreSQL + Workspaces; Hinweise des Speichers (`Backend.Hints`, heute: PostgreSQL sortiert nicht nach `C`); ein Workspace ließ sich nicht auswerten; WAL-Datei über 64 MB (der Checkpoint kommt nicht durch); ein Workspace auf einem anderen Schema-Stand als das Programm; der letzte Export @@ -1804,20 +1804,20 @@ kommen drei Abschnitte: **Umsetzung:** -- `internal/store/systemstand.go`: `Store.Bestand`, `Store.Belegung` (über - eine neue Dialekt-Methode `belegung`), `Store.Aktivitaet`, - `Store.Schemastand`. Keys werden in Go gezählt, mit denselben Regeln wie +- `internal/store/sysinfo.go`: `Store.Inventory`, `Store.DiskUsage` (über + eine neue Dialekt-Methode `diskUsage`), `Store.Activity`, + `Store.SchemaVersion`. Keys werden in Go gezählt, mit denselben Regeln wie die Seite „Zugänge“ (`APIKey.Unused`, `ExpiresSoon`). -- `internal/statistik`: Der `Sammler` fragt alle Workspaces ab und merkt sich - das Ergebnis 5 Minuten (`MindestAbstand`). Gerechnet wird beim Aufruf der +- `internal/stats`: Der `Collector` fragt alle Workspaces ab und merkt sich + das Ergebnis 5 Minuten (`MinInterval`). Gerechnet wird beim Aufruf der Seite, ohne Hintergrundlauf. Er öffnet dafür alle Workspaces — das tun das Aufräumen und der Export beim Start ohnehin. -- `internal/web/diagramm.go`: Balkendiagramme als SVG, vom Server +- `internal/web/chart.go`: Balkendiagramme als SVG, vom Server ausgerechnet. Kein JavaScript, keine Bibliothek, keine `style`-Attribute (CSP); Farben über Klassen aus `style.css`, damit das dunkle Schema passt. Tooltip je Tag über ``. - `export.Exporter` merkt sich den letzten Lauf über alle Workspaces - (`MerkeLauf`, `LetzterLauf`); `export.Intervall` ersetzt die Konstante in + (`RecordRun`, `LastRun`); `export.Interval` ersetzt die Konstante in `main.go`. **Nicht umgesetzt** (siehe 4.5): Laufzeitwerte wie Aufrufe je Werkzeug und @@ -1827,9 +1827,9 @@ Munin; dafür gibt es das Log des Cronjobs und die Probe (4.11). Freier Platz auf der Platte fehlt ebenfalls: Er braucht Systemaufrufe je Betriebssystem und ist ein klassischer Wert für das Monitoring. -Tests: `TestBestand`, `TestBelegung`, `TestAktivitaet`, `TestSchemastand` -(auch gegen PostgreSQL), `TestErhebung`, `TestMindestAbstand`, -`TestDiagramm`, `TestRunde125`, `TestGroesse`, `TestSystemseiteZustand`. +Tests: `TestInventory`, `TestDiskUsage`, `TestActivity`, `TestSchemaVersion` +(auch gegen PostgreSQL), `TestCollect`, `TestMinInterval`, +`TestChart`, `TestRound125`, `TestByteSize`, `TestSystemPageState`. Geprüft mit Screenshots (1280 und 500 Pixel) mit Testdaten über 30 Tage. ### 5.17 Überblick für Workspace-Admins — ✅ umgesetzt (2026-10-16) @@ -1856,11 +1856,11 @@ Systemseite. - **Aktivität:** dieselben Diagramme wie auf der Systemseite, nur für diesen Workspace. -Umsetzung: `internal/web/handlers_ueberblick.go`; -`statistik.Sammler.FuerWorkspace` (ohne Zwischenspeicher — nach einem neuen +Umsetzung: `internal/web/handlers_overview.go`; +`stats.Collector.ForWorkspace` (ohne Zwischenspeicher — nach einem neuen Key soll der Hinweis sofort verschwinden); das Diagramm-Template liegt jetzt in `partials.html`. Die Startseite des Workspace-Admins bleibt die -Projektliste. Test: `TestWorkspaceAdminUeberblick` (auch: Warnungen vor +Projektliste. Test: `TestWorkspaceAdminOverview` (auch: Warnungen vor Hinweisen, keine Technik auf der Seite, ein Projektzugang kommt nicht dran). ### 5.6 Verweise in Code ignorieren — ✅ umgesetzt (2026-10-16) @@ -1880,7 +1880,7 @@ verwies auf einen Eintrag „bezeichner“). Verweis wäre schlimmer als ein überflüssiger. - Bestehende Einträge behalten ihre alten Verweise in `entry_links`, bis sie das nächste Mal geschrieben werden. -- Tests: Fälle in `TestExtractLinks`, `TestReplaceLinksLaesstCodeStehen`. +- Tests: Fälle in `TestExtractLinks`, `TestReplaceLinksLeavesCodeAlone`. ### 5.5 Ausgewählte Einträge löschen — ✅ umgesetzt (2026-10-14) @@ -1922,7 +1922,7 @@ Zählung in 4.6 zählt das als Aktualisierung (`detail LIKE 'wiederhergestellt%' `keys.html` zeigt in den Konfigurationsblöcken noch `https://DEIN-SERVER/mcp`. Die Hilfe setzt die Adresse schon ein (`serverURL` in -`internal/web/handlers_hilfe.go`). Dieselbe Funktion für `keyListData` +`internal/web/handlers_help.go`). Dieselbe Funktion für `keyListData` verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen. **✅ Umgesetzt 2026-09-24:** `keyListData.ServerURL`, gesetzt in @@ -1931,7 +1931,7 @@ verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen. ### 5.3 Mehr Markdown in Einträgen — ✅ umgesetzt `entry.RenderHTML` nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in -Einträgen deshalb als Text, in der Hilfe (`internal/hilfe`) dagegen als +Einträgen deshalb als Text, in der Hilfe (`internal/help`) dagegen als Tabelle. **Lösung:** `extension.GFM` einschalten (Tabellen, Durchstreichen, Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die Sicherheitsannahme in `RenderHTML` ändert sich nicht. Danach den Hinweis auf @@ -1940,8 +1940,8 @@ Renderer laufen lassen. **✅ Umgesetzt 2026-09-24:** Neues Paket `internal/markdown` — ein goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und -`hilfe.Render` rufen nur noch dort hinein. Einziger Unterschied: -`RenderMitAnkern` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht — +`help.Render` rufen nur noch dort hinein. Einziger Unterschied: +`RenderWithAnchors` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht — Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche" soll nicht mit einer id der Seite selbst zusammenstoßen können. `markdown_test.go` prüft, dass auch mit GFM weder Roh-HTML noch @@ -1964,9 +1964,9 @@ Hilfe öffnet in einem neuen Tab, damit beim Nachschlagen im Editor kein ungespeicherter Text verloren geht. Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute -einfach fallen („zugnge-verwalten") —, sondern `ankerIDs` in +einfach fallen („zugnge-verwalten") —, sondern `anchorIDs` in `internal/markdown` über `store.Slugify` („zugaenge-verwalten"), dieselbe -Regel wie für Eintragsbezeichner. `TestHilfelinksZeigenAufVorhandeneAbschnitte` +Regel wie für Eintragsbezeichner. `TestHelpLinksPointToExistingSections` liest alle Templates, sucht jedes Hilfe-Ziel und prüft, dass Seite und Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so im Test, welcher Link dadurch ins Leere zeigen würde. @@ -1986,7 +1986,7 @@ ihrer Nachfolgerin — entweder der nächsten abgelösten oder, wenn es keine me gibt, der aktuell gültigen. Der Zeilenvergleich selbst (`internal/entry/diff.go`, `DiffLines`) ist eine klassische LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für -außergewöhnlich lange Einträge (`grobDiff` oberhalb von vier Millionen +außergewöhnlich lange Einträge (`coarseDiff` oberhalb von vier Millionen Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern. **✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte diff --git a/BETRIEB.md b/BETRIEB.md index 60a127b..895558d 100644 --- a/BETRIEB.md +++ b/BETRIEB.md @@ -102,7 +102,7 @@ Load-Balancer oder Monitoring gehört an `/ready`. | Meldung | Ursache | Abhilfe | |---|---|---| | `SESSION_SECRET ist nicht gesetzt` / `zu kurz` | Umgebungsvariable fehlt oder hat weniger als 32 Zeichen | Aus dem Passwortspeicher setzen. **Nicht** neu erzeugen, wenn es ein altes gibt, sonst sind alle Anmeldungen weg | -| `ADMIN_TOKEN ist nicht gesetzt` / `zu kurz` | dasselbe, mindestens 16 Zeichen | dasselbe | +| `ADMIN_TOKEN ist nicht gesetzt` / `zu kurz` | dasselbe, mindestens 32 Zeichen | dasselbe | | `Datenverzeichnis konnte nicht angelegt werden` | Volume fehlt oder gehört nicht dem Benutzer im Container (UID 10001) | `chown -R 10001:10001` auf das Volume, oder richtig einhängen | | `unable to open database file`, `permission denied` | dasselbe für eine einzelne Datei, oft nach einem Zurückkopieren als root | Besitzer der Datei korrigieren | | `address already in use` | Ein anderer Prozess belegt den Port | anderen Prozess beenden oder `PORT` ändern | diff --git a/LASTTEST.md b/LASTTEST.md index 46f8d2f..f52780b 100644 --- a/LASTTEST.md +++ b/LASTTEST.md @@ -188,15 +188,15 @@ reproduzierbar. jedem Schreiben den aktuellen Stand aus der Datenbank nach. Der letzte Aufruf liest damit immer den neuesten Stand, egal in welcher Reihenfolge die Aufrufe ankommen. Abgesichert durch `internal/search/registry_test.go` und -`internal/mcpserver/gleichzeitig_test.go`. +`internal/mcpserver/concurrency_test.go`. **Aufbau des Suchindex blockierte den ganzen Workspace (behoben).** Die Registry hatte nur eine Sperre für alle Projekte eines Workspace und hielt sie auch, während der Index eines Projekts aus der Datenbank aufgebaut wurde. Nach einem Neustart musste so jedes Projekt warten, bis das größte fertig eingelesen war. Seit 2026-10-06 hat jedes Projekt seine eigene Sperre. -Abgesichert durch `TestLangsamerAufbauBremstAndereProjekteNicht` und -`TestAenderungWaehrendDesAufbausGehtNichtVerloren`: Ein Schreibvorgang +Abgesichert durch `TestSlowBuildDoesNotBlockOtherProjects` und +`TestChangeDuringBuildIsNotLost`: Ein Schreibvorgang während des Aufbaus wartet, bis der Aufbau fertig ist, und trägt seinen Stand danach nach. diff --git a/README.md b/README.md index fd4c7cc..11971af 100644 --- a/README.md +++ b/README.md @@ -339,8 +339,8 @@ und entscheidet: Im zweiten Fall fällt der Haken „kuratiert" weg, das Protokoll vermerkt die Begründung, und die Nutzungsseite zeigt den Eintrag unter *Zur Prüfung*, bis ein -Mensch ihn wieder kuratiert. Einzelheiten: `internal/dubletten` und -`internal/mcpserver/kuratiert.go`. Jeder Kandidat nennt die gemeinsamen Wörter +Mensch ihn wieder kuratiert. Einzelheiten: `internal/duplicates` und +`internal/mcpserver/curated.go`. Jeder Kandidat nennt die gemeinsamen Wörter (`matched_words`). ### Gleichzeitiges Schreiben @@ -416,7 +416,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh einmal hinzufügen oder entfernen. - **Gibt es schon?** Beim Anlegen zeigt Munin unter dem Titel vorhandene Einträge mit ähnlichem Titel und die gemeinsamen Wörter — dieselbe Prüfung - wie bei `remember` über MCP (`internal/dubletten`), hier nur als Hinweis. + wie bei `remember` über MCP (`internal/duplicates`), hier nur als Hinweis. - **Rückfrage beim Verlassen:** Wer im Formular etwas geändert hat und die Seite verlässt, ohne zu speichern, bekommt eine Rückfrage des Browsers. - **Auch verwandt:** Unter einem Eintrag stehen neben den Rückverweisen die @@ -445,7 +445,7 @@ Graph brauchen JavaScript. Alles andere funktioniert auch ohne. | Variable | Standard | Bedeutung | |---|---|---| | `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. | -| `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 16 Zeichen. | +| `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 32 Zeichen. | | `DB_DRIVER` | `sqlite` | Wo die Daten liegen: `sqlite` oder `postgres` (siehe AUSBAUSTUFEN.md 4.10) | | `DATA_DIR` | `data` | Nur SQLite: Datenverzeichnis mit Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace | | `DATABASE_URL` | — | Nur PostgreSQL, dann **Pflicht**: z.B. `postgres://munin:geheim@host:5432/munin`. Schemas `munin_control` und je Workspace `munin_ws_<id>`. Lokal: `docker-compose.dev.yml` | @@ -799,10 +799,10 @@ scripts/backup.sh Sicherung aller Datenbankdateien Eine Übersicht über jede einzelne Datei steht in [REPOMAP.md](REPOMAP.md). **Hilfeseiten pflegen:** Die Hilfe unter `/hilfe` sind Markdown-Dateien in -`internal/hilfe/seiten/`, die mit ins Binary gebaut werden. Ändert sich eine +`internal/help/pages/`, die mit ins Binary gebaut werden. Ändert sich eine Funktion, gehört die Anpassung der Hilfe in denselben Commit. Eine neue Seite -braucht eine Datei *und* einen Eintrag in der Liste `seiten` in -`internal/hilfe/hilfe.go`. Ein Test prüft, dass beides zusammenpasst und dass +braucht eine Datei *und* einen Eintrag in der Liste `pages` in +`internal/help/help.go`. Ein Test prüft, dass beides zusammenpasst und dass alle Links auf `/hilfe/…` zu existierenden Seiten führen. Seiten mit `NurAdmin: true` sieht nur der Plattform-Admin; allgemeine Seiten dürfen nicht auf sie verlinken (auch das prüft ein Test). In den Seiten @@ -820,8 +820,8 @@ als statisches Binary ohne Laufzeitumgebung ausliefern. Welche Datenbank, entscheidet `DB_DRIVER`. Alle Abfragen sind für beide dieselben: Sie werden mit `?` geschrieben und laufen durch einen kleinen -Dialekt, der für PostgreSQL `$1, $2 …` daraus macht (`internal/store/dialekt.go`, -`verbindung.go`). Das SQL vermeidet deshalb alles, was nur eine der beiden +Dialekt, der für PostgreSQL `$1, $2 …` daraus macht (`internal/store/dialect.go`, +`pool.go`). Das SQL vermeidet deshalb alles, was nur eine der beiden kann. Die Regeln stehen als Kommentar in `internal/store/migrations/sqlite/001_init.sql`. @@ -839,8 +839,8 @@ Sie wird automatisch angewandt und vermerkt: für die Kontroll-Datenbank beim Start, für einen Workspace beim ersten Öffnen. Bestehende Migrationen werden nie verändert. -Zwei Tests passen auf: `TestMigrationsordnerGleich` (beide Ordner haben -dieselben Dateien, läuft immer) und `TestSchemaGleich` (beide ergeben dasselbe +Zwei Tests passen auf: `TestMigrationDirsMatch` (beide Ordner haben +dieselben Dateien, läuft immer) und `TestSchemaMatches` (beide ergeben dasselbe Schema, braucht PostgreSQL): ```bash diff --git a/REPOMAP.md b/REPOMAP.md index 08f1c0e..ceec4c5 100644 --- a/REPOMAP.md +++ b/REPOMAP.md @@ -2,6 +2,13 @@ Übersicht aller Dateien und Ordner mit ihrer Aufgabe. +**Sprache im Code:** Bezeichner (Pakete, Dateien, Typen, Funktionen, +Variablen, Felder, Tests, JS) sind englisch, ebenso Tabellen, Spalten, +Indizes und die Dateinamen der Migrationen. Kommentare, Doku, Hilfeseiten +und alle Texte der Weboberfläche (auch URLs wie `/hilfe`) bleiben deutsch. +Eine einmal ausgelieferte Migration wird nicht mehr umbenannt: Ihr +Dateiname steht als „angewendet“ in jeder bestehenden Datenbank. + ``` munin/ ├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die @@ -9,12 +16,12 @@ munin/ │ Mindestlänge), öffnet Kontroll-Datenbank und │ Workspace-Verwaltung (legt beim ersten Start den │ Workspace "Intern" an), setzt die -│ Bestandteile zusammen (baueHandler, auch vom +│ Bestandteile zusammen (buildHandler, auch vom │ Lasttest genutzt) und startet den Server. │ Enthält außerdem die Server-Timeouts (gegen │ Slowloris), die Body-Größenlimits und die │ täglichen Aufgaben (Protokoll aufräumen, -│ Export), die über fuerJedenWorkspace alle +│ Export), die über forEachWorkspace alle │ Workspaces erreichen. Tests: main_test.go, │ Lasttest: last_test.go (nur mit -tags last). │ @@ -56,18 +63,18 @@ munin/ │ │ │ der Treiber modernc.org/sqlite ist: Er ist reines │ │ │ Go und erlaubt damit ein statisches Binary. │ │ │ -│ │ ├── speicher.go ← Wo liegen die Daten? Interface Speicher, von +│ │ ├── backend.go ← Wo liegen die Daten? Interface Backend, von │ │ │ main.go anhand von DB_DRIVER gewählt │ │ │ (AUSBAUSTUFEN.md 4.10). -│ │ ├── speicher_sqlite.go ← Eine Datei je Workspace plus control.db (Standard). -│ │ ├── speicher_postgres.go← Ein Schema je Workspace (munin_ws_<id>) plus +│ │ ├── backend_sqlite.go ← Eine Datei je Workspace plus control.db (Standard). +│ │ ├── backend_postgres.go ← Ein Schema je Workspace (munin_ws_<id>) plus │ │ │ munin_control. Feste Poolgrößen, Prüfung gegen │ │ │ max_connections, Wiederholung beim Start, │ │ │ Workspace-Name als Schema-Kommentar. -│ │ ├── dialekt.go ← Was sich zwischen SQLite und PostgreSQL +│ │ ├── dialect.go ← Was sich zwischen SQLite und PostgreSQL │ │ │ unterscheidet: Platzhalter (? → $1) und │ │ │ Migrationsordner. Bewusst klein. -│ │ ├── verbindung.go ← pool/transaktion: Jede Abfrage läuft hier durch +│ │ ├── pool.go ← pool/transaction: Jede Abfrage läuft hier durch │ │ │ den Dialekt — vorbei kommt keine. │ │ │ │ │ ├── control.go ← Die zentrale Kontroll-Datenbank (OpenControl): @@ -110,28 +117,28 @@ munin/ │ │ │ fremden Projekten │ │ │ · LinkGraph liefert Knoten und Kanten für die │ │ │ Graph-Ansicht -│ │ │ Jede lesende Abfrage trägt nichtImPapierkorb +│ │ │ Jede lesende Abfrage trägt notInTrash │ │ │ (deleted_at IS NULL) — außer PutEntry, das │ │ │ einen Eintrag im Papierkorb bewusst sieht und │ │ │ ihn beim Überschreiben zurückholt. │ │ │ -│ │ ├── nutzung.go ← Kennzahlen der Nutzungsübersicht: UsageSummaries +│ │ ├── usage.go ← Kennzahlen der Nutzungsübersicht: UsageSummaries │ │ │ (Schreibvorgänge der letzten 30 Tage aus dem │ │ │ Protokoll, Bestand aus entries — für ein oder │ │ │ alle Projekte in zwei Abfragen), dazu │ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys. │ │ │ Keine eigene Zähltabelle. │ │ │ -│ │ ├── systemstand.go ← Kennzahlen eines ganzen Workspace für die +│ │ ├── sysinfo.go ← Kennzahlen eines ganzen Workspace für die │ │ │ Systemseite: Bestand, Belegung (Dialekt- │ │ │ Methode belegung), Aktivität je Tag, │ │ │ Schemastand. -│ │ ├── suchprotokoll.go ← Was Agenten mit recall suchen (search_log): +│ │ ├── searchlog.go ← Was Agenten mit recall suchen (search_log): │ │ │ LogSearch, SearchStatsFor, EmptySearches, │ │ │ PruneSearchLog. Eigene Tabelle statt Protokoll, │ │ │ 30 Tage, verschwindet mit dem Projekt. │ │ │ -│ │ ├── papierkorb.go ← Papierkorb: MoveEntryToTrash (statt DELETE), +│ │ ├── trash.go ← Papierkorb: MoveEntryToTrash (statt DELETE), │ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash │ │ │ (nur was schon im Papierkorb liegt) und │ │ │ PurgeTrash für die tägliche Aufgabe. Die Frist @@ -167,7 +174,7 @@ munin/ │ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln │ │ │ für portables SQL, an die sich alle weiteren │ │ │ Migrationen halten sollen. -│ │ ├── 002_projekt_in_nebentabellen.sql +│ │ ├── 002_project_in_child_tables.sql │ │ ← Führt project_id auch in entry_tags und │ │ entry_links. Eine bewusste Redundanz gegen zwei │ │ gemessene Bremsen: das Schlagwortmenü lief über @@ -175,34 +182,34 @@ munin/ │ │ und bei den Rückverweisen kippte durch ein │ │ ORDER BY die Verknüpfungsreihenfolge │ │ (3,1 ms statt 9 µs). -│ │ ├── 003_schreibbudget_je_projekt.sql +│ │ ├── 003_write_budget_per_project.sql │ │ ← Spalte write_limit_hourly an projects. Vorgabe 0 │ │ heißt "keine eigene Angabe" — dann gilt der │ │ Standard aus WRITE_LIMIT_HOURLY. So muss die │ │ Migration keine Daten anfassen. -│ │ ├── 004_aenderungshistorie.sql +│ │ ├── 004_change_history.sql │ │ ← Tabelle entry_versions für abgelöste Fassungen. │ │ Wird mit dem Eintrag gelöscht (ON DELETE CASCADE). -│ │ ├── 005_papierkorb.sql +│ │ ├── 005_trash.sql │ │ ← deleted_at/deleted_by an entries und ein │ │ Teilindex nur über den Papierkorb. Löschen setzt │ │ nur noch die Markierung; CASCADE greift erst │ │ beim endgültigen Löschen. -│ │ ├── 006_suchprotokoll.sql +│ │ ├── 006_search_log.sql │ │ ← Tabelle search_log (recall-Anfragen, 30 Tage). -│ │ └── 007_key_ablauf.sql +│ │ └── 007_key_expiry.sql │ │ ← expires_at an api_keys. Leer = läuft nie ab; │ │ abgelaufen gilt wie zurückgezogen. │ │ │ ├── statistik/ ── Kennzahlen aller Workspaces ─────────────────── -│ │ └── statistik.go ← Sammler für die Systemseite: Bestand, Platz, +│ │ └── stats.go ← Sammler für die Systemseite: Bestand, Platz, │ │ Schema-Stand und Aktivität der letzten 30 Tage │ │ über alle Workspaces. Rechnet beim Aufruf der │ │ Seite, höchstens alle 5 Minuten neu. │ │ │ ├── workspace/ ── Welche Datei gehört zu welcher Abteilung? ────── │ │ └── workspace.go ← Manager öffnet Workspaces über den -│ │ store.Speicher bei Bedarf und hält sie offen, +│ │ store.Backend bei Bedarf und hält sie offen, │ │ jeweils mit eigenem Suchindex. Datenbank und │ │ Index gehören zusammen, damit kein Index │ │ versehentlich an der falschen Datei hängt. @@ -257,7 +264,7 @@ munin/ │ │ └── diff.go ← Zeilenvergleich zweier Fassungen (DiffLines) als │ │ LCS ohne externe Abhängigkeit. Bei sehr langen │ │ Einträgen weicht er auf einen groben Vergleich -│ │ aus (grobDiff), damit Speicher und Rechenzeit +│ │ aus (coarseDiff), damit Speicher und Rechenzeit │ │ begrenzt bleiben. │ │ │ ├── markdown/ ── Markdown nach HTML, eine Regel für alles ─────── @@ -265,7 +272,7 @@ munin/ │ │ Hilfe: GFM (Tabellen, Checklisten, │ │ Durchstreichen, automatische Links), Roh-HTML │ │ gesperrt. Nur die Hilfe bekommt Überschrift- -│ │ Anker (RenderMitAnkern) — mit gefalteten +│ │ Anker (RenderWithAnchors) — mit gefalteten │ │ Umlauten über store.Slugify, damit die │ │ "?"-Links der Oberfläche darauf zeigen können. │ │ @@ -287,7 +294,7 @@ munin/ │ │ Fehler ein halbes Archiv statt einer Fehlerseite. │ │ │ ├── importer/ ── Gegenstück zum Export ────────────────────────── -│ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit +│ │ ├── archive.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit │ │ │ Grenzen für Anzahl und ausgepackte Größe. │ │ │ Übergeht _index.md, versteckte Ordner, __MACOSX. │ │ ├── parse.go ← Kleiner Frontmatter-Leser (kein YAML-Paket): @@ -305,7 +312,7 @@ munin/ │ │ │ Agenten, SearchFuzzy (mit Tippfehlern) für die │ │ │ Weboberfläche. │ │ │ -│ │ ├── varianten.go ← Wortvarianten je Suchwort, schwächer gewichtet +│ │ ├── variants.go ← Wortvarianten je Suchwort, schwächer gewichtet │ │ │ als ein exakter Treffer: Wortanfang ("deploy" → │ │ │ Deployment), Endung ("deployen"), Teilwort │ │ │ ("prozess" → Releaseprozess), Tippfehler. @@ -322,7 +329,7 @@ munin/ │ │ Aufbau nicht den ganzen Workspace aufhält. │ │ │ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ─────────────────── -│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel, +│ │ ├── help.go ← Liste der Hilfeseiten (Reihenfolge, Titel, │ │ │ Zielgruppe: alle / Admins / Plattform-Admin) und │ │ │ das Rendern mit Überschrift-Ankern. Erklärt, │ │ │ warum die Hilfe im Binary steckt statt als @@ -367,25 +374,25 @@ munin/ │ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die │ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit │ │ │ in server.go). -│ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage), +│ │ ├── handlers_usage.go ← Nutzungsseite je Projekt (/p/{projekt}/usage), │ │ │ für jede Rolle. Die Admin-Projektliste nutzt │ │ │ dieselben Zahlen (projectListItems). │ │ ├── handlers_system.go ← /admin/system (Plattform-Admin): Kasten -│ │ │ "Zustand" (systemHinweise: Warnungen und +│ │ │ "Zustand" (systemHints: Warnungen und │ │ │ Hinweise), Aktivität, Bestand, Speicher, │ │ │ Bereitschaft, offene Verbindungen je Workspace │ │ │ gegen das Budget, Fassung aus den Build-Infos. -│ │ ├── handlers_ueberblick.go +│ │ ├── handlers_overview.go │ │ │ ← /admin/overview: Überblick des Workspace für │ │ │ Workspace-Admins — Befunde je Projekt mit Link -│ │ │ (projektHinweise), Projekttabelle, Aktivität. -│ │ ├── diagramm.go ← Balkendiagramme der Systemseite als SVG, vom +│ │ │ (projectHints), Projekttabelle, Aktivität. +│ │ ├── chart.go ← Balkendiagramme der Systemseite als SVG, vom │ │ │ Server ausgerechnet (kein JavaScript, CSP-fest). -│ │ ├── handlers_suche.go ← /admin/search: Suche in allen Projekten des +│ │ ├── handlers_search.go ← /admin/search: Suche in allen Projekten des │ │ │ Workspace, nach Projekt gruppiert (Plattform- │ │ │ und Workspace-Admin; Keys bleiben bei einem │ │ │ Projekt). -│ │ ├── handlers_bereitschaft.go +│ │ ├── handlers_ready.go │ │ │ ← /live (Prozess läuft, für den Container-Neustart) │ │ │ und /ready (Datenbank antwortet, sonst 503; für │ │ │ Load-Balancer). /healthz ist Alias für /live. @@ -398,12 +405,12 @@ munin/ │ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import), │ │ │ nur mit Rolle admin. Läuft im Server, damit der │ │ │ Suchindex im selben Zug nachgezogen wird. -│ │ ├── handlers_papierkorb.go +│ │ ├── handlers_trash.go │ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen, │ │ │ endgültig löschen, Filter nach löschendem Zugang. │ │ │ Alles nur mit Schreibrecht, und bewusst nur hier │ │ │ — über MCP gibt es kein Wiederherstellen. -│ │ ├── handlers_hilfe.go ← Zeigt die eingebauten Hilfeseiten unter /hilfe +│ │ ├── handlers_help.go ← Zeigt die eingebauten Hilfeseiten unter /hilfe │ │ │ für jede Sitzung. Setzt vorher Server-Adresse │ │ │ und Projekt-Bezeichner in die Beispiele ein │ │ │ (serverURL, mit geprüftem Host-Header). @@ -426,7 +433,7 @@ munin/ │ │ Speichern ohne Änderung überschreibt nichts. │ │ │ ├── dubletten/ ── Gibt es schon? ────────────────────────────────── -│ │ └── dubletten.go ← Vor dem Anlegen: Gibt es einen Eintrag mit +│ │ └── duplicates.go ← Vor dem Anlegen: Gibt es einen Eintrag mit │ │ ähnlichem Titel? (Pruefe) Gemeinsames Präfix │ │ zählt nicht, Suchbegriffe nur wörtlich, die │ │ gemeinsamen Wörter kommen mit. Genutzt von @@ -441,7 +448,7 @@ munin/ │ │ Qualität der Antworten, weil er bei jedem Client │ │ im Kontext steht. │ │ -│ ├── kuratiert.go ← Kuratierte Einträge ändert remember nur mit +│ ├── curated.go ← Kuratierte Einträge ändert remember nur mit │ │ override_curated und Begründung; die Änderung │ │ landet auf der Nutzungsseite zur Prüfung. │ │ @@ -473,12 +480,12 @@ munin/ │ │ Knoten auseinander, Namen nur, wo Platz ist. │ │ Texte nur über textContent, nie innerHTML. │ ├── tagfilter.js ← Filterfeld auf der Seite "Alle Schlagworte". -│ ├── aehnlich.js ← "Gibt es schon?" unter dem Titel eines neuen +│ ├── similar.js ← "Gibt es schon?" unter dem Titel eines neuen │ │ Eintrags, fragt nach einer Tipp-Pause. -│ ├── ungespeichert.js ← Rückfrage beim Verlassen eines Formulars mit -│ │ ungespeicherten Änderungen (data-ungespeichert). -│ ├── tastenkuerzel.js ← "/" Suche, "n" neu, "e" bearbeiten, Strg+Enter -│ │ speichern. Ziel steht als data-kuerzel im +│ ├── unsaved.js ← Rückfrage beim Verlassen eines Formulars mit +│ │ ungespeicherten Änderungen (data-unsaved). +│ ├── shortcuts.js ← "/" Suche, "n" neu, "e" bearbeiten, Strg+Enter +│ │ speichern. Ziel steht als data-shortcut im │ │ Template, die Rechte also dort. │ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne │ Hintergrund). Die Anmeldeseite wählt je nach @@ -525,14 +532,14 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`. | Datei | Schwerpunkt | |---|---| | `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For | -| `internal/store/systemstand_test.go` | Bestand samt Key-Regeln, Belegung wächst mit dem Inhalt, Aktivität je Tag und Weg mit Zeitgrenze, Schemastand (auch gegen PostgreSQL) | -| `internal/statistik/statistik_test.go` | Summen über zwei Workspaces, alle 30 Tage gefüllt, aktive und ruhige Projekte, Zwischenspeicher für 5 Minuten | -| `internal/web/diagramm_test.go` | Balken gestapelt von der Nulllinie, Skala 1-2-5, kleine Werte sichtbar, Tooltip, Bytes lesbar | -| `internal/store/nutzung_test.go` | Zählung nach Art und Weg samt Zeitfenster, Bestand ohne Papierkorb, Verweise ins Leere, aktive Zugänge | -| `internal/store/papierkorb_test.go` | **Jeder lesende Weg blendet den Papierkorb aus** (Einzelabruf, Liste mit Filtern, Suchindex/Export, Zählen, Schlagworte, Rückverweise, Graph, Sammelbearbeitung); Wiederherstellen, Zurückholen durch Überschreiben, Konflikt, endgültiges Löschen, Frist | +| `internal/store/sysinfo_test.go` | Bestand samt Key-Regeln, Belegung wächst mit dem Inhalt, Aktivität je Tag und Weg mit Zeitgrenze, Schemastand (auch gegen PostgreSQL) | +| `internal/stats/stats_test.go` | Summen über zwei Workspaces, alle 30 Tage gefüllt, aktive und ruhige Projekte, Zwischenspeicher für 5 Minuten | +| `internal/web/chart_test.go` | Balken gestapelt von der Nulllinie, Skala 1-2-5, kleine Werte sichtbar, Tooltip, Bytes lesbar | +| `internal/store/usage_test.go` | Zählung nach Art und Weg samt Zeitfenster, Bestand ohne Papierkorb, Verweise ins Leere, aktive Zugänge | +| `internal/store/trash_test.go` | **Jeder lesende Weg blendet den Papierkorb aus** (Einzelabruf, Liste mit Filtern, Suchindex/Export, Zählen, Schlagworte, Rückverweise, Graph, Sammelbearbeitung); Wiederherstellen, Zurückholen durch Überschreiben, Konflikt, endgültiges Löschen, Frist | | `internal/store/store_test.go` | Migrationen, Slug-Prüfung, Abschottung, Dublettenvermeidung, optimistisches Sperren, Verweise, Protokoll, Änderungshistorie | | `internal/store/control_test.go` | Kontroll-Datenbank: Standard-Workspace bleibt stabil, Auflistung, Zuordnung Key → Workspace | -| `internal/store/dialekt_test.go` | Platzhalter-Umwandlung (Text, Kommentare, Bezeichner), **beide Migrationsordner gleich**, Schemanamen, Textkonstanten | +| `internal/store/dialect_test.go` | Platzhalter-Umwandlung (Text, Kommentare, Bezeichner), **beide Migrationsordner gleich**, Schemanamen, Textkonstanten | | `internal/store/postgres_test.go` | Nur mit `MUNIN_TEST_PG`: **Schema-Vergleich SQLite ↔ PostgreSQL**, Schema je Workspace samt Kommentar, Abschottung, Verbindungsbudget. Schaltet außerdem `testStore`/`testControl` auf PostgreSQL um | | `internal/importer/postgres_test.go` | Nur mit `MUNIN_TEST_PG`: Export als ZIP aus einem PostgreSQL-Workspace und Import in einen zweiten, Einträge danach gleich | | `internal/workspace/workspace_test.go` | Manager hält Dateien offen, trennt Workspaces, schließt sauber | @@ -540,21 +547,21 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`. | `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe | | `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen | | `internal/search/search_test.go` | Bewertung, Wortanfang, Aktualisieren und Entfernen | -| `internal/search/varianten_test.go` | Wortanfang neben exaktem Treffer, Komposita, Endungen, Stoppwörter, Tippfehler nur in SearchFuzzy | +| `internal/search/variants_test.go` | Wortanfang neben exaktem Treffer, Komposita, Endungen, Stoppwörter, Tippfehler nur in SearchFuzzy | | `internal/search/bench_test.go` | Laufzeit einer Suche bei 10.000 Einträgen und 20.000 Wörtern | | `internal/search/registry_test.go` | Index folgt der Datenbank, auch wenn sich Schreiber überholen, ein Eintrag gleich wieder gelöscht wird oder während des Aufbaus geschrieben wird; ein langsamer Aufbau bremst andere Projekte nicht | -| `internal/mcpserver/gleichzeitig_test.go` | Viele Agenten schreiben über MCP gleichzeitig denselben Eintrag: keine verlorene Fassung, Index aktuell | +| `internal/mcpserver/concurrency_test.go` | Viele Agenten schreiben über MCP gleichzeitig denselben Eintrag: keine verlorene Fassung, Index aktuell | | `internal/store/related_test.go` | Verwandte Einträge über zwei Ebenen, Papierkorb als Sackgasse, Grenze für veraltete Einträge, übernommene Zeitstempel beim Anlegen | | `internal/importer/importer_test.go` | **Rundlauf Export → ZIP → Import ohne Verlust**, auch mit dem ZIP aus dem Download, Vorschau, Überspringen/Überschreiben, Papierkorb, doppelte Bezeichner, fremde Köpfe, Grenzen des Archivs | | `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe | | `internal/web/download_test.go` | ZIP-Download: Inhalt und Protokolleintrag, nur Admins, kein fremdes Projekt, Workspace-ZIP für Plattform- und Workspace-Admin, sichere Dateinamen | | `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route | | `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse, `/live` und `/ready` (503 ohne Datenbank, ohne Einzelheiten) | -| `internal/web/oberflaeche_test.go` | Veraltete Einträge (Marke, Filter, Nutzungsseite), „Auch verwandt“, „Gibt es schon?“ (auch: nicht mit Leserecht, nicht bei vorhandenem Bezeichner), Formular-Attribute und Kürzel je Rolle, Protokollfilter, Systemseite (nur Plattform-Admin), **Ablaufdatum für Keys** (Ende der Sitzung), Suche über alle Projekte (nicht für Projektzugänge) | -| `internal/dubletten/dubletten_test.go` | Wann ein gemeinsames Titel-Präfix aus dem Vergleich fällt | +| `internal/web/ui_test.go` | Veraltete Einträge (Marke, Filter, Nutzungsseite), „Auch verwandt“, „Gibt es schon?“ (auch: nicht mit Leserecht, nicht bei vorhandenem Bezeichner), Formular-Attribute und Kürzel je Rolle, Protokollfilter, Systemseite (nur Plattform-Admin), **Ablaufdatum für Keys** (Ende der Sitzung), Suche über alle Projekte (nicht für Projektzugänge) | +| `internal/duplicates/duplicates_test.go` | Wann ein gemeinsames Titel-Präfix aus dem Vergleich fällt | | `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung (gleicher Bezeichner und ähnlicher Titel), Synonyme, Suchprotokoll, Konflikte, Rollen, **Projektabschottung** | -| `internal/store/suchprotokoll_test.go` | Kennzahlen und Anfragen ohne Treffer, Zeitfenster, Aufräumen, verschwindet mit dem Projekt | -| `internal/hilfe/hilfe_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML | +| `internal/store/searchlog_test.go` | Kennzahlen und Anfragen ohne Treffer, Zeitfenster, Aufräumen, verschwindet mit dem Projekt | +| `internal/help/help_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML | ## Datenfluss: Agent ruft Wissen ab @@ -570,10 +577,10 @@ KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall", → mcpserver/tools.go (recall) → search/registry.go Index dieses Projekts (beim ersten Mal aus der Datenbank aufgebaut) - → search/index.go (Search) BM25 über Wortvarianten (varianten.go) + → search/index.go (Search) BM25 über Wortvarianten (variants.go) → store/entries.go (EntryBySlug) Treffer nachladen → entry/entry.go (Snippet) Textausschnitt je Treffer - → store/suchprotokoll.go (LogSearch) Anfrage und Trefferzahl + → store/searchlog.go (LogSearch) Anfrage und Trefferzahl → JSON-Antwort mit Trefferliste und Hinweis zum weiteren Vorgehen ``` @@ -586,7 +593,7 @@ KI-Werkzeug POST /mcp { … "name": "remember", "arguments": { "slug": …, "c → requireWrite() Rolle read wird hier abgewiesen → dubletten (Pruefe) neuer Bezeichner, ähnlicher Titel? → candidates zurück, nichts schreiben - → kuratiert.go (istKuratiert) kuratiert und kein override_curated? + → curated.go (isCurated) kuratiert und kein override_curated? → judgment_required, nichts schreiben → store/entries.go (PutEntry) eine Transaktion: · Bezeichner vorhanden? → aktualisieren diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2cff9ef..b76e20e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -21,7 +21,7 @@ services: postgres: image: postgres:18 # Munin braucht je Workspace bis zu 3 Verbindungen (siehe - # internal/store/speicher_postgres.go). 200 reichen für rund 60 Workspaces. + # internal/store/backend_postgres.go). 200 reichen für rund 60 Workspaces. command: postgres -c max_connections=200 environment: POSTGRES_USER: munin diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 69cdb1a..d200540 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -15,9 +15,9 @@ var testSigner = NewSigner([]byte("ein-test-secret-das-lang-genug-ist-fuer-tests // ── Sitzungen ──────────────────────────────────────────────────────────────── func TestSessionRoundTrip(t *testing.T) { - cookie := testSigner.SignSession("key:abc123") + cookie := testSigner.SignSession("key:abc123", time.Now().Add(time.Hour)) - subject, ok := testSigner.VerifySession(cookie) + subject, _, ok := testSigner.VerifySession(cookie) if !ok { t.Fatal("frisch signierte Sitzung wurde als ungültig abgelehnt") } @@ -29,10 +29,10 @@ func TestSessionRoundTrip(t *testing.T) { // TestSessionRejectsWrongSecret ist der Kern des Verfahrens: ohne das richtige // Geheimnis darf sich niemand eine Sitzung ausstellen können. func TestSessionRejectsWrongSecret(t *testing.T) { - cookie := testSigner.SignSession(SubjectPlatformAdmin) - fremder := NewSigner([]byte("ein-voellig-anderes-secret")) + cookie := testSigner.SignSession(SubjectPlatformAdmin, time.Now().Add(time.Hour)) + foreign := NewSigner([]byte("ein-voellig-anderes-secret")) - if _, ok := fremder.VerifySession(cookie); ok { + if _, _, ok := foreign.VerifySession(cookie); ok { t.Error("Sitzung wurde mit falschem Secret akzeptiert") } } @@ -41,18 +41,45 @@ func TestSessionRejectsWrongSecret(t *testing.T) { // auslesen (er ist nur base64, nicht verschlüsselt), "key:xyz" durch "platform" // ersetzen und wieder einsetzen. func TestSessionRejectsTampering(t *testing.T) { - cookie := testSigner.SignSession("key:abc123") + cookie := testSigner.SignSession("key:abc123", time.Now().Add(time.Hour)) _, signature, _ := strings.Cut(cookie, ".") - gefaelscht := base64.RawURLEncoding.EncodeToString( + forged := base64.RawURLEncoding.EncodeToString( fmt.Appendf(nil, "%s|%d", SubjectPlatformAdmin, time.Now().Add(time.Hour).Unix()), ) - if _, ok := testSigner.VerifySession(gefaelscht + "." + signature); ok { + if _, _, ok := testSigner.VerifySession(forged + "." + signature); ok { t.Error("manipulierter Cookie wurde akzeptiert — die Signatur greift nicht") } } +// TestSessionTTLPerKind prüft die Obergrenze je Sitzungsart: Ein +// Plattform-Admin-Cookie darf höchstens PlatformAdminSessionTTL gelten. Länger +// laufende (etwa noch mit den früheren 30 Tagen ausgestellte) werden +// abgelehnt, obwohl sie korrekt signiert sind. Key-Sitzungen behalten 30 Tage. +func TestSessionTTLPerKind(t *testing.T) { + platform := PlatformSubject("abcdef0123456789", "") + now := time.Now() + + cases := []struct { + subject string + expiry time.Time + want bool + }{ + {platform, now.Add(PlatformAdminSessionTTL), true}, + {platform, now.Add(30 * 24 * time.Hour), false}, + {"key:ws:abc123", now.Add(SessionTTL), true}, + {"wsadmin:ws:abc123", now.Add(SessionTTL), true}, + {"key:ws:abc123", now.Add(SessionTTL + time.Hour), false}, + } + for _, tc := range cases { + _, _, ok := testSigner.VerifySession(testSigner.SignSession(tc.subject, tc.expiry)) + if ok != tc.want { + t.Errorf("%s, gültig noch %v: ok = %v, erwartet %v", tc.subject, tc.expiry.Sub(now).Round(time.Hour), ok, tc.want) + } + } +} + // TestSessionRejectsExpired baut die Nutzlast von Hand mit einem Zeitstempel in // der Vergangenheit und signiert sie korrekt — so wird wirklich nur die // Ablaufprüfung getestet, ohne eine Stunde zu warten. @@ -60,7 +87,7 @@ func TestSessionRejectsExpired(t *testing.T) { payload := fmt.Sprintf("key:abc123|%d", time.Now().Add(-time.Minute).Unix()) cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + testSigner.sign(payload) - if _, ok := testSigner.VerifySession(cookie); ok { + if _, _, ok := testSigner.VerifySession(cookie); ok { t.Error("abgelaufene Sitzung wurde akzeptiert") } } @@ -78,7 +105,7 @@ func TestSessionRejectsMalformed(t *testing.T) { } for _, cookie := range cases { - if _, ok := testSigner.VerifySession(cookie); ok { + if _, _, ok := testSigner.VerifySession(cookie); ok { t.Errorf("kaputter Cookie wurde akzeptiert: %q", cookie) } } @@ -115,7 +142,7 @@ func TestTokenDomainSeparation(t *testing.T) { subject := "key:abc123" csrf := testSigner.CSRFToken(subject) - if _, ok := testSigner.VerifySession( + if _, _, ok := testSigner.VerifySession( base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + csrf, ); ok { t.Error("ein CSRF-Token wurde als gültige Session-Signatur akzeptiert") @@ -139,16 +166,16 @@ func TestGenerateKeyFormat(t *testing.T) { // TestGenerateKeyIsUnique prüft, dass wirklich Zufall im Spiel ist. func TestGenerateKeyIsUnique(t *testing.T) { - gesehen := make(map[string]bool) + seen := make(map[string]bool) for i := 0; i < 100; i++ { key, err := GenerateKey() if err != nil { t.Fatal(err) } - if gesehen[key] { + if seen[key] { t.Fatal("zweimal derselbe Key erzeugt") } - gesehen[key] = true + seen[key] = true } } @@ -169,10 +196,10 @@ func TestHashKey(t *testing.T) { } func TestLooksLikeKey(t *testing.T) { - gueltig := KeyPrefix + strings.Repeat("ab", 32) + valid := KeyPrefix + strings.Repeat("ab", 32) cases := map[string]bool{ - gueltig: true, + valid: true, "": false, "wk_": false, strings.Repeat("ab", 32): false, // ohne Präfix @@ -180,9 +207,9 @@ func TestLooksLikeKey(t *testing.T) { KeyPrefix + strings.Repeat("g", 64): false, // kein Hex } - for key, erwartet := range cases { - if got := LooksLikeKey(key); got != erwartet { - t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, erwartet) + for key, want := range cases { + if got := LooksLikeKey(key); got != want { + t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, want) } } } @@ -247,10 +274,10 @@ func TestRolePermissions(t *testing.T) { func TestCookiePolicySecure(t *testing.T) { cases := []struct { - name string - env string - host string - erwartet bool + name string + env string + host string + want bool }{ {"Standard, echte Domain", "", "munin.intern", true}, {"Standard, localhost", "", "localhost:8080", false}, @@ -265,8 +292,8 @@ func TestCookiePolicySecure(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "http://"+tc.host+"/", nil) r.Host = tc.host - if got := policy.Secure(r); got != tc.erwartet { - t.Errorf("Secure = %v, erwartet %v", got, tc.erwartet) + if got := policy.Secure(r); got != tc.want { + t.Errorf("Secure = %v, erwartet %v", got, tc.want) } }) } @@ -344,19 +371,19 @@ func TestRateLimiterResetClearsCounter(t *testing.T) { func TestWriteBudgetAllowsUpToLimit(t *testing.T) { budget := NewWriteBudget(3, time.Hour) - const projekt = "p1" + const project = "p1" for i := 1; i <= 3; i++ { - ok, _, ueber := budget.Consume(projekt, 0) + ok, _, over := budget.Consume(project, 0) if !ok { t.Fatalf("Aufruf %d von 3 abgelehnt", i) } - if ueber != 0 { - t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, ueber) + if over != 0 { + t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, over) } } - ok, retryAfter, ueber := budget.Consume(projekt, 0) + ok, retryAfter, over := budget.Consume(project, 0) if ok { t.Error("der vierte Aufruf wurde angenommen") } @@ -365,12 +392,12 @@ func TestWriteBudgetAllowsUpToLimit(t *testing.T) { } // Genau 1 beim ersten abgelehnten Aufruf — daran hängt, dass die // Überschreitung nur einmal je Fenster protokolliert wird. - if ueber != 1 { - t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", ueber) + if over != 1 { + t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", over) } - if _, _, ueber := budget.Consume(projekt, 0); ueber != 2 { - t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", ueber) + if _, _, over := budget.Consume(project, 0); over != 2 { + t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", over) } } @@ -418,20 +445,20 @@ func TestWriteBudgetZeroDefaultDisables(t *testing.T) { func TestWriteBudgetRemainingCountsDown(t *testing.T) { budget := NewWriteBudget(5, time.Hour) - if frei, grenze := budget.Remaining("p1", 0); frei != 5 || grenze != 5 { - t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", frei, grenze) + if free, limit := budget.Remaining("p1", 0); free != 5 || limit != 5 { + t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", free, limit) } budget.Consume("p1", 0) budget.Consume("p1", 0) - if frei, _ := budget.Remaining("p1", 0); frei != 3 { - t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", frei) + if free, _ := budget.Remaining("p1", 0); free != 3 { + t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", free) } // Remaining selbst darf nichts verbrauchen. - if frei, _ := budget.Remaining("p1", 0); frei != 3 { - t.Errorf("frei = %d — Remaining hat mitgezählt", frei) + if free, _ := budget.Remaining("p1", 0); free != 3 { + t.Errorf("frei = %d — Remaining hat mitgezählt", free) } } @@ -511,16 +538,16 @@ func TestSetTrustedProxiesRejectsGarbage(t *testing.T) { SetTrustedProxies("") } -func TestPlatformSubjectHinUndZurueck(t *testing.T) { - faelle := []struct{ fingerabdruck, workspaceID string }{ +func TestPlatformSubjectRoundTrip(t *testing.T) { + cases := []struct{ fingerprint, workspaceID string }{ {"abc123", ""}, {"abc123", "ws-1"}, } - for _, f := range faelle { - subject := PlatformSubject(f.fingerabdruck, f.workspaceID) + for _, f := range cases { + subject := PlatformSubject(f.fingerprint, f.workspaceID) fp, ws, ok := ParsePlatformSubject(subject) - if !ok || fp != f.fingerabdruck || ws != f.workspaceID { - t.Errorf("%q → (%q, %q, %v), erwartet (%q, %q, true)", subject, fp, ws, ok, f.fingerabdruck, f.workspaceID) + if !ok || fp != f.fingerprint || ws != f.workspaceID { + t.Errorf("%q → (%q, %q, %v), erwartet (%q, %q, true)", subject, fp, ws, ok, f.fingerprint, f.workspaceID) } } @@ -532,7 +559,7 @@ func TestPlatformSubjectHinUndZurueck(t *testing.T) { } } -func TestFingerprintHaengtVomGeheimnisAb(t *testing.T) { +func TestFingerprintDependsOnSecret(t *testing.T) { a := NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert") b := NewSigner([]byte("geheimnis-zwei-geheimnis-zwei-geheimnis")).Fingerprint("wert") if a == b { diff --git a/internal/auth/csrf.go b/internal/auth/csrf.go index 7508c5b..bd8922b 100644 --- a/internal/auth/csrf.go +++ b/internal/auth/csrf.go @@ -41,6 +41,23 @@ func (s *Signer) ValidCSRFToken(token, subject string) bool { } // CSRFTokenFromRequest holt das Token aus dem Formular oder dem Header. +// SignHint signiert eine Erfolgsmeldung, die über einen Redirect in der +// Adresszeile mitreist (siehe withHint in internal/web). Ohne Signatur ließe +// sich mit einem präparierten Link ein beliebiger Text als Meldung in die +// Oberfläche schmuggeln — etwa „Bitte den Key an … schicken". +// +// Das Präfix "hint:" trennt diese Signaturen von Sitzungen und CSRF-Tokens: +// Eine signierte Meldung taugt nie als gültige Sitzung oder umgekehrt. +func (s *Signer) SignHint(message string) string { + return s.sign("hint:" + message) +} + +// ValidHint prüft, ob signature zu message passt — in konstanter Zeit, wie bei +// den anderen Vergleichen mit Geheimnissen. +func (s *Signer) ValidHint(message, signature string) bool { + return subtle.ConstantTimeCompare([]byte(signature), []byte(s.SignHint(message))) == 1 +} + func CSRFTokenFromRequest(r *http.Request) string { if token := r.Header.Get(CSRFHeaderName); token != "" { return token diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go index 80f9df7..0596bf9 100644 --- a/internal/auth/ratelimit.go +++ b/internal/auth/ratelimit.go @@ -14,98 +14,98 @@ import ( // ── Der gemeinsame Unterbau ────────────────────────────────────────────────── -// fensterStand ist der Zählerstand eines Schlüssels im laufenden Fenster. -type fensterStand struct { - anzahl int - fensterBis time.Time +// windowState ist der Zählerstand eines Schlüssels im laufenden Fenster. +type windowState struct { + count int + windowEnd time.Time } -// fensterZaehler zählt Ereignisse je Schlüssel innerhalb eines Zeitfensters. +// windowCounter zählt Ereignisse je Schlüssel innerhalb eines Zeitfensters. // // Was der Schlüssel bedeutet, entscheidet der Benutzer des Zählers: beim // RateLimiter ist es eine IP-Adresse, beim WriteBudget eine Projekt-ID. Für den // Zähler ist beides nur eine Zeichenkette — deshalb lässt sich dieselbe // Mechanik für beides verwenden. -type fensterZaehler struct { - mu sync.Mutex - fenster time.Duration - staende map[string]*fensterStand +type windowCounter struct { + mu sync.Mutex + window time.Duration + states map[string]*windowState } -// neuerZaehler erstellt einen Zähler und startet einen Hintergrund-Job, der +// newCounter erstellt einen Zähler und startet einen Hintergrund-Job, der // abgelaufene Einträge regelmäßig entfernt. // // Ohne das Aufräumen würde die Map unbegrenzt wachsen: Jede IP, die einmal // vorbeigeschaut hat, bliebe für immer darin stehen — bei Scannern und Bots sind // das schnell sehr viele. -func neuerZaehler(fenster time.Duration) *fensterZaehler { - z := &fensterZaehler{ - fenster: fenster, - staende: make(map[string]*fensterStand), +func newCounter(window time.Duration) *windowCounter { + z := &windowCounter{ + window: window, + states: make(map[string]*windowState), } go func() { ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() for range ticker.C { - z.aufraeumen() + z.cleanup() } }() return z } -// lies gibt den aktuellen Stand zurück, ohne zu zählen. -// restlaufzeit ist die verbleibende Dauer des Fensters. -func (z *fensterZaehler) lies(schluessel string) (anzahl int, restlaufzeit time.Duration) { +// peek gibt den aktuellen Stand zurück, ohne zu zählen. +// remaining ist die verbleibende Dauer des Fensters. +func (z *windowCounter) peek(key string) (count int, remaining time.Duration) { z.mu.Lock() defer z.mu.Unlock() - stand, vorhanden := z.staende[schluessel] + state, existing := z.states[key] now := time.Now() // Kein Eintrag oder Fenster abgelaufen: der Schlüssel steht bei null. - if !vorhanden || now.After(stand.fensterBis) { + if !existing || now.After(state.windowEnd) { return 0, 0 } - return stand.anzahl, stand.fensterBis.Sub(now) + return state.count, state.windowEnd.Sub(now) } -// zaehle erhöht den Zähler um eins und gibt den neuen Stand zurück. -func (z *fensterZaehler) zaehle(schluessel string) (anzahl int, restlaufzeit time.Duration) { +// count erhöht den Zähler um eins und gibt den neuen Stand zurück. +func (z *windowCounter) count(key string) (count int, remaining time.Duration) { z.mu.Lock() defer z.mu.Unlock() now := time.Now() - stand, vorhanden := z.staende[schluessel] + state, existing := z.states[key] // Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein // frisches ersetzt — sonst würde der alte Stand weitergezählt. - if !vorhanden || now.After(stand.fensterBis) { - stand = &fensterStand{fensterBis: now.Add(z.fenster)} - z.staende[schluessel] = stand + if !existing || now.After(state.windowEnd) { + state = &windowState{windowEnd: now.Add(z.window)} + z.states[key] = state } - stand.anzahl++ - return stand.anzahl, stand.fensterBis.Sub(now) + state.count++ + return state.count, state.windowEnd.Sub(now) } -// vergiss löscht den Zähler eines Schlüssels. -func (z *fensterZaehler) vergiss(schluessel string) { +// forget löscht den Zähler eines Schlüssels. +func (z *windowCounter) forget(key string) { z.mu.Lock() defer z.mu.Unlock() - delete(z.staende, schluessel) + delete(z.states, key) } -// aufraeumen entfernt abgelaufene Einträge aus der Map. -func (z *fensterZaehler) aufraeumen() { +// cleanup entfernt abgelaufene Einträge aus der Map. +func (z *windowCounter) cleanup() { z.mu.Lock() defer z.mu.Unlock() now := time.Now() - for schluessel, stand := range z.staende { - if now.After(stand.fensterBis) { - delete(z.staende, schluessel) + for key, state := range z.states { + if now.After(state.windowEnd) { + delete(z.states, key) } } } @@ -121,32 +121,32 @@ const ( // ohne das könnte ein Angreifer beliebig oft Passwörter und API-Keys // durchprobieren. type RateLimiter struct { - zaehler *fensterZaehler + counter *windowCounter } // NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche. func NewRateLimiter() *RateLimiter { - return &RateLimiter{zaehler: neuerZaehler(rateLimitWindow)} + return &RateLimiter{counter: newCounter(rateLimitWindow)} } // Allow prüft ob die IP noch einen Versuch frei hat, ohne ihn zu verbrauchen. // retryAfter gibt an wie lange bei einer Sperre noch gewartet werden muss. func (l *RateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) { - anzahl, restlaufzeit := l.zaehler.lies(ip) - if anzahl >= rateLimitMax { - return false, restlaufzeit + count, remaining := l.counter.peek(ip) + if count >= rateLimitMax { + return false, remaining } return true, 0 } // RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP. func (l *RateLimiter) RecordFailure(ip string) { - l.zaehler.zaehle(ip) + l.counter.count(ip) } // Reset löscht den Zähler einer IP nach einem erfolgreichen Zugriff. func (l *RateLimiter) Reset(ip string) { - l.zaehler.vergiss(ip) + l.counter.forget(ip) } // ── Schreibbudget je Projekt ───────────────────────────────────────────────── @@ -175,7 +175,7 @@ func (l *RateLimiter) Reset(ip string) { // Umgehen ein. Für eine verbindliche Zusage müsste der Zähler in die Datenbank — // siehe AUSBAUSTUFEN.md. Solange genau ein Prozess läuft, reicht das hier. type WriteBudget struct { - zaehler *fensterZaehler + counter *windowCounter standard int } @@ -183,9 +183,9 @@ type WriteBudget struct { // // standard gilt für alle Projekte, die keine eigene Obergrenze gesetzt haben. // Ein Wert von 0 oder kleiner schaltet die Begrenzung vollständig ab. -func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget { +func NewWriteBudget(standard int, window time.Duration) *WriteBudget { return &WriteBudget{ - zaehler: neuerZaehler(fenster), + counter: newCounter(window), standard: standard, } } @@ -193,7 +193,7 @@ func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget { // Consume verbucht einen Schreibvorgang für ein Projekt und sagt, ob er noch ins // Budget passte. // -// projektLimit ist die eigene Obergrenze des Projekts; bei 0 gilt der Standard +// projectLimit ist die eigene Obergrenze des Projekts; bei 0 gilt der Standard // aus NewWriteBudget. Die Obergrenze wird bei jedem Aufruf mitgegeben statt im // Budget gespeichert — so wirkt eine Änderung in der Verwaltung sofort, ohne // dass hier irgendein zwischengespeicherter Wert veralten könnte. @@ -202,48 +202,48 @@ func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget { // weiterversucht, das Fenster nie volllaufen lassen — und der Zählerstand wäre // keine ehrliche Auskunft darüber, wie viel gerade los ist. // -// ueberschreitung sagt, der wievielte Aufruf über der Grenze das war: 0 solange +// excess sagt, der wievielte Aufruf über der Grenze das war: 0 solange // alles im Rahmen ist, 1 beim allerersten abgelehnten Aufruf, dann aufsteigend. // // Dieser dritte Rückgabewert sieht nach Ballast aus, hat aber einen konkreten // Zweck: Der Aufrufer soll die Überschreitung protokollieren können, ohne bei // JEDEM abgelehnten Aufruf eine Zeile zu schreiben. Ein Agent in einer Schleife // erzeugt sonst genau die Datenflut, die das Budget verhindern soll — nur eben -// im Protokoll statt in den Einträgen. Mit ueberschreitung == 1 als Bedingung +// im Protokoll statt in den Einträgen. Mit excess == 1 als Bedingung // entsteht je Fenster genau ein Vermerk. -func (b *WriteBudget) Consume(projektID string, projektLimit int) (ok bool, retryAfter time.Duration, ueberschreitung int) { - grenze := b.grenzeFuer(projektLimit) - if grenze <= 0 { +func (b *WriteBudget) Consume(projectID string, projectLimit int) (ok bool, retryAfter time.Duration, excess int) { + limit := b.limitFor(projectLimit) + if limit <= 0 { return true, 0, 0 // Begrenzung abgeschaltet } - anzahl, restlaufzeit := b.zaehler.zaehle(projektID) - if anzahl > grenze { - return false, restlaufzeit, anzahl - grenze + count, remaining := b.counter.count(projectID) + if count > limit { + return false, remaining, count - limit } return true, 0, 0 } // Remaining sagt, wie viele Schreibvorgänge im laufenden Fenster noch frei sind, // ohne etwas zu verbrauchen. Für die Anzeige in der Verwaltung. -func (b *WriteBudget) Remaining(projektID string, projektLimit int) (frei int, grenze int) { - grenze = b.grenzeFuer(projektLimit) - if grenze <= 0 { +func (b *WriteBudget) Remaining(projectID string, projectLimit int) (free int, limit int) { + limit = b.limitFor(projectLimit) + if limit <= 0 { return 0, 0 } - anzahl, _ := b.zaehler.lies(projektID) - frei = grenze - anzahl - if frei < 0 { - frei = 0 + count, _ := b.counter.peek(projectID) + free = limit - count + if free < 0 { + free = 0 } - return frei, grenze + return free, limit } -// grenzeFuer löst die geltende Obergrenze auf: erst das Projekt, dann der Standard. -func (b *WriteBudget) grenzeFuer(projektLimit int) int { - if projektLimit > 0 { - return projektLimit +// limitFor löst die geltende Obergrenze auf: erst das Projekt, dann der Standard. +func (b *WriteBudget) limitFor(projectLimit int) int { + if projectLimit > 0 { + return projectLimit } return b.standard } diff --git a/internal/auth/session.go b/internal/auth/session.go index b943bd3..26a6bbb 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -20,8 +20,29 @@ import ( const SessionCookieName = "munin_session" // SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt. +// +// 30 Tage sind vertretbar, weil sich diese Sitzungen jederzeit beenden lassen: +// Bei Key und Workspace-Admin prüft der Server bei jedem Request in der +// Datenbank, ob der Zugang noch gilt — ein Widerruf wirkt sofort, auch auf +// einen gestohlenen Cookie. const SessionTTL = 30 * 24 * time.Hour +// PlatformAdminSessionTTL gilt für die Sitzung des Plattform-Admins. +// +// Kürzer, weil es hier keinen Widerruf in der Datenbank gibt: Ein gestohlener +// Cookie bliebe sonst bis zum Ablauf gültig — oder bis jemand das ADMIN_TOKEN +// tauscht. Und der Plattform-Admin kommt an alles. +const PlatformAdminSessionTTL = 12 * time.Hour + +// SessionTTLFor gibt die Laufzeit zurück, die eine Sitzung für dieses Subject +// höchstens haben darf. +func SessionTTLFor(subject string) time.Duration { + if strings.HasPrefix(subject, SubjectPlatformAdmin+"/") { + return PlatformAdminSessionTTL + } + return SessionTTL +} + // SubjectPlatformAdmin ist der Anfang des Session-Subjects des Plattform-Admins // (ADMIN_TOKEN). Das vollständige Subject baut PlatformSubject. const SubjectPlatformAdmin = "platform" @@ -69,43 +90,62 @@ func (s *Signer) sign(payload string) string { // wer ihn stiehlt, bekommt eine Sitzung, aber kein dauerhaftes Zugangsmittel für // die API. Und ob der zugehörige Key überhaupt noch gültig ist, prüft der Server // bei jedem Request neu in der Datenbank (siehe internal/web). -func (s *Signer) SignSession(subject string) string { - expiry := time.Now().Add(SessionTTL).Unix() - payload := fmt.Sprintf("%s|%d", subject, expiry) +// +// expiry ist der Ablaufzeitpunkt. Bei einer Anmeldung ist das jetzt plus +// SessionTTLFor(subject); wechselt der Plattform-Admin nur den Workspace, +// bleibt der bisherige Zeitpunkt (siehe setSessionCookie in internal/web). +func (s *Signer) SignSession(subject string, expiry time.Time) string { + payload := fmt.Sprintf("%s|%d", subject, expiry.Unix()) encoded := base64.RawURLEncoding.EncodeToString([]byte(payload)) return encoded + "." + s.sign(payload) } -// VerifySession prüft Signatur und Ablaufdatum und gibt das Subject zurück. -func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) { +// VerifySession prüft Signatur und Ablaufdatum und gibt Subject und +// Ablaufzeitpunkt zurück. +func (s *Signer) VerifySession(cookieValue string) (subject string, expiry time.Time, ok bool) { encoded, signature, found := strings.Cut(cookieValue, ".") if !found { - return "", false + return "", time.Time{}, false } payloadBytes, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { - return "", false + return "", time.Time{}, false } payload := string(payloadBytes) // Reihenfolge ist wichtig: erst die Signatur prüfen, dann den Inhalt auswerten. // Alles vor dieser Zeile stammt aus einer nicht vertrauenswürdigen Quelle. if subtle.ConstantTimeCompare([]byte(signature), []byte(s.sign(payload))) != 1 { - return "", false + return "", time.Time{}, false } subject, expiryStr, found := strings.Cut(payload, "|") if !found { - return "", false + return "", time.Time{}, false } - expiry, err := strconv.ParseInt(expiryStr, 10, 64) - if err != nil || time.Now().Unix() > expiry { - return "", false + unix, err := strconv.ParseInt(expiryStr, 10, 64) + if err != nil { + return "", time.Time{}, false + } + expiry = time.Unix(unix, 0) + + now := time.Now() + if now.After(expiry) { + return "", time.Time{}, false } - return subject, true + // Eine Sitzung, die länger gilt, als für ihre Art erlaubt ist, wird + // abgelehnt. Das erwischt Plattform-Admin-Cookies, die noch mit den + // früheren 30 Tagen ausgestellt wurden — die enden damit sofort, statt + // noch wochenlang zu gelten. (Eine Minute Spielraum für Uhren, die nicht + // ganz gleich gehen.) + if expiry.Sub(now) > SessionTTLFor(subject)+time.Minute { + return "", time.Time{}, false + } + + return subject, expiry, true } // Fingerprint leitet aus einem Wert einen kurzen, nicht umkehrbaren diff --git a/internal/dubletten/dubletten.go b/internal/duplicates/duplicates.go similarity index 65% rename from internal/dubletten/dubletten.go rename to internal/duplicates/duplicates.go index 21b32a1..5d74382 100644 --- a/internal/dubletten/dubletten.go +++ b/internal/duplicates/duplicates.go @@ -1,11 +1,11 @@ -// Package dubletten erkennt, ob ein neuer Eintrag einem vorhandenen ähnelt +// Package duplicates erkennt, ob ein neuer Eintrag einem vorhandenen ähnelt // (AUSBAUSTUFEN.md 3.3). // // Eigenes Paket, weil zwei Wege es brauchen: "remember" über MCP (fragt // zurück, statt anzulegen) und das Formular "Neuer Eintrag" der Weboberfläche // (zeigt beim Tippen „Gibt es schon?"). Beide sollen dieselbe Prüfung haben — // zwei getrennte würden mit der Zeit unterschiedlich urteilen. -package dubletten +package duplicates // Dubletten über Ähnlichkeit erkennen. // @@ -41,8 +41,8 @@ import ( ) const ( - // Schwelle ist der Mindestanteil gemeinsamer Titelwörter, ab dem - // ein vorhandener Eintrag als Kandidat gilt (siehe search.Ueberdeckung). + // Threshold ist der Mindestanteil gemeinsamer Titelwörter, ab dem + // ein vorhandener Eintrag als Kandidat gilt (siehe search.Coverage). // // 0,5 heißt: Die Hälfte der Wörter stimmt überein. „Deployment-Ablauf" // und „Deployment-Prozess" erreichen das gerade — genau der Fall, um den @@ -50,18 +50,18 @@ const ( // „Datenbank-Zugang". Ein solcher Fehlalarm kostet den Agenten einen // zweiten Aufruf mit force; eine übersehene Dublette kostet auf Dauer // mehr. - Schwelle = 0.5 + Threshold = 0.5 - // suchtiefe: So viele Suchtreffer werden genauer verglichen. + // searchDepth: So viele Suchtreffer werden genauer verglichen. // Die Suche findet Kandidaten, die Überdeckung entscheidet. - suchtiefe = 10 + searchDepth = 10 - // maxKandidaten: So viele Kandidaten gehen höchstens zurück. - maxKandidaten = 5 + // maxCandidates: So viele Kandidaten gehen höchstens zurück. + maxCandidates = 5 ) -// Kandidat ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt. -type Kandidat struct { +// Candidate ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt. +type Candidate struct { Slug string Title string Overlap int // Übereinstimmung in Prozent @@ -71,7 +71,7 @@ type Kandidat struct { MatchedWords []string } -// Pruefe sucht vorhandene Einträge, die einem neu anzulegenden ähneln. Ist +// Check sucht vorhandene Einträge, die einem neu anzulegenden ähneln. Ist // slug schon vergeben, wird aktualisiert statt angelegt — dann gibt es nichts // zu prüfen, und das Ergebnis ist leer. Ein leerer slug (das Formular, bevor // jemand einen Bezeichner eingetragen hat) überspringt diese Prüfung. @@ -80,11 +80,11 @@ type Kandidat struct { // Richtungen. So findet ein neuer „Release-Ablauf" den vorhandenen // „Deployment-Prozess", wenn dort „release" als Suchbegriff steht. Ein // Suchbegriff zählt dabei nur wörtlich, ein Titelwort auch mit Varianten -// (siehe search.UeberdeckungMitSuchbegriffen). -func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project, - slug, titel, inhalt string) ([]Kandidat, error) { +// (siehe search.CoverageWithSearchTerms). +func Check(ctx context.Context, ws *workspace.Workspace, project store.Project, + slug, title, content string) ([]Candidate, error) { - if strings.TrimSpace(titel) == "" { + if strings.TrimSpace(title) == "" { return nil, nil } if slug != "" { @@ -102,10 +102,10 @@ func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project, return nil, err } - neuerName := titel + " " + entry.AuchGesuchtAls(inhalt) + newName := title + " " + entry.AlsoSearchedAs(content) - var kandidaten []Kandidat - for _, t := range idx.Search(neuerName, suchtiefe) { + var candidates []Candidate + for _, t := range idx.Search(newName, searchDepth) { e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID) if errors.Is(err, store.ErrNotFound) { continue // Index kennt einen Eintrag, den es nicht mehr gibt @@ -115,51 +115,51 @@ func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project, } // Ein gemeinsames Präfix wie „Munin – " sagt nichts über das Thema, - // siehe ohneGemeinsamesPraefix. - themaNeu, themaVorhanden := ohneGemeinsamesPraefix(titel, e.Title) + // siehe withoutCommonPrefix. + topicNew, topicExisting := withoutCommonPrefix(title, e.Title) // In beide Richtungen, der höhere Wert zählt: „Deployment" (neu) ist // vollständig in „Deployment-Prozess" enthalten, umgekehrt nur zur // Hälfte. Beides ist ein Hinweis auf dasselbe Thema. - anteil, gemeinsam := search.UeberdeckungMitSuchbegriffen( - themaNeu, themaVorhanden, entry.AuchGesuchtAls(e.Content)) - rueckwaerts, gemeinsamRueckwaerts := search.UeberdeckungMitSuchbegriffen( - themaVorhanden, themaNeu, entry.AuchGesuchtAls(inhalt)) - if rueckwaerts > anteil { - anteil, gemeinsam = rueckwaerts, gemeinsamRueckwaerts + share, shared := search.CoverageWithSearchTerms( + topicNew, topicExisting, entry.AlsoSearchedAs(e.Content)) + backwards, sharedBackwards := search.CoverageWithSearchTerms( + topicExisting, topicNew, entry.AlsoSearchedAs(content)) + if backwards > share { + share, shared = backwards, sharedBackwards } - if anteil < Schwelle { + if share < Threshold { continue } - kandidaten = append(kandidaten, Kandidat{ + candidates = append(candidates, Candidate{ Slug: e.Slug, Title: e.Title, - Overlap: int(math.Round(anteil * 100)), - MatchedWords: gemeinsam, + Overlap: int(math.Round(share * 100)), + MatchedWords: shared, }) } // Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge // der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall. - sort.SliceStable(kandidaten, func(i, j int) bool { - return kandidaten[i].Overlap > kandidaten[j].Overlap + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].Overlap > candidates[j].Overlap }) - if len(kandidaten) > maxKandidaten { - kandidaten = kandidaten[:maxKandidaten] + if len(candidates) > maxCandidates { + candidates = candidates[:maxCandidates] } - return kandidaten, nil + return candidates, nil } -// titelTrenner trennt ein Präfix vom eigentlichen Thema eines Titels: +// titleSeparator trennt ein Präfix vom eigentlichen Thema eines Titels: // „Munin – Architektur", „Munin — Architektur", „Munin - Architektur", // „Munin: Architektur", „Munin | Architektur". // // Ein Bindestrich zählt nur mit Leerzeichen auf beiden Seiten. Ohne // Leerzeichen ist er Teil eines Worts — „Deployment-Ablauf" hat kein Präfix. -var titelTrenner = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`) +var titleSeparator = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`) -// ohneGemeinsamesPraefix entfernt ein Präfix, das beide Titel gemeinsam +// withoutCommonPrefix entfernt ein Präfix, das beide Titel gemeinsam // haben, und gibt die beiden Themen zurück. // // # Warum? @@ -175,28 +175,28 @@ var titelTrenner = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`) // verschiedene („Munin – Deployment" und „Hugin – Deployment") oder nur einer // eines, bleibt der volle Titel im Vergleich — dann kann das Präfix etwas // unterscheiden. Bleibt nach dem Präfix nichts übrig, ebenfalls. -func ohneGemeinsamesPraefix(neu, vorhanden string) (string, string) { - praefixNeu, themaNeu, okNeu := titelTeilen(neu) - praefixVorhanden, themaVorhanden, okVorhanden := titelTeilen(vorhanden) - if !okNeu || !okVorhanden || praefixNeu != praefixVorhanden { - return neu, vorhanden +func withoutCommonPrefix(newer, existing string) (string, string) { + prefixNew, topicNew, okNew := titleParts(newer) + prefixExisting, topicExisting, okExisting := titleParts(existing) + if !okNew || !okExisting || prefixNew != prefixExisting { + return newer, existing } - return themaNeu, themaVorhanden + return topicNew, topicExisting } -// titelTeilen trennt einen Titel am ersten Trenner in Präfix und Thema. Das +// titleParts trennt einen Titel am ersten Trenner in Präfix und Thema. Das // Präfix kommt vereinheitlicht zurück (klein, einfache Leerzeichen), damit // „Munin" und „munin" als dasselbe gelten. ok ist false, wenn es keinen // Trenner gibt oder eine Seite leer ist. -func titelTeilen(titel string) (praefix, thema string, ok bool) { - pos := titelTrenner.FindStringIndex(titel) +func titleParts(title string) (prefix, topic string, ok bool) { + pos := titleSeparator.FindStringIndex(title) if pos == nil { - return "", titel, false + return "", title, false } - praefix = strings.Join(strings.Fields(strings.ToLower(titel[:pos[0]])), " ") - thema = strings.TrimSpace(titel[pos[1]:]) - if praefix == "" || thema == "" { - return "", titel, false + prefix = strings.Join(strings.Fields(strings.ToLower(title[:pos[0]])), " ") + topic = strings.TrimSpace(title[pos[1]:]) + if prefix == "" || topic == "" { + return "", title, false } - return praefix, thema, true + return prefix, topic, true } diff --git a/internal/dubletten/dubletten_test.go b/internal/duplicates/duplicates_test.go similarity index 77% rename from internal/dubletten/dubletten_test.go rename to internal/duplicates/duplicates_test.go index 96835d2..977e680 100644 --- a/internal/dubletten/dubletten_test.go +++ b/internal/duplicates/duplicates_test.go @@ -1,14 +1,14 @@ -package dubletten +package duplicates import "testing" -// TestOhneGemeinsamesPraefix prüft, wann das Präfix eines Titels aus dem +// TestWithoutCommonPrefix prüft, wann das Präfix eines Titels aus dem // Dublettenvergleich herausfällt — nur wenn beide Titel dasselbe haben. -func TestOhneGemeinsamesPraefix(t *testing.T) { - faelle := []struct { - name string - neu, vorhanden string - themaNeu, themaVorhand string +func TestWithoutCommonPrefix(t *testing.T) { + cases := []struct { + name string + newer, existing string + topicNew, topicExisting string }{ {"gleiches Präfix mit Halbgeviertstrich", "Munin – Architektur", "Munin – Überblick", "Architektur", "Überblick"}, @@ -36,12 +36,12 @@ func TestOhneGemeinsamesPraefix(t *testing.T) { "Munin – ", "Munin – Überblick", "Munin – ", "Munin – Überblick"}, } - for _, f := range faelle { + for _, f := range cases { t.Run(f.name, func(t *testing.T) { - neu, vorhanden := ohneGemeinsamesPraefix(f.neu, f.vorhanden) - if neu != f.themaNeu || vorhanden != f.themaVorhand { + newer, existing := withoutCommonPrefix(f.newer, f.existing) + if newer != f.topicNew || existing != f.topicExisting { t.Errorf("ohneGemeinsamesPraefix(%q, %q) = (%q, %q), erwartet (%q, %q)", - f.neu, f.vorhanden, neu, vorhanden, f.themaNeu, f.themaVorhand) + f.newer, f.existing, newer, existing, f.topicNew, f.topicExisting) } }) } diff --git a/internal/entry/diff.go b/internal/entry/diff.go index 9a4f0f9..bfbd5af 100644 --- a/internal/entry/diff.go +++ b/internal/entry/diff.go @@ -33,14 +33,14 @@ const maxDiffCells = 4_000_000 // verschobener, aber unveränderter Absatz wird dabei nicht zwingend als // "gleich" erkannt, wenn sich in seiner Umgebung etwas geändert hat — für die // Frage "was hat sich bei diesem Schreibvorgang geändert" reicht das. -func DiffLines(alt, neu string) []DiffLine { - altZeilen := splitLines(alt) - neuZeilen := splitLines(neu) +func DiffLines(old, newer string) []DiffLine { + oldLines := splitLines(old) + newLines := splitLines(newer) - if len(altZeilen)*len(neuZeilen) > maxDiffCells { - return grobDiff(altZeilen, neuZeilen) + if len(oldLines)*len(newLines) > maxDiffCells { + return coarseDiff(oldLines, newLines) } - return lcsDiff(altZeilen, neuZeilen) + return lcsDiff(oldLines, newLines) } func splitLines(s string) []string { @@ -50,15 +50,15 @@ func splitLines(s string) []string { return strings.Split(s, "\n") } -// grobDiff dient als Rückfallebene für außergewöhnlich lange Einträge: keine +// coarseDiff dient als Rückfallebene für außergewöhnlich lange Einträge: keine // Zeilenzuordnung, nur der komplette alte Text als entfernt und der // komplette neue als hinzugefügt. -func grobDiff(altZeilen, neuZeilen []string) []DiffLine { - lines := make([]DiffLine, 0, len(altZeilen)+len(neuZeilen)) - for _, z := range altZeilen { +func coarseDiff(oldLines, newLines []string) []DiffLine { + lines := make([]DiffLine, 0, len(oldLines)+len(newLines)) + for _, z := range oldLines { lines = append(lines, DiffLine{Op: DiffRemove, Text: z}) } - for _, z := range neuZeilen { + for _, z := range newLines { lines = append(lines, DiffLine{Op: DiffAdd, Text: z}) } return lines diff --git a/internal/entry/diff_test.go b/internal/entry/diff_test.go index d898fec..eee6bcb 100644 --- a/internal/entry/diff_test.go +++ b/internal/entry/diff_test.go @@ -30,13 +30,13 @@ func TestDiffLinesIdentical(t *testing.T) { } func TestDiffLinesChangeInMiddle(t *testing.T) { - alt := "Zeile 1\nZeile 2\nZeile 3" - neu := "Zeile 1\nGeändert\nZeile 3" + old := "Zeile 1\nZeile 2\nZeile 3" + newer := "Zeile 1\nGeändert\nZeile 3" - got := formatDiff(DiffLines(alt, neu)) - erwartet := " Zeile 1\n- Zeile 2\n+ Geändert\n Zeile 3\n" - if got != erwartet { - t.Errorf("DiffLines =\n%s\nerwartet:\n%s", got, erwartet) + got := formatDiff(DiffLines(old, newer)) + want := " Zeile 1\n- Zeile 2\n+ Geändert\n Zeile 3\n" + if got != want { + t.Errorf("DiffLines =\n%s\nerwartet:\n%s", got, want) } } @@ -57,14 +57,14 @@ func TestDiffLinesEmptyOldContent(t *testing.T) { func TestDiffLinesFallsBackForHugeInput(t *testing.T) { // Erzwingt den grobDiff-Pfad ohne wirklich riesige Strings zu bauen: die // Grenze fällt auf Zeilenanzahl, nicht Textlänge. - altGross := make([]byte, 0, 2200*2) - neuGross := make([]byte, 0, 2200*2) + oldUpper := make([]byte, 0, 2200*2) + newUpper := make([]byte, 0, 2200*2) for i := 0; i < 2200; i++ { - altGross = append(altGross, 'a', '\n') - neuGross = append(neuGross, 'b', '\n') + oldUpper = append(oldUpper, 'a', '\n') + newUpper = append(newUpper, 'b', '\n') } - lines := DiffLines(string(altGross), string(neuGross)) + lines := DiffLines(string(oldUpper), string(newUpper)) for _, l := range lines { if l.Op == DiffEqual { t.Fatal("grobDiff sollte keine DiffEqual-Zeilen liefern") diff --git a/internal/entry/entry.go b/internal/entry/entry.go index ac2a371..50b8d98 100644 --- a/internal/entry/entry.go +++ b/internal/entry/entry.go @@ -27,18 +27,18 @@ import ( // Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf // verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres // Escaping einsetzt. -func RenderHTML(quelle string, linkBase string) (string, error) { +func RenderHTML(source string, linkBase string) (string, error) { // Verweise vor dem Rendern in Markdown-Links umschreiben. Danach wäre es zu // spät: goldmark hätte die eckigen Klammern dann schon als Text behandelt. // - // (Der Parameter heißt "quelle" statt "markdown", weil "markdown" jetzt der + // (Der Parameter heißt "source" statt "markdown", weil "markdown" jetzt der // Name des importierten Pakets ist — sonst wäre das Paket in dieser // Funktion verdeckt.) - vorbereitet := store.ReplaceLinks(quelle, func(slug string) string { + prepared := store.ReplaceLinks(source, func(slug string) string { return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug) }) - return markdown.Render(vorbereitet) + return markdown.Render(prepared) } // RenderPlain gibt den Inhalt als reinen Text zurück — Verweise werden auf ihren @@ -58,34 +58,34 @@ const snippetRadius = 120 func Snippet(content, query string) string { text := RenderPlain(content) - nadel := trefferwort(text, query) - if nadel == "" { + needle := hitWord(text, query) + if needle == "" { // Kein Wort der Anfrage steht wörtlich im Text (z.B. weil der Fund über // eine Endung oder ein Synonym kam) — dann der Anfang des Texts als // Vorschau. - return kuerzen(text, snippetRadius+snippetRadius) + return truncate(text, snippetRadius+snippetRadius) } - idx := strings.Index(strings.ToLower(text), strings.ToLower(nadel)) + idx := strings.Index(strings.ToLower(text), strings.ToLower(needle)) start := max(idx-snippetRadius, 0) - ende := min(idx+len(nadel)+snippetRadius, len(text)) + end := min(idx+len(needle)+snippetRadius, len(text)) - ausschnitt := strings.TrimSpace(text[start:ende]) + excerpt := strings.TrimSpace(text[start:end]) if start > 0 { - ausschnitt = "…" + ausschnitt + excerpt = "…" + excerpt } - if ende < len(text) { - ausschnitt += "…" + if end < len(text) { + excerpt += "…" } - return ausschnitt + return excerpt } -// minTrefferwort ist die Mindestlänge eines einzelnen Worts der Anfrage, an +// minHitWord ist die Mindestlänge eines einzelnen Worts der Anfrage, an // dem sich der Ausschnitt ausrichten darf. Kürzere Wörter („wie", „an") stehen // in fast jedem Text und sagen nichts über den Treffer. -const minTrefferwort = 4 +const minHitWord = 4 -// trefferwort bestimmt, an welcher Stelle der Ausschnitt ansetzt: das Stück +// hitWord bestimmt, an welcher Stelle der Ausschnitt ansetzt: das Stück // der Anfrage, das im Text vorkommt. "" heißt: nichts davon kommt vor. // // # Warum nicht einfach die ganze Anfrage? @@ -99,42 +99,42 @@ const minTrefferwort = 4 // genau eine Wortfolge), danach die einzelnen Wörter, längste zuerst. Die // Länge ist ein einfacher Ersatz für „aussagekräftig": „Zertifikatswechsel" // verrät mehr über die Fundstelle als „Dienst". -func trefferwort(text, query string) string { +func hitWord(text, query string) string { query = strings.TrimSpace(query) if query == "" { return "" } - klein := strings.ToLower(text) - if strings.Contains(klein, strings.ToLower(query)) { + lower := strings.ToLower(text) + if strings.Contains(lower, strings.ToLower(query)) { return query } // FieldsFunc zerlegt an jedem Zeichen, für das die Funktion true liefert — // hier an allem, was kein Buchstabe und keine Ziffer ist. - woerter := strings.FieldsFunc(query, func(r rune) bool { + words := strings.FieldsFunc(query, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) // Längste zuerst. SliceStable lässt gleich lange Wörter in der Reihenfolge // der Anfrage stehen — so ist das Ergebnis immer dasselbe. - sort.SliceStable(woerter, func(i, j int) bool { - return utf8.RuneCountInString(woerter[i]) > utf8.RuneCountInString(woerter[j]) + sort.SliceStable(words, func(i, j int) bool { + return utf8.RuneCountInString(words[i]) > utf8.RuneCountInString(words[j]) }) - for _, w := range woerter { - if utf8.RuneCountInString(w) < minTrefferwort { + for _, w := range words { + if utf8.RuneCountInString(w) < minHitWord { break // sortiert: alle folgenden sind noch kürzer } - if strings.Contains(klein, strings.ToLower(w)) { + if strings.Contains(lower, strings.ToLower(w)) { return w } } return "" } -// kuerzen schneidet einen Text auf höchstens n Zeichen. -func kuerzen(text string, n int) string { +// truncate schneidet einen Text auf höchstens n Zeichen. +func truncate(text string, n int) string { text = strings.TrimSpace(text) if len(text) <= n { return text @@ -148,34 +148,34 @@ func kuerzen(text string, n int) string { // Wichtig: Erst wird der Text escaped, dann werden die <mark>-Auszeichnungen // eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen. func HighlightSnippet(content, query string) string { - ausschnitt := Snippet(content, query) - sicher := html.EscapeString(ausschnitt) + excerpt := Snippet(content, query) + safe := html.EscapeString(excerpt) if query == "" { - return sicher + return safe } // Hervorgehoben wird dasselbe Stück der Anfrage, an dem sich der // Ausschnitt ausgerichtet hat. Gesucht wird im escapten Text nach dem // ebenfalls escapten Stück, damit die Positionen zusammenpassen. - treffer := trefferwort(ausschnitt, query) - if treffer == "" { - return sicher + hits := hitWord(excerpt, query) + if hits == "" { + return safe } - nadel := html.EscapeString(treffer) - idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel)) + needle := html.EscapeString(hits) + idx := strings.Index(strings.ToLower(safe), strings.ToLower(needle)) if idx == -1 { - return sicher + return safe } - return sicher[:idx] + "<mark>" + sicher[idx:idx+len(nadel)] + "</mark>" + sicher[idx+len(nadel):] + return safe[:idx] + "<mark>" + safe[idx:idx+len(needle)] + "</mark>" + safe[idx+len(needle):] } -// auchGesuchtAlsPraefix leitet die Zeile ein, in der ein Eintrag weitere +// alsoSearchedAsPrefix leitet die Zeile ein, in der ein Eintrag weitere // Suchbegriffe nennt (siehe instructions in internal/mcpserver). -const auchGesuchtAlsPraefix = "auch gesucht als" +const alsoSearchedAsPrefix = "auch gesucht als" -// AuchGesuchtAls gibt die Begriffe aus der Zeile „Auch gesucht als: …" eines +// AlsoSearchedAs gibt die Begriffe aus der Zeile „Auch gesucht als: …" eines // Eintrags zurück, so wie sie dort stehen. Ohne eine solche Zeile: "". // // Agenten schreiben die Zeile nicht immer gleich. Erkannt werden deshalb auch @@ -187,33 +187,33 @@ const auchGesuchtAlsPraefix = "auch gesucht als" // // Gibt es mehrere solcher Zeilen, zählt die letzte — die Anleitung bittet // darum, die Zeile ans Ende zu stellen. -func AuchGesuchtAls(content string) string { - ergebnis := "" - for _, zeile := range strings.Split(content, "\n") { +func AlsoSearchedAs(content string) string { + result := "" + for _, line := range strings.Split(content, "\n") { // Aufzählungszeichen, Zitatzeichen und Hervorhebungen am Anfang weg. - zeile = strings.TrimLeft(strings.TrimSpace(zeile), "-*_> ") - if !strings.HasPrefix(strings.ToLower(zeile), auchGesuchtAlsPraefix) { + line = strings.TrimLeft(strings.TrimSpace(line), "-*_> ") + if !strings.HasPrefix(strings.ToLower(line), alsoSearchedAsPrefix) { continue } - rest := zeile[len(auchGesuchtAlsPraefix):] + rest := line[len(alsoSearchedAsPrefix):] rest = strings.TrimLeft(rest, ":*_ ") - ergebnis = strings.TrimSpace(rest) + result = strings.TrimSpace(rest) } - return ergebnis + return result } // TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder // die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat. func TitleFromContent(content string) string { - for _, zeile := range strings.Split(content, "\n") { - zeile = strings.TrimSpace(zeile) - if zeile == "" { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { continue } - zeile = strings.TrimLeft(zeile, "#") - zeile = strings.TrimSpace(zeile) - if zeile != "" { - return kuerzen(zeile, 120) + line = strings.TrimLeft(line, "#") + line = strings.TrimSpace(line) + if line != "" { + return truncate(line, 120) } } return "Ohne Titel" diff --git a/internal/entry/entry_test.go b/internal/entry/entry_test.go index 2d33068..c19036b 100644 --- a/internal/entry/entry_test.go +++ b/internal/entry/entry_test.go @@ -12,19 +12,19 @@ import ( // einschleust, hängt damit allein daran, dass goldmark kein Roh-HTML durchreicht. // Wird diese Einstellung je gelockert, schlägt dieser Test an. func TestRenderHTMLEscapesRawHTML(t *testing.T) { - boesartig := []string{ + malicious := []string{ "<script>alert(1)</script>", "<img src=x onerror=alert(1)>", "<iframe src=\"https://fremd.example\"></iframe>", } - for _, eingabe := range boesartig { - html, err := RenderHTML(eingabe, "/p/test/e/") + for _, input := range malicious { + html, err := RenderHTML(input, "/p/test/e/") if err != nil { - t.Fatalf("RenderHTML(%q): %v", eingabe, err) + t.Fatalf("RenderHTML(%q): %v", input, err) } if strings.Contains(html, "<script") || strings.Contains(html, "onerror") || strings.Contains(html, "<iframe") { - t.Errorf("Roh-HTML kam durch: %q → %q", eingabe, html) + t.Errorf("Roh-HTML kam durch: %q → %q", input, html) } } } @@ -48,9 +48,9 @@ func TestRenderHTMLKeepsMarkdown(t *testing.T) { t.Fatal(err) } - for _, erwartet := range []string{"<h1", "<ul>", "<li>", "<code>"} { - if !strings.Contains(html, erwartet) { - t.Errorf("%s fehlt im Ergebnis: %s", erwartet, html) + for _, want := range []string{"<h1", "<ul>", "<li>", "<code>"} { + if !strings.Contains(html, want) { + t.Errorf("%s fehlt im Ergebnis: %s", want, html) } } } @@ -97,10 +97,10 @@ func TestSnippetWithoutMatch(t *testing.T) { } } -// TestSnippetBeiGanzerFrage prüft den Normalfall bei Agenten: Die Anfrage ist +// TestSnippetForWholeQuestion prüft den Normalfall bei Agenten: Die Anfrage ist // eine ganze Frage, die so nie im Text steht. Der Ausschnitt muss trotzdem an // der Fundstelle ansetzen, nicht am Textanfang. -func TestSnippetBeiGanzerFrage(t *testing.T) { +func TestSnippetForWholeQuestion(t *testing.T) { text := strings.Repeat("Vorlauf. ", 40) + "Der Zertifikatswechsel ist im März. " + strings.Repeat("Nachlauf. ", 40) got := Snippet(text, "wie läuft der Zertifikatswechsel") @@ -110,9 +110,9 @@ func TestSnippetBeiGanzerFrage(t *testing.T) { } } -// TestSnippetLaengstesWortZuerst prüft die Wahl zwischen mehreren Wörtern: +// TestSnippetLongestWordFirst prüft die Wahl zwischen mehreren Wörtern: // Das längere, aussagekräftigere gewinnt, auch wenn das kürzere früher steht. -func TestSnippetLaengstesWortZuerst(t *testing.T) { +func TestSnippetLongestWordFirst(t *testing.T) { text := "Dienst. " + strings.Repeat("Füllwort. ", 60) + "Kubernetes-Cluster hier." got := Snippet(text, "dienst kubernetes") @@ -122,10 +122,10 @@ func TestSnippetLaengstesWortZuerst(t *testing.T) { } } -func TestTrefferwort(t *testing.T) { +func TestHitWord(t *testing.T) { text := "Wir deployen über GitHub Actions." - faelle := map[string]string{ + cases := map[string]string{ "GitHub Actions": "GitHub Actions", // ganze Anfrage kommt vor "wie deployen wir": "deployen", "wie wir": "", // nur kurze Wörter @@ -134,16 +134,16 @@ func TestTrefferwort(t *testing.T) { "actions, github?": "actions", // gleich lang: Reihenfolge der Anfrage } - for anfrage, erwartet := range faelle { - if got := trefferwort(text, anfrage); got != erwartet { - t.Errorf("trefferwort(%q) = %q, erwartet %q", anfrage, got, erwartet) + for query, want := range cases { + if got := hitWord(text, query); got != want { + t.Errorf("trefferwort(%q) = %q, erwartet %q", query, got, want) } } } -// TestHighlightSnippetEinzelwort prüft, dass bei einer ganzen Frage das Wort +// TestHighlightSnippetSingleWord prüft, dass bei einer ganzen Frage das Wort // hervorgehoben wird, an dem sich der Ausschnitt ausrichtet. -func TestHighlightSnippetEinzelwort(t *testing.T) { +func TestHighlightSnippetSingleWord(t *testing.T) { got := HighlightSnippet("Wir deployen über GitHub Actions.", "wie deployen wir") if !strings.Contains(got, "<mark>deployen</mark>") { @@ -200,16 +200,16 @@ func TestTitleFromContent(t *testing.T) { " \n \n": "Ohne Titel", } - for eingabe, erwartet := range cases { - if got := TitleFromContent(eingabe); got != erwartet { - t.Errorf("TitleFromContent(%q) = %q, erwartet %q", eingabe, got, erwartet) + for input, want := range cases { + if got := TitleFromContent(input); got != want { + t.Errorf("TitleFromContent(%q) = %q, erwartet %q", input, got, want) } } } func TestTitleFromContentIsShortened(t *testing.T) { - lang := strings.Repeat("Wort ", 100) - got := TitleFromContent(lang) + long := strings.Repeat("Wort ", 100) + got := TitleFromContent(long) if len(got) > 130 { t.Errorf("der Titel ist %d Zeichen lang — er wurde nicht gekürzt", len(got)) @@ -218,8 +218,8 @@ func TestTitleFromContentIsShortened(t *testing.T) { // ── Auch gesucht als ───────────────────────────────────────────────────────── -func TestAuchGesuchtAls(t *testing.T) { - faelle := map[string]string{ +func TestAlsoSearchedAs(t *testing.T) { + cases := map[string]string{ "Text.\n\nAuch gesucht als: release, rollout": "release, rollout", "Text.\n*Auch gesucht als:* release": "release", "- **Auch gesucht als:** release, auslieferung": "release, auslieferung", @@ -229,9 +229,9 @@ func TestAuchGesuchtAls(t *testing.T) { "Wir haben auch gesucht als Team.": "", // nicht am Zeilenanfang } - for inhalt, erwartet := range faelle { - if got := AuchGesuchtAls(inhalt); got != erwartet { - t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", inhalt, got, erwartet) + for content, want := range cases { + if got := AlsoSearchedAs(content); got != want { + t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", content, got, want) } } } diff --git a/internal/export/export.go b/internal/export/export.go index fecac36..496c819 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -67,38 +67,38 @@ type Exporter struct { // letzter ist der zuletzt abgeschlossene Lauf über alle Workspaces — // für die Systemseite. Der Mutex schützt ihn, weil der tägliche Lauf in // einer eigenen Goroutine schreibt, während die Seite liest. - mu sync.Mutex - letzter Lauf + mu sync.Mutex + last Run } -// Intervall bestimmt, wie oft der Markdown-Export automatisch läuft (siehe +// Interval bestimmt, wie oft der Markdown-Export automatisch läuft (siehe // main.go). Die Systemseite meldet einen Export als überfällig, wenn der // letzte Lauf deutlich länger zurückliegt. -const Intervall = 24 * time.Hour +const Interval = 24 * time.Hour -// Lauf beschreibt einen Export über alle Workspaces. -type Lauf struct { - Ende time.Time // Nullzeitpunkt = seit dem Start noch keiner fertig - Dauer time.Duration - Stats Stats - Fehler string // leer = vollständig +// Run beschreibt einen Export über alle Workspaces. +type Run struct { + End time.Time // Nullzeitpunkt = seit dem Start noch keiner fertig + Duration time.Duration + Stats Stats + Err string // leer = vollständig } -// MerkeLauf hält das Ergebnis eines Laufs über alle Workspaces fest. Den +// RecordRun hält das Ergebnis eines Laufs über alle Workspaces fest. Den // Knopf "Exportieren" für einen einzelnen Workspace merkt sich der Exporter // nicht: Er sagt nichts darüber, ob die übrigen gesichert sind. -func (e *Exporter) MerkeLauf(l Lauf) { +func (e *Exporter) RecordRun(l Run) { e.mu.Lock() defer e.mu.Unlock() - e.letzter = l + e.last = l } -// LetzterLauf liefert den zuletzt gemerkten Lauf (Nullwert, wenn noch keiner +// LastRun liefert den zuletzt gemerkten Lauf (Nullwert, wenn noch keiner // fertig ist). -func (e *Exporter) LetzterLauf() Lauf { +func (e *Exporter) LastRun() Run { e.mu.Lock() defer e.mu.Unlock() - return e.letzter + return e.last } // New erstellt einen Exporter, der unter root schreibt. @@ -123,42 +123,42 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st var stats Stats // Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim - // Projekt-Bezeichner (siehe projektDateien). IDs aus store.NewID bestehen + // Projekt-Bezeichner (siehe projectFiles). IDs aus store.NewID bestehen // nur aus Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese // Prüfung. if !store.ValidSlug(project.WorkspaceID) { return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID) } - dateien, err := projektDateien(ctx, s, project) + files, err := projectFiles(ctx, s, project) if err != nil { return stats, err } - ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug) - if err := os.MkdirAll(ziel, 0750); err != nil { + target := filepath.Join(e.root, project.WorkspaceID, project.Slug) + if err := os.MkdirAll(target, 0750); err != nil { return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) } // Namen merken, um danach zu wissen, was übrig bleibt. - erwartet := make(map[string]bool, len(dateien)) - for _, d := range dateien { + want := make(map[string]bool, len(files)) + for _, d := range files { // Im Ordner des Projekts zählt nur der Dateiname, ohne den // Projektordner davor. - name := filepath.Base(d.Pfad) - erwartet[name] = true + name := filepath.Base(d.Path) + want[name] = true - if err := schreibeDatei(filepath.Join(ziel, name), d.Inhalt); err != nil { + if err := writeFile(filepath.Join(target, name), d.Content); err != nil { return stats, err } stats.Written++ } - entfernt, err := raeumeAuf(ziel, erwartet) + removed, err := cleanup(target, want) if err != nil { return stats, err } - stats.Removed = entfernt + stats.Removed = removed return stats, nil } @@ -166,41 +166,41 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st // ExportWorkspace exportiert alle Projekte eines Workspace. s muss die // Workspace-Datei von ws sein. func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store.Workspace) (Stats, error) { - var gesamt Stats + var total Stats if !store.ValidSlug(ws.ID) { - return gesamt, fmt.Errorf("ungültige Workspace-ID %q", ws.ID) + return total, fmt.Errorf("ungültige Workspace-ID %q", ws.ID) } - ordner := filepath.Join(e.root, ws.ID) - if err := os.MkdirAll(ordner, 0750); err != nil { - return gesamt, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) + dir := filepath.Join(e.root, ws.ID) + if err := os.MkdirAll(dir, 0750); err != nil { + return total, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) } // Ohne diese Datei wäre der Ordner nur eine ID — niemand wüsste beim // Wiederherstellen, welche Abteilung darin steckt. - if err := schreibeDatei(filepath.Join(ordner, workspaceDateiname), rendereWorkspace(ws)); err != nil { - return gesamt, err + if err := writeFile(filepath.Join(dir, workspaceFilename), renderWorkspace(ws)); err != nil { + return total, err } - gesamt.Written++ + total.Written++ - projekte, err := s.ListProjects(ctx, ws.ID) + projects, err := s.ListProjects(ctx, ws.ID) if err != nil { - return gesamt, err + return total, err } - for _, p := range projekte { + for _, p := range projects { stats, err := e.ExportProject(ctx, s, p) if err != nil { - return gesamt, fmt.Errorf("Projekt %q: %w", p.Slug, err) + return total, fmt.Errorf("Projekt %q: %w", p.Slug, err) } - gesamt.Written += stats.Written - gesamt.Removed += stats.Removed + total.Written += stats.Written + total.Removed += stats.Removed } - return gesamt, nil + return total, nil } -// projektDateien stellt alle Dateien eines Projekts zusammen, ohne etwas zu +// projectFiles stellt alle Dateien eines Projekts zusammen, ohne etwas zu // schreiben: je Eintrag eine Datei und die Übersicht _index.md, alle unter // "<projekt-slug>/". // @@ -208,7 +208,7 @@ func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store // hierauf auf. So legt nur eine Stelle fest, wie ein Export aussieht — und // ein heruntergeladenes ZIP passt genauso in den Import wie ein Ordner aus // EXPORT_DIR. -func projektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) { +func projectFiles(ctx context.Context, s *store.Store, project store.Project) ([]File, error) { // Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe // store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier // noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts @@ -218,51 +218,51 @@ func projektDateien(ctx context.Context, s *store.Store, project store.Project) return nil, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug) } - eintraege, err := s.AllEntries(ctx, project.ID) + entries, err := s.AllEntries(ctx, project.ID) if err != nil { return nil, err } - dateien := make([]Datei, 0, len(eintraege)+1) - for _, eintrag := range eintraege { - if !store.ValidSlug(eintrag.Slug) { - return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug) + files := make([]File, 0, len(entries)+1) + for _, entry := range entries { + if !store.ValidSlug(entry.Slug) { + return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", entry.Slug, project.Slug) } - dateien = append(dateien, Datei{ - Pfad: project.Slug + "/" + eintrag.Slug + ".md", - Inhalt: rendere(eintrag), - Geaendert: eintrag.UpdatedAt, + files = append(files, File{ + Path: project.Slug + "/" + entry.Slug + ".md", + Content: render(entry), + Modified: entry.UpdatedAt, }) } // Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist. - dateien = append(dateien, Datei{ - Pfad: project.Slug + "/" + indexDateiname, - Inhalt: rendereIndex(project, eintraege), + files = append(files, File{ + Path: project.Slug + "/" + indexFilename, + Content: renderIndex(project, entries), }) - return dateien, nil + return files, nil } -// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird. -const indexDateiname = "_index.md" +// indexFilename ist die Übersicht, die je Projekt mit erzeugt wird. +const indexFilename = "_index.md" -// workspaceDateiname nennt den Workspace, zu dem ein ID-Ordner gehört. -const workspaceDateiname = "_workspace.md" +// workspaceFilename nennt den Workspace, zu dem ein ID-Ordner gehört. +const workspaceFilename = "_workspace.md" -// rendereWorkspace baut den Inhalt von _workspace.md. Wie beim Index ohne +// renderWorkspace baut den Inhalt von _workspace.md. Wie beim Index ohne // Zeitstempel des Laufs, damit ein unveränderter Workspace keinen Diff erzeugt. -func rendereWorkspace(ws store.Workspace) string { +func renderWorkspace(ws store.Workspace) string { var b strings.Builder b.WriteString("---\n") - schreibeFeld(&b, "id", ws.ID) - schreibeFeld(&b, "name", ws.Name) + writeField(&b, "id", ws.ID) + writeField(&b, "name", ws.Name) b.WriteString("---\n\n") b.WriteString("# Workspace " + ws.Name + "\n") return b.String() } -// schreibeDatei legt eine Datei an — nur, wenn sich der Inhalt geändert hat. +// writeFile legt eine Datei an — nur, wenn sich der Inhalt geändert hat. // // # Warum der Vergleich? // @@ -270,69 +270,69 @@ func rendereWorkspace(ws store.Workspace) string { // geschrieben, änderte sich zwar der Inhalt nicht, wohl aber die Änderungszeit — // und Werkzeuge, die darauf schauen, meldeten ständig Bewegung, wo keine ist. // Git selbst stört das nicht, aber Sicherungsprogramme und Dateiüberwachungen schon. -func schreibeDatei(pfad, inhalt string) error { - if vorhanden, err := os.ReadFile(pfad); err == nil && string(vorhanden) == inhalt { +func writeFile(filePath, content string) error { + if existing, err := os.ReadFile(filePath); err == nil && string(existing) == content { return nil } - if err := os.WriteFile(pfad, []byte(inhalt), 0640); err != nil { - return fmt.Errorf("%s konnte nicht geschrieben werden: %w", pfad, err) + if err := os.WriteFile(filePath, []byte(content), 0640); err != nil { + return fmt.Errorf("%s konnte nicht geschrieben werden: %w", filePath, err) } return nil } -// raeumeAuf entfernt Markdown-Dateien, die zu keinem Eintrag mehr gehören. +// cleanup entfernt Markdown-Dateien, die zu keinem Eintrag mehr gehören. // // Es werden ausschließlich .md-Dateien angefasst und keine Unterordner — falls // jemand eigene Notizen daneben legt, bleiben sie unberührt. -func raeumeAuf(ordner string, erwartet map[string]bool) (int, error) { - eintraege, err := os.ReadDir(ordner) +func cleanup(dir string, want map[string]bool) (int, error) { + entries, err := os.ReadDir(dir) if err != nil { return 0, fmt.Errorf("Exportordner konnte nicht gelesen werden: %w", err) } - var entfernt int - for _, e := range eintraege { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || erwartet[e.Name()] { + var removed int + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || want[e.Name()] { continue } - if err := os.Remove(filepath.Join(ordner, e.Name())); err != nil { - return entfernt, fmt.Errorf("%s konnte nicht entfernt werden: %w", e.Name(), err) + if err := os.Remove(filepath.Join(dir, e.Name())); err != nil { + return removed, fmt.Errorf("%s konnte nicht entfernt werden: %w", e.Name(), err) } - entfernt++ + removed++ } - return entfernt, nil + return removed, nil } -// rendere baut den Dateiinhalt eines Eintrags: YAML-Kopf plus Markdown. +// render baut den Dateiinhalt eines Eintrags: YAML-Kopf plus Markdown. // // Der Kopf enthält alle Felder, damit sich der Bestand daraus auch wieder // einlesen ließe — nicht nur Titel und Text. -func rendere(e store.Entry) string { +func render(e store.Entry) string { var b strings.Builder b.WriteString("---\n") - schreibeFeld(&b, "slug", e.Slug) - schreibeFeld(&b, "title", e.Title) + writeField(&b, "slug", e.Slug) + writeField(&b, "title", e.Title) if len(e.Tags) > 0 { b.WriteString("tags: [") for i, t := range e.Tags { if i > 0 { b.WriteString(", ") } - b.WriteString(yamlWert(t)) + b.WriteString(yamlValue(t)) } b.WriteString("]\n") } - schreibeFeld(&b, "source", e.Source) + writeField(&b, "source", e.Source) if e.Author != "" { - schreibeFeld(&b, "author", e.Author) + writeField(&b, "author", e.Author) } b.WriteString("curated: " + strconv.FormatBool(e.Curated) + "\n") b.WriteString("version: " + strconv.Itoa(e.Version) + "\n") - schreibeFeld(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339)) - schreibeFeld(&b, "updated_at", e.UpdatedAt.UTC().Format(time.RFC3339)) + writeField(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339)) + writeField(&b, "updated_at", e.UpdatedAt.UTC().Format(time.RFC3339)) b.WriteString("---\n\n") b.WriteString("# " + e.Title + "\n\n") @@ -342,17 +342,17 @@ func rendere(e store.Entry) string { return b.String() } -// rendereIndex baut die Übersicht eines Projekts, nach Schlagwort gruppiert. +// renderIndex baut die Übersicht eines Projekts, nach Schlagwort gruppiert. // // Bewusst OHNE Zeitstempel im Kopf: Er würde sich bei jedem Lauf ändern und // damit auch dann einen Git-Diff erzeugen, wenn sich inhaltlich nichts getan // hat. Wann exportiert wurde, sagen der Git-Commit und die Änderungszeit der // Datei ohnehin — nur eben nur dann, wenn es etwas zu sagen gab. -func rendereIndex(project store.Project, eintraege []store.Entry) string { +func renderIndex(project store.Project, entries []store.Entry) string { var b strings.Builder b.WriteString("---\n") - schreibeFeld(&b, "title", project.Name+" — Übersicht") + writeField(&b, "title", project.Name+" — Übersicht") b.WriteString("---\n\n") b.WriteString("# " + project.Name + "\n\n") @@ -360,40 +360,40 @@ func rendereIndex(project store.Project, eintraege []store.Entry) string { b.WriteString(project.Description + "\n\n") } b.WriteString(fmt.Sprintf("%d Einträge. Automatisch erzeugt — Änderungen hier wirken nicht zurück.\n\n", - len(eintraege))) + len(entries))) // Nach erstem Schlagwort gruppieren; Einträge ohne Schlagwort ans Ende. - const ohne = "Ohne Schlagwort" - gruppen := make(map[string][]store.Entry) - for _, e := range eintraege { - kategorie := ohne + const without = "Ohne Schlagwort" + groups := make(map[string][]store.Entry) + for _, e := range entries { + category := without if len(e.Tags) > 0 { - kategorie = e.Tags[0] + category = e.Tags[0] } - gruppen[kategorie] = append(gruppen[kategorie], e) + groups[category] = append(groups[category], e) } - kategorien := make([]string, 0, len(gruppen)) - for k := range gruppen { - kategorien = append(kategorien, k) + categories := make([]string, 0, len(groups)) + for k := range groups { + categories = append(categories, k) } - sort.Slice(kategorien, func(i, j int) bool { - if kategorien[i] == ohne { + sort.Slice(categories, func(i, j int) bool { + if categories[i] == without { return false } - if kategorien[j] == ohne { + if categories[j] == without { return true } - return kategorien[i] < kategorien[j] + return categories[i] < categories[j] }) - for _, kategorie := range kategorien { - b.WriteString("## " + kategorie + "\n\n") + for _, category := range categories { + b.WriteString("## " + category + "\n\n") - liste := gruppen[kategorie] - sort.Slice(liste, func(i, j int) bool { return liste[i].Title < liste[j].Title }) + list := groups[category] + sort.Slice(list, func(i, j int) bool { return list[i].Title < list[j].Title }) - for _, e := range liste { + for _, e := range list { b.WriteString(fmt.Sprintf("- [%s](%s.md)", e.Title, e.Slug)) if e.Curated { b.WriteString(" ✓") @@ -409,19 +409,19 @@ func rendereIndex(project store.Project, eintraege []store.Entry) string { return b.String() } -// schreibeFeld hängt ein YAML-Feld an. -func schreibeFeld(b *strings.Builder, name, wert string) { - b.WriteString(name + ": " + yamlWert(wert) + "\n") +// writeField hängt ein YAML-Feld an. +func writeField(b *strings.Builder, name, value string) { + b.WriteString(name + ": " + yamlValue(value) + "\n") } -// yamlWert bringt einen Wert in eine sichere YAML-Schreibweise. +// yamlValue bringt einen Wert in eine sichere YAML-Schreibweise. // // Immer in doppelte Anführungszeichen und die enthaltenen maskieren: Damit ist // egal, ob der Wert einen Doppelpunkt, ein Rautezeichen oder einen Zeilenumbruch // enthält. Ohne diese Vorsicht würde ein Titel wie "Fehler: Zugriff verweigert" // die Datei unlesbar machen. -func yamlWert(s string) string { - ersetzt := strings.NewReplacer( +func yamlValue(s string) string { + replaced := strings.NewReplacer( `\`, `\\`, `"`, `\"`, "\n", `\n`, @@ -429,5 +429,5 @@ func yamlWert(s string) string { "\t", `\t`, ).Replace(s) - return `"` + ersetzt + `"` + return `"` + replaced + `"` } diff --git a/internal/export/export_test.go b/internal/export/export_test.go index 8cc4843..0d0c434 100644 --- a/internal/export/export_test.go +++ b/internal/export/export_test.go @@ -10,8 +10,8 @@ import ( "munin/internal/store" ) -// aufbau legt Datenbank, Projekt und Exporter an. -func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) { +// setup legt Datenbank, Projekt und Exporter an. +func setup(t *testing.T) (*Exporter, *store.Store, store.Project, string) { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) @@ -25,7 +25,7 @@ func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) { // der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für // den Test reicht eine beliebige ID. workspaceID := store.NewID() - projekt, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams") + project, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams") if err != nil { t.Fatal(err) } @@ -34,33 +34,33 @@ func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) { // gleich der Workspace-Ordner, damit die Tests Pfade wie // ziel/plattform/eintrag.md prüfen können, ohne die ID zu kennen. root := t.TempDir() - return New(root), db, projekt, filepath.Join(root, workspaceID) + return New(root), db, project, filepath.Join(root, workspaceID) } -// lies gibt den Inhalt einer exportierten Datei zurück. -func lies(t *testing.T, teile ...string) string { +// readFile gibt den Inhalt einer exportierten Datei zurück. +func readFile(t *testing.T, parts ...string) string { t.Helper() - inhalt, err := os.ReadFile(filepath.Join(teile...)) + content, err := os.ReadFile(filepath.Join(parts...)) if err != nil { t.Fatalf("Datei nicht lesbar: %v", err) } - return string(inhalt) + return string(content) } func TestExportWritesEntries(t *testing.T) { - e, db, projekt, ziel := aufbau(t) + e, db, project, target := setup(t) ctx := context.Background() if _, err := db.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "deployment", Title: "Deployment-Prozess", + ProjectID: project.ID, Slug: "deployment", Title: "Deployment-Prozess", Content: "Wir deployen über GitHub Actions.", Tags: []string{"ops", "deployment"}, Source: store.SourceAgent, Author: "claude", Curated: true, }); err != nil { t.Fatal(err) } - stats, err := e.ExportProject(ctx, db, projekt) + stats, err := e.ExportProject(ctx, db, project) if err != nil { t.Fatalf("Export: %v", err) } @@ -69,11 +69,11 @@ func TestExportWritesEntries(t *testing.T) { t.Errorf("%d Dateien geschrieben, erwartet 2", stats.Written) } - inhalt := lies(t, ziel, "plattform", "deployment.md") + content := readFile(t, target, "plattform", "deployment.md") // Der Kopf muss alle Felder tragen, damit sich der Bestand daraus auch // wieder einlesen ließe. - for _, erwartet := range []string{ + for _, want := range []string{ `slug: "deployment"`, `title: "Deployment-Prozess"`, `tags: ["deployment", "ops"]`, @@ -84,12 +84,12 @@ func TestExportWritesEntries(t *testing.T) { "created_at:", "updated_at:", } { - if !strings.Contains(inhalt, erwartet) { - t.Errorf("%q fehlt im Dateikopf:\n%s", erwartet, inhalt) + if !strings.Contains(content, want) { + t.Errorf("%q fehlt im Dateikopf:\n%s", want, content) } } - if !strings.Contains(inhalt, "Wir deployen über GitHub Actions.") { + if !strings.Contains(content, "Wir deployen über GitHub Actions.") { t.Error("der Inhalt fehlt") } } @@ -99,32 +99,32 @@ func TestExportWritesEntries(t *testing.T) { // Ein Titel wie "Fehler: Zugriff verweigert" enthält einen Doppelpunkt und würde // ohne Anführungszeichen die YAML-Struktur zerstören. func TestExportEscapesYAML(t *testing.T) { - e, db, projekt, ziel := aufbau(t) + e, db, project, target := setup(t) ctx := context.Background() - heikel := `Fehler: "Zugriff verweigert" — siehe #42` + sensitive := `Fehler: "Zugriff verweigert" — siehe #42` if _, err := db.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "heikel", Title: heikel, + ProjectID: project.ID, Slug: "heikel", Title: sensitive, Content: "Text", Source: store.SourceHuman, }); err != nil { t.Fatal(err) } - if _, err := e.ExportProject(ctx, db, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, project); err != nil { t.Fatal(err) } - inhalt := lies(t, ziel, "plattform", "heikel.md") + content := readFile(t, target, "plattform", "heikel.md") // Der Wert muss in Anführungszeichen stehen und die enthaltenen maskiert sein. - if !strings.Contains(inhalt, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) { - t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", inhalt) + if !strings.Contains(content, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) { + t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", content) } // Der Kopf muss weiterhin genau zwei Trennlinien haben. - if anzahl := strings.Count(inhalt, "\n---\n"); anzahl != 1 { - kopfEnde := strings.Index(inhalt[4:], "---") - if kopfEnde == -1 { + if count := strings.Count(content, "\n---\n"); count != 1 { + headEnd := strings.Index(content[4:], "---") + if headEnd == -1 { t.Error("der Dateikopf wurde nicht sauber abgeschlossen") } } @@ -133,37 +133,37 @@ func TestExportEscapesYAML(t *testing.T) { // TestExportRemovesOrphans prüft, dass gelöschte Einträge auch aus dem Export // verschwinden — sonst wäre die Sicherung nach kurzer Zeit voller Karteileichen. func TestExportRemovesOrphans(t *testing.T) { - e, db, projekt, ziel := aufbau(t) + e, db, project, target := setup(t) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "bleibt", Title: "Bleibt", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "bleibt", Title: "Bleibt", Content: "x", Source: store.SourceHuman}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "geht", Title: "Geht", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "geht", Title: "Geht", Content: "x", Source: store.SourceHuman}) - if _, err := e.ExportProject(ctx, db, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, project); err != nil { t.Fatal(err) } - if _, err := os.Stat(filepath.Join(ziel, "plattform", "geht.md")); err != nil { + if _, err := os.Stat(filepath.Join(target, "plattform", "geht.md")); err != nil { t.Fatal("die Datei wurde gar nicht erst angelegt") } // Ein Eintrag im Papierkorb gehört nicht mehr in den Export. - if err := db.MoveEntryToTrash(ctx, projekt.ID, "geht", "test"); err != nil { + if err := db.MoveEntryToTrash(ctx, project.ID, "geht", "test"); err != nil { t.Fatal(err) } - stats, err := e.ExportProject(ctx, db, projekt) + stats, err := e.ExportProject(ctx, db, project) if err != nil { t.Fatal(err) } if stats.Removed != 1 { t.Errorf("%d Dateien entfernt, erwartet 1", stats.Removed) } - if _, err := os.Stat(filepath.Join(ziel, "plattform", "geht.md")); err == nil { + if _, err := os.Stat(filepath.Join(target, "plattform", "geht.md")); err == nil { t.Error("die Datei des gelöschten Eintrags liegt noch da") } - if _, err := os.Stat(filepath.Join(ziel, "plattform", "bleibt.md")); err != nil { + if _, err := os.Stat(filepath.Join(target, "plattform", "bleibt.md")); err != nil { t.Error("beim Aufräumen wurde zu viel gelöscht") } } @@ -171,25 +171,25 @@ func TestExportRemovesOrphans(t *testing.T) { // TestExportLeavesForeignFilesAlone prüft, dass eigene Dateien im Zielordner // nicht angetastet werden. func TestExportLeavesForeignFilesAlone(t *testing.T) { - e, db, projekt, ziel := aufbau(t) + e, db, project, target := setup(t) ctx := context.Background() - ordner := filepath.Join(ziel, "plattform") - if err := os.MkdirAll(ordner, 0750); err != nil { + dir := filepath.Join(target, "plattform") + if err := os.MkdirAll(dir, 0750); err != nil { t.Fatal(err) } // Eine Nicht-Markdown-Datei und ein Unterordner müssen überleben. - os.WriteFile(filepath.Join(ordner, "notizen.txt"), []byte("privat"), 0640) - os.MkdirAll(filepath.Join(ordner, "anhaenge"), 0750) + os.WriteFile(filepath.Join(dir, "notizen.txt"), []byte("privat"), 0640) + os.MkdirAll(filepath.Join(dir, "anhaenge"), 0750) - if _, err := e.ExportProject(ctx, db, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, project); err != nil { t.Fatal(err) } - if _, err := os.Stat(filepath.Join(ordner, "notizen.txt")); err != nil { + if _, err := os.Stat(filepath.Join(dir, "notizen.txt")); err != nil { t.Error("eine fremde Datei wurde entfernt") } - if _, err := os.Stat(filepath.Join(ordner, "anhaenge")); err != nil { + if _, err := os.Stat(filepath.Join(dir, "anhaenge")); err != nil { t.Error("ein Unterordner wurde entfernt") } } @@ -197,31 +197,31 @@ func TestExportLeavesForeignFilesAlone(t *testing.T) { // TestExportIsStable prüft, dass ein zweiter Lauf ohne Änderungen die Dateien // nicht anfasst — sonst meldeten Sicherungswerkzeuge ständig Bewegung. func TestExportIsStable(t *testing.T) { - e, db, projekt, ziel := aufbau(t) + e, db, project, target := setup(t) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "eintrag", Title: "Eintrag", Content: "Inhalt", Source: store.SourceHuman}) - if _, err := e.ExportProject(ctx, db, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, project); err != nil { t.Fatal(err) } - pfad := filepath.Join(ziel, "plattform", "eintrag.md") - vorher, err := os.Stat(pfad) + filePath := filepath.Join(target, "plattform", "eintrag.md") + before, err := os.Stat(filePath) if err != nil { t.Fatal(err) } - if _, err := e.ExportProject(ctx, db, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, project); err != nil { t.Fatal(err) } - nachher, err := os.Stat(pfad) + after, err := os.Stat(filePath) if err != nil { t.Fatal(err) } - if !vorher.ModTime().Equal(nachher.ModTime()) { + if !before.ModTime().Equal(after.ModTime()) { t.Error("die unveränderte Datei wurde neu geschrieben") } } @@ -232,58 +232,58 @@ func TestExportIsStable(t *testing.T) { // Für eine Git-Sicherung ist das der entscheidende Punkt: Ein Lauf ohne // Änderungen darf keinen Diff erzeugen. func TestExportProducesNoDiffWhenUnchanged(t *testing.T) { - e, db, projekt, ziel := aufbau(t) + e, db, project, target := setup(t) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "eintrag", Title: "Eintrag", Content: "Inhalt", Tags: []string{"ops"}, Source: store.SourceHuman}) - if _, err := e.ExportProject(ctx, db, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, project); err != nil { t.Fatal(err) } // Inhalt aller Dateien merken. - ordner := filepath.Join(ziel, "plattform") - vorher := make(map[string]string) - dateien, _ := os.ReadDir(ordner) - for _, d := range dateien { - vorher[d.Name()] = lies(t, ordner, d.Name()) + dir := filepath.Join(target, "plattform") + before := make(map[string]string) + files, _ := os.ReadDir(dir) + for _, d := range files { + before[d.Name()] = readFile(t, dir, d.Name()) } - if _, err := e.ExportProject(ctx, db, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, project); err != nil { t.Fatal(err) } - for name, alt := range vorher { - if neu := lies(t, ordner, name); neu != alt { + for name, old := range before { + if newer := readFile(t, dir, name); newer != old { t.Errorf("%s hat sich ohne inhaltliche Änderung verändert:\n--- vorher ---\n%s\n--- nachher ---\n%s", - name, alt, neu) + name, old, newer) } } } // TestExportIndex prüft die Übersicht. func TestExportIndex(t *testing.T) { - e, db, projekt, ziel := aufbau(t) + e, db, project, target := setup(t) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "Alpha", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "Alpha", Content: "x", Tags: []string{"ops"}, Source: store.SourceHuman, Curated: true}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "Beta", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "Beta", Content: "x", Source: store.SourceAgent}) - if _, err := e.ExportProject(ctx, db, projekt); err != nil { + if _, err := e.ExportProject(ctx, db, project); err != nil { t.Fatal(err) } - index := lies(t, ziel, "plattform", "_index.md") + index := readFile(t, target, "plattform", "_index.md") // Kein Zeitstempel im Kopf — siehe TestExportProducesNoDiffWhenUnchanged. if strings.Contains(index, "generated_at") { t.Error("die Übersicht trägt einen Zeitstempel und erzeugt damit bei jedem Lauf einen Diff") } - for _, erwartet := range []string{ + for _, want := range []string{ "# Plattform-Team", "Wissen des Teams", "## ops", @@ -291,8 +291,8 @@ func TestExportIndex(t *testing.T) { "[Alpha](a.md)", "[Beta](b.md)", } { - if !strings.Contains(index, erwartet) { - t.Errorf("%q fehlt in der Übersicht:\n%s", erwartet, index) + if !strings.Contains(index, want) { + t.Errorf("%q fehlt in der Übersicht:\n%s", want, index) } } } @@ -300,43 +300,43 @@ func TestExportIndex(t *testing.T) { // TestExportAllProjects prüft, dass jedes Projekt seinen eigenen Ordner bekommt — // die Abschottung gilt auch im Export. func TestExportAllProjects(t *testing.T) { - e, db, projektA, ziel := aufbau(t) + e, db, projectA, target := setup(t) ctx := context.Background() // Beide Projekte liegen in derselben Workspace-Datei — dieselbe - // workspace_id wie projektA reicht, eine eigene Workspace ist nicht nötig. - projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "anderes", "Anderes Team", "") + // workspace_id wie projectA reicht, eine eigene Workspace ist nicht nötig. + projectB, err := db.CreateProject(ctx, projectA.WorkspaceID, "anderes", "Anderes Team", "") if err != nil { t.Fatal(err) } - db.PutEntry(ctx, store.EntryWrite{ProjectID: projektA.ID, Slug: "a-wissen", Title: "A", + db.PutEntry(ctx, store.EntryWrite{ProjectID: projectA.ID, Slug: "a-wissen", Title: "A", Content: "gehört zu A", Source: store.SourceHuman}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projektB.ID, Slug: "b-wissen", Title: "B", + db.PutEntry(ctx, store.EntryWrite{ProjectID: projectB.ID, Slug: "b-wissen", Title: "B", Content: "gehört zu B", Source: store.SourceHuman}) - ws := store.Workspace{ID: projektA.WorkspaceID, Name: "Intern"} + ws := store.Workspace{ID: projectA.WorkspaceID, Name: "Intern"} if _, err := e.ExportWorkspace(ctx, db, ws); err != nil { t.Fatal(err) } - if inhalt := lies(t, ziel, "plattform", "a-wissen.md"); !strings.Contains(inhalt, "gehört zu A") { + if content := readFile(t, target, "plattform", "a-wissen.md"); !strings.Contains(content, "gehört zu A") { t.Error("Projekt A wurde nicht korrekt exportiert") } - if inhalt := lies(t, ziel, "anderes", "b-wissen.md"); !strings.Contains(inhalt, "gehört zu B") { + if content := readFile(t, target, "anderes", "b-wissen.md"); !strings.Contains(content, "gehört zu B") { t.Error("Projekt B wurde nicht korrekt exportiert") } // Kein Eintrag darf im Ordner des anderen Projekts liegen. - if _, err := os.Stat(filepath.Join(ziel, "plattform", "b-wissen.md")); err == nil { + if _, err := os.Stat(filepath.Join(target, "plattform", "b-wissen.md")); err == nil { t.Error("ein Eintrag aus Projekt B landete im Ordner von Projekt A") } } func TestExportEmptyProject(t *testing.T) { - e, db, projekt, ziel := aufbau(t) + e, db, project, target := setup(t) - stats, err := e.ExportProject(context.Background(), db, projekt) + stats, err := e.ExportProject(context.Background(), db, project) if err != nil { t.Fatalf("ein leeres Projekt führte zu einem Fehler: %v", err) } @@ -344,15 +344,15 @@ func TestExportEmptyProject(t *testing.T) { if stats.Written != 1 { t.Errorf("%d Dateien, erwartet 1", stats.Written) } - if _, err := os.Stat(filepath.Join(ziel, "plattform", "_index.md")); err != nil { + if _, err := os.Stat(filepath.Join(target, "plattform", "_index.md")); err != nil { t.Error("auch bei einem leeren Projekt sollte eine Übersicht entstehen") } } -// TestExportTrenntWorkspaces: Zwei Workspaces mit einem Projekt gleichen +// TestExportSeparatesWorkspaces: Zwei Workspaces mit einem Projekt gleichen // Bezeichners dürfen sich im Export weder überschreiben noch gegenseitig // Dateien wegräumen. Vor der Ebene je Workspace passierte genau das. -func TestExportTrenntWorkspaces(t *testing.T) { +func TestExportSeparatesWorkspaces(t *testing.T) { ctx := context.Background() root := t.TempDir() e := New(root) @@ -362,7 +362,7 @@ func TestExportTrenntWorkspaces(t *testing.T) { ws store.Workspace db *store.Store } - var beide []workspace + var both []workspace for _, name := range []string{"Vertrieb", "Technik"} { db, err := store.Open(filepath.Join(t.TempDir(), name+".db")) if err != nil { @@ -371,33 +371,33 @@ func TestExportTrenntWorkspaces(t *testing.T) { t.Cleanup(func() { db.Close() }) ws := store.Workspace{ID: store.NewID(), Name: name} - projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform", "") + project, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform", "") if err != nil { t.Fatal(err) } - if _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "nur-" + strings.ToLower(name), + if _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "nur-" + strings.ToLower(name), Title: name, Content: "gehört zu " + name, Source: store.SourceHuman}); err != nil { t.Fatal(err) } - beide = append(beide, workspace{ws, db}) + both = append(both, workspace{ws, db}) } // Zweimal hintereinander: Beim zweiten Lauf würde das Aufräumen die // Dateien des anderen Workspace löschen, wenn sie im selben Ordner lägen. - for lauf := 0; lauf < 2; lauf++ { - for _, w := range beide { + for run := 0; run < 2; run++ { + for _, w := range both { if _, err := e.ExportWorkspace(ctx, w.db, w.ws); err != nil { t.Fatal(err) } } } - for _, w := range beide { - datei := "nur-" + strings.ToLower(w.ws.Name) + ".md" - if inhalt := lies(t, root, w.ws.ID, "plattform", datei); !strings.Contains(inhalt, "gehört zu "+w.ws.Name) { + for _, w := range both { + file := "nur-" + strings.ToLower(w.ws.Name) + ".md" + if content := readFile(t, root, w.ws.ID, "plattform", file); !strings.Contains(content, "gehört zu "+w.ws.Name) { t.Errorf("Eintrag von %s fehlt oder ist falsch", w.ws.Name) } - if inhalt := lies(t, root, w.ws.ID, workspaceDateiname); !strings.Contains(inhalt, w.ws.Name) { + if content := readFile(t, root, w.ws.ID, workspaceFilename); !strings.Contains(content, w.ws.Name) { t.Errorf("_workspace.md nennt %s nicht", w.ws.Name) } } diff --git a/internal/export/zip.go b/internal/export/zip.go index a0ee4d4..12efebb 100644 --- a/internal/export/zip.go +++ b/internal/export/zip.go @@ -10,20 +10,20 @@ import ( "munin/internal/store" ) -// Datei ist eine Datei des Exports, noch nicht geschrieben. -type Datei struct { - // Pfad ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter +// File ist eine Datei des Exports, noch nicht geschrieben. +type File struct { + // Path ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter // Windows — so verlangt es das ZIP-Format. - Pfad string - Inhalt string + Path string + Content string - // Geaendert ist der Zeitpunkt, den die Datei im ZIP trägt. Bei Einträgen + // Modified ist der Zeitpunkt, den die Datei im ZIP trägt. Bei Einträgen // ihre letzte Änderung, damit ein entpacktes Archiv in jedem Dateimanager // die richtige Reihenfolge zeigt. Leer bedeutet: Zeitpunkt des Downloads. - Geaendert time.Time + Modified time.Time } -// ProjektDateien stellt den Export eines einzelnen Projekts zusammen: +// ProjectFiles stellt den Export eines einzelnen Projekts zusammen: // // <projekt>/ // _index.md @@ -31,71 +31,71 @@ type Datei struct { // // Das ist genau das, was der Import eines Projekts erwartet (Unterordner // erlaubt, Dateien mit "_" am Anfang werden übergangen). -func ProjektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) { - return projektDateien(ctx, s, project) +func ProjectFiles(ctx context.Context, s *store.Store, project store.Project) ([]File, error) { + return projectFiles(ctx, s, project) } -// WorkspaceDateien stellt den Export aller Projekte eines Workspace zusammen, +// WorkspaceFiles stellt den Export aller Projekte eines Workspace zusammen, // im selben Aufbau wie ein Workspace-Ordner unter EXPORT_DIR: // // _workspace.md // <projekt-a>/… // <projekt-b>/… -func WorkspaceDateien(ctx context.Context, s *store.Store, ws store.Workspace) ([]Datei, error) { - dateien := []Datei{{Pfad: workspaceDateiname, Inhalt: rendereWorkspace(ws)}} +func WorkspaceFiles(ctx context.Context, s *store.Store, ws store.Workspace) ([]File, error) { + files := []File{{Path: workspaceFilename, Content: renderWorkspace(ws)}} - projekte, err := s.ListProjects(ctx, ws.ID) + projects, err := s.ListProjects(ctx, ws.ID) if err != nil { return nil, err } - for _, p := range projekte { - projekt, err := projektDateien(ctx, s, p) + for _, p := range projects { + project, err := projectFiles(ctx, s, p) if err != nil { return nil, fmt.Errorf("Projekt %q: %w", p.Slug, err) } - dateien = append(dateien, projekt...) + files = append(files, project...) } - return dateien, nil + return files, nil } -// SchreibeZIP packt die Dateien als ZIP-Archiv nach w. +// WriteZIP packt die Dateien als ZIP-Archiv nach w. // // # Warum erst sammeln, dann packen? // // Beim Download schreibt w direkt in die Antwort an den Browser. Ist der // erste Teil des Archivs einmal unterwegs, lässt sich ein Fehler nicht mehr // als Fehlerseite melden — der Browser bekäme nur ein kaputtes ZIP. Deshalb -// liest der Aufrufer zuerst alles aus der Datenbank (ProjektDateien, -// WorkspaceDateien). Was danach noch schiefgehen kann, ist nur noch die +// liest der Aufrufer zuerst alles aus der Datenbank (ProjectFiles, +// WorkspaceFiles). Was danach noch schiefgehen kann, ist nur noch die // Verbindung selbst. -func SchreibeZIP(w io.Writer, dateien []Datei) error { - jetzt := time.Now() - archiv := zip.NewWriter(w) +func WriteZIP(w io.Writer, files []File) error { + now := time.Now() + archive := zip.NewWriter(w) - for _, d := range dateien { - geaendert := d.Geaendert - if geaendert.IsZero() { - geaendert = jetzt + for _, d := range files { + changed := d.Modified + if changed.IsZero() { + changed = now } // CreateHeader statt Create, um Komprimierung und Zeitstempel // selbst festzulegen. Deflate ist das Verfahren, das jedes // Entpackprogramm kennt. - f, err := archiv.CreateHeader(&zip.FileHeader{ - Name: d.Pfad, + f, err := archive.CreateHeader(&zip.FileHeader{ + Name: d.Path, Method: zip.Deflate, - Modified: geaendert, + Modified: changed, }) if err != nil { return err } - if _, err := io.WriteString(f, d.Inhalt); err != nil { + if _, err := io.WriteString(f, d.Content); err != nil { return err } } // Close schreibt das Inhaltsverzeichnis ans Ende des Archivs. Ohne diesen // Schritt ist das ZIP unbrauchbar, auch wenn alle Dateien darin stehen. - return archiv.Close() + return archive.Close() } diff --git a/internal/help/help.go b/internal/help/help.go new file mode 100644 index 0000000..091d959 --- /dev/null +++ b/internal/help/help.go @@ -0,0 +1,141 @@ +// Package help enthält die eingebaute Onboarding-Dokumentation der +// Weboberfläche: wie man Munin einrichtet, benutzt und was die Begriffe +// bedeuten. +// +// # Warum eingebettet und nicht als Einträge in einem Projekt? +// +// Die Hilfe beschreibt das Programm selbst. Sie muss also zu genau der Fassung +// passen, die gerade läuft — ändert sich eine Funktion, ändert sich im selben +// Commit auch ihre Beschreibung. Als Einträge in der Datenbank würde sie +// unbemerkt veralten, und in jedem neuen Workspace müsste sie erst jemand +// anlegen. +// +// Die Seiten sind Markdown-Dateien im Ordner pages/. //go:embed packt sie +// beim Kompilieren ins Binary, genau wie templates/ und static/. +package help + +import ( + "embed" + "fmt" + + "munin/internal/markdown" +) + +//go:embed pages/*.md +var pagesFS embed.FS + +// Page beschreibt eine Hilfeseite. +type Page struct { + Slug string // Teil der Adresse: /hilfe/<Slug> + Title string // Überschrift und Name in der Navigation + Summary string // ein Satz für die Übersicht + + // For legt fest, wer die Seite sieht. Wer nicht dazugehört, findet sie + // weder in der Navigation, noch lässt sie sich direkt aufrufen (404). + // Geheim ist darin nichts — aber sie beschreibt Knöpfe, die andere gar + // nicht sehen. + For Audience +} + +// Audience einer Hilfeseite. +type Audience int + +const ( + // ForEveryone: jeder, der angemeldet ist. + ForEveryone Audience = iota + // ForAdmins: Workspace-Admins und der Plattform-Admin. + ForAdmins + // ForPlatformAdmin: nur der Plattform-Admin. + ForPlatformAdmin +) + +// Reader beschreibt, wer die Hilfe gerade liest — so viel, wie für die +// Auswahl der Seiten nötig ist. +type Reader struct { + PlatformAdmin bool + WorkspaceAdmin bool +} + +// CanRead sagt, ob l die Seite sehen darf. +func (s Page) CanRead(l Reader) bool { + switch s.For { + case ForPlatformAdmin: + return l.PlatformAdmin + case ForAdmins: + return l.PlatformAdmin || l.WorkspaceAdmin + default: + return true + } +} + +// pages legt Reihenfolge und Inhalt der Navigation fest. Die Datei zu einer +// Page heißt immer pages/<Slug>.md — so gibt es nur eine Stelle, an der ein +// Name steht, und der Test in help_test.go prüft, dass beides zusammenpasst. +var pages = []Page{ + {Slug: "start", Title: "Willkommen", Summary: "Was Munin ist und wo man anfängt."}, + {Slug: "begriffe", Title: "Begriffe", Summary: "Workspace, Projekt, Eintrag, Bezeichner, Rollen — was sie bedeuten."}, + {Slug: "mcp-einrichten", Title: "Agent anbinden", Summary: "Claude Code, Cline oder OpenCode mit Munin verbinden."}, + {Slug: "mit-agenten-arbeiten", Title: "Mit Agenten arbeiten", Summary: "Wie man Agenten anspricht, damit sie das Gedächtnis wirklich nutzen."}, + {Slug: "weboberflaeche", Title: "Weboberfläche", Summary: "Einträge im Browser suchen, schreiben, vergleichen und verknüpfen."}, + {Slug: "zugaenge", Title: "Zugänge und Sicherheit", Summary: "Keys, Rollen, Protokoll und was man beim Teilen von Wissen bedenken sollte."}, + {Slug: "workspace-admin", Title: "Für Workspace-Admins", Summary: "Projekte und Zugänge einer Abteilung verwalten, Protokoll, Suche über alle Projekte.", For: ForAdmins}, + {Slug: "plattform-admin", Title: "Für Plattform-Admins", Summary: "Workspaces, Workspace-Admins, Schreibbudget, System, Sicherung.", For: ForPlatformAdmin}, +} + +// All gibt alle Seiten in der Reihenfolge der Navigation zurück, auch die +// nur für Admins. Für die Anzeige ist Sichtbar die richtige Funktion. +// +// Zurückgegeben wird eine Kopie: Wer die Liste verändert, soll damit nicht aus +// Versehen die Navigation aller anderen Anfragen verändern. +func All() []Page { + clone := make([]Page, len(pages)) + copy(clone, pages) + return clone +} + +// Visible gibt die Seiten zurück, die l sehen darf, in der Reihenfolge der +// Navigation. +func Visible(l Reader) []Page { + var result []Page + for _, s := range pages { + if s.CanRead(l) { + result = append(result, s) + } + } + return result +} + +// Find sucht eine Seite über ihren Slug. +func Find(slug string) (Page, bool) { + for _, s := range pages { + if s.Slug == slug { + return s, true + } + } + return Page{}, false +} + +// Markdown liefert den Rohtext einer Seite. +func (s Page) Markdown() (string, error) { + content, err := pagesFS.ReadFile("pages/" + s.Slug + ".md") + if err != nil { + return "", fmt.Errorf("Hilfeseite %q fehlt: %w", s.Slug, err) + } + return string(content), nil +} + +// Render wandelt eine Hilfeseite in HTML. +// +// Es ist derselbe Renderer wie für Einträge (internal/markdown), damit die +// Hilfe nichts zeigt, was im eigenen Eintrag dann anders aussieht. Einziger +// Unterschied: Überschriften bekommen hier eine id, damit die Oberfläche mit +// "?"-Links direkt auf einen Abschnitt zeigen kann, etwa +// /hilfe/weboberflaeche#markdown-im-inhalt. +// +// Roh-HTML bleibt auch hier gesperrt. Die Seiten stammen zwar aus dem +// Repository und nicht von Nutzern, aber die Platzhalter (siehe +// web/handlers_help.go) setzen Werte aus der Anfrage ein — und dann soll +// daraus auf keinen Fall HTML werden können. +func Render(source string) (string, error) { + return markdown.RenderWithAnchors(source) +} diff --git a/internal/help/help_test.go b/internal/help/help_test.go new file mode 100644 index 0000000..0ff62ab --- /dev/null +++ b/internal/help/help_test.go @@ -0,0 +1,188 @@ +package help + +import ( + "io/fs" + "regexp" + "strings" + "testing" +) + +// TestEveryPageHasAFile stellt sicher, dass die Liste in help.go und die +// Dateien in pages/ zusammenpassen — in beide Richtungen. Eine Seite ohne +// Datei gäbe einen Fehler beim Aufruf, eine Datei ohne Listeneintrag wäre +// unerreichbar und würde unbemerkt veralten. +func TestEveryPageHasAFile(t *testing.T) { + listed := map[string]bool{} + for _, s := range All() { + listed[s.Slug] = true + if _, err := s.Markdown(); err != nil { + t.Errorf("Seite %q: %v", s.Slug, err) + } + } + + files, err := fs.Glob(pagesFS, "pages/*.md") + if err != nil { + t.Fatal(err) + } + for _, d := range files { + slug := strings.TrimSuffix(strings.TrimPrefix(d, "pages/"), ".md") + if !listed[slug] { + t.Errorf("Datei %s steht nicht in der Seitenliste", d) + } + } +} + +// TestInternalLinksLeadToPages prüft jeden Link der Form (/hilfe/…) in +// allen Seiten. Wird eine Seite umbenannt, fällt ein vergessener Verweis hier +// auf statt erst als 404 beim Lesen. +func TestInternalLinksLeadToPages(t *testing.T) { + link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`) + + for _, s := range All() { + md, err := s.Markdown() + if err != nil { + t.Fatal(err) + } + for _, hits := range link.FindAllStringSubmatch(md, -1) { + if _, ok := Find(hits[1]); !ok { + t.Errorf("Seite %q verlinkt auf unbekannte Hilfeseite %q", s.Slug, hits[1]) + } + } + } +} + +func TestFindUnknownPage(t *testing.T) { + if _, ok := Find("gibt-es-nicht"); ok { + t.Error("Finde meldet eine Seite, die es nicht gibt") + } +} + +func TestRenderTablesAndNoRawHTML(t *testing.T) { + html, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n\n<script>alert(1)</script>") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(html, "<table>") { + t.Error("Tabellen werden nicht als Tabelle dargestellt") + } + if strings.Contains(html, "<script>") { + t.Error("Roh-HTML wird durchgelassen") + } +} + +// TestAllReturnsCopy stellt sicher, dass ein Aufrufer die Navigation nicht +// für alle anderen verändern kann. +func TestAllReturnsCopy(t *testing.T) { + a := All() + a[0].Title = "verändert" + if All()[0].Title == "verändert" { + t.Error("Alle gibt die interne Liste statt einer Kopie heraus") + } +} + +// Die drei Lesergruppen, für die Tests. +var ( + projectReader = Reader{} + workspaceAdmin = Reader{WorkspaceAdmin: true} + platformAdmin = Reader{PlatformAdmin: true} +) + +func TestVisiblePerAudience(t *testing.T) { + contains := func(pages []Page, slug string) bool { + for _, s := range pages { + if s.Slug == slug { + return true + } + } + return false + } + + for _, f := range []struct { + name string + reader Reader + workspacePage, platformPage bool + }{ + {"Projektzugang", projectReader, false, false}, + {"Workspace-Admin", workspaceAdmin, true, false}, + {"Plattform-Admin", platformAdmin, true, true}, + } { + visible := Visible(f.reader) + if contains(visible, "workspace-admin") != f.workspacePage { + t.Errorf("%s: Seite für Workspace-Admins sichtbar = %v", f.name, !f.workspacePage) + } + if contains(visible, "plattform-admin") != f.platformPage { + t.Errorf("%s: Seite für Plattform-Admins sichtbar = %v", f.name, !f.platformPage) + } + if !contains(visible, "start") { + t.Errorf("%s sieht die Startseite nicht", f.name) + } + } + if len(Visible(platformAdmin)) != len(All()) { + t.Error("der Plattform-Admin sieht nicht alle Seiten") + } +} + +// TestPagesLinkOnlyVisible: Ein Link auf eine Seite, die der Leser +// nicht sehen darf, liefe für ihn ins Leere (404). Geprüft für jede +// Lesergruppe: Was sie sieht, verlinkt nur auf das, was sie auch sieht. +func TestPagesLinkOnlyVisible(t *testing.T) { + link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)[#)]`) + + for _, reader := range []Reader{projectReader, workspaceAdmin} { + for _, s := range Visible(reader) { + md, err := s.Markdown() + if err != nil { + t.Fatal(err) + } + for _, hits := range link.FindAllStringSubmatch(md, -1) { + if target, ok := Find(hits[1]); ok && !target.CanRead(reader) { + t.Errorf("Seite %q verlinkt auf %q, die dieser Leser nicht sieht (%+v)", s.Slug, target.Slug, reader) + } + } + } + } +} + +// TestLinkAnchorsExist: Jeder Link auf einen Abschnitt einer Hilfeseite — +// (/hilfe/seite#abschnitt) oder innerhalb der Seite (#abschnitt) — zeigt auf +// eine Überschrift, die es dort gibt. Sonst landet man oben auf der Seite +// statt beim gemeinten Abschnitt, und niemand merkt es. +// +// Die Hilfe-Links der Templates prüft TestHelpLinksPointToExistingSections +// in internal/web. +func TestLinkAnchorsExist(t *testing.T) { + idPattern := regexp.MustCompile(`<h[1-6] id="([^"]+)"`) + anchor := map[string]map[string]bool{} + for _, s := range All() { + md, err := s.Markdown() + if err != nil { + t.Fatal(err) + } + html, err := Render(md) + if err != nil { + t.Fatal(err) + } + anchor[s.Slug] = map[string]bool{} + for _, m := range idPattern.FindAllStringSubmatch(html, -1) { + anchor[s.Slug][m[1]] = true + } + } + + link := regexp.MustCompile(`\]\((?:/hilfe/([a-z0-9-]+))?#([a-z0-9-]+)\)`) + for _, s := range All() { + md, _ := s.Markdown() + for _, m := range link.FindAllStringSubmatch(md, -1) { + target := m[1] + if target == "" { + target = s.Slug + } + if _, exists := anchor[target]; !exists { + t.Errorf("%s: Link auf unbekannte Seite %q", s.Slug, target) + continue + } + if !anchor[target][m[2]] { + t.Errorf("%s: Abschnitt #%s gibt es auf %q nicht", s.Slug, m[2], target) + } + } + } +} diff --git a/internal/hilfe/seiten/begriffe.md b/internal/help/pages/begriffe.md similarity index 100% rename from internal/hilfe/seiten/begriffe.md rename to internal/help/pages/begriffe.md diff --git a/internal/hilfe/seiten/mcp-einrichten.md b/internal/help/pages/mcp-einrichten.md similarity index 100% rename from internal/hilfe/seiten/mcp-einrichten.md rename to internal/help/pages/mcp-einrichten.md diff --git a/internal/hilfe/seiten/mit-agenten-arbeiten.md b/internal/help/pages/mit-agenten-arbeiten.md similarity index 100% rename from internal/hilfe/seiten/mit-agenten-arbeiten.md rename to internal/help/pages/mit-agenten-arbeiten.md diff --git a/internal/hilfe/seiten/plattform-admin.md b/internal/help/pages/plattform-admin.md similarity index 96% rename from internal/hilfe/seiten/plattform-admin.md rename to internal/help/pages/plattform-admin.md index 17e53f4..ac33111 100644 --- a/internal/hilfe/seiten/plattform-admin.md +++ b/internal/help/pages/plattform-admin.md @@ -12,6 +12,11 @@ weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe (`{{SERVER}}/hilfe`) reicht als Einstieg. +Deine Anmeldung als Plattform-Admin gilt 12 Stunden, danach fragt Munin +erneut nach dem ADMIN_TOKEN. Das ist Absicht: Anders als ein Key lässt sich +diese Sitzung nicht einzeln widerrufen, und sie kommt an alles. Ein Wechsel des +Workspace verlängert sie nicht. + Soll eine Abteilung ihre Projekte selbst anlegen, reicht es, ihr einen [Workspace-Admin](#workspace-admins) einzurichten. Die Schritte 2 und 3 übernimmt dann sie. diff --git a/internal/hilfe/seiten/start.md b/internal/help/pages/start.md similarity index 100% rename from internal/hilfe/seiten/start.md rename to internal/help/pages/start.md diff --git a/internal/hilfe/seiten/weboberflaeche.md b/internal/help/pages/weboberflaeche.md similarity index 100% rename from internal/hilfe/seiten/weboberflaeche.md rename to internal/help/pages/weboberflaeche.md diff --git a/internal/hilfe/seiten/workspace-admin.md b/internal/help/pages/workspace-admin.md similarity index 100% rename from internal/hilfe/seiten/workspace-admin.md rename to internal/help/pages/workspace-admin.md diff --git a/internal/hilfe/seiten/zugaenge.md b/internal/help/pages/zugaenge.md similarity index 100% rename from internal/hilfe/seiten/zugaenge.md rename to internal/help/pages/zugaenge.md diff --git a/internal/hilfe/hilfe.go b/internal/hilfe/hilfe.go deleted file mode 100644 index 2d38748..0000000 --- a/internal/hilfe/hilfe.go +++ /dev/null @@ -1,141 +0,0 @@ -// Package hilfe enthält die eingebaute Onboarding-Dokumentation der -// Weboberfläche: wie man Munin einrichtet, benutzt und was die Begriffe -// bedeuten. -// -// # Warum eingebettet und nicht als Einträge in einem Projekt? -// -// Die Hilfe beschreibt das Programm selbst. Sie muss also zu genau der Fassung -// passen, die gerade läuft — ändert sich eine Funktion, ändert sich im selben -// Commit auch ihre Beschreibung. Als Einträge in der Datenbank würde sie -// unbemerkt veralten, und in jedem neuen Workspace müsste sie erst jemand -// anlegen. -// -// Die Seiten sind Markdown-Dateien im Ordner seiten/. //go:embed packt sie -// beim Kompilieren ins Binary, genau wie templates/ und static/. -package hilfe - -import ( - "embed" - "fmt" - - "munin/internal/markdown" -) - -//go:embed seiten/*.md -var seitenFS embed.FS - -// Seite beschreibt eine Hilfeseite. -type Seite struct { - Slug string // Teil der Adresse: /hilfe/<Slug> - Titel string // Überschrift und Name in der Navigation - Kurz string // ein Satz für die Übersicht - - // Fuer legt fest, wer die Seite sieht. Wer nicht dazugehört, findet sie - // weder in der Navigation, noch lässt sie sich direkt aufrufen (404). - // Geheim ist darin nichts — aber sie beschreibt Knöpfe, die andere gar - // nicht sehen. - Fuer Zielgruppe -} - -// Zielgruppe einer Hilfeseite. -type Zielgruppe int - -const ( - // FuerAlle: jeder, der angemeldet ist. - FuerAlle Zielgruppe = iota - // FuerAdmins: Workspace-Admins und der Plattform-Admin. - FuerAdmins - // FuerPlattformAdmin: nur der Plattform-Admin. - FuerPlattformAdmin -) - -// Leser beschreibt, wer die Hilfe gerade liest — so viel, wie für die -// Auswahl der Seiten nötig ist. -type Leser struct { - PlattformAdmin bool - WorkspaceAdmin bool -} - -// DarfLesen sagt, ob l die Seite sehen darf. -func (s Seite) DarfLesen(l Leser) bool { - switch s.Fuer { - case FuerPlattformAdmin: - return l.PlattformAdmin - case FuerAdmins: - return l.PlattformAdmin || l.WorkspaceAdmin - default: - return true - } -} - -// seiten legt Reihenfolge und Inhalt der Navigation fest. Die Datei zu einer -// Seite heißt immer seiten/<Slug>.md — so gibt es nur eine Stelle, an der ein -// Name steht, und der Test in hilfe_test.go prüft, dass beides zusammenpasst. -var seiten = []Seite{ - {Slug: "start", Titel: "Willkommen", Kurz: "Was Munin ist und wo man anfängt."}, - {Slug: "begriffe", Titel: "Begriffe", Kurz: "Workspace, Projekt, Eintrag, Bezeichner, Rollen — was sie bedeuten."}, - {Slug: "mcp-einrichten", Titel: "Agent anbinden", Kurz: "Claude Code, Cline oder OpenCode mit Munin verbinden."}, - {Slug: "mit-agenten-arbeiten", Titel: "Mit Agenten arbeiten", Kurz: "Wie man Agenten anspricht, damit sie das Gedächtnis wirklich nutzen."}, - {Slug: "weboberflaeche", Titel: "Weboberfläche", Kurz: "Einträge im Browser suchen, schreiben, vergleichen und verknüpfen."}, - {Slug: "zugaenge", Titel: "Zugänge und Sicherheit", Kurz: "Keys, Rollen, Protokoll und was man beim Teilen von Wissen bedenken sollte."}, - {Slug: "workspace-admin", Titel: "Für Workspace-Admins", Kurz: "Projekte und Zugänge einer Abteilung verwalten, Protokoll, Suche über alle Projekte.", Fuer: FuerAdmins}, - {Slug: "plattform-admin", Titel: "Für Plattform-Admins", Kurz: "Workspaces, Workspace-Admins, Schreibbudget, System, Sicherung.", Fuer: FuerPlattformAdmin}, -} - -// Alle gibt alle Seiten in der Reihenfolge der Navigation zurück, auch die -// nur für Admins. Für die Anzeige ist Sichtbar die richtige Funktion. -// -// Zurückgegeben wird eine Kopie: Wer die Liste verändert, soll damit nicht aus -// Versehen die Navigation aller anderen Anfragen verändern. -func Alle() []Seite { - kopie := make([]Seite, len(seiten)) - copy(kopie, seiten) - return kopie -} - -// Sichtbar gibt die Seiten zurück, die l sehen darf, in der Reihenfolge der -// Navigation. -func Sichtbar(l Leser) []Seite { - var ergebnis []Seite - for _, s := range seiten { - if s.DarfLesen(l) { - ergebnis = append(ergebnis, s) - } - } - return ergebnis -} - -// Finde sucht eine Seite über ihren Slug. -func Finde(slug string) (Seite, bool) { - for _, s := range seiten { - if s.Slug == slug { - return s, true - } - } - return Seite{}, false -} - -// Markdown liefert den Rohtext einer Seite. -func (s Seite) Markdown() (string, error) { - inhalt, err := seitenFS.ReadFile("seiten/" + s.Slug + ".md") - if err != nil { - return "", fmt.Errorf("Hilfeseite %q fehlt: %w", s.Slug, err) - } - return string(inhalt), nil -} - -// Render wandelt eine Hilfeseite in HTML. -// -// Es ist derselbe Renderer wie für Einträge (internal/markdown), damit die -// Hilfe nichts zeigt, was im eigenen Eintrag dann anders aussieht. Einziger -// Unterschied: Überschriften bekommen hier eine id, damit die Oberfläche mit -// "?"-Links direkt auf einen Abschnitt zeigen kann, etwa -// /hilfe/weboberflaeche#markdown-im-inhalt. -// -// Roh-HTML bleibt auch hier gesperrt. Die Seiten stammen zwar aus dem -// Repository und nicht von Nutzern, aber die Platzhalter (siehe -// web/handlers_hilfe.go) setzen Werte aus der Anfrage ein — und dann soll -// daraus auf keinen Fall HTML werden können. -func Render(quelle string) (string, error) { - return markdown.RenderMitAnkern(quelle) -} diff --git a/internal/hilfe/hilfe_test.go b/internal/hilfe/hilfe_test.go deleted file mode 100644 index fce26c3..0000000 --- a/internal/hilfe/hilfe_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package hilfe - -import ( - "io/fs" - "regexp" - "strings" - "testing" -) - -// TestJedeSeiteHatEineDatei stellt sicher, dass die Liste in hilfe.go und die -// Dateien in seiten/ zusammenpassen — in beide Richtungen. Eine Seite ohne -// Datei gäbe einen Fehler beim Aufruf, eine Datei ohne Listeneintrag wäre -// unerreichbar und würde unbemerkt veralten. -func TestJedeSeiteHatEineDatei(t *testing.T) { - gelistet := map[string]bool{} - for _, s := range Alle() { - gelistet[s.Slug] = true - if _, err := s.Markdown(); err != nil { - t.Errorf("Seite %q: %v", s.Slug, err) - } - } - - dateien, err := fs.Glob(seitenFS, "seiten/*.md") - if err != nil { - t.Fatal(err) - } - for _, d := range dateien { - slug := strings.TrimSuffix(strings.TrimPrefix(d, "seiten/"), ".md") - if !gelistet[slug] { - t.Errorf("Datei %s steht nicht in der Seitenliste", d) - } - } -} - -// TestInterneLinksFuehrenZuSeiten prüft jeden Link der Form (/hilfe/…) in -// allen Seiten. Wird eine Seite umbenannt, fällt ein vergessener Verweis hier -// auf statt erst als 404 beim Lesen. -func TestInterneLinksFuehrenZuSeiten(t *testing.T) { - link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`) - - for _, s := range Alle() { - md, err := s.Markdown() - if err != nil { - t.Fatal(err) - } - for _, treffer := range link.FindAllStringSubmatch(md, -1) { - if _, ok := Finde(treffer[1]); !ok { - t.Errorf("Seite %q verlinkt auf unbekannte Hilfeseite %q", s.Slug, treffer[1]) - } - } - } -} - -func TestFindeUnbekannteSeite(t *testing.T) { - if _, ok := Finde("gibt-es-nicht"); ok { - t.Error("Finde meldet eine Seite, die es nicht gibt") - } -} - -func TestRenderTabellenUndKeinRohHTML(t *testing.T) { - html, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n\n<script>alert(1)</script>") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(html, "<table>") { - t.Error("Tabellen werden nicht als Tabelle dargestellt") - } - if strings.Contains(html, "<script>") { - t.Error("Roh-HTML wird durchgelassen") - } -} - -// TestAlleGibtKopie stellt sicher, dass ein Aufrufer die Navigation nicht -// für alle anderen verändern kann. -func TestAlleGibtKopie(t *testing.T) { - a := Alle() - a[0].Titel = "verändert" - if Alle()[0].Titel == "verändert" { - t.Error("Alle gibt die interne Liste statt einer Kopie heraus") - } -} - -// Die drei Lesergruppen, für die Tests. -var ( - projektLeser = Leser{} - workspaceAdmin = Leser{WorkspaceAdmin: true} - plattformAdmin = Leser{PlattformAdmin: true} -) - -func TestSichtbarJeLesergruppe(t *testing.T) { - enthaelt := func(seiten []Seite, slug string) bool { - for _, s := range seiten { - if s.Slug == slug { - return true - } - } - return false - } - - for _, f := range []struct { - name string - leser Leser - workspaceSeite, plattSei bool - }{ - {"Projektzugang", projektLeser, false, false}, - {"Workspace-Admin", workspaceAdmin, true, false}, - {"Plattform-Admin", plattformAdmin, true, true}, - } { - sichtbar := Sichtbar(f.leser) - if enthaelt(sichtbar, "workspace-admin") != f.workspaceSeite { - t.Errorf("%s: Seite für Workspace-Admins sichtbar = %v", f.name, !f.workspaceSeite) - } - if enthaelt(sichtbar, "plattform-admin") != f.plattSei { - t.Errorf("%s: Seite für Plattform-Admins sichtbar = %v", f.name, !f.plattSei) - } - if !enthaelt(sichtbar, "start") { - t.Errorf("%s sieht die Startseite nicht", f.name) - } - } - if len(Sichtbar(plattformAdmin)) != len(Alle()) { - t.Error("der Plattform-Admin sieht nicht alle Seiten") - } -} - -// TestSeitenVerlinkenNurSichtbares: Ein Link auf eine Seite, die der Leser -// nicht sehen darf, liefe für ihn ins Leere (404). Geprüft für jede -// Lesergruppe: Was sie sieht, verlinkt nur auf das, was sie auch sieht. -func TestSeitenVerlinkenNurSichtbares(t *testing.T) { - link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)[#)]`) - - for _, leser := range []Leser{projektLeser, workspaceAdmin} { - for _, s := range Sichtbar(leser) { - md, err := s.Markdown() - if err != nil { - t.Fatal(err) - } - for _, treffer := range link.FindAllStringSubmatch(md, -1) { - if ziel, ok := Finde(treffer[1]); ok && !ziel.DarfLesen(leser) { - t.Errorf("Seite %q verlinkt auf %q, die dieser Leser nicht sieht (%+v)", s.Slug, ziel.Slug, leser) - } - } - } - } -} - -// TestAnkerInLinksGibtEs: Jeder Link auf einen Abschnitt einer Hilfeseite — -// (/hilfe/seite#abschnitt) oder innerhalb der Seite (#abschnitt) — zeigt auf -// eine Überschrift, die es dort gibt. Sonst landet man oben auf der Seite -// statt beim gemeinten Abschnitt, und niemand merkt es. -// -// Die Hilfe-Links der Templates prüft TestHilfelinksZeigenAufVorhandeneAbschnitte -// in internal/web. -func TestAnkerInLinksGibtEs(t *testing.T) { - idMuster := regexp.MustCompile(`<h[1-6] id="([^"]+)"`) - anker := map[string]map[string]bool{} - for _, s := range Alle() { - md, err := s.Markdown() - if err != nil { - t.Fatal(err) - } - html, err := Render(md) - if err != nil { - t.Fatal(err) - } - anker[s.Slug] = map[string]bool{} - for _, m := range idMuster.FindAllStringSubmatch(html, -1) { - anker[s.Slug][m[1]] = true - } - } - - link := regexp.MustCompile(`\]\((?:/hilfe/([a-z0-9-]+))?#([a-z0-9-]+)\)`) - for _, s := range Alle() { - md, _ := s.Markdown() - for _, m := range link.FindAllStringSubmatch(md, -1) { - ziel := m[1] - if ziel == "" { - ziel = s.Slug - } - if _, gibt := anker[ziel]; !gibt { - t.Errorf("%s: Link auf unbekannte Seite %q", s.Slug, ziel) - continue - } - if !anker[ziel][m[2]] { - t.Errorf("%s: Abschnitt #%s gibt es auf %q nicht", s.Slug, m[2], ziel) - } - } - } -} diff --git a/internal/importer/archiv.go b/internal/importer/archive.go similarity index 50% rename from internal/importer/archiv.go rename to internal/importer/archive.go index ce394cd..a6ccd56 100644 --- a/internal/importer/archiv.go +++ b/internal/importer/archive.go @@ -17,25 +17,25 @@ import ( // tatsächlich ausgepackt wird, nicht was das Archiv über sich behauptet: Die // Größenangaben im Inneren eines ZIP-Archivs kann jeder frei hineinschreiben. const ( - // MaxDateien ist die Höchstzahl an Markdown-Dateien je Import. - MaxDateien = 5000 + // MaxFiles ist die Höchstzahl an Markdown-Dateien je Import. + MaxFiles = 5000 - // MaxDateiBytes entspricht der Grenze für ein Formular der Weboberfläche. + // MaxFileBytes entspricht der Grenze für ein Formular der Weboberfläche. // Ein Eintrag, der sich nicht im Browser anlegen ließe, soll auch über // den Import nicht hineinkommen. - MaxDateiBytes = 1 << 20 // 1 MB + MaxFileBytes = 1 << 20 // 1 MB - // MaxGesamtBytes begrenzt den ausgepackten Inhalt insgesamt. - MaxGesamtBytes = 100 << 20 // 100 MB + // MaxTotalBytes begrenzt den ausgepackten Inhalt insgesamt. + MaxTotalBytes = 100 << 20 // 100 MB ) -// Datei ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet. -type Datei struct { - Pfad string - Inhalt []byte +// File ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet. +type File struct { + Path string + Content []byte } -// LeseZip liest alle Markdown-Dateien aus einem ZIP-Archiv. +// ReadZip liest alle Markdown-Dateien aus einem ZIP-Archiv. // // Übergangen werden: // - alles, was nicht auf .md oder .markdown endet (Bilder, PDFs, …) @@ -47,76 +47,76 @@ type Datei struct { // Unterordner sind erlaubt. So lässt sich ein ganzer Doku-Ordner auf einmal // hochladen. Die Einträge landen trotzdem alle nebeneinander im Projekt, // denn Munin kennt keine Ordner. -func LeseZip(daten []byte) ([]Datei, error) { - archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten))) +func ReadZip(data []byte) ([]File, error) { + archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, errors.New("die Datei ist kein lesbares ZIP-Archiv") } - var dateien []Datei - var gesamt int64 + var files []File + var total int64 - for _, f := range archiv.File { - if f.FileInfo().IsDir() || !istEintragsdatei(f.Name) { + for _, f := range archive.File { + if f.FileInfo().IsDir() || !isEntryFile(f.Name) { continue } - if len(dateien) >= MaxDateien { - return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxDateien) + if len(files) >= MaxFiles { + return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxFiles) } - inhalt, err := leseBegrenzt(f, MaxDateiBytes) + content, err := readLimited(f, MaxFileBytes) if err != nil { return nil, fmt.Errorf("%s: %w", f.Name, err) } - gesamt += int64(len(inhalt)) - if gesamt > MaxGesamtBytes { - return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxGesamtBytes>>20) + total += int64(len(content)) + if total > MaxTotalBytes { + return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxTotalBytes>>20) } - dateien = append(dateien, Datei{Pfad: f.Name, Inhalt: inhalt}) + files = append(files, File{Path: f.Name, Content: content}) } - if len(dateien) == 0 { + if len(files) == 0 { return nil, errors.New("im Archiv liegt keine Markdown-Datei (.md)") } - return dateien, nil + return files, nil } -// istEintragsdatei entscheidet anhand des Pfads, ob eine Datei ein Eintrag -// werden soll (siehe LeseZip). -func istEintragsdatei(pfad string) bool { - for _, teil := range strings.Split(pfad, "/") { - if strings.HasPrefix(teil, ".") || teil == "__MACOSX" { +// isEntryFile entscheidet anhand des Pfads, ob eine Datei ein Eintrag +// werden soll (siehe ReadZip). +func isEntryFile(filePath string) bool { + for _, part := range strings.Split(filePath, "/") { + if strings.HasPrefix(part, ".") || part == "__MACOSX" { return false } } - name := path.Base(pfad) + name := path.Base(filePath) if strings.HasPrefix(name, "_") { return false } - endung := strings.ToLower(path.Ext(name)) - return endung == ".md" || endung == ".markdown" + suffix := strings.ToLower(path.Ext(name)) + return suffix == ".md" || suffix == ".markdown" } -// leseBegrenzt packt eine Datei aus und bricht ab, sobald sie größer als -// grenze wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die +// readLimited packt eine Datei aus und bricht ab, sobald sie größer als +// limit wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die // Datei zu groß. -func leseBegrenzt(f *zip.File, grenze int64) ([]byte, error) { +func readLimited(f *zip.File, limit int64) ([]byte, error) { r, err := f.Open() if err != nil { return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err) } defer r.Close() - inhalt, err := io.ReadAll(io.LimitReader(r, grenze+1)) + content, err := io.ReadAll(io.LimitReader(r, limit+1)) if err != nil { return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err) } - if int64(len(inhalt)) > grenze { - return nil, fmt.Errorf("größer als %d KB", grenze>>10) + if int64(len(content)) > limit { + return nil, fmt.Errorf("größer als %d KB", limit>>10) } - return inhalt, nil + return content, nil } diff --git a/internal/importer/importer.go b/internal/importer/importer.go index c9b9bdd..6c5ea71 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -35,150 +35,150 @@ import ( "munin/internal/store" ) -// Modus legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist. -type Modus string +// Mode legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist. +type Mode string const ( - ModusUeberspringen = Modus("skip") - ModusUeberschreiben = Modus("overwrite") + ModeSkip = Mode("skip") + ModeOverwrite = Mode("overwrite") ) -// Aktion ist das Ergebnis für eine einzelne Datei. -type Aktion string +// Action ist das Ergebnis für eine einzelne Datei. +type Action string const ( - AktionNeu = Aktion("neu") - AktionUeberschrieben = Aktion("überschrieben") - AktionUnveraendert = Aktion("unverändert") - AktionUebersprungen = Aktion("übersprungen") - AktionFehler = Aktion("Fehler") + ActionCreated = Action("neu") + ActionOverwritten = Action("überschrieben") + ActionUnchanged = Action("unverändert") + ActionSkipped = Action("übersprungen") + ActionFailed = Action("Fehler") ) -// Optionen steuern einen Import. -type Optionen struct { - Modus Modus +// Options steuern einen Import. +type Options struct { + Mode Mode - // Vorschau: nur ausrechnen, was passieren würde, nichts schreiben. - Vorschau bool + // Preview: nur ausrechnen, was passieren würde, nichts schreiben. + Preview bool - // Autor steht an Einträgen, deren Datei keinen Autor nennt, etwa bei + // Author steht an Einträgen, deren Datei keinen Autor nennt, etwa bei // vorhandener Dokumentation. Üblich ist der Name des importierenden // Zugangs. - Autor string + Author string } -// Zeile ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt. -type Zeile struct { - Pfad string +// Row ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt. +type Row struct { + Path string Slug string Title string - Aktion Aktion - Grund string // Erläuterung, etwa bei Fehler oder Übersprungen + Action Action + Reason string // Erläuterung, etwa bei Fehler oder Übersprungen } -// Bericht fasst einen Import zusammen. -type Bericht struct { - Zeilen []Zeile +// Report fasst einen Import zusammen. +type Report struct { + Rows []Row - Neu int - Ueberschrieben int - Unveraendert int - Uebersprungen int - Fehler int + Created int + Overwritten int + Unchanged int + Skipped int + Failed int - // Geschrieben sind die tatsächlich geschriebenen Einträge. Der Aufrufer + // Written sind die tatsächlich geschriebenen Einträge. Der Aufrufer // braucht sie, um den Suchindex nachzuziehen. Bei einer Vorschau leer. - Geschrieben []store.Entry + Written []store.Entry } -// Zusammenfassung ist eine Zeile für Meldung und Protokoll. -func (b Bericht) Zusammenfassung() string { +// Summary ist eine Zeile für Meldung und Protokoll. +func (b Report) Summary() string { return fmt.Sprintf("%d neu, %d überschrieben, %d unverändert, %d übersprungen, %d Fehler", - b.Neu, b.Ueberschrieben, b.Unveraendert, b.Uebersprungen, b.Fehler) + b.Created, b.Overwritten, b.Unchanged, b.Skipped, b.Failed) } -// Importiere liest die Dateien in das Projekt ein, oder rechnet bei -// opt.Vorschau nur aus, was passieren würde. +// Import liest die Dateien in das Projekt ein, oder rechnet bei +// opt.Preview nur aus, was passieren würde. // // Ein Fehler in einer einzelnen Datei bricht den Import nicht ab. Er steht im -// Bericht, und die übrigen Dateien laufen weiter. Nur ein Fehler der +// Report, und die übrigen Dateien laufen weiter. Nur ein Fehler der // Datenbank selbst beendet den Import. Was bis dahin geschrieben war, bleibt // geschrieben: Jeder Eintrag ist für sich vollständig oder gar nicht da (eine // Transaktion je Eintrag), aber der Import als Ganzes ist keine Transaktion. // Ihn danach im Modus „Überspringen“ zu wiederholen, macht den Rest. -func Importiere(ctx context.Context, s *store.Store, projectID string, dateien []Datei, opt Optionen) (Bericht, error) { - var b Bericht +func Import(ctx context.Context, s *store.Store, projectID string, files []File, opt Options) (Report, error) { + var b Report - if opt.Modus != ModusUeberspringen && opt.Modus != ModusUeberschreiben { - return b, fmt.Errorf("unbekannter Modus %q", opt.Modus) + if opt.Mode != ModeSkip && opt.Mode != ModeOverwrite { + return b, fmt.Errorf("unbekannter Modus %q", opt.Mode) } // Den Bestand einmal vorab laden statt je Datei nachzufragen: Bei einigen // tausend Dateien sind das zwei Abfragen statt einiger tausend. - vorhanden, err := s.AllEntries(ctx, projectID) + existing, err := s.AllEntries(ctx, projectID) if err != nil { return b, err } - bestand := make(map[string]store.Entry, len(vorhanden)) - for _, e := range vorhanden { - bestand[e.Slug] = e + inventory := make(map[string]store.Entry, len(existing)) + for _, e := range existing { + inventory[e.Slug] = e } // Einträge im Papierkorb belegen ihren Bezeichner weiter (siehe // store.PutEntry). Sie müssen eigens erkannt werden: Sonst holte ein // Import im Modus „Überspringen“ gelöschte Einträge stillschweigend // zurück, weil sie für AllEntries nicht existieren. - geloescht, err := s.ListTrash(ctx, projectID, "") + deleted, err := s.ListTrash(ctx, projectID, "") if err != nil { return b, err } - imPapierkorb := make(map[string]bool, len(geloescht)) - for _, e := range geloescht { - imPapierkorb[e.Slug] = true + inTrash := make(map[string]bool, len(deleted)) + for _, e := range deleted { + inTrash[e.Slug] = true } // Welche Datei hat welchen Bezeichner schon belegt? Zwei Dateien mit // demselben Bezeichner in einem Archiv (etwa "ops/deploy.md" und // "alt/deploy.md") würden sich sonst gegenseitig überschreiben. - belegtDurch := make(map[string]string) + usedBy := make(map[string]string) - for _, d := range dateien { - e, err := Parse(d.Pfad, d.Inhalt) - zeile := Zeile{Pfad: d.Pfad, Slug: e.Slug, Title: e.Title} + for _, d := range files { + e, err := Parse(d.Path, d.Content) + line := Row{Path: d.Path, Slug: e.Slug, Title: e.Title} switch { case err != nil: - zeile.Aktion, zeile.Grund = AktionFehler, err.Error() + line.Action, line.Reason = ActionFailed, err.Error() - case belegtDurch[e.Slug] != "": - zeile.Aktion = AktionFehler - zeile.Grund = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, belegtDurch[e.Slug]) + case usedBy[e.Slug] != "": + line.Action = ActionFailed + line.Reason = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, usedBy[e.Slug]) default: - belegtDurch[e.Slug] = d.Pfad - alt, gibtEs := bestand[e.Slug] + usedBy[e.Slug] = d.Path + old, exists := inventory[e.Slug] switch { - case gibtEs && gleicherInhalt(alt, e): - zeile.Aktion = AktionUnveraendert - case (gibtEs || imPapierkorb[e.Slug]) && opt.Modus == ModusUeberspringen: - zeile.Aktion = AktionUebersprungen - zeile.Grund = "Bezeichner schon vorhanden" - if imPapierkorb[e.Slug] { - zeile.Grund = "Bezeichner gehört einem Eintrag im Papierkorb" + case exists && sameContent(old, e): + line.Action = ActionUnchanged + case (exists || inTrash[e.Slug]) && opt.Mode == ModeSkip: + line.Action = ActionSkipped + line.Reason = "Bezeichner schon vorhanden" + if inTrash[e.Slug] { + line.Reason = "Bezeichner gehört einem Eintrag im Papierkorb" } - case gibtEs || imPapierkorb[e.Slug]: - zeile.Aktion = AktionUeberschrieben - zeile.Grund = "bisheriger Stand steht im Verlauf" - if imPapierkorb[e.Slug] { - zeile.Grund = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf" + case exists || inTrash[e.Slug]: + line.Action = ActionOverwritten + line.Reason = "bisheriger Stand steht im Verlauf" + if inTrash[e.Slug] { + line.Reason = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf" } default: - zeile.Aktion = AktionNeu + line.Action = ActionCreated } - if !opt.Vorschau && (zeile.Aktion == AktionNeu || zeile.Aktion == AktionUeberschrieben) { - geschrieben, err := s.PutEntry(ctx, schreibauftrag(projectID, e, opt.Autor, zeile.Aktion == AktionNeu)) + if !opt.Preview && (line.Action == ActionCreated || line.Action == ActionOverwritten) { + written, err := s.PutEntry(ctx, writeRequest(projectID, e, opt.Author, line.Action == ActionCreated)) if err != nil { // Ungültige Angaben (etwa ein zu langer Titel) meldet // PutEntry vor jedem Schreibzugriff. Sie betreffen nur @@ -187,38 +187,38 @@ func Importiere(ctx context.Context, s *store.Store, projectID string, dateien [ if ctx.Err() != nil { return b, ctx.Err() } - zeile.Aktion, zeile.Grund = AktionFehler, err.Error() + line.Action, line.Reason = ActionFailed, err.Error() } else { - b.Geschrieben = append(b.Geschrieben, geschrieben) + b.Written = append(b.Written, written) } } } - b.Zeilen = append(b.Zeilen, zeile) - switch zeile.Aktion { - case AktionNeu: - b.Neu++ - case AktionUeberschrieben: - b.Ueberschrieben++ - case AktionUnveraendert: - b.Unveraendert++ - case AktionUebersprungen: - b.Uebersprungen++ - case AktionFehler: - b.Fehler++ + b.Rows = append(b.Rows, line) + switch line.Action { + case ActionCreated: + b.Created++ + case ActionOverwritten: + b.Overwritten++ + case ActionUnchanged: + b.Unchanged++ + case ActionSkipped: + b.Skipped++ + case ActionFailed: + b.Failed++ } } return b, nil } -// schreibauftrag macht aus einer ausgewerteten Datei einen Schreibvorgang. +// writeRequest macht aus einer ausgewerteten Datei einen Schreibvorgang. // // Der Autor hängt davon ab, was passiert. Beim Anlegen bleibt der aus der // Datei, denn eine Wiederherstellung soll zeigen, wer den Eintrag // ursprünglich geschrieben hat. Beim Überschreiben steht der importierende // Zugang da: Diese Fassung hat er erzeugt, und so steht es auch im Verlauf. -func schreibauftrag(projectID string, e Eintrag, autor string, neu bool) store.EntryWrite { +func writeRequest(projectID string, e Entry, author string, isNew bool) store.EntryWrite { w := store.EntryWrite{ ProjectID: projectID, Slug: e.Slug, @@ -236,19 +236,19 @@ func schreibauftrag(projectID string, e Eintrag, autor string, neu bool) store.E if w.Source == "" { w.Source = store.SourceHuman } - if w.Author == "" || !neu { - w.Author = autor + if w.Author == "" || !isNew { + w.Author = author } return w } -// gleicherInhalt sagt, ob eine Datei nichts am vorhandenen Eintrag ändern +// sameContent sagt, ob eine Datei nichts am vorhandenen Eintrag ändern // würde. Verglichen wird, was ein Mensch als Inhalt ansieht: Titel, Text, // Schlagworte, Prüfvermerk. Autor und Zeitstempel zählen nicht. Sonst wäre // nach einem Export und Re-Import nie etwas „unverändert“. -func gleicherInhalt(alt store.Entry, neu Eintrag) bool { - return alt.Title == neu.Title && - alt.Content == neu.Content && - alt.Curated == neu.Curated && - slices.Equal(alt.Tags, store.NormalizeTags(neu.Tags)) +func sameContent(old store.Entry, newer Entry) bool { + return old.Title == newer.Title && + old.Content == newer.Content && + old.Curated == newer.Curated && + slices.Equal(old.Tags, store.NormalizeTags(newer.Tags)) } diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index e4538e2..69c0da3 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -16,7 +16,7 @@ import ( ) // testStore öffnet eine frische Datenbank mit einem Projekt. -func testStore(t *testing.T, projektSlug string) (*store.Store, store.Project) { +func testStore(t *testing.T, projectSlug string) (*store.Store, store.Project) { t.Helper() s, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { @@ -24,48 +24,48 @@ func testStore(t *testing.T, projektSlug string) (*store.Store, store.Project) { } t.Cleanup(func() { s.Close() }) - p, err := s.CreateProject(context.Background(), store.NewID(), projektSlug, "Projekt "+projektSlug, "") + p, err := s.CreateProject(context.Background(), store.NewID(), projectSlug, "Projekt "+projectSlug, "") if err != nil { t.Fatal(err) } return s, p } -// packe baut ein ZIP-Archiv aus Pfad → Inhalt. -func packe(t *testing.T, dateien map[string]string) []byte { +// pack baut ein ZIP-Archiv aus Pfad → Inhalt. +func pack(t *testing.T, files map[string]string) []byte { t.Helper() - var puffer bytes.Buffer - w := zip.NewWriter(&puffer) - for pfad, inhalt := range dateien { - f, err := w.Create(pfad) + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for filePath, content := range files { + f, err := w.Create(filePath) if err != nil { t.Fatal(err) } - f.Write([]byte(inhalt)) + f.Write([]byte(content)) } if err := w.Close(); err != nil { t.Fatal(err) } - return puffer.Bytes() + return buf.Bytes() } -// packeOrdner packt alle Dateien eines Ordners, so wie ein Mensch den +// packDir packt alle Dateien eines Ordners, so wie ein Mensch den // Exportordner eines Projekts zippen würde. -func packeOrdner(t *testing.T, ordner string) []byte { +func packDir(t *testing.T, dir string) []byte { t.Helper() - dateien := map[string]string{} - eintraege, err := os.ReadDir(ordner) + files := map[string]string{} + entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } - for _, e := range eintraege { - inhalt, err := os.ReadFile(filepath.Join(ordner, e.Name())) + for _, e := range entries { + content, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatal(err) } - dateien["projekt/"+e.Name()] = string(inhalt) + files["projekt/"+e.Name()] = string(content) } - return packe(t, dateien) + return pack(t, files) } // TestRoundTripFromExport ist der wichtigste Test: Was der Export schreibt, @@ -73,49 +73,49 @@ func packeOrdner(t *testing.T, ordner string) []byte { // Autor, Prüfvermerk und Änderungsdatum. func TestRoundTripFromExport(t *testing.T) { ctx := context.Background() - quelle, qp := testStore(t, "quelle") + source, qp := testStore(t, "quelle") - alt := time.Date(2023, 5, 1, 8, 30, 0, 0, time.UTC) + old := time.Date(2023, 5, 1, 8, 30, 0, 0, time.UTC) original := []store.EntryWrite{ {Slug: "deployment", Title: "Deployment: so geht's", Content: "Schritt 1\n\n# Keine Titelzeile\n\nsiehe [[rollback]]", Tags: []string{"ops", "release"}, Source: store.SourceAgent, Author: "agent-1", Curated: true, - CreatedAt: alt, UpdatedAt: alt}, + CreatedAt: old, UpdatedAt: old}, {Slug: "rollback", Title: `Rollback mit "Anführungszeichen"`, Content: "Zurück.\nZweite Zeile", Source: store.SourceHuman, Author: "anna"}, } for _, w := range original { w.ProjectID = qp.ID - if _, err := quelle.PutEntry(ctx, w); err != nil { + if _, err := source.PutEntry(ctx, w); err != nil { t.Fatal(err) } } - exportOrdner := t.TempDir() + exportDir := t.TempDir() qp.WorkspaceID = "ws-test" - if _, err := export.New(exportOrdner).ExportProject(ctx, quelle, qp); err != nil { + if _, err := export.New(exportDir).ExportProject(ctx, source, qp); err != nil { t.Fatal(err) } - dateien, err := LeseZip(packeOrdner(t, filepath.Join(exportOrdner, "ws-test", "quelle"))) + files, err := ReadZip(packDir(t, filepath.Join(exportDir, "ws-test", "quelle"))) if err != nil { t.Fatal(err) } - if len(dateien) != 2 { - t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(dateien)) + if len(files) != 2 { + t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(files)) } - ziel, zp := testStore(t, "ziel") - bericht, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "importeur"}) + target, zp := testStore(t, "ziel") + report, err := Import(ctx, target, zp.ID, files, Options{Mode: ModeSkip, Author: "importeur"}) if err != nil { t.Fatal(err) } - if bericht.Neu != 2 || bericht.Fehler != 0 { - t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen) + if report.Created != 2 || report.Failed != 0 { + t.Fatalf("Bericht: %s — %+v", report.Summary(), report.Rows) } for _, w := range original { - a, _ := quelle.EntryBySlug(ctx, qp.ID, w.Slug) - b, err := ziel.EntryBySlug(ctx, zp.ID, w.Slug) + a, _ := source.EntryBySlug(ctx, qp.ID, w.Slug) + b, err := target.EntryBySlug(ctx, zp.ID, w.Slug) if err != nil { t.Fatalf("%s fehlt nach dem Import: %v", w.Slug, err) } @@ -129,62 +129,62 @@ func TestRoundTripFromExport(t *testing.T) { } // Die Verweise sind wieder da, rollback wird von deployment verlinkt. - rueck, _ := ziel.Backlinks(ctx, zp.ID, "rollback") - if len(rueck) != 1 || rueck[0].Slug != "deployment" { - t.Errorf("Rückverweise nach dem Import: %+v", rueck) + back, _ := target.Backlinks(ctx, zp.ID, "rollback") + if len(back) != 1 || back[0].Slug != "deployment" { + t.Errorf("Rückverweise nach dem Import: %+v", back) } // Ein zweiter Lauf ändert nichts und erzeugt keine neuen Fassungen. - nochmal, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "importeur"}) + again, err := Import(ctx, target, zp.ID, files, Options{Mode: ModeOverwrite, Author: "importeur"}) if err != nil { t.Fatal(err) } - if nochmal.Unveraendert != 2 { - t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", nochmal.Zusammenfassung()) + if again.Unchanged != 2 { + t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", again.Summary()) } } -// TestRoundTripFromDownload: Das ZIP aus dem Download (export.SchreibeZIP) +// TestRoundTripFromDownload: Das ZIP aus dem Download (export.WriteZIP) // muss ohne Umweg wieder in den Import passen — genau dafür ist es da. func TestRoundTripFromDownload(t *testing.T) { ctx := context.Background() - quelle, qp := testStore(t, "quelle") + source, qp := testStore(t, "quelle") for _, w := range []store.EntryWrite{ {Slug: "deployment", Title: "Deployment", Content: "siehe [[rollback]]", Tags: []string{"ops"}, Source: store.SourceAgent, Author: "agent-1"}, {Slug: "rollback", Title: "Rollback", Content: "Zurück.", Source: store.SourceHuman, Author: "anna"}, } { w.ProjectID = qp.ID - if _, err := quelle.PutEntry(ctx, w); err != nil { + if _, err := source.PutEntry(ctx, w); err != nil { t.Fatal(err) } } - dateien, err := export.ProjektDateien(ctx, quelle, qp) + files, err := export.ProjectFiles(ctx, source, qp) if err != nil { t.Fatal(err) } var zip bytes.Buffer - if err := export.SchreibeZIP(&zip, dateien); err != nil { + if err := export.WriteZIP(&zip, files); err != nil { t.Fatal(err) } - gelesen, err := LeseZip(zip.Bytes()) + read, err := ReadZip(zip.Bytes()) if err != nil { t.Fatal(err) } - ziel, zp := testStore(t, "ziel") - bericht, err := Importiere(ctx, ziel, zp.ID, gelesen, Optionen{Modus: ModusUeberspringen, Autor: "importeur"}) + target, zp := testStore(t, "ziel") + report, err := Import(ctx, target, zp.ID, read, Options{Mode: ModeSkip, Author: "importeur"}) if err != nil { t.Fatal(err) } - if bericht.Neu != 2 || bericht.Fehler != 0 { - t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen) + if report.Created != 2 || report.Failed != 0 { + t.Fatalf("Bericht: %s — %+v", report.Summary(), report.Rows) } for _, slug := range []string{"deployment", "rollback"} { - a, _ := quelle.EntryBySlug(ctx, qp.ID, slug) - b, err := ziel.EntryBySlug(ctx, zp.ID, slug) + a, _ := source.EntryBySlug(ctx, qp.ID, slug) + b, err := target.EntryBySlug(ctx, zp.ID, slug) if err != nil { t.Fatalf("%s fehlt nach dem Import: %v", slug, err) } @@ -205,36 +205,36 @@ func TestConflictModes(t *testing.T) { t.Fatal(err) } - dateien := []Datei{ - {Pfad: "vorhanden.md", Inhalt: []byte("# Vorhanden\n\nneu")}, - {Pfad: "frisch.md", Inhalt: []byte("# Frisch\n\nText")}, + files := []File{ + {Path: "vorhanden.md", Content: []byte("# Vorhanden\n\nneu")}, + {Path: "frisch.md", Content: []byte("# Frisch\n\nText")}, } - // Vorschau schreibt nichts. - vorschau, err := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Vorschau: true, Autor: "imp"}) + // Preview schreibt nichts. + preview, err := Import(ctx, s, p.ID, files, Options{Mode: ModeOverwrite, Preview: true, Author: "imp"}) if err != nil { t.Fatal(err) } - if vorschau.Neu != 1 || vorschau.Ueberschrieben != 1 || len(vorschau.Geschrieben) != 0 { - t.Errorf("Vorschau: %s", vorschau.Zusammenfassung()) + if preview.Created != 1 || preview.Overwritten != 1 || len(preview.Written) != 0 { + t.Errorf("Vorschau: %s", preview.Summary()) } if _, err := s.EntryBySlug(ctx, p.ID, "frisch"); err == nil { t.Error("die Vorschau hat geschrieben") } // Überspringen lässt Vorhandenes in Ruhe. - ueber, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "imp"}) - if ueber.Neu != 1 || ueber.Uebersprungen != 1 { - t.Errorf("Überspringen: %s", ueber.Zusammenfassung()) + over, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip, Author: "imp"}) + if over.Created != 1 || over.Skipped != 1 { + t.Errorf("Überspringen: %s", over.Summary()) } if e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden"); e.Content != "alt" { t.Error("Überspringen hat überschrieben") } // Überschreiben legt eine neue Fassung an, die alte steht im Verlauf. - neu, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "imp"}) - if neu.Ueberschrieben != 1 || neu.Unveraendert != 1 { - t.Errorf("Überschreiben: %s", neu.Zusammenfassung()) + newer, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeOverwrite, Author: "imp"}) + if newer.Overwritten != 1 || newer.Unchanged != 1 { + t.Errorf("Überschreiben: %s", newer.Summary()) } e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden") if e.Content != "neu" || e.Version != 2 || e.Author != "imp" { @@ -253,10 +253,10 @@ func TestTrashIsNotRevivedWhenSkipping(t *testing.T) { t.Fatal(err) } - dateien := []Datei{{Pfad: "weg.md", Inhalt: []byte("# Weg\n\nwieder da")}} - b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen}) - if b.Uebersprungen != 1 || !strings.Contains(b.Zeilen[0].Grund, "Papierkorb") { - t.Errorf("Bericht: %s, Grund %q", b.Zusammenfassung(), b.Zeilen[0].Grund) + files := []File{{Path: "weg.md", Content: []byte("# Weg\n\nwieder da")}} + b, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip}) + if b.Skipped != 1 || !strings.Contains(b.Rows[0].Reason, "Papierkorb") { + t.Errorf("Bericht: %s, Grund %q", b.Summary(), b.Rows[0].Reason) } if _, err := s.EntryBySlug(ctx, p.ID, "weg"); err == nil { t.Error("der gelöschte Eintrag wurde zurückgeholt") @@ -267,13 +267,13 @@ func TestDuplicateSlugInArchive(t *testing.T) { ctx := context.Background() s, p := testStore(t, "test") - dateien := []Datei{ - {Pfad: "ops/deploy.md", Inhalt: []byte("eins")}, - {Pfad: "alt/deploy.md", Inhalt: []byte("zwei")}, + files := []File{ + {Path: "ops/deploy.md", Content: []byte("eins")}, + {Path: "alt/deploy.md", Content: []byte("zwei")}, } - b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen}) - if b.Neu != 1 || b.Fehler != 1 || !strings.Contains(b.Zeilen[1].Grund, "ops/deploy.md") { - t.Errorf("Bericht: %s — %+v", b.Zusammenfassung(), b.Zeilen) + b, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip}) + if b.Created != 1 || b.Failed != 1 || !strings.Contains(b.Rows[1].Reason, "ops/deploy.md") { + t.Errorf("Bericht: %s — %+v", b.Summary(), b.Rows) } } @@ -286,17 +286,17 @@ func TestParsePlainMarkdown(t *testing.T) { t.Errorf("gelesen: %+v", e) } - ohneUeberschrift, err := Parse("notiz.md", []byte("Nur Text")) + noHeading, err := Parse("notiz.md", []byte("Nur Text")) if err != nil { t.Fatal(err) } - if ohneUeberschrift.Title != "notiz" { - t.Errorf("Titel ohne Überschrift: %q", ohneUeberschrift.Title) + if noHeading.Title != "notiz" { + t.Errorf("Titel ohne Überschrift: %q", noHeading.Title) } } func TestParseForeignFrontmatter(t *testing.T) { - datei := `--- + file := `--- title: 'It''s a title' date: 2024-01-01 tags: @@ -306,7 +306,7 @@ draft: false --- Inhalt` - e, err := Parse("post.md", []byte(datei)) + e, err := Parse("post.md", []byte(file)) if err != nil { t.Fatal(err) } @@ -326,44 +326,44 @@ func TestParseInlineListWithCommaInQuotes(t *testing.T) { } func TestParseRejects(t *testing.T) { - faelle := map[string]string{ + cases := map[string]string{ "offener Kopf": "---\ntitle: x\nText ohne Ende", "leer": "---\ntitle: x\n---\n\n", "ungültiger slug": "---\nslug: \"Mit Leerzeichen\"\n---\nText", "nur eine Überschrift": "# Titel\n", } - for name, inhalt := range faelle { - if _, err := Parse("x.md", []byte(inhalt)); err == nil { + for name, content := range cases { + if _, err := Parse("x.md", []byte(content)); err == nil { t.Errorf("%s: kein Fehler", name) } } } -func TestLeseZipSkipsAndLimits(t *testing.T) { - archiv := packe(t, map[string]string{ +func TestReadZipSkipsAndLimits(t *testing.T) { + archive := pack(t, map[string]string{ "projekt/_index.md": "Übersicht", "projekt/eintrag.md": "Text", "projekt/bild.png": "PNG", "__MACOSX/projekt/._eintrag.md": "Müll", "projekt/.obsidian/workspace.md": "Einstellungen", }) - dateien, err := LeseZip(archiv) + files, err := ReadZip(archive) if err != nil { t.Fatal(err) } - if len(dateien) != 1 || dateien[0].Pfad != "projekt/eintrag.md" { - t.Errorf("gelesen: %+v", dateien) + if len(files) != 1 || files[0].Path != "projekt/eintrag.md" { + t.Errorf("gelesen: %+v", files) } - zuGross := packe(t, map[string]string{"gross.md": strings.Repeat("x", MaxDateiBytes+1)}) - if _, err := LeseZip(zuGross); err == nil { + tooLarge := pack(t, map[string]string{"gross.md": strings.Repeat("x", MaxFileBytes+1)}) + if _, err := ReadZip(tooLarge); err == nil { t.Error("eine zu große Datei wurde angenommen") } - if _, err := LeseZip([]byte("kein zip")); err == nil { + if _, err := ReadZip([]byte("kein zip")); err == nil { t.Error("Unsinn wurde als Archiv angenommen") } - if _, err := LeseZip(packe(t, map[string]string{"bild.png": "x"})); err == nil { + if _, err := ReadZip(pack(t, map[string]string{"bild.png": "x"})); err == nil { t.Error("ein Archiv ohne Markdown wurde angenommen") } } diff --git a/internal/importer/parse.go b/internal/importer/parse.go index 732af5e..21300a3 100644 --- a/internal/importer/parse.go +++ b/internal/importer/parse.go @@ -10,9 +10,9 @@ import ( "munin/internal/store" ) -// Eintrag ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben. -type Eintrag struct { - Pfad string // Pfad innerhalb des Archivs, für die Anzeige +// Entry ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben. +type Entry struct { + Path string // Pfad innerhalb des Archivs, für die Anzeige Slug string Title string Content string @@ -42,44 +42,44 @@ type Eintrag struct { // // Felder, die Munin nicht kennt, werden ignoriert. So scheitert der Import // nicht an einem Kopf aus Hugo, Obsidian oder Jekyll. -func Parse(pfad string, inhalt []byte) (Eintrag, error) { - e := Eintrag{Pfad: pfad} +func Parse(filePath string, content []byte) (Entry, error) { + e := Entry{Path: filePath} // Windows-Zeilenenden und ein Byte-Order-Mark am Anfang würden sonst den // Kopf unkenntlich machen. Beides kommt bei Dateien aus Editoren unter // Windows häufig vor. - text := strings.ReplaceAll(string(inhalt), "\r\n", "\n") + text := strings.ReplaceAll(string(content), "\r\n", "\n") text = strings.TrimPrefix(text, "\uFEFF") - kopf, rumpf, err := trenneKopf(text) + head, body, err := splitFrontmatter(text) if err != nil { return e, err } - for name, wert := range kopf { + for name, value := range head { switch name { case "slug": - e.Slug = wert.text + e.Slug = value.text case "title": - e.Title = wert.text + e.Title = value.text case "tags": - e.Tags = wert.liste - if len(e.Tags) == 0 && wert.text != "" { + e.Tags = value.list + if len(e.Tags) == 0 && value.text != "" { // "tags: ops" ohne Klammern: ein einzelnes Schlagwort. - e.Tags = []string{wert.text} + e.Tags = []string{value.text} } case "source": - if wert.text == store.SourceHuman || wert.text == store.SourceAgent { - e.Source = wert.text + if value.text == store.SourceHuman || value.text == store.SourceAgent { + e.Source = value.text } case "author": - e.Author = wert.text + e.Author = value.text case "curated": - e.Curated = wert.text == "true" + e.Curated = value.text == "true" case "created_at": - e.CreatedAt = leseZeit(wert.text) + e.CreatedAt = parseTimestamp(value.text) case "updated_at": - e.UpdatedAt = leseZeit(wert.text) + e.UpdatedAt = parseTimestamp(value.text) } } @@ -88,19 +88,19 @@ func Parse(pfad string, inhalt []byte) (Eintrag, error) { // wieder weg, sonst stünde sie nach jedem Export und Import einmal mehr // da. Eine Überschrift, die NICHT dem Titel entspricht, bleibt stehen: // Dann ist sie Teil des Textes. - rumpf = strings.TrimLeft(rumpf, "\n") - if ueberschrift, rest, ok := ersteUeberschrift(rumpf); ok { + body = strings.TrimLeft(body, "\n") + if heading, rest, ok := firstHeading(body); ok { switch { case e.Title == "": - e.Title = ueberschrift - rumpf = rest - case ueberschrift == e.Title: - rumpf = rest + e.Title = heading + body = rest + case heading == e.Title: + body = rest } } - e.Content = strings.TrimSpace(rumpf) + e.Content = strings.TrimSpace(body) - name := strings.TrimSuffix(path.Base(pfad), path.Ext(pfad)) + name := strings.TrimSuffix(path.Base(filePath), path.Ext(filePath)) if e.Title == "" { e.Title = name } @@ -117,20 +117,20 @@ func Parse(pfad string, inhalt []byte) (Eintrag, error) { return e, nil } -// ersteUeberschrift erkennt eine Überschrift erster Ebene („# Titel“) in der +// firstHeading erkennt eine Überschrift erster Ebene („# Titel“) in der // ersten Zeile und gibt sie samt dem Rest des Textes zurück. -func ersteUeberschrift(text string) (ueberschrift, rest string, ok bool) { - zeile, rest, _ := strings.Cut(text, "\n") - if !strings.HasPrefix(zeile, "# ") { +func firstHeading(text string) (heading, rest string, ok bool) { + line, rest, _ := strings.Cut(text, "\n") + if !strings.HasPrefix(line, "# ") { return "", text, false } - return strings.TrimSpace(zeile[2:]), rest, true + return strings.TrimSpace(line[2:]), rest, true } -// leseZeit versteht die Schreibweise des Exports (RFC 3339) und ein bloßes +// parseTimestamp versteht die Schreibweise des Exports (RFC 3339) und ein bloßes // Datum. Alles andere ergibt den Nullzeitpunkt, also „jetzt“ beim Anlegen. // Ein unlesbares Datum ist kein Grund, eine ganze Datei abzulehnen. -func leseZeit(s string) time.Time { +func parseTimestamp(s string) time.Time { for _, format := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} { if t, err := time.Parse(format, s); err == nil { return t @@ -141,14 +141,14 @@ func leseZeit(s string) time.Time { // ── Kopf (Frontmatter) ─────────────────────────────────────────────────────── -// kopfWert ist ein Feld aus dem Kopf: entweder ein einzelner Text oder eine +// headValue ist ein Feld aus dem Kopf: entweder ein einzelner Text oder eine // Liste. -type kopfWert struct { - text string - liste []string +type headValue struct { + text string + list []string } -// trenneKopf trennt einen Kopf zwischen zwei Zeilen „---“ vom Rest der Datei. +// splitFrontmatter trennt einen Kopf zwischen zwei Zeilen „---“ vom Rest der Datei. // Ohne Kopf ist die ganze Datei der Rumpf. // // # Warum kein YAML-Paket? @@ -168,59 +168,59 @@ type kopfWert struct { // tags: // - a // - b -func trenneKopf(text string) (map[string]kopfWert, string, error) { +func splitFrontmatter(text string) (map[string]headValue, string, error) { if !strings.HasPrefix(text, "---\n") { return nil, text, nil } - zeilen := strings.Split(text, "\n") - kopf := make(map[string]kopfWert) - var offeneListe string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt + lines := strings.Split(text, "\n") + head := make(map[string]headValue) + var openList string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt - for i := 1; i < len(zeilen); i++ { - zeile := zeilen[i] + for i := 1; i < len(lines); i++ { + line := lines[i] - if strings.TrimSpace(zeile) == "---" { - return kopf, strings.Join(zeilen[i+1:], "\n"), nil + if strings.TrimSpace(line) == "---" { + return head, strings.Join(lines[i+1:], "\n"), nil } // Listenpunkt einer Blockliste, etwa " - ops" unter "tags:". - if eingerueckt := strings.TrimSpace(zeile); offeneListe != "" && strings.HasPrefix(eingerueckt, "- ") { - w := kopf[offeneListe] - w.liste = append(w.liste, einzelwert(strings.TrimSpace(eingerueckt[2:]))) - kopf[offeneListe] = w + if indented := strings.TrimSpace(line); openList != "" && strings.HasPrefix(indented, "- ") { + w := head[openList] + w.list = append(w.list, singleValue(strings.TrimSpace(indented[2:]))) + head[openList] = w continue } - offeneListe = "" + openList = "" - if strings.TrimSpace(zeile) == "" || strings.HasPrefix(strings.TrimSpace(zeile), "#") { + if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { continue } - name, wert, ok := strings.Cut(zeile, ":") + name, value, ok := strings.Cut(line, ":") if !ok { continue // keine Zuweisung, etwas, das dieser kleine Leser nicht kennt } name = strings.TrimSpace(name) - wert = strings.TrimSpace(wert) + value = strings.TrimSpace(value) switch { - case wert == "": - offeneListe = name - kopf[name] = kopfWert{} - case strings.HasPrefix(wert, "["): - kopf[name] = kopfWert{liste: inlineListe(wert)} + case value == "": + openList = name + head[name] = headValue{} + case strings.HasPrefix(value, "["): + head[name] = headValue{list: inlineList(value)} default: - kopf[name] = kopfWert{text: einzelwert(wert)} + head[name] = headValue{text: singleValue(value)} } } return nil, "", errors.New("der Kopf der Datei (zwischen den Zeilen \"---\") ist nicht abgeschlossen") } -// einzelwert entfernt Anführungszeichen und löst die Maskierungen auf, die +// singleValue entfernt Anführungszeichen und löst die Maskierungen auf, die // der Export schreibt (siehe export.yamlWert). -func einzelwert(s string) string { +func singleValue(s string) string { switch { case len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"': return strings.NewReplacer(`\\`, `\`, `\"`, `"`, `\n`, "\n", `\t`, "\t").Replace(s[1 : len(s)-1]) @@ -231,44 +231,44 @@ func einzelwert(s string) string { return s } -// inlineListe zerlegt „[a, "b, c", d]“ in seine Bestandteile. Kommas innerhalb +// inlineList zerlegt „[a, "b, c", d]“ in seine Bestandteile. Kommas innerhalb // von Anführungszeichen trennen nicht. -func inlineListe(s string) []string { +func inlineList(s string) []string { s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "[") s = strings.TrimSuffix(s, "]") - var teile []string - var aktuell strings.Builder - var inZeichen byte // 0 = außerhalb, sonst das öffnende Anführungszeichen + var parts []string + var current strings.Builder + var inChars byte // 0 = außerhalb, sonst das öffnende Anführungszeichen - fertig := func() { - if t := strings.TrimSpace(aktuell.String()); t != "" { - teile = append(teile, einzelwert(t)) + done := func() { + if t := strings.TrimSpace(current.String()); t != "" { + parts = append(parts, singleValue(t)) } - aktuell.Reset() + current.Reset() } for i := 0; i < len(s); i++ { c := s[i] switch { - case inZeichen == '"' && c == '\\' && i+1 < len(s): - // Maskiertes Zeichen: beide Bytes übernehmen, einzelwert löst es auf. - aktuell.WriteByte(c) + case inChars == '"' && c == '\\' && i+1 < len(s): + // Maskiertes Zeichen: beide Bytes übernehmen, singleValue löst es auf. + current.WriteByte(c) i++ - aktuell.WriteByte(s[i]) - case inZeichen != 0 && c == inZeichen: - inZeichen = 0 - aktuell.WriteByte(c) - case inZeichen == 0 && (c == '"' || c == '\''): - inZeichen = c - aktuell.WriteByte(c) - case inZeichen == 0 && c == ',': - fertig() + current.WriteByte(s[i]) + case inChars != 0 && c == inChars: + inChars = 0 + current.WriteByte(c) + case inChars == 0 && (c == '"' || c == '\''): + inChars = c + current.WriteByte(c) + case inChars == 0 && c == ',': + done() default: - aktuell.WriteByte(c) + current.WriteByte(c) } } - fertig() - return teile + done() + return parts } diff --git a/internal/importer/postgres_test.go b/internal/importer/postgres_test.go index 1625178..6e24360 100644 --- a/internal/importer/postgres_test.go +++ b/internal/importer/postgres_test.go @@ -10,7 +10,7 @@ import ( "munin/internal/store" ) -// TestRundreisePostgres prüft Export und Import gegen PostgreSQL: Ein Projekt +// TestRoundTripPostgres prüft Export und Import gegen PostgreSQL: Ein Projekt // wird als ZIP exportiert (derselbe Weg wie „als ZIP herunterladen“) und in // einen zweiten, leeren Workspace importiert. Danach müssen dieselben Einträge // mit demselben Inhalt dastehen. @@ -18,7 +18,7 @@ import ( // Läuft nur mit MUNIN_TEST_PG (siehe internal/store/postgres_test.go). Export // und Import lesen und schreiben ausschließlich über den Store, sind also // nicht an SQLite gebunden — dieser Test belegt das. -func TestRundreisePostgres(t *testing.T) { +func TestRoundTripPostgres(t *testing.T) { url := os.Getenv("MUNIN_TEST_PG") if url == "" { t.Skip("MUNIN_TEST_PG ist nicht gesetzt — PostgreSQL-Test übersprungen") @@ -32,7 +32,7 @@ func TestRundreisePostgres(t *testing.T) { t.Cleanup(func() { sp.Close() }) // Zwei frische Workspace-Schemas, nach dem Test wieder weg. - oeffne := func() *store.Store { + open := func() *store.Store { id := store.NewID() s, err := sp.OpenWorkspace(id) if err != nil { @@ -44,11 +44,11 @@ func TestRundreisePostgres(t *testing.T) { }) return s } - quelle, ziel := oeffne(), oeffne() + source, target := open(), open() // Quelle: ein Projekt mit Schlagworten, Verweis, Umlauten und einem // kuratierten Eintrag. - projekt, err := quelle.CreateProject(ctx, "ws", "plattform", "Plattform", "") + project, err := source.CreateProject(ctx, "ws", "plattform", "Plattform", "") if err != nil { t.Fatal(err) } @@ -58,50 +58,50 @@ func TestRundreisePostgres(t *testing.T) { {Slug: "vpn", Title: "VPN-Zugang", Content: "Zugang über vpn.intern — Änderungen bei der IT.", Tags: []string{"ops", "zugang"}, Source: store.SourceAgent}, } { - e.ProjectID = projekt.ID - if _, err := quelle.PutEntry(ctx, e); err != nil { + e.ProjectID = project.ID + if _, err := source.PutEntry(ctx, e); err != nil { t.Fatal(err) } } // Export als ZIP und wieder auspacken — wie Download und Upload. - dateien, err := export.ProjektDateien(ctx, quelle, projekt) + files, err := export.ProjectFiles(ctx, source, project) if err != nil { t.Fatal(err) } var zip bytes.Buffer - if err := export.SchreibeZIP(&zip, dateien); err != nil { + if err := export.WriteZIP(&zip, files); err != nil { t.Fatal(err) } - gelesen, err := LeseZip(zip.Bytes()) + read, err := ReadZip(zip.Bytes()) if err != nil { t.Fatal(err) } - zielProjekt, err := ziel.CreateProject(ctx, "ws", "plattform", "Plattform", "") + targetProject, err := target.CreateProject(ctx, "ws", "plattform", "Plattform", "") if err != nil { t.Fatal(err) } - bericht, err := Importiere(ctx, ziel, zielProjekt.ID, gelesen, Optionen{Modus: ModusUeberspringen}) + report, err := Import(ctx, target, targetProject.ID, read, Options{Mode: ModeSkip}) if err != nil { t.Fatal(err) } - if bericht.Neu != 2 || bericht.Fehler != 0 { - t.Fatalf("Import: %d neu, %d Fehler, erwartet 2 neu und 0 Fehler: %+v", bericht.Neu, bericht.Fehler, bericht.Zeilen) + if report.Created != 2 || report.Failed != 0 { + t.Fatalf("Import: %d neu, %d Fehler, erwartet 2 neu und 0 Fehler: %+v", report.Created, report.Failed, report.Rows) } for _, slug := range []string{"deployment", "vpn"} { - vorher, err := quelle.EntryBySlug(ctx, projekt.ID, slug) + before, err := source.EntryBySlug(ctx, project.ID, slug) if err != nil { t.Fatal(err) } - nachher, err := ziel.EntryBySlug(ctx, zielProjekt.ID, slug) + after, err := target.EntryBySlug(ctx, targetProject.ID, slug) if err != nil { t.Fatalf("%s fehlt nach dem Import: %v", slug, err) } - if nachher.Title != vorher.Title || nachher.Content != vorher.Content || - nachher.Curated != vorher.Curated || len(nachher.Tags) != len(vorher.Tags) { - t.Errorf("%s unterscheidet sich:\nvorher: %+v\nnachher: %+v", slug, vorher, nachher) + if after.Title != before.Title || after.Content != before.Content || + after.Curated != before.Curated || len(after.Tags) != len(before.Tags) { + t.Errorf("%s unterscheidet sich:\nvorher: %+v\nnachher: %+v", slug, before, after) } } } diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go index e95e281..49193b5 100644 --- a/internal/markdown/markdown.go +++ b/internal/markdown/markdown.go @@ -1,6 +1,6 @@ // Package markdown ist die eine Stelle, an der Munin Markdown in HTML wandelt — // für Einträge (internal/entry) genauso wie für die eingebaute Hilfe -// (internal/hilfe). +// (internal/help). // // # Warum ein eigenes Paket? // @@ -34,14 +34,14 @@ import ( "munin/internal/store" ) -// neuerRenderer baut einen goldmark-Renderer mit den Erweiterungen, die überall +// newRenderer baut einen goldmark-Renderer mit den Erweiterungen, die überall // gelten. Zusätzliche Parser-Optionen (etwa Überschrift-Anker) kommen als // Parameter dazu. // // Das "..." vor dem Typ heißt in Go "beliebig viele Werte dieses Typs" -// (variadische Funktion) — man kann neuerRenderer() also auch ganz ohne +// (variadische Funktion) — man kann newRenderer() also auch ganz ohne // Zusatzoptionen aufrufen. -func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown { +func newRenderer(extra ...parser.Option) goldmark.Markdown { return goldmark.New( // GFM ("GitHub Flavored Markdown") bündelt vier Erweiterungen: // Tabellen, ~~Durchstreichen~~, Checklisten (- [ ] / - [x]) und @@ -49,7 +49,7 @@ func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown { // Das ist, was die meisten aus GitHub oder GitLab gewohnt sind — und was // Agenten ohnehin schreiben. goldmark.WithExtensions(extension.GFM), - goldmark.WithParserOptions(zusatz...), + goldmark.WithParserOptions(extra...), ) } @@ -58,48 +58,48 @@ func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown { // Renderer hat keinen Zustand, der sich zwischen zwei Aufrufen ändert, und ist // damit auch bei gleichzeitigen Anfragen sicher. var ( - // ohneAnker ist für Einträge. Überschriften bekommen hier bewusst KEINE + // withoutAnchors ist für Einträge. Überschriften bekommen hier bewusst KEINE // id-Attribute: Eintragstexte kommen von Menschen und Agenten, und eine // Überschrift wie "## suche" ergäbe id="suche" — die könnte mit einer id // der Seite selbst zusammenstoßen, auf die sich das JavaScript verlässt. - ohneAnker = neuerRenderer() + withoutAnchors = newRenderer() - // mitAnker ist für die Hilfe. Jede Überschrift bekommt eine id aus ihrem + // withAnchors ist für die Hilfe. Jede Überschrift bekommt eine id aus ihrem // Text ("## Markdown im Inhalt" → id="markdown-im-inhalt"), damit Links // aus der Oberfläche direkt auf einen Abschnitt zeigen können // (AUSBAUSTUFEN.md 5.4). Die Hilfeseiten liegen im Repository, ihre // Überschriften sind also bekannt und unbedenklich. - mitAnker = neuerRenderer(parser.WithAutoHeadingID()) + withAnchors = newRenderer(parser.WithAutoHeadingID()) ) // Render wandelt Markdown in HTML, ohne Überschrift-Anker. Für Einträge. -func Render(quelle string) (string, error) { - return umwandeln(ohneAnker, quelle) +func Render(source string) (string, error) { + return convert(withoutAnchors, source) } -// RenderMitAnkern wandelt Markdown in HTML und gibt jeder Überschrift eine id. +// RenderWithAnchors wandelt Markdown in HTML und gibt jeder Überschrift eine id. // Für die Hilfe. -func RenderMitAnkern(quelle string) (string, error) { +func RenderWithAnchors(source string) (string, error) { // Jede Seite bekommt ihren eigenen Anker-Generator. Er merkt sich, welche // ids schon vergeben sind — und das darf nicht in die nächste Seite // hinüberwirken (und bei gleichzeitigen Anfragen schon gar nicht). - kontext := parser.NewContext(parser.WithIDs(&ankerIDs{vergeben: map[string]bool{}})) - return umwandeln(mitAnker, quelle, parser.WithContext(kontext)) + context := parser.NewContext(parser.WithIDs(&anchorIDs{assigned: map[string]bool{}})) + return convert(withAnchors, source, parser.WithContext(context)) } -// umwandeln ist der gemeinsame Teil beider Funktionen. -func umwandeln(renderer goldmark.Markdown, quelle string, optionen ...parser.ParseOption) (string, error) { +// convert ist der gemeinsame Teil beider Funktionen. +func convert(renderer goldmark.Markdown, source string, options ...parser.ParseOption) (string, error) { // bytes.Buffer ist ein wachsender Speicher, in den goldmark das Ergebnis // schreibt. Das & übergibt einen Zeiger darauf, damit goldmark genau diesen // Puffer füllt und nicht eine Kopie davon. - var puffer bytes.Buffer - if err := renderer.Convert([]byte(quelle), &puffer, optionen...); err != nil { + var buf bytes.Buffer + if err := renderer.Convert([]byte(source), &buf, options...); err != nil { return "", fmt.Errorf("Markdown konnte nicht dargestellt werden: %w", err) } - return puffer.String(), nil + return buf.String(), nil } -// ankerIDs erzeugt die id-Attribute der Überschriften in der Hilfe. +// anchorIDs erzeugt die id-Attribute der Überschriften in der Hilfe. // // # Warum nicht goldmarks eingebaute Anker? // @@ -115,34 +115,34 @@ func umwandeln(renderer goldmark.Markdown, quelle string, optionen ...parser.Par // goldmark erwartet hier ein Interface (parser.IDs) mit zwei Methoden: // Generate und Put. In Go muss man ein Interface nicht ausdrücklich // "implementieren" — es reicht, dass der Typ Methoden mit genau diesen Namen -// und Signaturen hat. Der Compiler prüft das an der Stelle, wo &ankerIDs{…} +// und Signaturen hat. Der Compiler prüft das an der Stelle, wo &anchorIDs{…} // an parser.WithIDs übergeben wird. -type ankerIDs struct { - vergeben map[string]bool +type anchorIDs struct { + assigned map[string]bool } // Generate bildet aus dem Text einer Überschrift eine id. Kommt dieselbe // Überschrift zweimal vor, bekommt die zweite ein "-1" angehängt, die dritte // ein "-2" — ids müssen auf einer Seite eindeutig sein. -func (a *ankerIDs) Generate(text []byte, _ ast.NodeKind) []byte { - basis := store.Slugify(string(text)) - if basis == "" { +func (a *anchorIDs) Generate(text []byte, _ ast.NodeKind) []byte { + base := store.Slugify(string(text)) + if base == "" { // Eine Überschrift nur aus Sonderzeichen ("## ???") — dann eben // ein neutraler Name, eindeutig gemacht wie jeder andere. - basis = "abschnitt" + base = "abschnitt" } - id := basis - for nummer := 1; a.vergeben[id]; nummer++ { - id = fmt.Sprintf("%s-%d", basis, nummer) + id := base + for number := 1; a.assigned[id]; number++ { + id = fmt.Sprintf("%s-%d", base, number) } - a.vergeben[id] = true + a.assigned[id] = true return []byte(id) } // Put merkt sich eine von Hand vergebene id, damit Generate sie nicht noch // einmal erzeugt. (Von Hand vergebene ids nutzen die Hilfeseiten derzeit // nicht, goldmark verlangt die Methode aber.) -func (a *ankerIDs) Put(id []byte) { - a.vergeben[string(id)] = true +func (a *anchorIDs) Put(id []byte) { + a.assigned[string(id)] = true } diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go index 1aa2843..00ca16a 100644 --- a/internal/markdown/markdown_test.go +++ b/internal/markdown/markdown_test.go @@ -5,11 +5,11 @@ import ( "testing" ) -// TestGFMErweiterungen: Tabellen, Durchstreichen, Checklisten und automatische +// TestGFMExtensions: Tabellen, Durchstreichen, Checklisten und automatische // Links funktionieren — in Einträgen genauso wie in der Hilfe (AUSBAUSTUFEN.md 5.3). -func TestGFMErweiterungen(t *testing.T) { - faelle := []struct { - name, quelle, erwartet string +func TestGFMExtensions(t *testing.T) { + cases := []struct { + name, source, want string }{ {"Tabelle", "| a | b |\n|---|---|\n| 1 | 2 |", "<table>"}, {"Durchstreichen", "~~veraltet~~", "<del>veraltet</del>"}, @@ -21,50 +21,50 @@ func TestGFMErweiterungen(t *testing.T) { // Beide Renderer prüfen: Sie sollen sich nur in den Ankern unterscheiden. renderer := map[string]func(string) (string, error){ "Render": Render, - "RenderMitAnkern": RenderMitAnkern, + "RenderMitAnkern": RenderWithAnchors, } for rName, render := range renderer { - for _, f := range faelle { - html, err := render(f.quelle) + for _, f := range cases { + html, err := render(f.source) if err != nil { t.Fatalf("%s/%s: %v", rName, f.name, err) } - if !strings.Contains(html, f.erwartet) { - t.Errorf("%s/%s: %q fehlt in %q", rName, f.name, f.erwartet, html) + if !strings.Contains(html, f.want) { + t.Errorf("%s/%s: %q fehlt in %q", rName, f.name, f.want, html) } } } } -// TestKeinRohHTML ist der wichtigste Test dieses Pakets: Die Weboberfläche setzt +// TestNoRawHTML ist der wichtigste Test dieses Pakets: Die Weboberfläche setzt // das Ergebnis ohne weiteres Escaping in die Seite. Das geht nur, solange hier // weder Skripte noch gefährliche Link-Ziele durchkommen — auch nicht mit den // GFM-Erweiterungen. -func TestKeinRohHTML(t *testing.T) { - boese := []string{ +func TestNoRawHTML(t *testing.T) { + evil := []string{ "<script>alert(1)</script>", `<img src=x onerror="alert(1)">`, "[klick](javascript:alert(1))", "| a |\n|---|\n| <script>alert(1)</script> |", "- [ ] <script>alert(1)</script>", } - for _, quelle := range boese { - for _, render := range []func(string) (string, error){Render, RenderMitAnkern} { - html, _ := render(quelle) + for _, source := range evil { + for _, render := range []func(string) (string, error){Render, RenderWithAnchors} { + html, _ := render(source) if strings.Contains(html, "<script") || strings.Contains(html, "onerror") || strings.Contains(html, "javascript:") { - t.Errorf("gefährlicher Inhalt durchgelassen: %q → %q", quelle, html) + t.Errorf("gefährlicher Inhalt durchgelassen: %q → %q", source, html) } } } } -// TestAnker: Nur die Hilfe bekommt ids an den Überschriften, mit gefalteten +// TestAnchor: Nur die Hilfe bekommt ids an den Überschriften, mit gefalteten // Umlauten und eindeutig je Seite. -func TestAnker(t *testing.T) { - quelle := "## Zugänge verwalten\n\n## Protokoll\n\n## Protokoll\n\n## ???" +func TestAnchor(t *testing.T) { + source := "## Zugänge verwalten\n\n## Protokoll\n\n## Protokoll\n\n## ???" - html, err := RenderMitAnkern(quelle) + html, err := RenderWithAnchors(source) if err != nil { t.Fatal(err) } @@ -76,15 +76,15 @@ func TestAnker(t *testing.T) { // Ein zweiter Aufruf fängt wieder von vorn an — die vergebenen ids der // ersten Seite dürfen nicht hinüberwirken. - nochmal, _ := RenderMitAnkern("## Protokoll") - if !strings.Contains(nochmal, `id="protokoll"`) { - t.Errorf("zweite Seite bekommt nicht die einfache id: %q", nochmal) + again, _ := RenderWithAnchors("## Protokoll") + if !strings.Contains(again, `id="protokoll"`) { + t.Errorf("zweite Seite bekommt nicht die einfache id: %q", again) } // Einträge: keine ids, damit Eintragstext nicht mit ids der Seite // kollidieren kann. - eintrag, _ := Render(quelle) - if strings.Contains(eintrag, "id=") { - t.Errorf("Eintrag hat Überschrift-ids bekommen: %q", eintrag) + entry, _ := Render(source) + if strings.Contains(entry, "id=") { + t.Errorf("Eintrag hat Überschrift-ids bekommen: %q", entry) } } diff --git a/internal/mcpserver/gleichzeitig_test.go b/internal/mcpserver/concurrency_test.go similarity index 60% rename from internal/mcpserver/gleichzeitig_test.go rename to internal/mcpserver/concurrency_test.go index 1f947f1..c9488ec 100644 --- a/internal/mcpserver/gleichzeitig_test.go +++ b/internal/mcpserver/concurrency_test.go @@ -14,24 +14,24 @@ import ( "munin/internal/workspace" ) -// verbinde öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann. +// connect öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann. // -// Anders als ruf meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf +// Anders als call meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf // darf nur aus der Goroutine des Tests selbst kommen, nicht aus den // nebenläufigen Agenten unten. -func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name string) (*mcp.ClientSession, func(), error) { +func connect(h *Handler, ws *workspace.Workspace, project store.Project, name string) (*mcp.ClientSession, func(), error) { sess := web.Session{ Subject: "key:" + ws.ID + ":" + name, WorkspaceID: ws.ID, - Key: store.APIKey{ID: name, Name: name, ProjectID: projekt.ID, Role: string(auth.RoleWrite)}, - Project: projekt, + Key: store.APIKey{ID: name, Name: name, ProjectID: project.ID, Role: string(auth.RoleWrite)}, + Project: project, Role: auth.RoleWrite, } ctx := web.WithSessionForTest(context.Background(), sess) ctx = web.WithWorkspaceForTest(ctx, ws) clientTransport, serverTransport := mcp.NewInMemoryTransports() - serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil) + serverSession, err := h.serverFor(project).Connect(ctx, serverTransport, nil) if err != nil { return nil, nil, err } @@ -41,14 +41,14 @@ func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name s serverSession.Close() return nil, nil, err } - schliessen := func() { + closeIt := func() { clientSession.Close() serverSession.Close() } - return clientSession, schliessen, nil + return clientSession, closeIt, nil } -// TestGleichzeitigesSchreibenHaeltSuchindexAktuell prüft, dass der Suchindex +// TestConcurrentWritesKeepSearchIndexCurrent prüft, dass der Suchindex // nach gleichzeitigen Schreibvorgängen auf denselben Eintrag den Stand der // Datenbank kennt. // @@ -60,62 +60,62 @@ func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name s // Jeder Schreibvorgang bekommt deshalb ein eigenes, eindeutiges Wort. Am Ende // muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden // sein. -func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) { - h, ws, projekt := aufbauMitBudget(t, 100000) +func TestConcurrentWritesKeepSearchIndexCurrent(t *testing.T) { + h, ws, project := setupWithBudget(t, 100000) ctx := context.Background() // Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der // Test würde nichts prüfen (siehe Registry.Update). - idx, err := ws.Search.For(ctx, projekt.ID) + idx, err := ws.Search.For(ctx, project.ID) if err != nil { t.Fatal(err) } - const runden, agenten, jeAgent = 20, 8, 10 + const rounds, agents, perAgent = 20, 8, 10 - for runde := 0; runde < runden; runde++ { - slug := fmt.Sprintf("umkaempft-%02d", runde) + for round := 0; round < rounds; round++ { + slug := fmt.Sprintf("umkaempft-%02d", round) var wg sync.WaitGroup var mu sync.Mutex - var fehler []error + var errs []error - for a := 0; a < agenten; a++ { + for a := 0; a < agents; a++ { wg.Add(1) go func(a int) { defer wg.Done() - melde := func(err error) { + report := func(err error) { mu.Lock() - fehler = append(fehler, err) + errs = append(errs, err) mu.Unlock() } - client, schliessen, err := verbinde(h, ws, projekt, fmt.Sprintf("agent-%d", a)) + client, closeIt, err := connect(h, ws, project, fmt.Sprintf("agent-%d", a)) if err != nil { - melde(err) + report(err) return } - defer schliessen() + defer closeIt() - for i := 0; i < jeAgent; i++ { + for i := 0; i < perAgent; i++ { // Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt. - wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende") - erg, err := client.CallTool(ctx, &mcp.CallToolParams{ + word := fmt.Sprintf("marke%sx%s", letters(round*1000+a*100+i), "ende") + res, err := client.CallTool(ctx, &mcp.CallToolParams{ Name: "remember", // force: Jede Runde legt unter neuem Bezeichner mit // demselben Titel an. Die Dublettenprüfung würde das // zu Recht anhalten — hier geht es aber ums // gleichzeitige Schreiben, nicht um Dubletten. Arguments: map[string]any{ - "slug": slug, "title": "Umkämpft", "content": "Stand " + wort, "force": true, + "slug": slug, "title": "Umkämpft", "content": "Stand " + word, "force": true, }, }) if err != nil { - melde(err) + report(err) return } - if erg.IsError { - melde(fmt.Errorf("remember: %s", text(erg))) + if res.IsError { + report(fmt.Errorf("remember: %s", text(res))) return } } @@ -123,37 +123,37 @@ func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) { } wg.Wait() - if len(fehler) > 0 { - t.Fatalf("Runde %d: %d Fehler, erster: %v", runde, len(fehler), fehler[0]) + if len(errs) > 0 { + t.Fatalf("Runde %d: %d Fehler, erster: %v", round, len(errs), errs[0]) } - e, err := ws.Store.EntryBySlug(ctx, projekt.ID, slug) + e, err := ws.Store.EntryBySlug(ctx, project.ID, slug) if err != nil { t.Fatal(err) } - if e.Version != agenten*jeAgent { + if e.Version != agents*perAgent { t.Errorf("Runde %d: Fassung %d, erwartet %d — ein Schreibvorgang ging verloren", - runde, e.Version, agenten*jeAgent) + round, e.Version, agents*perAgent) } // Das Wort der letzten Fassung steht nach "Stand " im Inhalt. - letztesWort := e.Content[len("Stand "):] - gefunden := false - for _, r := range idx.Search(letztesWort, 5) { + lastWord := e.Content[len("Stand "):] + found := false + for _, r := range idx.Search(lastWord, 5) { if r.DocID == slug { - gefunden = true + found = true } } - if !gefunden { + if !found { t.Errorf("Runde %d: Datenbank hat Fassung %d mit %q, der Suchindex kennt das Wort nicht — "+ - "er hält einen älteren Stand", runde, e.Version, letztesWort) + "er hält einen älteren Stand", round, e.Version, lastWord) } } } -// buchstaben schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9. +// letters schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9. // So entsteht für jeden Schreibvorgang ein eindeutiges Wort ohne Ziffern. -func buchstaben(n int) string { +func letters(n int) string { s := fmt.Sprintf("%06d", n) b := []byte(s) for i := range b { diff --git a/internal/mcpserver/kuratiert.go b/internal/mcpserver/curated.go similarity index 82% rename from internal/mcpserver/kuratiert.go rename to internal/mcpserver/curated.go index 0c975d4..b5fdac0 100644 --- a/internal/mcpserver/kuratiert.go +++ b/internal/mcpserver/curated.go @@ -8,7 +8,7 @@ package mcpserver // still: Der Agent erfuhr nicht, dass er geprüftes Wissen ersetzt hatte, und // kein Mensch bekam es mit. // -// Jetzt läuft es in zwei Schritten, wie bei den Dubletten (dubletten.go): +// Jetzt läuft es in zwei Schritten, wie bei den Dubletten (duplicates.go): // // 1. remember auf einen kuratierten Eintrag schreibt NICHT, sondern meldet // judgment_required mit einer Erklärung. @@ -37,9 +37,9 @@ import ( "munin/internal/workspace" ) -// istKuratiert prüft, ob unter slug ein kuratierter Eintrag liegt. +// isCurated prüft, ob unter slug ein kuratierter Eintrag liegt. // Gibt es den Eintrag nicht, ist er auch nicht kuratiert. -func istKuratiert(ctx context.Context, ws *workspace.Workspace, project store.Project, slug string) (bool, error) { +func isCurated(ctx context.Context, ws *workspace.Workspace, project store.Project, slug string) (bool, error) { e, err := ws.Store.EntryBySlug(ctx, project.ID, slug) if errors.Is(err, store.ErrNotFound) { return false, nil @@ -50,20 +50,20 @@ func istKuratiert(ctx context.Context, ws *workspace.Workspace, project store.Pr return e.Curated, nil } -// kuratiertHinweis ist die Antwort an den Agenten, wenn er einen kuratierten +// curatedHint ist die Antwort an den Agenten, wenn er einen kuratierten // Eintrag ohne override_curated ändern will. Sie sagt ausdrücklich, was er // tun soll — wie alle Meldungen an Modelle. -const kuratiertHinweis = "Nichts geschrieben: Dieser Eintrag ist kuratiert — ein Mensch hat ihn geprüft. " + +const curatedHint = "Nichts geschrieben: Dieser Eintrag ist kuratiert — ein Mensch hat ihn geprüft. " + "Geprüftes Wissen hat Vorrang vor eigenen Annahmen. Ändere ihn nur, wenn er nachweislich nicht " + "mehr stimmt (etwa weil du gerade das Gegenteil beobachtet hast). Dann denselben Aufruf mit " + "\"override_curated\": true und unter \"reason\" einer kurzen Begründung wiederholen. " + "Die Markierung \"kuratiert\" fällt dabei weg, und ein Mensch prüft die Änderung. " + "Sonst: nichts ändern, oder die Ergänzung in einem eigenen Eintrag festhalten und mit [[slug]] verweisen." -// begruendungPruefen stellt sicher, dass ein override_curated eine +// checkJustification stellt sicher, dass ein override_curated eine // Begründung trägt. Ohne sie wüsste der prüfende Mensch nicht, warum der // geprüfte Stand ersetzt wurde. -func begruendungPruefen(in rememberInput) error { +func checkJustification(in rememberInput) error { if in.OverrideCurated && strings.TrimSpace(in.Reason) == "" { return errors.New("override_curated braucht eine Begründung unter \"reason\": " + "Warum stimmt der kuratierte Stand nicht mehr?") diff --git a/internal/mcpserver/mcpserver_test.go b/internal/mcpserver/mcpserver_test.go index 368aaf0..30b9b09 100644 --- a/internal/mcpserver/mcpserver_test.go +++ b/internal/mcpserver/mcpserver_test.go @@ -18,18 +18,18 @@ import ( "munin/internal/workspace" ) -// aufbau legt Datenbank, Projekt und Handler an. +// setup legt Datenbank, Projekt und Handler an. // // Das Schreibbudget ist bewusst großzügig: Die meisten Tests haben mit der // Begrenzung nichts zu tun, sollen aber trotzdem durch denselben Code laufen wie -// der Betrieb. Wer die Grenze prüfen will, nimmt aufbauMitBudget. -func aufbau(t *testing.T) (*Handler, *workspace.Workspace, store.Project) { +// der Betrieb. Wer die Grenze prüfen will, nimmt setupWithBudget. +func setup(t *testing.T) (*Handler, *workspace.Workspace, store.Project) { t.Helper() - return aufbauMitBudget(t, 1000) + return setupWithBudget(t, 1000) } -// aufbauMitBudget wie aufbau, aber mit einer wählbaren Obergrenze je Stunde. -func aufbauMitBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, store.Project) { +// setupWithBudget wie setup, aber mit einer wählbaren Obergrenze je Stunde. +func setupWithBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, store.Project) { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) @@ -43,7 +43,7 @@ func aufbauMitBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, s // der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für // den Test reicht eine beliebige ID. workspaceID := store.NewID() - projekt, err := db.CreateProject(ctx, workspaceID, "test", "Testprojekt", "") + project, err := db.CreateProject(ctx, workspaceID, "test", "Testprojekt", "") if err != nil { t.Fatal(err) } @@ -51,31 +51,31 @@ func aufbauMitBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, s ws := &workspace.Workspace{ID: workspaceID, Store: db, Search: search.NewRegistry(db)} budget := auth.NewWriteBudget(limit, time.Hour) - return New(budget), ws, projekt + return New(budget), ws, project } -// ruf ruft ein MCP-Werkzeug auf. +// call ruft ein MCP-Werkzeug auf. // // Statt einen HTTP-Server zu starten, verbindet sich der Client über ein // In-Memory-Transportpaar direkt mit dem Server — dieselbe Logik, ohne Netzwerk. // Die Sitzung (Projekt und Rolle) wird im Context mitgegeben, so wie es sonst // web.APIAuth tut. -func ruf(t *testing.T, h *Handler, ws *workspace.Workspace, projekt store.Project, rolle auth.Role, werkzeug string, args map[string]any) *mcp.CallToolResult { +func call(t *testing.T, h *Handler, ws *workspace.Workspace, project store.Project, role auth.Role, tool string, args map[string]any) *mcp.CallToolResult { t.Helper() sess := web.Session{ Subject: "key:" + ws.ID + ":test", WorkspaceID: ws.ID, - Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: projekt.ID, Role: string(rolle)}, - Project: projekt, - Role: rolle, + Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: project.ID, Role: string(role)}, + Project: project, + Role: role, } ctx := web.WithSessionForTest(context.Background(), sess) ctx = web.WithWorkspaceForTest(ctx, ws) clientTransport, serverTransport := mcp.NewInMemoryTransports() - serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil) + serverSession, err := h.serverFor(project).Connect(ctx, serverTransport, nil) if err != nil { t.Fatalf("Serververbindung: %v", err) } @@ -88,11 +88,11 @@ func ruf(t *testing.T, h *Handler, ws *workspace.Workspace, projekt store.Projec } defer clientSession.Close() - ergebnis, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: werkzeug, Arguments: args}) + result, err := clientSession.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: args}) if err != nil { - t.Fatalf("Aufruf %q: %v", werkzeug, err) + t.Fatalf("Aufruf %q: %v", tool, err) } - return ergebnis + return result } // text fasst die Textbausteine eines Ergebnisses zusammen. @@ -112,12 +112,12 @@ func text(r *mcp.CallToolResult) string { // Anleitung mitgeliefert wird. Die Anleitung ist das wirksamste Stellrad für die // Qualität der Antworten — sie darf nicht versehentlich wegfallen. func TestToolsAndInstructions(t *testing.T) { - h, _, projekt := aufbau(t) + h, _, project := setup(t) ctx := context.Background() clientTransport, serverTransport := mcp.NewInMemoryTransports() - serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil) + serverSession, err := h.serverFor(project).Connect(ctx, serverTransport, nil) if err != nil { t.Fatal(err) } @@ -134,18 +134,18 @@ func TestToolsAndInstructions(t *testing.T) { t.Error("die Anleitung fehlt oder erklärt recall nicht") } - liste, err := clientSession.ListTools(ctx, nil) + list, err := clientSession.ListTools(ctx, nil) if err != nil { t.Fatal(err) } - vorhanden := make(map[string]bool) - for _, tool := range liste.Tools { - vorhanden[tool.Name] = true + existing := make(map[string]bool) + for _, tool := range list.Tools { + existing[tool.Name] = true } for _, name := range []string{"recall", "get", "list", "remember", "forget", "related"} { - if !vorhanden[name] { + if !existing[name] { t.Errorf("Werkzeug %q fehlt", name) } } @@ -156,25 +156,25 @@ func TestToolsAndInstructions(t *testing.T) { // TestRememberDeduplicatesBySlug prüft den Kern des Gedächtnisses: Zweimal // dasselbe Thema unter demselben Bezeichner ergibt einen Eintrag, nicht zwei. func TestRememberDeduplicatesBySlug(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) db := ws.Store - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "Erste Erkenntnis", }) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "Zweite, bessere Erkenntnis", }) - anzahl, err := db.CountEntries(context.Background(), projekt.ID) + count, err := db.CountEntries(context.Background(), project.ID) if err != nil { t.Fatal(err) } - if anzahl != 1 { - t.Errorf("%d Einträge, erwartet 1 — es wurde eine Dublette angelegt", anzahl) + if count != 1 { + t.Errorf("%d Einträge, erwartet 1 — es wurde eine Dublette angelegt", count) } - e, _ := db.EntryBySlug(context.Background(), projekt.ID, "deployment") + e, _ := db.EntryBySlug(context.Background(), project.ID, "deployment") if e.Content != "Zweite, bessere Erkenntnis" { t.Error("der Eintrag wurde nicht aktualisiert") } @@ -185,14 +185,14 @@ func TestRememberDeduplicatesBySlug(t *testing.T) { // TestRememberRecordsProvenance prüft, dass nachvollziehbar bleibt, wer geschrieben hat. func TestRememberRecordsProvenance(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) db := ws.Store - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "notiz", "title": "Notiz", "content": "Inhalt", }) - e, _ := db.EntryBySlug(context.Background(), projekt.ID, "notiz") + e, _ := db.EntryBySlug(context.Background(), project.ID, "notiz") if e.Source != store.SourceAgent { t.Errorf("Source = %q, erwartet %q", e.Source, store.SourceAgent) } @@ -204,41 +204,41 @@ func TestRememberRecordsProvenance(t *testing.T) { // TestRememberDerivesSlug prüft, dass ein fehlender Bezeichner aus dem Titel // abgeleitet wird — das Modell soll nicht an Formalien scheitern. func TestRememberDerivesSlug(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) db := ws.Store - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "title": "Der Deployment-Prozess", "content": "Inhalt", }) - if _, err := db.EntryBySlug(context.Background(), projekt.ID, "der-deployment-prozess"); err != nil { + if _, err := db.EntryBySlug(context.Background(), project.ID, "der-deployment-prozess"); err != nil { t.Errorf("kein Eintrag unter dem abgeleiteten Bezeichner: %v", err) } } // TestRememberDetectsConflict prüft das optimistische Sperren. func TestRememberDetectsConflict(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "geteilt", "title": "T", "content": "Fassung 1", }) // Agent A schreibt und erhöht damit auf Fassung 2. - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "geteilt", "title": "T", "content": "von A", }) // Agent B hatte noch Fassung 1 gelesen. - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "geteilt", "title": "T", "content": "von B", "expected_version": 1, }) - if !ergebnis.IsError { + if !result.IsError { t.Fatal("der Konflikt wurde nicht erkannt — B hat A stillschweigend überschrieben") } // Die Fehlermeldung muss dem Modell sagen, was zu tun ist. - if !strings.Contains(text(ergebnis), "get") { - t.Errorf("die Fehlermeldung erklärt das weitere Vorgehen nicht: %s", text(ergebnis)) + if !strings.Contains(text(result), "get") { + t.Errorf("die Fehlermeldung erklärt das weitere Vorgehen nicht: %s", text(result)) } } @@ -246,47 +246,47 @@ func TestRememberDetectsConflict(t *testing.T) { // TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang nichts verändern kann. func TestReadRoleCannotWrite(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) db := ws.Store // Jedes Werkzeug bekommt nur die Parameter, die es kennt — sonst weist schon // die Schemaprüfung des SDK den Aufruf ab und die Rollenprüfung käme nie dran. - faelle := map[string]map[string]any{ + cases := map[string]map[string]any{ "remember": {"slug": "x", "title": "T", "content": "C"}, "forget": {"slug": "x"}, } - for werkzeug, args := range faelle { - t.Run(werkzeug, func(t *testing.T) { - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, werkzeug, args) - if !ergebnis.IsError { + for tool, args := range cases { + t.Run(tool, func(t *testing.T) { + result := call(t, h, ws, project, auth.RoleRead, tool, args) + if !result.IsError { t.Error("der Nur-Lese-Zugang durfte schreiben") } - if !strings.Contains(text(ergebnis), "Leserecht") { - t.Errorf("die Fehlermeldung nennt den Grund nicht: %s", text(ergebnis)) + if !strings.Contains(text(result), "Leserecht") { + t.Errorf("die Fehlermeldung nennt den Grund nicht: %s", text(result)) } }) } - if n, _ := db.CountEntries(context.Background(), projekt.ID); n != 0 { + if n, _ := db.CountEntries(context.Background(), project.ID); n != 0 { t.Errorf("%d Einträge angelegt, erwartet 0", n) } } func TestReadRoleCanRead(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "notiz", "title": "Notiz", "content": "Inhalt", }) - for _, werkzeug := range []string{"recall", "list"} { + for _, tool := range []string{"recall", "list"} { args := map[string]any{} - if werkzeug == "recall" { + if tool == "recall" { args["query"] = "Notiz" } - if ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, werkzeug, args); ergebnis.IsError { - t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", werkzeug, text(ergebnis)) + if result := call(t, h, ws, project, auth.RoleRead, tool, args); result.IsError { + t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", tool, text(result)) } } } @@ -297,73 +297,73 @@ func TestReadRoleCanRead(t *testing.T) { // "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis, // und das Modell erfährt nie, dass es den Eintrag gäbe. func TestRecallFindsByPrefix(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Wir deployen über GitHub Actions.", }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "deploy"}) - if ergebnis.IsError { - t.Fatalf("recall schlug fehl: %s", text(ergebnis)) + result := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "deploy"}) + if result.IsError { + t.Fatalf("recall schlug fehl: %s", text(result)) } - if !strings.Contains(text(ergebnis), "deployment-prozess") { - t.Errorf("die Präfix-Suche fand den Eintrag nicht: %s", text(ergebnis)) + if !strings.Contains(text(result), "deployment-prozess") { + t.Errorf("die Präfix-Suche fand den Eintrag nicht: %s", text(result)) } } // TestRecallEmptyGivesHint prüft, dass ein leeres Ergebnis dem Modell sagt, was // als Nächstes zu tun ist. func TestRecallEmptyGivesHint(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"}) - if !strings.Contains(text(ergebnis), "remember") { - t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(ergebnis)) + result := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"}) + if !strings.Contains(text(result), "remember") { + t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(result)) } } -// TestRecallMitSynonymen prüft, dass Synonyme einen Eintrag finden, der das +// TestRecallWithSynonyms prüft, dass Synonyme einen Eintrag finden, der das // Thema unter einem anderen Namen führt. Ohne sie findet eine Frage nach dem // „Deployment" den „Release-Ablauf" nicht — kein Wort passt. -func TestRecallMitSynonymen(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRecallWithSynonyms(t *testing.T) { + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "release-ablauf", "title": "Release-Ablauf", "content": "Neue Fassungen gehen dienstags raus.", }) - ohne := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{ + without := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{ "query": "wie deployen wir", }) - if strings.Contains(text(ohne), "release-ablauf") { - t.Fatalf("der Eintrag wurde schon ohne Synonyme gefunden — der Test prüft so nichts: %s", text(ohne)) + if strings.Contains(text(without), "release-ablauf") { + t.Fatalf("der Eintrag wurde schon ohne Synonyme gefunden — der Test prüft so nichts: %s", text(without)) } - mit := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{ + with := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{ "query": "wie deployen wir", "synonyms": []any{"release", "rollout"}, }) - if mit.IsError { - t.Fatalf("recall schlug fehl: %s", text(mit)) + if with.IsError { + t.Fatalf("recall schlug fehl: %s", text(with)) } - if !strings.Contains(text(mit), "release-ablauf") { - t.Errorf("das Synonym fand den Eintrag nicht: %s", text(mit)) + if !strings.Contains(text(with), "release-ablauf") { + t.Errorf("das Synonym fand den Eintrag nicht: %s", text(with)) } } -// TestRecallProtokolliert prüft, dass jede Suche im Suchprotokoll landet — +// TestRecallIsLogged prüft, dass jede Suche im Suchprotokoll landet — // mit Synonymen und der Zahl der Treffer. -func TestRecallProtokolliert(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRecallIsLogged(t *testing.T) { + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{ + call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{ "query": "gibtesnicht", "synonyms": []any{"auchnicht"}, }) - st, err := ws.Store.SearchStatsFor(context.Background(), projekt.ID, time.Now()) + st, err := ws.Store.SearchStatsFor(context.Background(), project.ID, time.Now()) if err != nil { t.Fatal(err) } @@ -372,90 +372,90 @@ func TestRecallProtokolliert(t *testing.T) { } } -// TestMitSynonymenDeckelt prüft die Obergrenze für Synonyme. -func TestMitSynonymenDeckelt(t *testing.T) { - viele := make([]string, recallMaxSynonyms+5) - for i := range viele { - viele[i] = fmt.Sprintf("wort%d", i) +// TestWithSynonymsCaps prüft die Obergrenze für Synonyme. +func TestWithSynonymsCaps(t *testing.T) { + many := make([]string, recallMaxSynonyms+5) + for i := range many { + many[i] = fmt.Sprintf("wort%d", i) } - got := mitSynonymen("frage", begrenzeSynonyme(viele)) + got := withSynonyms("frage", limitSynonyms(many)) if strings.Contains(got, fmt.Sprintf("wort%d", recallMaxSynonyms)) { t.Errorf("mehr als %d Synonyme wurden übernommen: %q", recallMaxSynonyms, got) } - if got := begrenzeSynonyme([]string{" ", "release", ""}); len(got) != 1 { + if got := limitSynonyms([]string{" ", "release", ""}); len(got) != 1 { t.Errorf("begrenzeSynonyme = %q, erwartet nur [release]", got) } - if mitSynonymen("frage", nil) != "frage" { + if withSynonyms("frage", nil) != "frage" { t.Error("ohne Synonyme soll die Anfrage unverändert bleiben") } } -// TestRecallLeerVerweistAufSynonyme prüft, dass ein leeres Ergebnis zuerst +// TestRecallEmptyPointsToSynonyms prüft, dass ein leeres Ergebnis zuerst // zum erneuten Suchen mit anderen Begriffen anhält — nicht gleich zum Anlegen // eines neuen Eintrags, der dann womöglich eine Dublette ist. -func TestRecallLeerVerweistAufSynonyme(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRecallEmptyPointsToSynonyms(t *testing.T) { + h, ws, project := setup(t) - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"}) - if !strings.Contains(text(ergebnis), "synonyms") { - t.Errorf("der Hinweis nennt keine erneute Suche mit Synonymen: %s", text(ergebnis)) + result := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"}) + if !strings.Contains(text(result), "synonyms") { + t.Errorf("der Hinweis nennt keine erneute Suche mit Synonymen: %s", text(result)) } } func TestRecallFiltersByTag(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "a", "title": "Deployment A", "content": "deployment", "tags": []any{"ops"}, }) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"}, - "force": true, // ähnlicher Titel ist hier gewollt, siehe dubletten.go + "force": true, // ähnlicher Titel ist hier gewollt, siehe duplicates.go }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", + result := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "deployment", "tags": []any{"ops"}}) - if strings.Contains(text(ergebnis), `"slug": "b"`) || strings.Contains(text(ergebnis), `"slug":"b"`) { - t.Errorf("die Schlagwortfilterung griff nicht: %s", text(ergebnis)) + if strings.Contains(text(result), `"slug": "b"`) || strings.Contains(text(result), `"slug":"b"`) { + t.Errorf("die Schlagwortfilterung griff nicht: %s", text(result)) } } // TestGetReturnsBacklinks prüft die Verweise in beide Richtungen — sie ersetzen // die frühere Ordnerhierarchie als Struktur zwischen Einträgen. func TestGetReturnsBacklinks(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "ziel", "title": "Ziel", "content": "Inhalt", }) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "quelle", "title": "Quelle", "content": "siehe [[ziel]]", }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "ziel"}) - if !strings.Contains(text(ergebnis), "quelle") { - t.Errorf("der Rückverweis fehlt: %s", text(ergebnis)) + result := call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "ziel"}) + if !strings.Contains(text(result), "quelle") { + t.Errorf("der Rückverweis fehlt: %s", text(result)) } - andersherum := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "quelle"}) - if !strings.Contains(text(andersherum), "ziel") { + reversed := call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "quelle"}) + if !strings.Contains(text(reversed), "ziel") { t.Error("der ausgehende Verweis fehlt") } } func TestGetUnknownSlugExplains(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"}) - if !ergebnis.IsError { + result := call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"}) + if !result.IsError { t.Fatal("ein unbekannter Bezeichner wurde nicht als Fehler gemeldet") } // Die Meldung soll dem Modell den nächsten Schritt nennen. - if !strings.Contains(text(ergebnis), "recall") { - t.Errorf("die Meldung hilft nicht weiter: %s", text(ergebnis)) + if !strings.Contains(text(result), "recall") { + t.Errorf("die Meldung hilft nicht weiter: %s", text(result)) } } @@ -464,39 +464,39 @@ func TestGetUnknownSlugExplains(t *testing.T) { // TestProjectIsolation ist der wichtigste Test: Die Werkzeuge eines Projekts // dürfen unter keinen Umständen an die Daten eines anderen kommen. func TestProjectIsolation(t *testing.T) { - h, ws, projektA := aufbau(t) + h, ws, projectA := setup(t) db := ws.Store ctx := context.Background() - projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "fremd", "Fremdes Projekt", "") + projectB, err := db.CreateProject(ctx, projectA.WorkspaceID, "fremd", "Fremdes Projekt", "") if err != nil { t.Fatal(err) } // Wissen in Projekt B ablegen. - ruf(t, h, ws, projektB, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, projectB, auth.RoleWrite, "remember", map[string]any{ "slug": "geheim", "title": "Geheim", "content": "Vertrauliches aus Projekt B", }) // Mit den Werkzeugen von Projekt A danach suchen. - suche := ruf(t, h, ws, projektA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"}) - if strings.Contains(text(suche), "Vertrauliches") { + search := call(t, h, ws, projectA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"}) + if strings.Contains(text(search), "Vertrauliches") { t.Error("recall in Projekt A fand Inhalte aus Projekt B") } - lesen := ruf(t, h, ws, projektA, auth.RoleRead, "get", map[string]any{"slug": "geheim"}) - if !lesen.IsError { + read := call(t, h, ws, projectA, auth.RoleRead, "get", map[string]any{"slug": "geheim"}) + if !read.IsError { t.Error("get in Projekt A lieferte einen Eintrag aus Projekt B") } - liste := ruf(t, h, ws, projektA, auth.RoleRead, "list", map[string]any{}) - if strings.Contains(text(liste), "geheim") { + list := call(t, h, ws, projectA, auth.RoleRead, "list", map[string]any{}) + if strings.Contains(text(list), "geheim") { t.Error("list in Projekt A zeigte einen Eintrag aus Projekt B") } // Und der Gegentest: Löschen darf ebenfalls nicht durchschlagen. - ruf(t, h, ws, projektA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"}) - if n, _ := db.CountEntries(ctx, projektB.ID); n != 1 { + call(t, h, ws, projectA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"}) + if n, _ := db.CountEntries(ctx, projectB.ID); n != 1 { t.Error("forget in Projekt A hat einen Eintrag in Projekt B gelöscht") } } @@ -506,33 +506,33 @@ func TestProjectIsolation(t *testing.T) { // TestRecallLimitIsCapped prüft, dass ein Client nicht beliebig viele Treffer // anfordern kann — eine riesige Antwort sprengt das Kontextfenster des Modells. func TestRecallLimitIsCapped(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) for i := 0; i < 60; i++ { - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)), "title": "Eintrag", "content": "gemeinsames Stichwort", - "force": true, // 60 gleiche Titel sind hier gewollt, siehe dubletten.go + "force": true, // 60 gleiche Titel sind hier gewollt, siehe duplicates.go }) } - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", + result := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "gemeinsames", "limit": 9999}) // Mehr als recallMaxLimit Treffer dürfen nicht zurückkommen. - if anzahl := strings.Count(text(ergebnis), `"slug"`); anzahl > recallMaxLimit { - t.Errorf("%d Treffer zurückgegeben, Obergrenze ist %d", anzahl, recallMaxLimit) + if count := strings.Count(text(result), `"slug"`); count > recallMaxLimit { + t.Errorf("%d Treffer zurückgegeben, Obergrenze ist %d", count, recallMaxLimit) } } func TestRememberRejectsEmptyContent(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{"slug": "leer", "title": "T", "content": ""}) - if !ergebnis.IsError { + if !result.IsError { t.Error("ein leerer Inhalt wurde angenommen") } } @@ -542,38 +542,38 @@ func TestRememberRejectsEmptyContent(t *testing.T) { // TestWriteBudgetStopsRunawayAgent prüft den eigentlichen Zweck der Begrenzung: // Ein Agent in einer Schleife wird gestoppt, statt bis zum Plattenende zu schreiben. func TestWriteBudgetStopsRunawayAgent(t *testing.T) { - const grenze = 3 - h, ws, projekt := aufbauMitBudget(t, grenze) + const limit = 3 + h, ws, project := setupWithBudget(t, limit) db := ws.Store - for i := 0; i < grenze; i++ { - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + for i := 0; i < limit; i++ { + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt", - "force": true, // gleiche Titel sind hier gewollt, siehe dubletten.go + "force": true, // gleiche Titel sind hier gewollt, siehe duplicates.go }) - if ergebnis.IsError { - t.Fatalf("Aufruf %d von %d abgelehnt, obwohl im Budget: %s", i+1, grenze, text(ergebnis)) + if result.IsError { + t.Fatalf("Aufruf %d von %d abgelehnt, obwohl im Budget: %s", i+1, limit, text(result)) } } - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "eintrag-zuviel", "title": "Zu viel", "content": "Inhalt", }) - if !ergebnis.IsError { + if !result.IsError { t.Fatal("Aufruf über der Grenze wurde angenommen") } // Die Meldung geht an ein Sprachmodell. Sie muss sagen, was los ist UND was // zu tun ist — sonst versucht es das Modell sofort wieder. - meldung := text(ergebnis) - for _, erwartet := range []string{"Schreibbudget", "NICHT sofort erneut"} { - if !strings.Contains(meldung, erwartet) { - t.Errorf("Meldung enthält %q nicht: %s", erwartet, meldung) + message := text(result) + for _, want := range []string{"Schreibbudget", "NICHT sofort erneut"} { + if !strings.Contains(message, want) { + t.Errorf("Meldung enthält %q nicht: %s", want, message) } } // Und der abgelehnte Aufruf darf nichts geschrieben haben. - if _, err := db.EntryBySlug(context.Background(), projekt.ID, "eintrag-zuviel"); err == nil { + if _, err := db.EntryBySlug(context.Background(), project.ID, "eintrag-zuviel"); err == nil { t.Error("der abgelehnte Aufruf hat den Eintrag trotzdem angelegt") } } @@ -582,30 +582,30 @@ func TestWriteBudgetStopsRunawayAgent(t *testing.T) { // gedeckelt ist. Ein erschöpftes Budget darf das Gedächtnis nicht unlesbar machen — // sonst wäre der Agent nicht gebremst, sondern blind. func TestWriteBudgetLeavesReadingAlone(t *testing.T) { - h, ws, projekt := aufbauMitBudget(t, 1) + h, ws, project := setupWithBudget(t, 1) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "vorhanden", "title": "Vorhanden", "content": "Inhalt", }) // Budget ist jetzt aufgebraucht. - if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + if result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "noch-einer", "title": "Noch einer", "content": "Inhalt", - }); !ergebnis.IsError { + }); !result.IsError { t.Fatal("Vorbedingung verfehlt: das Budget ist noch nicht erschöpft") } - for _, werkzeug := range []string{"recall", "get", "list"} { + for _, tool := range []string{"recall", "get", "list"} { args := map[string]any{} - switch werkzeug { + switch tool { case "recall": args["query"] = "Inhalt" case "get": args["slug"] = "vorhanden" } - if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, werkzeug, args); ergebnis.IsError { - t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", werkzeug, text(ergebnis)) + if result := call(t, h, ws, project, auth.RoleWrite, tool, args); result.IsError { + t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", tool, text(result)) } } } @@ -613,26 +613,26 @@ func TestWriteBudgetLeavesReadingAlone(t *testing.T) { // TestWriteBudgetIsPerProject prüft die Mandantentrennung: Ein Projekt, das sein // Budget verbraucht, darf kein anderes mitbremsen. func TestWriteBudgetIsPerProject(t *testing.T) { - h, ws, projekt := aufbauMitBudget(t, 1) + h, ws, project := setupWithBudget(t, 1) db := ws.Store ctx := context.Background() - zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "zweites", "Zweites Projekt", "") + second, err := db.CreateProject(ctx, project.WorkspaceID, "zweites", "Zweites Projekt", "") if err != nil { t.Fatal(err) } - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"}) - if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"}) + if result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "b", "title": "B", "content": "Inhalt", - }); !ergebnis.IsError { + }); !result.IsError { t.Fatal("Vorbedingung verfehlt: das erste Projekt ist noch nicht am Limit") } - if ergebnis := ruf(t, h, ws, zweites, auth.RoleWrite, "remember", map[string]any{ + if result := call(t, h, ws, second, auth.RoleWrite, "remember", map[string]any{ "slug": "c", "title": "C", "content": "Inhalt", - }); ergebnis.IsError { - t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(ergebnis)) + }); result.IsError { + t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(result)) } } @@ -640,48 +640,48 @@ func TestWriteBudgetIsPerProject(t *testing.T) { // Projekts den Standard schlägt — und zwar die aus der Sitzung, damit eine // Änderung in der Verwaltung sofort wirkt. func TestWriteBudgetUsesProjectLimit(t *testing.T) { - h, ws, projekt := aufbauMitBudget(t, 100) + h, ws, project := setupWithBudget(t, 100) // Dasselbe Projekt, aber mit einer eigenen, engeren Grenze — so, wie es // web.APIAuth nach einer Änderung in der Verwaltung aus der Datenbank läse. - projekt.WriteLimitHourly = 1 + project.WriteLimitHourly = 1 - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"}) + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"}) - if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + if result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "b", "title": "B", "content": "Inhalt", - }); !ergebnis.IsError { + }); !result.IsError { t.Error("die eigene Obergrenze des Projekts wurde nicht angewandt") } } -// TestRememberProtokolliertAngelegtOderAktualisiert: Das Protokoll muss +// TestRememberLogsCreatedOrUpdated: Das Protokoll muss // unterscheiden, ob ein Schreibvorgang neu angelegt oder aktualisiert hat — // daran lässt sich ablesen, ob Agenten vorhandene Einträge fortschreiben oder // Dubletten erzeugen (AUSBAUSTUFEN.md 4.6). -func TestRememberProtokolliertAngelegtOderAktualisiert(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRememberLogsCreatedOrUpdated(t *testing.T) { + h, ws, project := setup(t) - for _, inhalt := range []string{"Erste Erkenntnis", "Zweite Erkenntnis"} { - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ - "slug": "deployment", "title": "Deployment", "content": inhalt, + for _, content := range []string{"Erste Erkenntnis", "Zweite Erkenntnis"} { + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ + "slug": "deployment", "title": "Deployment", "content": content, }) } - ereignisse, err := ws.Store.ListAudit(context.Background(), store.AuditFilter{ProjectID: projekt.ID}) + events, err := ws.Store.ListAudit(context.Background(), store.AuditFilter{ProjectID: project.ID}) if err != nil { t.Fatal(err) } var details []string - for _, e := range ereignisse { + for _, e := range events { if e.Action == store.ActionEntryWrite { details = append(details, e.Detail) } } // ListAudit liefert die neuesten zuerst. - erwartet := []string{store.DetailEntryUpdated, store.DetailEntryCreated} - if fmt.Sprint(details) != fmt.Sprint(erwartet) { - t.Errorf("Details = %v, erwartet %v", details, erwartet) + want := []string{store.DetailEntryUpdated, store.DetailEntryCreated} + if fmt.Sprint(details) != fmt.Sprint(want) { + t.Errorf("Details = %v, erwartet %v", details, want) } } @@ -691,39 +691,39 @@ func TestRememberProtokolliertAngelegtOderAktualisiert(t *testing.T) { // ist der Eintrag weg — get, recall und list sehen ihn nicht mehr —, in der // Datenbank liegt er aber im Papierkorb, samt Name des Zugangs. func TestForgetMovesToTrash(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) ctx := context.Background() - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "Wir deployen mit Blaugrün", }) - antwort := ruf(t, h, ws, projekt, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}) - if antwort.IsError { - t.Fatalf("forget: %s", text(antwort)) + answer := call(t, h, ws, project, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}) + if answer.IsError { + t.Fatalf("forget: %s", text(answer)) } - if !strings.Contains(text(antwort), "Papierkorb") { - t.Errorf("die Antwort erwähnt den Papierkorb nicht: %s", text(antwort)) + if !strings.Contains(text(answer), "Papierkorb") { + t.Errorf("die Antwort erwähnt den Papierkorb nicht: %s", text(answer)) } - if r := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "deployment"}); !r.IsError { + if r := call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "deployment"}); !r.IsError { t.Error("get findet den gelöschten Eintrag noch") } - if r := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "Blaugrün"}); strings.Contains(text(r), "deployment") { + if r := call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "Blaugrün"}); strings.Contains(text(r), "deployment") { t.Errorf("recall findet den gelöschten Eintrag noch: %s", text(r)) } - if r := ruf(t, h, ws, projekt, auth.RoleRead, "list", map[string]any{}); strings.Contains(text(r), "deployment") { + if r := call(t, h, ws, project, auth.RoleRead, "list", map[string]any{}); strings.Contains(text(r), "deployment") { t.Errorf("list zeigt den gelöschten Eintrag noch: %s", text(r)) } - papierkorb, _ := ws.Store.ListTrash(ctx, projekt.ID, "") - if len(papierkorb) != 1 || papierkorb[0].DeletedBy != "test-agent" { - t.Errorf("Papierkorb = %+v, erwartet „deployment“, gelöscht von test-agent", papierkorb) + trash, _ := ws.Store.ListTrash(ctx, project.ID, "") + if len(trash) != 1 || trash[0].DeletedBy != "test-agent" { + t.Errorf("Papierkorb = %+v, erwartet „deployment“, gelöscht von test-agent", trash) } // Ein zweites forget auf denselben Bezeichner ist ein Fehler — aus Sicht // des Agenten gibt es den Eintrag nicht mehr. - if r := ruf(t, h, ws, projekt, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}); !r.IsError { + if r := call(t, h, ws, project, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}); !r.IsError { t.Error("zweites forget meldete keinen Fehler") } } @@ -732,40 +732,40 @@ func TestForgetMovesToTrash(t *testing.T) { // im Papierkorb liegt, wird der Eintrag zurückgeholt und überschrieben — und // der Agent erfährt das in der Antwort. func TestRememberRevivesFromTrash(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) ctx := context.Background() - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "alt", }) - ruf(t, h, ws, projekt, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}) + call(t, h, ws, project, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}) - antwort := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + answer := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment", "title": "Deployment", "content": "neu", }) - if antwort.IsError { - t.Fatalf("remember: %s", text(antwort)) + if answer.IsError { + t.Fatalf("remember: %s", text(answer)) } - if !strings.Contains(text(antwort), "Papierkorb") { - t.Errorf("die Antwort sagt nicht, dass ein gelöschter Eintrag zurückgeholt wurde: %s", text(antwort)) + if !strings.Contains(text(answer), "Papierkorb") { + t.Errorf("die Antwort sagt nicht, dass ein gelöschter Eintrag zurückgeholt wurde: %s", text(answer)) } - if strings.Contains(text(antwort), "angelegt") { - t.Errorf("die Antwort behauptet, der Eintrag sei neu angelegt: %s", text(antwort)) + if strings.Contains(text(answer), "angelegt") { + t.Errorf("die Antwort behauptet, der Eintrag sei neu angelegt: %s", text(answer)) } - e, err := ws.Store.EntryBySlug(ctx, projekt.ID, "deployment") + e, err := ws.Store.EntryBySlug(ctx, project.ID, "deployment") if err != nil || e.Content != "neu" || e.Version != 2 { t.Errorf("nach remember: %+v, %v", e, err) } - ereignisse, _ := ws.Store.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) - gefunden := false - for _, ev := range ereignisse { + events, _ := ws.Store.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID}) + found := false + for _, ev := range events { if ev.Action == store.ActionEntryRestore && ev.EntrySlug == "deployment" { - gefunden = true + found = true } } - if !gefunden { + if !found { t.Error("das Protokoll vermerkt das Zurückholen nicht") } } @@ -773,64 +773,64 @@ func TestRememberRevivesFromTrash(t *testing.T) { // ── Verwandte Einträge und Alter (AUSBAUSTUFEN.md 3.7, 3.5) ────────────────── func TestRelatedFindsNeighbours(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "release", "title": "Release-Prozess", "content": "Vorher [[checkliste]] durchgehen.", }) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "checkliste", "title": "Checkliste", "content": "Siehe auch [[rollback]].", }) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "rollback", "title": "Rollback", "content": "Zurück auf die letzte Fassung.", }) // Auch ein Lesezugang darf das Umfeld abfragen. - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release"}) - if ergebnis.IsError { - t.Fatalf("Fehler: %s", text(ergebnis)) + result := call(t, h, ws, project, auth.RoleRead, "related", map[string]any{"slug": "release"}) + if result.IsError { + t.Fatalf("Fehler: %s", text(result)) } - antwort := text(ergebnis) - for _, erwartet := range []string{`"checkliste"`, `"rollback"`, `"distance":2`, `"via":"checkliste"`} { - if !strings.Contains(antwort, erwartet) { - t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort) + answer := text(result) + for _, want := range []string{`"checkliste"`, `"rollback"`, `"distance":2`, `"via":"checkliste"`} { + if !strings.Contains(answer, want) { + t.Errorf("%s fehlt in der Antwort: %s", want, answer) } } - nurDirekt := text(ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release", "distance": 1})) - if strings.Contains(nurDirekt, "rollback") { - t.Errorf("distance 1 lieferte die zweite Reihe: %s", nurDirekt) + directOnly := text(call(t, h, ws, project, auth.RoleRead, "related", map[string]any{"slug": "release", "distance": 1})) + if strings.Contains(directOnly, "rollback") { + t.Errorf("distance 1 lieferte die zweite Reihe: %s", directOnly) } } func TestRelatedUnknownSlugExplains(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) - ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "gibtesnicht"}) - if !ergebnis.IsError || !strings.Contains(text(ergebnis), "recall") { - t.Errorf("unbekannter Bezeichner nicht hilfreich gemeldet: %s", text(ergebnis)) + result := call(t, h, ws, project, auth.RoleRead, "related", map[string]any{"slug": "gibtesnicht"}) + if !result.IsError || !strings.Contains(text(result), "recall") { + t.Errorf("unbekannter Bezeichner nicht hilfreich gemeldet: %s", text(result)) } } // TestStaleEntriesAreMarked prüft, dass alte Einträge in recall, get und list // als "stale" erscheinen und frische nicht. func TestStaleEntriesAreMarked(t *testing.T) { - h, ws, projekt := aufbau(t) + h, ws, project := setup(t) ctx := context.Background() - vorZweiJahren := time.Now().AddDate(-2, 0, 0) + twoYearsAgo := time.Now().AddDate(-2, 0, 0) if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "altes-deployment", Title: "Deployment alt", - Content: "Deployment per FTP", Source: store.SourceHuman, UpdatedAt: vorZweiJahren, + ProjectID: project.ID, Slug: "altes-deployment", Title: "Deployment alt", + Content: "Deployment per FTP", Source: store.SourceHuman, UpdatedAt: twoYearsAgo, }); err != nil { t.Fatal(err) } - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "neues-deployment", "title": "Deployment neu", "content": "Deployment per Pipeline", - "force": true, // ähnlich zu „Deployment alt" ist hier gewollt, siehe dubletten.go + "force": true, // ähnlich zu „Deployment alt" ist hier gewollt, siehe duplicates.go }) - recall := text(ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "Deployment"})) + recall := text(call(t, h, ws, project, auth.RoleRead, "recall", map[string]any{"query": "Deployment"})) if strings.Count(recall, `"stale":true`) != 1 { t.Errorf("recall: genau ein Treffer sollte stale sein: %s", recall) } @@ -838,199 +838,199 @@ func TestStaleEntriesAreMarked(t *testing.T) { t.Errorf("recall: Hinweis auf veraltete Treffer fehlt: %s", recall) } - alt := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "altes-deployment"})) - if !strings.Contains(alt, `"stale":true`) || !strings.Contains(alt, "prüfen") { - t.Errorf("get: alter Eintrag nicht markiert: %s", alt) + old := text(call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "altes-deployment"})) + if !strings.Contains(old, `"stale":true`) || !strings.Contains(old, "prüfen") { + t.Errorf("get: alter Eintrag nicht markiert: %s", old) } - neu := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "neues-deployment"})) - if strings.Contains(neu, "stale") { - t.Errorf("get: frischer Eintrag als stale markiert: %s", neu) + newer := text(call(t, h, ws, project, auth.RoleRead, "get", map[string]any{"slug": "neues-deployment"})) + if strings.Contains(newer, "stale") { + t.Errorf("get: frischer Eintrag als stale markiert: %s", newer) } - liste := text(ruf(t, h, ws, projekt, auth.RoleRead, "list", nil)) - if strings.Count(liste, `"stale":true`) != 1 { - t.Errorf("list: genau ein Eintrag sollte stale sein: %s", liste) + list := text(call(t, h, ws, project, auth.RoleRead, "list", nil)) + if strings.Count(list, `"stale":true`) != 1 { + t.Errorf("list: genau ein Eintrag sollte stale sein: %s", list) } } // ── Dubletten ──────────────────────────────────────────────────────────────── -// TestRememberMeldetDublette prüft den Kern von 3.3: Ein neuer Eintrag mit +// TestRememberReportsDuplicate prüft den Kern von 3.3: Ein neuer Eintrag mit // ähnlichem Titel wird nicht angelegt, stattdessen kommen die Kandidaten // zurück — und erst mit force wird doch angelegt. -func TestRememberMeldetDublette(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRememberReportsDuplicate(t *testing.T) { + h, ws, project := setup(t) ctx := context.Background() - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.", }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "Auch über die Pipeline.", }) - if ergebnis.IsError { - t.Fatalf("die Dublettenmeldung soll kein Fehler sein: %s", text(ergebnis)) + if result.IsError { + t.Fatalf("die Dublettenmeldung soll kein Fehler sein: %s", text(result)) } - antwort := text(ergebnis) - for _, erwartet := range []string{`"judgment_required":true`, `"deployment-prozess"`, "force", `"matched_words":["deployment"]`} { - if !strings.Contains(antwort, erwartet) { - t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort) + answer := text(result) + for _, want := range []string{`"judgment_required":true`, `"deployment-prozess"`, "force", `"matched_words":["deployment"]`} { + if !strings.Contains(answer, want) { + t.Errorf("%s fehlt in der Antwort: %s", want, answer) } } - if _, err := ws.Store.EntryBySlug(ctx, projekt.ID, "deployment-ablauf"); !errors.Is(err, store.ErrNotFound) { + if _, err := ws.Store.EntryBySlug(ctx, project.ID, "deployment-ablauf"); !errors.Is(err, store.ErrNotFound) { t.Fatalf("die Dublette wurde trotzdem angelegt (err = %v)", err) } // Mit force wird angelegt. - ergebnis = ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result = call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "Auch über die Pipeline.", "force": true, }) - if !strings.Contains(text(ergebnis), `"created":true`) { - t.Errorf("mit force wurde nicht angelegt: %s", text(ergebnis)) + if !strings.Contains(text(result), `"created":true`) { + t.Errorf("mit force wurde nicht angelegt: %s", text(result)) } } -// TestRememberAktualisierenOhnePruefung prüft, dass das Fortschreiben eines +// TestRememberUpdateWithoutCheck prüft, dass das Fortschreiben eines // vorhandenen Eintrags nie angehalten wird — auch wenn es ähnliche gibt. -func TestRememberAktualisierenOhnePruefung(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRememberUpdateWithoutCheck(t *testing.T) { + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "A", }) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "B", "force": true, }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "B, ergänzt", }) - if strings.Contains(text(ergebnis), "judgment_required") { - t.Errorf("eine Aktualisierung wurde angehalten: %s", text(ergebnis)) + if strings.Contains(text(result), "judgment_required") { + t.Errorf("eine Aktualisierung wurde angehalten: %s", text(result)) } } -// TestRememberUnaehnlichLegtAn prüft, dass ein anderes Thema ohne Umweg +// TestRememberDissimilarCreates prüft, dass ein anderes Thema ohne Umweg // angelegt wird. -func TestRememberUnaehnlichLegtAn(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRememberDissimilarCreates(t *testing.T) { + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.", }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "vpn-zugang", "title": "VPN-Zugang", "content": "Über die Pipeline beantragen.", }) - if !strings.Contains(text(ergebnis), `"created":true`) { - t.Errorf("ein unähnlicher Eintrag wurde nicht angelegt: %s", text(ergebnis)) + if !strings.Contains(text(result), `"created":true`) { + t.Errorf("ein unähnlicher Eintrag wurde nicht angelegt: %s", text(result)) } } -// TestRememberDubletteUeberSuchbegriffe prüft das Zusammenspiel mit der Zeile +// TestRememberDuplicateViaSearchTerms prüft das Zusammenspiel mit der Zeile // „Auch gesucht als": Ein neuer „Release-Ablauf" findet den vorhandenen // „Deployment-Prozess", weil dort „release" als Suchbegriff steht. -func TestRememberDubletteUeberSuchbegriffe(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRememberDuplicateViaSearchTerms(t *testing.T) { + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.\n\nAuch gesucht als: release, rollout", }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "release-ablauf", "title": "Release-Ablauf", "content": "Dienstags.", }) - if !strings.Contains(text(ergebnis), `"judgment_required":true`) { - t.Errorf("die Dublette über die Suchbegriffe wurde nicht erkannt: %s", text(ergebnis)) + if !strings.Contains(text(result), `"judgment_required":true`) { + t.Errorf("die Dublette über die Suchbegriffe wurde nicht erkannt: %s", text(result)) } } -// TestRememberGemeinsamesPraefixKeineDublette prüft den Fall aus dem Betrieb +// TestRememberCommonPrefixNoDuplicate prüft den Fall aus dem Betrieb // (2026-10-16): Titel nach dem Muster „Projekt – Thema" lösten allein wegen // des gleichen Präfixes eine Dublettenmeldung aus. „Munin – Architektur" // wurde nicht angelegt, Kandidat war „Munin – Überblick" mit 50 %. // // Eine echte Dublette unter demselben Präfix muss aber weiter auffallen. -func TestRememberGemeinsamesPraefixKeineDublette(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRememberCommonPrefixNoDuplicate(t *testing.T) { + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "munin-ueberblick", "title": "Munin – Überblick", "content": "Was Munin ist.", }) - for _, neu := range []map[string]any{ + for _, newer := range []map[string]any{ {"slug": "munin-architektur", "title": "Munin – Architektur", "content": "Aufbau."}, {"slug": "munin-ausbaustand", "title": "Munin – Ausbaustand", "content": "Was fertig ist."}, } { - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", neu) - if !strings.Contains(text(ergebnis), `"created":true`) { - t.Errorf("%s wurde nicht angelegt: %s", neu["slug"], text(ergebnis)) + result := call(t, h, ws, project, auth.RoleWrite, "remember", newer) + if !strings.Contains(text(result), `"created":true`) { + t.Errorf("%s wurde nicht angelegt: %s", newer["slug"], text(result)) } } - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "munin-deployment-prozess", "title": "Munin – Deployment-Prozess", "content": "Über die Pipeline.", }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "munin-deployment-ablauf", "title": "Munin – Deployment-Ablauf", "content": "Auch über die Pipeline.", }) - if !strings.Contains(text(ergebnis), `"judgment_required":true`) || - !strings.Contains(text(ergebnis), `"munin-deployment-prozess"`) { - t.Errorf("die Dublette unter gleichem Präfix wurde nicht erkannt: %s", text(ergebnis)) + if !strings.Contains(text(result), `"judgment_required":true`) || + !strings.Contains(text(result), `"munin-deployment-prozess"`) { + t.Errorf("die Dublette unter gleichem Präfix wurde nicht erkannt: %s", text(result)) } } -// TestRememberSuchbegriffNurWoertlich prüft den zweiten Fall aus dem Betrieb +// TestRememberSearchTermOnlyLiteral prüft den zweiten Fall aus dem Betrieb // (2026-10-16): Ein Statuseintrag mit dem Suchbegriff „postgres-migration" // machte „Munin – Betrieb mit PostgreSQL" zur vermeintlichen Dublette, weil // „postgres" der Wortanfang von „postgresql" ist. Suchbegriffe zählen jetzt // nur wörtlich. -func TestRememberSuchbegriffNurWoertlich(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRememberSearchTermOnlyLiteral(t *testing.T) { + h, ws, project := setup(t) - ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "munin-ausbaustand", "title": "Munin – Ausbaustand und nächste Schritte", "content": "Stand der Dinge.\n\nAuch gesucht als: roadmap, status, postgres-migration", }) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "munin-postgres-betrieb", "title": "Munin – Betrieb mit PostgreSQL", "content": "Sichern und so.", }) - if !strings.Contains(text(ergebnis), `"created":true`) { - t.Errorf("wurde wegen eines Suchbegriffs als Dublette gemeldet: %s", text(ergebnis)) + if !strings.Contains(text(result), `"created":true`) { + t.Errorf("wurde wegen eines Suchbegriffs als Dublette gemeldet: %s", text(result)) } } // ── Kuratierte Einträge ────────────────────────────────────────────────────── -// legeKuratiertAn legt einen von einem Menschen geprüften Eintrag an. -func legeKuratiertAn(t *testing.T, ws *workspace.Workspace, projekt store.Project) { +// createCurated legt einen von einem Menschen geprüften Eintrag an. +func createCurated(t *testing.T, ws *workspace.Workspace, project store.Project) { t.Helper() if _, err := ws.Store.PutEntry(context.Background(), store.EntryWrite{ - ProjectID: projekt.ID, Slug: "vpn", Title: "VPN-Zugang", + ProjectID: project.ID, Slug: "vpn", Title: "VPN-Zugang", Content: "Geprüfter Stand: Zugang über vpn.intern.", Source: store.SourceHuman, Curated: true, }); err != nil { t.Fatal(err) } } -// TestRememberKuratiertBrauchtFreigabe prüft den ersten Schritt: Ohne +// TestRememberCuratedNeedsApproval prüft den ersten Schritt: Ohne // override_curated wird ein kuratierter Eintrag nicht angetastet. -func TestRememberKuratiertBrauchtFreigabe(t *testing.T) { - h, ws, projekt := aufbau(t) - legeKuratiertAn(t, ws, projekt) +func TestRememberCuratedNeedsApproval(t *testing.T) { + h, ws, project := setup(t) + createCurated(t, ws, project) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "vpn", "title": "VPN-Zugang", "content": "Ungeprüfte Vermutung.", }) - if ergebnis.IsError { - t.Fatalf("die Meldung soll kein Fehler sein: %s", text(ergebnis)) + if result.IsError { + t.Fatalf("die Meldung soll kein Fehler sein: %s", text(result)) } - if !strings.Contains(text(ergebnis), `"judgment_required":true`) || - !strings.Contains(text(ergebnis), "override_curated") { - t.Errorf("Meldung ohne Hinweis auf override_curated: %s", text(ergebnis)) + if !strings.Contains(text(result), `"judgment_required":true`) || + !strings.Contains(text(result), "override_curated") { + t.Errorf("Meldung ohne Hinweis auf override_curated: %s", text(result)) } - e, err := ws.Store.EntryBySlug(context.Background(), projekt.ID, "vpn") + e, err := ws.Store.EntryBySlug(context.Background(), project.ID, "vpn") if err != nil { t.Fatal(err) } @@ -1039,40 +1039,40 @@ func TestRememberKuratiertBrauchtFreigabe(t *testing.T) { } } -// TestRememberKuratiertOhneBegruendung prüft, dass override_curated ohne +// TestRememberCuratedWithoutJustification prüft, dass override_curated ohne // Begründung abgelehnt wird. -func TestRememberKuratiertOhneBegruendung(t *testing.T) { - h, ws, projekt := aufbau(t) - legeKuratiertAn(t, ws, projekt) +func TestRememberCuratedWithoutJustification(t *testing.T) { + h, ws, project := setup(t) + createCurated(t, ws, project) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "vpn", "title": "VPN-Zugang", "content": "Neu.", "override_curated": true, }) - if !ergebnis.IsError || !strings.Contains(text(ergebnis), "reason") { - t.Errorf("override_curated ohne reason wurde nicht abgelehnt: %s", text(ergebnis)) + if !result.IsError || !strings.Contains(text(result), "reason") { + t.Errorf("override_curated ohne reason wurde nicht abgelehnt: %s", text(result)) } } -// TestRememberKuratiertMitFreigabe prüft den zweiten Schritt: Mit Begründung +// TestRememberCuratedWithApproval prüft den zweiten Schritt: Mit Begründung // wird geschrieben, die Markierung fällt, und der Eintrag steht zur Prüfung // auf der Nutzungsseite — bis ein Mensch ihn wieder kuratiert. -func TestRememberKuratiertMitFreigabe(t *testing.T) { - h, ws, projekt := aufbau(t) +func TestRememberCuratedWithApproval(t *testing.T) { + h, ws, project := setup(t) ctx := context.Background() - legeKuratiertAn(t, ws, projekt) + createCurated(t, ws, project) - ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + result := call(t, h, ws, project, auth.RoleWrite, "remember", map[string]any{ "slug": "vpn", "title": "VPN-Zugang", "content": "Zugang jetzt über vpn2.intern.", "override_curated": true, "reason": "vpn.intern antwortet nicht mehr, Umzug laut Ticket 4711", }) - if ergebnis.IsError { - t.Fatalf("remember schlug fehl: %s", text(ergebnis)) + if result.IsError { + t.Fatalf("remember schlug fehl: %s", text(result)) } - if !strings.Contains(text(ergebnis), "kuratiert") { - t.Errorf("die Antwort sagt nicht, dass die Markierung weg ist: %s", text(ergebnis)) + if !strings.Contains(text(result), "kuratiert") { + t.Errorf("die Antwort sagt nicht, dass die Markierung weg ist: %s", text(result)) } - e, err := ws.Store.EntryBySlug(ctx, projekt.ID, "vpn") + e, err := ws.Store.EntryBySlug(ctx, project.ID, "vpn") if err != nil { t.Fatal(err) } @@ -1080,22 +1080,22 @@ func TestRememberKuratiertMitFreigabe(t *testing.T) { t.Errorf("Eintrag nach der Änderung: curated=%v, content=%q", e.Curated, e.Content) } - offen, err := ws.Store.CuratedOverrides(ctx, projekt.ID, 10) + pending, err := ws.Store.CuratedOverrides(ctx, project.ID, 10) if err != nil { t.Fatal(err) } - if len(offen) != 1 || offen[0].Slug != "vpn" || !strings.Contains(offen[0].Reason, "Ticket 4711") { - t.Fatalf("Liste zur Prüfung = %+v, erwartet vpn mit Begründung", offen) + if len(pending) != 1 || pending[0].Slug != "vpn" || !strings.Contains(pending[0].Reason, "Ticket 4711") { + t.Fatalf("Liste zur Prüfung = %+v, erwartet vpn mit Begründung", pending) } // Ein Mensch prüft und setzt den Haken neu → aus der Liste. if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "vpn", Title: e.Title, Content: e.Content, + ProjectID: project.ID, Slug: "vpn", Title: e.Title, Content: e.Content, Source: store.SourceHuman, Curated: true, }); err != nil { t.Fatal(err) } - if offen, _ := ws.Store.CuratedOverrides(ctx, projekt.ID, 10); len(offen) != 0 { - t.Errorf("nach erneuter Kuratierung noch in der Liste: %+v", offen) + if pending, _ := ws.Store.CuratedOverrides(ctx, project.ID, 10); len(pending) != 0 { + t.Errorf("nach erneuter Kuratierung noch in der Liste: %+v", pending) } } diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 2a203ea..1386268 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -229,9 +229,9 @@ type rememberInput struct { Force bool `json:"force,omitempty" jsonschema:"Optional: true legt den Eintrag an, obwohl es ähnliche gibt. Nur verwenden, nachdem die gemeldeten candidates geprüft wurden und keiner passt."` } -// dublettenKandidat ist ein vorhandener Eintrag, der einem neu anzulegenden -// ähnelt (siehe internal/dubletten) — so, wie MCP ihn ausliefert. -type dublettenKandidat struct { +// duplicateCandidate ist ein vorhandener Eintrag, der einem neu anzulegenden +// ähnelt (siehe internal/duplicates) — so, wie MCP ihn ausliefert. +type duplicateCandidate struct { Slug string `json:"slug"` Title string `json:"title"` Overlap int `json:"overlap" jsonschema:"Übereinstimmung der Titel in Prozent"` @@ -250,8 +250,8 @@ type rememberOutput struct { Message string `json:"message"` // Nur gesetzt, wenn NICHT geschrieben wurde, weil es ähnliche Einträge gibt. - JudgmentRequired bool `json:"judgment_required,omitempty" jsonschema:"true: nichts geschrieben — erst die Meldung lesen und entscheiden"` - Candidates []dublettenKandidat `json:"candidates,omitempty" jsonschema:"Vorhandene Einträge, die dem neuen ähneln"` + JudgmentRequired bool `json:"judgment_required,omitempty" jsonschema:"true: nichts geschrieben — erst die Meldung lesen und entscheiden"` + Candidates []duplicateCandidate `json:"candidates,omitempty" jsonschema:"Vorhandene Einträge, die dem neuen ähneln"` } type forgetInput struct { diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go index a3b8060..35be5f7 100644 --- a/internal/mcpserver/tools.go +++ b/internal/mcpserver/tools.go @@ -11,7 +11,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "munin/internal/auth" - "munin/internal/dubletten" + "munin/internal/duplicates" "munin/internal/entry" "munin/internal/store" "munin/internal/web" @@ -108,14 +108,14 @@ func (h *Handler) requireWrite(ctx context.Context) error { // Abfrage: web.APIAuth hat die Projektzeile für DIESEN Request gerade eben // frisch aus der Datenbank geholt. Der Wert ist damit aktuell, und der // Schreibpfad kostet keine zusätzliche Abfrage. - erlaubt, retryAfter, ueberschreitung := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly) - if erlaubt { + allowed, retryAfter, excess := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly) + if allowed { return nil } // Nur die erste Ablehnung je Fenster vermerken — sonst schriebe eine // Schleife im Agenten das Protokoll voll, statt die Einträge. - if ueberschreitung == 1 { + if excess == 1 { log.Printf("Schreibbudget erschöpft: Projekt %q (%s), Zugang %q — weitere Schreibversuche werden bis zum Fensterende abgelehnt", sess.Project.Slug, sess.Project.ID, sess.Key.Name) } @@ -174,16 +174,16 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { // Etwas großzügiger suchen als am Ende ausgegeben wird: Die // Schlagwortfilterung unten entfernt womöglich noch Treffer. - synonyme := begrenzeSynonyme(in.Synonyms) - treffer := idx.Search(mitSynonymen(in.Query, synonyme), limit*3) - gesucht := store.NormalizeTags(in.Tags) + synonyms := limitSynonyms(in.Synonyms) + hits := idx.Search(withSynonyms(in.Query, synonyms), limit*3) + wanted := store.NormalizeTags(in.Tags) - jetzt := time.Now() - veraltet := 0 + now := time.Now() + stale := 0 - ergebnisse := make([]recallMatch, 0, limit) - for _, t := range treffer { - if len(ergebnisse) >= limit { + results := make([]recallMatch, 0, limit) + for _, t := range hits { + if len(results) >= limit { break } @@ -195,24 +195,24 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { return nil, recallOutput{}, err } - if !hatAlleTags(e.Tags, gesucht) { + if !hasAllTags(e.Tags, wanted) { continue } - alt := e.Stale(jetzt) - if alt { - veraltet++ + old := e.Stale(now) + if old { + stale++ } - ergebnisse = append(ergebnisse, recallMatch{ + results = append(results, recallMatch{ Slug: e.Slug, Title: e.Title, - Snippet: entry.Snippet(e.Content, mitSynonymen(in.Query, synonyme)), + Snippet: entry.Snippet(e.Content, withSynonyms(in.Query, synonyms)), Tags: e.Tags, Source: e.Source, Curated: e.Curated, Updated: e.UpdatedAt.Format("2006-01-02 15:04"), - Stale: alt, + Stale: old, }) } @@ -221,11 +221,11 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { sess, _ := web.SessionFromContext(ctx) ws.Store.LogSearch(context.WithoutCancel(ctx), store.SearchLogEntry{ ProjectID: project.ID, APIKeyID: sess.Key.ID, - Query: in.Query, Synonyms: synonyme, Results: len(ergebnisse), + Query: in.Query, Synonyms: synonyms, Results: len(results), }) - out := recallOutput{Results: ergebnisse, Total: len(ergebnisse)} - if len(ergebnisse) == 0 { + out := recallOutput{Results: results, Total: len(results)} + if len(results) == 0 { out.Hint = "Nichts gefunden. Erst mit anderen Begriffen erneut suchen (Synonyme, Übersetzung, " + "Abkürzungen unter \"synonyms\") oder mit \"list\" einen Überblick verschaffen. " + "Erst wenn es wirklich nichts gibt und etwas Neues festgehalten werden soll, " + @@ -236,9 +236,9 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { // nicht nach hinten: Ein alter Eintrag kann der einzige zum // Thema sein. Der Hinweis sorgt dafür, dass das Modell das // Feld "stale" nicht übersieht. - if veraltet > 0 { + if stale > 0 { out.Hint += fmt.Sprintf(" %d der Treffer sind seit über einem Jahr unverändert (\"stale\": true) "+ - "— vor Verwendung prüfen, ob sie noch stimmen.", veraltet) + "— vor Verwendung prüfen, ob sie noch stimmen.", stale) } } return nil, out, nil @@ -246,7 +246,7 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { ) } -// mitSynonymen hängt die Synonyme an die Anfrage an, sodass sie in derselben +// withSynonyms hängt die Synonyme an die Anfrage an, sodass sie in derselben // Suche mitlaufen. // // # Warum so einfach? @@ -256,44 +256,44 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { // gefunden; einer, der beide Namen enthält, steht weiter oben. Genau das ist // gewollt — eine eigene Gewichtung für Synonyme bräuchte es dafür nicht. // -// Die Synonyme sollten vorher durch begrenzeSynonyme gegangen sein. -func mitSynonymen(query string, synonyme []string) string { - if len(synonyme) == 0 { +// Die Synonyme sollten vorher durch limitSynonyms gegangen sein. +func withSynonyms(query string, synonyms []string) string { + if len(synonyms) == 0 { return query } - return query + " " + strings.Join(synonyme, " ") + return query + " " + strings.Join(synonyms, " ") } -// begrenzeSynonyme entfernt leere Angaben und kappt die Liste bei +// limitSynonyms entfernt leere Angaben und kappt die Liste bei // recallMaxSynonyms. // // Die Obergrenze verhindert, dass ein übereifriges Modell dreißig Begriffe // mitschickt: Dann passt am Ende fast jeder Eintrag auf irgendeinen davon. -func begrenzeSynonyme(synonyme []string) []string { - var sauber []string - for _, s := range synonyme { +func limitSynonyms(synonyms []string) []string { + var clean []string + for _, s := range synonyms { if s = strings.TrimSpace(s); s != "" { - sauber = append(sauber, s) + clean = append(clean, s) } - if len(sauber) == recallMaxSynonyms { + if len(clean) == recallMaxSynonyms { break } } - return sauber + return clean } -// hatAlleTags prüft, ob alle gesuchten Schlagworte vorhanden sind. -func hatAlleTags(vorhanden, gesucht []string) bool { - if len(gesucht) == 0 { +// hasAllTags prüft, ob alle gesuchten Schlagworte vorhanden sind. +func hasAllTags(existing, wanted []string) bool { + if len(wanted) == 0 { return true } - menge := make(map[string]bool, len(vorhanden)) - for _, t := range vorhanden { - menge[t] = true + set := make(map[string]bool, len(existing)) + for _, t := range existing { + set[t] = true } - for _, g := range gesucht { - if !menge[g] { + for _, g := range wanted { + if !set[g] { return false } } @@ -323,13 +323,13 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) { return nil, getOutput{}, err } - rueck, err := ws.Store.Backlinks(ctx, project.ID, in.Slug) + back, err := ws.Store.Backlinks(ctx, project.ID, in.Slug) if err != nil { return nil, getOutput{}, err } - linkedBy := make([]string, 0, len(rueck)) - for _, b := range rueck { + linkedBy := make([]string, 0, len(back)) + for _, b := range back { linkedBy = append(linkedBy, b.Slug) } @@ -378,7 +378,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) { limit = listMaxLimit } - eintraege, err := ws.Store.ListEntries(ctx, project.ID, store.EntryFilter{ + entries, err := ws.Store.ListEntries(ctx, project.ID, store.EntryFilter{ Tag: in.Tag, Curated: in.Curated, Limit: limit, @@ -387,7 +387,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) { return nil, listOutput{}, err } - gesamt, err := ws.Store.CountEntries(ctx, project.ID) + total, err := ws.Store.CountEntries(ctx, project.ID) if err != nil { return nil, listOutput{}, err } @@ -397,9 +397,9 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) { return nil, listOutput{}, err } - jetzt := time.Now() - items := make([]listItem, 0, len(eintraege)) - for _, e := range eintraege { + now := time.Now() + items := make([]listItem, 0, len(entries)) + for _, e := range entries { items = append(items, listItem{ Slug: e.Slug, Title: e.Title, @@ -407,16 +407,16 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) { Source: e.Source, Curated: e.Curated, Updated: e.UpdatedAt.Format("2006-01-02 15:04"), - Stale: e.Stale(jetzt), + Stale: e.Stale(now), }) } - tagNamen := make([]string, 0, len(tags)) + tagNames := make([]string, 0, len(tags)) for _, t := range tags { - tagNamen = append(tagNamen, t.Tag) + tagNames = append(tagNames, t.Tag) } - return nil, listOutput{Entries: items, Total: gesamt, Tags: tagNamen}, nil + return nil, listOutput{Entries: items, Total: total, Tags: tagNames}, nil }, ) } @@ -445,7 +445,7 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) { if strings.TrimSpace(in.Content) == "" { return nil, rememberOutput{}, errors.New("content darf nicht leer sein") } - if err := begruendungPruefen(in); err != nil { + if err := checkJustification(in); err != nil { return nil, rememberOutput{}, err } @@ -459,31 +459,31 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) { return nil, rememberOutput{}, errors.New("slug fehlt und ließ sich nicht aus dem Titel ableiten") } - titel := strings.TrimSpace(in.Title) - if titel == "" { - titel = entry.TitleFromContent(in.Content) + title := strings.TrimSpace(in.Title) + if title == "" { + title = entry.TitleFromContent(in.Content) } // Gibt es schon einen ähnlichen Eintrag unter anderem Bezeichner? // Dann nicht anlegen, sondern die Kandidaten zurückgeben (siehe - // internal/dubletten). Kein Fehler: Das Modell soll die Kandidaten als + // internal/duplicates). Kein Fehler: Das Modell soll die Kandidaten als // Daten lesen und entscheiden, nicht einen Fehlschlag behandeln. if !in.Force { - gefunden, err := dubletten.Pruefe(ctx, ws, project, slug, titel, in.Content) + found, err := duplicates.Check(ctx, ws, project, slug, title, in.Content) if err != nil { return nil, rememberOutput{}, err } - kandidaten := make([]dublettenKandidat, 0, len(gefunden)) - for _, k := range gefunden { - kandidaten = append(kandidaten, dublettenKandidat{ + candidates := make([]duplicateCandidate, 0, len(found)) + for _, k := range found { + candidates = append(candidates, duplicateCandidate{ Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, MatchedWords: k.MatchedWords, }) } - if len(kandidaten) > 0 { + if len(candidates) > 0 { return nil, rememberOutput{ Slug: slug, JudgmentRequired: true, - Candidates: kandidaten, + Candidates: candidates, Message: "Nichts geschrieben: Es gibt bereits ähnliche Einträge (candidates). " + "\"matched_words\" nennt die gemeinsamen Wörter. " + "Passt einer davon, ihn mit \"get\" laden, den neuen Inhalt einarbeiten und " + @@ -495,24 +495,24 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) { } // Kuratierter Eintrag? Dann nur mit override_curated (siehe - // kuratiert.go). Wie bei den Dubletten kein Fehler, sondern eine + // curated.go). Wie bei den Dubletten kein Fehler, sondern eine // Meldung, die das Modell liest und auf die es entscheidet. - warKuratiert, err := istKuratiert(ctx, ws, project, slug) + wasCurated, err := isCurated(ctx, ws, project, slug) if err != nil { return nil, rememberOutput{}, err } - if warKuratiert && !in.OverrideCurated { + if wasCurated && !in.OverrideCurated { return nil, rememberOutput{ Slug: slug, JudgmentRequired: true, - Message: kuratiertHinweis, + Message: curatedHint, }, nil } - geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{ + written, err := ws.Store.PutEntry(ctx, store.EntryWrite{ ProjectID: project.ID, Slug: slug, - Title: titel, + Title: title, Content: in.Content, Tags: in.Tags, Source: store.SourceAgent, @@ -529,13 +529,13 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) { return nil, rememberOutput{}, err } - ws.Search.Refresh(ctx, project.ID, geschrieben.Slug) + ws.Search.Refresh(ctx, project.ID, written.Slug) // Hat der Agent einen kuratierten Eintrag geändert, steht das samt // Begründung im Protokoll. Daraus baut die Nutzungsseite die Liste // „bitte prüfen" (store.CuratedOverrides). - detail := store.EntryWriteDetail(geschrieben) - if warKuratiert { + detail := store.EntryWriteDetail(written) + if wasCurated { detail = store.CuratedOverrideDetail(in.Reason) } @@ -552,23 +552,23 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) { // (PutEntry vergibt beim Anlegen immer 1) — eine Nachfrage vorher // könnte bei zwei gleichzeitigen Schreibern danebenliegen, und sie // sähe einen Eintrag im Papierkorb nicht. - istNeu := geschrieben.Version == 1 + isNew := written.Version == 1 - meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version) + message := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, written.Version) switch { - case warKuratiert: - meldung = fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d). Er war kuratiert; die Markierung "+ + case wasCurated: + message = fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d). Er war kuratiert; die Markierung "+ "ist aufgehoben, und ein Mensch prüft die Änderung. Der geprüfte Stand steht im Verlauf.", - slug, geschrieben.Version) - case istNeu: - meldung = fmt.Sprintf("Eintrag %q angelegt.", slug) - case geschrieben.RestoredFromTrash: + slug, written.Version) + case isNew: + message = fmt.Sprintf("Eintrag %q angelegt.", slug) + case written.RestoredFromTrash: // Der Bezeichner gehörte einem gelöschten Eintrag. Das Modell // konnte ihn vorher mit recall/get nicht sehen — es soll aber // wissen, dass es gerade etwas Gelöschtes wiederbelebt hat. - meldung = fmt.Sprintf("Unter %q lag ein gelöschter Eintrag im Papierkorb. Er wurde zurückgeholt "+ + message = fmt.Sprintf("Unter %q lag ein gelöschter Eintrag im Papierkorb. Er wurde zurückgeholt "+ "und mit deinem Inhalt überschrieben (Fassung %d); der gelöschte Stand steht im Verlauf. "+ - "War das nicht beabsichtigt, den Menschen darauf hinweisen.", slug, geschrieben.Version) + "War das nicht beabsichtigt, den Menschen darauf hinweisen.", slug, written.Version) ws.Store.LogAudit(ctx, store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx), Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceMCP, @@ -578,9 +578,9 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) { return nil, rememberOutput{ Slug: slug, - Version: geschrieben.Version, - Created: istNeu, - Message: meldung, + Version: written.Version, + Created: isNew, + Message: message, }, nil }, ) @@ -652,12 +652,12 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) { return nil, relatedOutput{}, err } - abstand := in.Distance - if abstand <= 0 { - abstand = relatedDefaultDistance + distance := in.Distance + if distance <= 0 { + distance = relatedDefaultDistance } - if abstand > relatedMaxDistance { - abstand = relatedMaxDistance + if distance > relatedMaxDistance { + distance = relatedMaxDistance } limit := in.Limit @@ -668,7 +668,7 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) { limit = relatedMaxLimit } - verwandte, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, abstand, limit) + related, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, distance, limit) if errors.Is(err, store.ErrNotFound) { return nil, relatedOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug) } @@ -676,9 +676,9 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) { return nil, relatedOutput{}, err } - jetzt := time.Now() - items := make([]relatedItem, 0, len(verwandte)) - for _, v := range verwandte { + now := time.Now() + items := make([]relatedItem, 0, len(related)) + for _, v := range related { items = append(items, relatedItem{ Slug: v.Slug, Title: v.Title, @@ -686,7 +686,7 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) { Via: v.Via, Relation: v.Relation, Updated: v.UpdatedAt.Format("2006-01-02 15:04"), - Stale: store.StaleSince(v.UpdatedAt, jetzt), + Stale: store.StaleSince(v.UpdatedAt, now), }) } diff --git a/internal/search/bench_test.go b/internal/search/bench_test.go index 5f3e1b4..8194ee5 100644 --- a/internal/search/bench_test.go +++ b/internal/search/bench_test.go @@ -7,27 +7,27 @@ import ( "testing" ) -// baueGrossenIndex legt einen Index in der Größenordnung eines großen Projekts +// buildLargeIndex legt einen Index in der Größenordnung eines großen Projekts // an: 10.000 Einträge à 150 Wörter aus einem Wortschatz von 20.000 Wörtern. // // Die Wörter sind künstlich ("wort123"), das genügt: Für die Laufzeit zählt, // wie viele verschiedene Wörter der Index durchgehen muss, nicht was sie heißen. // Der feste Startwert macht jeden Lauf gleich. -func baueGrossenIndex() *Index { +func buildLargeIndex() *Index { const ( - eintraege = 10_000 - woerter = 150 - wortschatz = 20_000 + entries = 10_000 + words = 150 + vocabulary = 20_000 ) - zufall := rand.New(rand.NewSource(1)) + rng := rand.New(rand.NewSource(1)) idx := NewIndex() var text strings.Builder - for i := 0; i < eintraege; i++ { + for i := 0; i < entries; i++ { text.Reset() - for j := 0; j < woerter; j++ { - fmt.Fprintf(&text, "wort%d ", zufall.Intn(wortschatz)) + for j := 0; j < words; j++ { + fmt.Fprintf(&text, "wort%d ", rng.Intn(vocabulary)) } idx.Index(fmt.Sprintf("eintrag-%d", i), "Titel", text.String(), nil) } @@ -39,7 +39,7 @@ func baueGrossenIndex() *Index { // // go test ./internal/search/ -bench . -run '^$' func BenchmarkSearch(b *testing.B) { - idx := baueGrossenIndex() + idx := buildLargeIndex() b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/internal/search/index.go b/internal/search/index.go index cabedb2..5615cae 100644 --- a/internal/search/index.go +++ b/internal/search/index.go @@ -225,10 +225,10 @@ func (s *Index) removeUnsafe(docID string) { // limit begrenzt die Anzahl der zurückgegebenen Ergebnisse. // // Neben exakten Treffern findet Search auch Wortanfänge, Wörter mit anderer -// Endung und zusammengesetzte Wörter (siehe varianten.go). Tippfehler +// Endung und zusammengesetzte Wörter (siehe variants.go). Tippfehler // gleicht Search nicht aus — dafür gibt es SearchFuzzy. func (s *Index) Search(queryStr string, limit int) []Result { - return s.suche(queryStr, limit, false) + return s.search(queryStr, limit, false) } // SearchFuzzy sucht wie Search, findet aber zusätzlich Wörter mit Tippfehlern @@ -240,15 +240,15 @@ func (s *Index) Search(queryStr string, limit int) []Result { // Tippfehler; bei ihnen brächte die Toleranz vor allem zusätzliche, unsichere // Treffer. Deshalb nutzt das MCP-Werkzeug recall weiterhin Search. func (s *Index) SearchFuzzy(queryStr string, limit int) []Result { - return s.suche(queryStr, limit, true) + return s.search(queryStr, limit, true) } -// suche ist die gemeinsame Umsetzung von Search und SearchFuzzy. -func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result { +// search ist die gemeinsame Umsetzung von Search und SearchFuzzy. +func (s *Index) search(queryStr string, limit int, typo bool) []Result { s.mu.RLock() defer s.mu.RUnlock() - queryTokens := ohneStoppwoerter(tokenize(queryStr)) + queryTokens := withoutStopwords(tokenize(queryStr)) if len(queryTokens) == 0 || len(s.docs) == 0 { return nil } @@ -265,9 +265,9 @@ func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result { // Je Dokument zählt für dieses Suchwort nur die beste Variante — // ein exakter Treffer wird also nicht durch denselben Treffer als // Wortanfang noch einmal aufgewertet. - beste := make(map[string]float64) + best := make(map[string]float64) - for _, v := range s.variantenFuer(term, tippfehler) { + for _, v := range s.variantsFor(term, typo) { // IDF (Inverse Document Frequency): // Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der"). // Formel: log((N - nq + 0.5) / (nq + 0.5) + 1) @@ -285,15 +285,15 @@ func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result { tf := float64(p.freq) tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen)) - wert := v.gewicht * idf * tfNorm - if wert > beste[p.docID] { - beste[p.docID] = wert + value := v.weight * idf * tfNorm + if value > best[p.docID] { + best[p.docID] = value } } } - for docID, wert := range beste { - scores[docID] += wert + for docID, value := range best { + scores[docID] += value } } @@ -325,7 +325,7 @@ func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result { // Es werden nur Buchstaben und Ziffern behalten, alles andere gilt als Trennzeichen. // Einzeichen-Tokens werden ignoriert (kaum aussagekräftig). // -// Umlaute und ß werden dabei vereinheitlicht — siehe faltUmlaut. +// Umlaute und ß werden dabei vereinheitlicht — siehe foldUmlauts. func tokenize(text string) []string { text = strings.ToLower(text) var tokens []string @@ -333,7 +333,7 @@ func tokenize(text string) []string { for _, r := range text { if unicode.IsLetter(r) || unicode.IsDigit(r) { - current.WriteString(faltUmlaut(r)) + current.WriteString(foldUmlauts(r)) } else { if current.Len() > 1 { tokens = append(tokens, current.String()) @@ -349,7 +349,7 @@ func tokenize(text string) []string { return tokens } -// faltUmlaut bildet Umlaute und ß auf ihre Grundform ab. +// foldUmlauts bildet Umlaute und ß auf ihre Grundform ab. // // # Wozu? // @@ -375,7 +375,7 @@ func tokenize(text string) []string { // // Alle anderen Zeichen bleiben unverändert; für lateinische Buchstaben und // Ziffern kostet die Funktion nur einen Vergleich. -func faltUmlaut(r rune) string { +func foldUmlauts(r rune) string { switch r { case 'ä': return "a" diff --git a/internal/search/registry.go b/internal/search/registry.go index 48d63ab..9951021 100644 --- a/internal/search/registry.go +++ b/internal/search/registry.go @@ -28,16 +28,16 @@ type Registry struct { // mu schützt nur die Zuordnung Projekt → Fach und wird immer nur kurz // gehalten. Die eigentliche Arbeit (Aufbau, Aktualisierung) läuft unter // der Sperre des jeweiligen Fachs. - mu sync.Mutex - faecher map[string]*fach // Schlüssel: Projekt-ID + mu sync.Mutex + slots map[string]*slot // Schlüssel: Projekt-ID - // nachDemLesen wird nur von Tests gesetzt: Es läuft beim Aufbau zwischen + // afterRead wird nur von Tests gesetzt: Es läuft beim Aufbau zwischen // dem Lesen der Einträge und dem Befüllen des Index. So lässt sich ein // langsamer Aufbau nachstellen. - nachDemLesen func(projectID string) + afterRead func(projectID string) } -// fach hält den Index eines einzelnen Projekts. +// slot hält den Index eines einzelnen Projekts. // // # Warum eine Sperre je Projekt? // @@ -48,7 +48,7 @@ type Registry struct { // // Mit einer Sperre je Projekt wartet nur, wer auf genau dieses Projekt // zugreift. Das muss er auch: Ohne fertigen Index gibt es nichts zu suchen. -type fach struct { +type slot struct { mu sync.Mutex idx *Index // nil, solange der Index noch nicht (oder nicht mehr) aufgebaut ist } @@ -62,8 +62,8 @@ type fach struct { // genau passenden ungeprüften nicht verdrängen. const curatedBoost = 1.2 -// boostFuer gibt den Suchfaktor eines Eintrags zurück. -func boostFuer(e store.Entry) float64 { +// boostFor gibt den Suchfaktor eines Eintrags zurück. +func boostFor(e store.Entry) float64 { if e.Curated { return curatedBoost } @@ -74,31 +74,31 @@ func boostFuer(e store.Entry) float64 { // Zugriff auf ein Projekt (siehe For). func NewRegistry(s *store.Store) *Registry { return &Registry{ - store: s, - faecher: make(map[string]*fach), + store: s, + slots: make(map[string]*slot), } } -// fachFuer gibt das Fach eines Projekts zurück und legt es bei Bedarf an — +// slotFor gibt das Fach eines Projekts zurück und legt es bei Bedarf an — // zunächst leer, aufgebaut wird erst in For. -func (r *Registry) fachFuer(projectID string) *fach { +func (r *Registry) slotFor(projectID string) *slot { r.mu.Lock() defer r.mu.Unlock() - f, ok := r.faecher[projectID] + f, ok := r.slots[projectID] if !ok { - f = &fach{} - r.faecher[projectID] = f + f = &slot{} + r.slots[projectID] = f } return f } -// vorhandenesFach gibt das Fach eines Projekts zurück, ohne eines anzulegen. -func (r *Registry) vorhandenesFach(projectID string) (*fach, bool) { +// existingSlot gibt das Fach eines Projekts zurück, ohne eines anzulegen. +func (r *Registry) existingSlot(projectID string) (*slot, bool) { r.mu.Lock() defer r.mu.Unlock() - f, ok := r.faecher[projectID] + f, ok := r.slots[projectID] return f, ok } @@ -113,7 +113,7 @@ func (r *Registry) vorhandenesFach(projectID string) (*fach, bool) { // erste auf. Die anderen warten an der Sperre des Fachs und bekommen danach // den fertigen Index. func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) { - f := r.fachFuer(projectID) + f := r.slotFor(projectID) f.mu.Lock() defer f.mu.Unlock() @@ -134,17 +134,17 @@ func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) { // fill liest alle Einträge eines Projekts und nimmt sie in den Index auf. func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error { - eintraege, err := r.store.AllEntries(ctx, projectID) + entries, err := r.store.AllEntries(ctx, projectID) if err != nil { return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err) } - if r.nachDemLesen != nil { - r.nachDemLesen(projectID) + if r.afterRead != nil { + r.afterRead(projectID) } - for _, e := range eintraege { - idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFuer(e)) + for _, e := range entries { + idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFor(e)) } return nil } @@ -170,7 +170,7 @@ func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error // Reihenfolge die Aufrufe ankommen. Das kostet eine kleine Leseabfrage je // Schreibvorgang. func (r *Registry) Refresh(ctx context.Context, projectID, slug string) { - f, ok := r.vorhandenesFach(projectID) + f, ok := r.existingSlot(projectID) if !ok { return } @@ -200,7 +200,7 @@ func (r *Registry) Refresh(ctx context.Context, projectID, slug string) { log.Printf("Suchindex: %q in Projekt %s nicht lesbar, Index wird neu aufgebaut: %v", slug, projectID, err) f.idx = nil default: - f.idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFuer(e)) + f.idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFor(e)) } } @@ -208,5 +208,5 @@ func (r *Registry) Refresh(ctx context.Context, projectID, slug string) { func (r *Registry) Forget(projectID string) { r.mu.Lock() defer r.mu.Unlock() - delete(r.faecher, projectID) + delete(r.slots, projectID) } diff --git a/internal/search/registry_test.go b/internal/search/registry_test.go index 3aa1c09..eb2204c 100644 --- a/internal/search/registry_test.go +++ b/internal/search/registry_test.go @@ -9,9 +9,9 @@ import ( "munin/internal/store" ) -// aufbauRegistry legt eine Datenbank mit einem Projekt und eine Registry mit +// setupRegistry legt eine Datenbank mit einem Projekt und eine Registry mit // bereits aufgebautem Index an. -func aufbauRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Index) { +func setupRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Index) { t.Helper() db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) @@ -21,24 +21,24 @@ func aufbauRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Inde t.Cleanup(func() { db.Close() }) ctx := context.Background() - projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") + project, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") if err != nil { t.Fatal(err) } r := NewRegistry(db) // Den Index vorher anlegen — sonst sind Aktualisierungen ein No-op. - idx, err := r.For(ctx, projekt.ID) + idx, err := r.For(ctx, project.ID) if err != nil { t.Fatal(err) } - return r, db, projekt, idx + return r, db, project, idx } -func schreibe(t *testing.T, db *store.Store, projekt store.Project, slug, inhalt string) store.Entry { +func write(t *testing.T, db *store.Store, project store.Project, slug, content string) store.Entry { t.Helper() e, err := db.PutEntry(context.Background(), store.EntryWrite{ - ProjectID: projekt.ID, Slug: slug, Title: "Eintrag", Content: inhalt, Source: store.SourceAgent, + ProjectID: project.ID, Slug: slug, Title: "Eintrag", Content: content, Source: store.SourceAgent, }) if err != nil { t.Fatal(err) @@ -46,8 +46,8 @@ func schreibe(t *testing.T, db *store.Store, projekt store.Project, slug, inhalt return e } -func findet(idx *Index, wort, slug string) bool { - for _, r := range idx.Search(wort, 10) { +func finds(idx *Index, word, slug string) bool { + for _, r := range idx.Search(word, 10) { if r.DocID == slug { return true } @@ -55,54 +55,54 @@ func findet(idx *Index, wort, slug string) bool { return false } -// TestUeberholteAktualisierungHaeltIndexAktuell stellt den Fall nach, dass sich +// TestStaleUpdateKeepsIndexCurrent stellt den Fall nach, dass sich // zwei Schreiber zwischen Datenbank und Index überholen: Fassung 2 und 3 sind // gespeichert, aber die Meldung für Fassung 3 kommt beim Index zuerst an. // // Unter Last lässt sich das kaum zuverlässig auslösen (siehe -// TestGleichzeitigesSchreibenHaeltSuchindexAktuell im Paket mcpserver), hier +// TestConcurrentWritesKeepSearchIndexCurrent im Paket mcpserver), hier // wird die Reihenfolge deshalb von Hand vertauscht. -func TestUeberholteAktualisierungHaeltIndexAktuell(t *testing.T) { - r, db, projekt, idx := aufbauRegistry(t) +func TestStaleUpdateKeepsIndexCurrent(t *testing.T) { + r, db, project, idx := setupRegistry(t) - schreibe(t, db, projekt, "umkaempft", "Stand wortzwei") - schreibe(t, db, projekt, "umkaempft", "Stand wortdrei") + write(t, db, project, "umkaempft", "Stand wortzwei") + write(t, db, project, "umkaempft", "Stand wortdrei") - r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 3 - r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 2, verspätet + r.Refresh(context.Background(), project.ID, "umkaempft") // Schreiber von Fassung 3 + r.Refresh(context.Background(), project.ID, "umkaempft") // Schreiber von Fassung 2, verspätet - if !findet(idx, "wortdrei", "umkaempft") { + if !finds(idx, "wortdrei", "umkaempft") { t.Error("der Index kennt den aktuellen Stand (Fassung 3) nicht") } - if findet(idx, "wortzwei", "umkaempft") { + if finds(idx, "wortzwei", "umkaempft") { t.Error("der Index hält einen überholten Stand (Fassung 2)") } } -// TestVerspaeteteAktualisierungNachLoeschenBleibtWeg: Ein Eintrag wird +// TestLateUpdateAfterDeleteStaysGone: Ein Eintrag wird // geschrieben und gleich darauf gelöscht. Kommt die Meldung des Schreibers erst // nach der des Löschens beim Index an, darf der Eintrag nicht wieder auftauchen. -func TestVerspaeteteAktualisierungNachLoeschenBleibtWeg(t *testing.T) { - r, db, projekt, idx := aufbauRegistry(t) +func TestLateUpdateAfterDeleteStaysGone(t *testing.T) { + r, db, project, idx := setupRegistry(t) ctx := context.Background() - schreibe(t, db, projekt, "kurzlebig", "Stand wortgeloescht") - if err := db.MoveEntryToTrash(ctx, projekt.ID, "kurzlebig", "test"); err != nil { + write(t, db, project, "kurzlebig", "Stand wortgeloescht") + if err := db.MoveEntryToTrash(ctx, project.ID, "kurzlebig", "test"); err != nil { t.Fatal(err) } - r.Refresh(ctx, projekt.ID, "kurzlebig") // Löschender - r.Refresh(ctx, projekt.ID, "kurzlebig") // Schreiber, verspätet + r.Refresh(ctx, project.ID, "kurzlebig") // Löschender + r.Refresh(ctx, project.ID, "kurzlebig") // Schreiber, verspätet - if findet(idx, "wortgeloescht", "kurzlebig") { + if finds(idx, "wortgeloescht", "kurzlebig") { t.Error("ein gelöschter Eintrag ist wieder im Suchindex") } } -// TestLangsamerAufbauBremstAndereProjekteNicht: Während der Index von Projekt A +// TestSlowBuildDoesNotBlockOtherProjects: Während der Index von Projekt A // noch aufgebaut wird, muss Projekt B ohne Wartezeit durchkommen. Früher hielt // der Aufbau eine Sperre für den ganzen Workspace. -func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) { +func TestSlowBuildDoesNotBlockOtherProjects(t *testing.T) { db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) @@ -110,40 +110,40 @@ func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) { t.Cleanup(func() { db.Close() }) ctx := context.Background() - gross, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "") + big, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "") if err != nil { t.Fatal(err) } - klein, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "") + small, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "") if err != nil { t.Fatal(err) } r := NewRegistry(db) - imAufbau := make(chan struct{}) - weiter := make(chan struct{}) - r.nachDemLesen = func(projectID string) { - if projectID == gross.ID { - close(imAufbau) - <-weiter // Aufbau hängt, bis der Test ihn freigibt + building := make(chan struct{}) + release := make(chan struct{}) + r.afterRead = func(projectID string) { + if projectID == big.ID { + close(building) + <-release // Aufbau hängt, bis der Test ihn freigibt } } - fertig := make(chan error) + done := make(chan error) go func() { - _, err := r.For(ctx, gross.ID) - fertig <- err + _, err := r.For(ctx, big.ID) + done <- err }() - <-imAufbau + <-building // Projekt B, während A noch aufgebaut wird. - klar := make(chan error) + smallDone := make(chan error) go func() { - _, err := r.For(ctx, klein.ID) - klar <- err + _, err := r.For(ctx, small.ID) + smallDone <- err }() select { - case err := <-klar: + case err := <-smallDone: if err != nil { t.Fatal(err) } @@ -151,17 +151,17 @@ func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) { t.Fatal("Projekt B wartet auf den Aufbau von Projekt A") } - close(weiter) - if err := <-fertig; err != nil { + close(release) + if err := <-done; err != nil { t.Fatal(err) } } -// TestAenderungWaehrendDesAufbausGehtNichtVerloren: Der Aufbau hat die +// TestChangeDuringBuildIsNotLost: Der Aufbau hat die // Einträge schon gelesen, da wird einer davon geändert. Das Refresh des // Schreibers muss warten und danach den neuen Stand nachtragen — sonst hielte // der fertige Index den Stand von vor der Änderung. -func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) { +func TestChangeDuringBuildIsNotLost(t *testing.T) { db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) @@ -169,66 +169,66 @@ func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) { t.Cleanup(func() { db.Close() }) ctx := context.Background() - projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") + project, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") if err != nil { t.Fatal(err) } - schreibe(t, db, projekt, "eintrag", "Stand wortalt") + write(t, db, project, "eintrag", "Stand wortalt") r := NewRegistry(db) - refreshFertig := make(chan struct{}) - r.nachDemLesen = func(string) { + refreshDone := make(chan struct{}) + r.afterRead = func(string) { // Die Einträge sind gelesen, der Index noch nicht befüllt. Jetzt // schreibt jemand und meldet es, wie es die Handler tun. - schreibe(t, db, projekt, "eintrag", "Stand wortneu") + write(t, db, project, "eintrag", "Stand wortneu") go func() { - r.Refresh(ctx, projekt.ID, "eintrag") - close(refreshFertig) + r.Refresh(ctx, project.ID, "eintrag") + close(refreshDone) }() // Refresh darf nicht durchlaufen, solange der Aufbau läuft. select { - case <-refreshFertig: + case <-refreshDone: t.Error("Refresh lief während des Aufbaus durch") case <-time.After(50 * time.Millisecond): } } - idx, err := r.For(ctx, projekt.ID) + idx, err := r.For(ctx, project.ID) if err != nil { t.Fatal(err) } - <-refreshFertig + <-refreshDone - if !findet(idx, "wortneu", "eintrag") { + if !finds(idx, "wortneu", "eintrag") { t.Error("die Änderung während des Aufbaus fehlt im Index") } - if findet(idx, "wortalt", "eintrag") { + if finds(idx, "wortalt", "eintrag") { t.Error("der Index hält den Stand von vor der Änderung") } } -// TestRegistryBevorzugtKuratierte prüft, dass die Registry kuratierten +// TestRegistryPrefersCurated prüft, dass die Registry kuratierten // Einträgen den Suchfaktor mitgibt — beim Aufbau wie beim Nachziehen. -func TestRegistryBevorzugtKuratierte(t *testing.T) { - r, db, projekt, idx := aufbauRegistry(t) +func TestRegistryPrefersCurated(t *testing.T) { + r, db, project, idx := setupRegistry(t) ctx := context.Background() - for _, kuratiert := range []bool{false, true} { + for _, curated := range []bool{false, true} { slug := "ungeprueft" - if kuratiert { + if curated { slug = "geprueft" } if _, err := db.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: slug, Title: "Deployment", Content: "Ablauf.", - Source: store.SourceHuman, Curated: kuratiert, + ProjectID: project.ID, Slug: slug, Title: "Deployment", Content: "Ablauf.", + Source: store.SourceHuman, Curated: curated, }); err != nil { t.Fatal(err) } - r.Refresh(ctx, projekt.ID, slug) + r.Refresh(ctx, project.ID, slug) } - treffer := idx.Search("deployment", 10) - if len(treffer) != 2 || treffer[0].DocID != "geprueft" { - t.Errorf("Treffer = %v, erwartet den kuratierten zuerst", ergebnisIDs(treffer)) + hits := idx.Search("deployment", 10) + if len(hits) != 2 || hits[0].DocID != "geprueft" { + t.Errorf("Treffer = %v, erwartet den kuratierten zuerst", resultIDs(hits)) } } diff --git a/internal/search/search_test.go b/internal/search/search_test.go index 7241c11..4eeecf5 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -5,8 +5,8 @@ import ( "testing" ) -// baueIndex legt einen Index mit ein paar Dokumenten an. -func baueIndex() *Index { +// buildIndex legt einen Index mit ein paar Dokumenten an. +func buildIndex() *Index { idx := NewIndex() idx.Index("deployment-prozess", "Deployment-Prozess", "Wir deployen über GitHub Actions auf den Produktivserver.", []string{"ops", "deployment"}) @@ -17,20 +17,20 @@ func baueIndex() *Index { return idx } -// ergebnisIDs gibt die Kennungen der Treffer in Reihenfolge zurück. -func ergebnisIDs(treffer []Result) []string { - ids := make([]string, len(treffer)) - for i, t := range treffer { +// resultIDs gibt die Kennungen der Treffer in Reihenfolge zurück. +func resultIDs(hits []Result) []string { + ids := make([]string, len(hits)) + for i, t := range hits { ids[i] = t.DocID } return ids } func TestSearchFindsExactTerm(t *testing.T) { - treffer := baueIndex().Search("bastion", 10) + hits := buildIndex().Search("bastion", 10) - if len(treffer) != 1 || treffer[0].DocID != "server-zugang" { - t.Errorf("Treffer = %v, erwartet nur server-zugang", ergebnisIDs(treffer)) + if len(hits) != 1 || hits[0].DocID != "server-zugang" { + t.Errorf("Treffer = %v, erwartet nur server-zugang", resultIDs(hits)) } } @@ -41,13 +41,13 @@ func TestSearchRanksTitleHigher(t *testing.T) { idx.Index("im-titel", "Deployment", "Ein Text über etwas ganz anderes.", nil) idx.Index("im-text", "Irgendwas", "Hier kommt das Wort Deployment einmal vor.", nil) - treffer := idx.Search("deployment", 10) + hits := idx.Search("deployment", 10) - if len(treffer) != 2 { - t.Fatalf("%d Treffer, erwartet 2", len(treffer)) + if len(hits) != 2 { + t.Fatalf("%d Treffer, erwartet 2", len(hits)) } - if treffer[0].DocID != "im-titel" { - t.Errorf("bester Treffer = %q, erwartet den Titel-Treffer", treffer[0].DocID) + if hits[0].DocID != "im-titel" { + t.Errorf("bester Treffer = %q, erwartet den Titel-Treffer", hits[0].DocID) } } @@ -57,21 +57,21 @@ func TestSearchRanksTitleHigher(t *testing.T) { // bei einer KI-Anfrage ein stiller Fehltreffer: Das Modell erfährt schlicht // nicht, dass es den Eintrag gäbe. func TestSearchFindsByPrefix(t *testing.T) { - treffer := baueIndex().Search("deploy", 10) + hits := buildIndex().Search("deploy", 10) - if len(treffer) == 0 { + if len(hits) == 0 { t.Fatal("die Präfix-Suche fand nichts") } - if treffer[0].DocID != "deployment-prozess" { - t.Errorf("bester Treffer = %q, erwartet deployment-prozess", treffer[0].DocID) + if hits[0].DocID != "deployment-prozess" { + t.Errorf("bester Treffer = %q, erwartet deployment-prozess", hits[0].DocID) } } // TestSearchPrefixNeedsThreeChars prüft, dass sehr kurze Bruchstücke nicht // greifen — "de" würde sonst auf halbe Wörterbücher passen. func TestSearchPrefixNeedsThreeChars(t *testing.T) { - if treffer := baueIndex().Search("de", 10); len(treffer) > 0 { - t.Errorf("zweibuchstabiges Präfix lieferte %d Treffer", len(treffer)) + if hits := buildIndex().Search("de", 10); len(hits) > 0 { + t.Errorf("zweibuchstabiges Präfix lieferte %d Treffer", len(hits)) } } @@ -82,32 +82,32 @@ func TestSearchExactBeatsPrefix(t *testing.T) { idx.Index("exakt", "Test", "Das Wort ssh kommt hier vor.", nil) idx.Index("praefix", "Test", "Hier steht sshd, also nur ein Wortanfang.", nil) - treffer := idx.Search("ssh", 10) + hits := idx.Search("ssh", 10) - if len(treffer) == 0 { + if len(hits) == 0 { t.Fatal("keine Treffer") } - if treffer[0].DocID != "exakt" { - t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID) + if hits[0].DocID != "exakt" { + t.Errorf("bester Treffer = %q, erwartet den exakten", hits[0].DocID) } } func TestSearchTagsAreIndexed(t *testing.T) { - treffer := baueIndex().Search("sicherheit", 10) + hits := buildIndex().Search("sicherheit", 10) - if len(treffer) != 1 || treffer[0].DocID != "server-zugang" { - t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", ergebnisIDs(treffer)) + if len(hits) != 1 || hits[0].DocID != "server-zugang" { + t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", resultIDs(hits)) } } func TestSearchIsCaseInsensitive(t *testing.T) { - idx := baueIndex() + idx := buildIndex() - klein := idx.Search("bastion", 10) - gross := idx.Search("BASTION", 10) + lower := idx.Search("bastion", 10) + upper := idx.Search("BASTION", 10) - if len(klein) != len(gross) || len(klein) == 0 { - t.Errorf("Groß-/Kleinschreibung macht einen Unterschied: %d vs. %d", len(klein), len(gross)) + if len(lower) != len(upper) || len(lower) == 0 { + t.Errorf("Groß-/Kleinschreibung macht einen Unterschied: %d vs. %d", len(lower), len(upper)) } } @@ -117,14 +117,14 @@ func TestSearchRespectsLimit(t *testing.T) { idx.Index(strings.Repeat("x", i+1), "Titel", "gemeinsames Stichwort", nil) } - if treffer := idx.Search("gemeinsames", 5); len(treffer) != 5 { - t.Errorf("%d Treffer, erwartet 5", len(treffer)) + if hits := idx.Search("gemeinsames", 5); len(hits) != 5 { + t.Errorf("%d Treffer, erwartet 5", len(hits)) } } func TestSearchEmptyQuery(t *testing.T) { - if treffer := baueIndex().Search("", 10); len(treffer) != 0 { - t.Errorf("eine leere Anfrage lieferte %d Treffer", len(treffer)) + if hits := buildIndex().Search("", 10); len(hits) != 0 { + t.Errorf("eine leere Anfrage lieferte %d Treffer", len(hits)) } } @@ -135,32 +135,32 @@ func TestIndexUpdateReplaces(t *testing.T) { idx.Index("eintrag", "Alt", "Das alte Stichwort lautet Apfelbaum.", nil) idx.Index("eintrag", "Neu", "Das neue Stichwort lautet Birnbaum.", nil) - if treffer := idx.Search("apfelbaum", 10); len(treffer) != 0 { + if hits := idx.Search("apfelbaum", 10); len(hits) != 0 { t.Error("der alte Inhalt ist noch im Index") } - if treffer := idx.Search("birnbaum", 10); len(treffer) != 1 { + if hits := idx.Search("birnbaum", 10); len(hits) != 1 { t.Error("der neue Inhalt fehlt im Index") } } func TestIndexRemove(t *testing.T) { - idx := baueIndex() + idx := buildIndex() idx.Remove("server-zugang") - if treffer := idx.Search("bastion", 10); len(treffer) != 0 { + if hits := idx.Search("bastion", 10); len(hits) != 0 { t.Error("der entfernte Eintrag ist noch auffindbar") } // Die übrigen Einträge müssen unberührt bleiben. - if treffer := idx.Search("onboarding", 10); len(treffer) == 0 { + if hits := idx.Search("onboarding", 10); len(hits) == 0 { t.Error("beim Entfernen wurde zu viel gelöscht") } } func TestRemoveUnknownIsHarmless(t *testing.T) { - idx := baueIndex() + idx := buildIndex() idx.Remove("gibtesnicht") - if treffer := idx.Search("bastion", 10); len(treffer) != 1 { + if hits := idx.Search("bastion", 10); len(hits) != 1 { t.Error("das Entfernen eines unbekannten Eintrags hat den Index beschädigt") } } @@ -183,31 +183,31 @@ func TestTokenizeIgnoresSingleChars(t *testing.T) { func TestTokenizeSplitsOnPunctuation(t *testing.T) { got := tokenize("GitHub-Actions, SSH/SCP; done.") - erwartet := map[string]bool{"github": true, "actions": true, "ssh": true, "scp": true, "done": true} + want := map[string]bool{"github": true, "actions": true, "ssh": true, "scp": true, "done": true} for _, token := range got { - if !erwartet[token] { + if !want[token] { t.Errorf("unerwartetes Token %q in %v", token, got) } } - if len(got) != len(erwartet) { - t.Errorf("tokenize = %v, erwartet %d Token", got, len(erwartet)) + if len(got) != len(want) { + t.Errorf("tokenize = %v, erwartet %d Token", got, len(want)) } } // ── Umlaute ────────────────────────────────────────────────────────────────── -// TestUmlautfaltung prüft, dass Umlaute und ß beim Suchen keine Rolle spielen. +// TestUmlautFolding prüft, dass Umlaute und ß beim Suchen keine Rolle spielen. // // Ohne das findet „prufung" den Eintrag „Prüfung" nicht — für den Index sind es // zwei verschiedene Wörter. Genau so tippt aber, wer auf einer fremden Tastatur // sitzt. -func TestUmlautfaltung(t *testing.T) { +func TestUmlautFolding(t *testing.T) { idx := NewIndex() idx.Index("bericht", "Prüfung der Größe", "Bei der Prüfung ergab sich eine Störung. Die Maßnahme läuft.", []string{"prüfung"}) // Jeweils: mit Umlaut geschrieben und ohne — beide müssen treffen. - paare := [][2]string{ + pairs := [][2]string{ {"prüfung", "prufung"}, {"größe", "grosse"}, {"störung", "storung"}, @@ -215,28 +215,28 @@ func TestUmlautfaltung(t *testing.T) { {"läuft", "lauft"}, } - for _, paar := range paare { - for _, anfrage := range paar { - if treffer := idx.Search(anfrage, 10); len(treffer) == 0 { - t.Errorf("Suche nach %q fand nichts", anfrage) + for _, pair := range pairs { + for _, query := range pair { + if hits := idx.Search(query, 10); len(hits) == 0 { + t.Errorf("Suche nach %q fand nichts", query) } } } } -// TestUmlautfaltungImSchlagwort prüft, dass auch Schlagworte gefaltet werden. -func TestUmlautfaltungImSchlagwort(t *testing.T) { +// TestUmlautFoldingInTag prüft, dass auch Schlagworte gefaltet werden. +func TestUmlautFoldingInTag(t *testing.T) { idx := NewIndex() idx.Index("eintrag", "Titel", "Inhalt", []string{"prüfung"}) - if treffer := idx.Search("prufung", 10); len(treffer) == 0 { + if hits := idx.Search("prufung", 10); len(hits) == 0 { t.Error("Schlagwort mit Umlaut ist ohne Umlaut nicht auffindbar") } } -// TestFaltungVerschontNormaleWoerter stellt sicher, dass die Faltung nicht mehr +// TestFoldingSparesNormalWords stellt sicher, dass die Faltung nicht mehr // anfasst als nötig — „neue" darf nicht zu „nu" werden. -func TestFaltungVerschontNormaleWoerter(t *testing.T) { +func TestFoldingSparesNormalWords(t *testing.T) { cases := map[string]string{ "neue": "neue", "steuer": "steuer", @@ -246,64 +246,64 @@ func TestFaltungVerschontNormaleWoerter(t *testing.T) { "tür": "tur", } - for eingabe, erwartet := range cases { - got := tokenize(eingabe) - if len(got) != 1 || got[0] != erwartet { - t.Errorf("tokenize(%q) = %v, erwartet [%s]", eingabe, got, erwartet) + for input, want := range cases { + got := tokenize(input) + if len(got) != 1 || got[0] != want { + t.Errorf("tokenize(%q) = %v, erwartet [%s]", input, got, want) } } } -// TestPraefixMitUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche. -func TestPraefixMitUmlaut(t *testing.T) { +// TestPrefixWithUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche. +func TestPrefixWithUmlaut(t *testing.T) { idx := NewIndex() idx.Index("eintrag", "Überwachung", "Die Überwachung meldet Störungen.", nil) // "uberwach" ist Präfix von "uberwachung" — beides gefaltet. - if treffer := idx.Search("uberwach", 10); len(treffer) == 0 { + if hits := idx.Search("uberwach", 10); len(hits) == 0 { t.Error("Präfix ohne Umlaut fand den Eintrag nicht") } } // ── Bevorzugung (Boost) ────────────────────────────────────────────────────── -// TestBoostBevorzugtBeiGleicherRelevanz prüft, dass der Faktor bei sonst +// TestBoostPrefersOnEqualRelevance prüft, dass der Faktor bei sonst // gleichen Dokumenten den Ausschlag gibt. -func TestBoostBevorzugtBeiGleicherRelevanz(t *testing.T) { +func TestBoostPrefersOnEqualRelevance(t *testing.T) { idx := NewIndex() idx.Index("normal", "Deployment", "Ablauf.", nil) idx.IndexWithBoost("bevorzugt", "Deployment", "Ablauf.", nil, 1.2) - treffer := idx.Search("deployment", 10) + hits := idx.Search("deployment", 10) - if len(treffer) != 2 || treffer[0].DocID != "bevorzugt" { - t.Errorf("Treffer = %v, erwartet bevorzugt zuerst", ergebnisIDs(treffer)) + if len(hits) != 2 || hits[0].DocID != "bevorzugt" { + t.Errorf("Treffer = %v, erwartet bevorzugt zuerst", resultIDs(hits)) } } -// TestBoostVerdraengtKeinenGenauenTreffer prüft, dass der kleine Faktor einen +// TestBoostDoesNotDisplaceExactHit prüft, dass der kleine Faktor einen // klar besser passenden Eintrag nicht überholt. -func TestBoostVerdraengtKeinenGenauenTreffer(t *testing.T) { +func TestBoostDoesNotDisplaceExactHit(t *testing.T) { idx := NewIndex() idx.Index("genau", "Zertifikatswechsel", "Der Zertifikatswechsel im März.", nil) idx.IndexWithBoost("rand", "Wartung", "Allgemeines, unter anderem ein Zertifikatswechsel.", nil, 1.2) - treffer := idx.Search("zertifikatswechsel", 10) + hits := idx.Search("zertifikatswechsel", 10) - if len(treffer) != 2 || treffer[0].DocID != "genau" { - t.Errorf("Treffer = %v, erwartet genau zuerst", ergebnisIDs(treffer)) + if len(hits) != 2 || hits[0].DocID != "genau" { + t.Errorf("Treffer = %v, erwartet genau zuerst", resultIDs(hits)) } } -// TestBoostOhneWertIstNeutral prüft, dass ein Faktor ≤ 0 kein Dokument +// TestBoostWithoutValueIsNeutral prüft, dass ein Faktor ≤ 0 kein Dokument // unauffindbar macht. -func TestBoostOhneWertIstNeutral(t *testing.T) { +func TestBoostWithoutValueIsNeutral(t *testing.T) { idx := NewIndex() idx.IndexWithBoost("null", "Deployment", "Ablauf.", nil, 0) - treffer := idx.Search("deployment", 10) + hits := idx.Search("deployment", 10) - if len(treffer) != 1 || treffer[0].Score <= 0 { - t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", treffer) + if len(hits) != 1 || hits[0].Score <= 0 { + t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", hits) } } diff --git a/internal/search/varianten.go b/internal/search/variants.go similarity index 63% rename from internal/search/varianten.go rename to internal/search/variants.go index 337bc47..6573d3b 100644 --- a/internal/search/varianten.go +++ b/internal/search/variants.go @@ -26,39 +26,39 @@ import ( // Gewichte der Varianten — ein exakter Treffer zählt 1. const ( - // gewichtWortanfang gilt für Wörter, die mit dem Suchwort oder seinem + // weightWordPrefix gilt für Wörter, die mit dem Suchwort oder seinem // Stamm beginnen: „deploy" → „deployment". - gewichtWortanfang = 0.5 + weightWordPrefix = 0.5 - // gewichtTeilwort gilt für Wörter, die das Suchwort irgendwo enthalten: + // weightSubword gilt für Wörter, die das Suchwort irgendwo enthalten: // „prozess" → „releaseprozess". Niedriger als der Wortanfang, weil // Teilwörter öfter zufällig passen. - gewichtTeilwort = 0.3 + weightSubword = 0.3 - // gewichtTippfehler gilt für Wörter, die sich um ein oder zwei Buchstaben + // weightTypo gilt für Wörter, die sich um ein oder zwei Buchstaben // unterscheiden: „deploymnet" → „deployment". - gewichtTippfehler = 0.3 + weightTypo = 0.3 ) // Mindestlängen der Suchwörter für die einzelnen Varianten. Kürzere Wörter // passen auf zu vieles: „de" ist der Anfang halber Wörterbücher, und „port" // steckt in „Reporting", „Export" und „Support". const ( - minWortanfang = 3 - minTeilwort = 5 - minStamm = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt - minTippfehler = 4 + minWordPrefix = 3 + minSubword = 5 + minStem = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt + minTypo = 4 ) -// variante ist eine Gruppe von Fundstellen, die über dieselbe Art von +// variant ist eine Gruppe von Fundstellen, die über dieselbe Art von // Ähnlichkeit gefunden wurden, samt ihrem Gewicht. -type variante struct { +type variant struct { postings []posting - gewicht float64 + weight float64 } -// variantenFuer sucht alle Fundstellen eines Suchworts: exakt, über den -// Wortanfang, als Teilwort und — nur wenn tippfehler gesetzt ist und sonst +// variantsFor sucht alle Fundstellen eines Suchworts: exakt, über den +// Wortanfang, als Teilwort und — nur wenn typo gesetzt ist und sonst // gar nichts passt — mit Tippfehlern. // // Muss unter der Lesesperre des Index aufgerufen werden. @@ -69,85 +69,85 @@ type variante struct { // geht die Funktion einmal den ganzen Wortschatz des Projekts durch. Gemessen // (siehe bench_test.go) sind das bei 20.000 verschiedenen Wörtern einige // Zehntel Millisekunden je Anfrage — für Agenten wie Menschen nicht spürbar. -func (s *Index) variantenFuer(term string, tippfehler bool) []variante { - var ergebnis []variante +func (s *Index) variantsFor(term string, typo bool) []variant { + var result []variant - if exakt := s.invertedIndex[term]; len(exakt) > 0 { - ergebnis = append(ergebnis, variante{postings: exakt, gewicht: 1}) + if exact := s.invertedIndex[term]; len(exact) > 0 { + result = append(result, variant{postings: exact, weight: 1}) } - stamm := kuerzeEndung(term) - sucheWortanfang := len(term) >= minWortanfang - sucheTeilwort := len(term) >= minTeilwort + stem := trimSuffix(term) + searchWordPrefix := len(term) >= minWordPrefix + searchSubword := len(term) >= minSubword // Je Variante: Dokument → höchste Häufigkeit eines passenden Worts. // Höchste statt Summe aus demselben Grund wie oben: „deploy", „deployment" // und „deployt" im selben Eintrag sollen nicht dreifach zählen. - wortanfang := make(map[string]int) - teilwort := make(map[string]int) + wordPrefix := make(map[string]int) + subword := make(map[string]int) - for wort, postings := range s.invertedIndex { + for word, postings := range s.invertedIndex { switch { - case sucheWortanfang && strings.HasPrefix(wort, term): - merkeHoechste(wortanfang, postings) - case stamm != "" && strings.HasPrefix(wort, stamm): - merkeHoechste(wortanfang, postings) - case sucheTeilwort && strings.Contains(wort, term): - merkeHoechste(teilwort, postings) + case searchWordPrefix && strings.HasPrefix(word, term): + keepHighest(wordPrefix, postings) + case stem != "" && strings.HasPrefix(word, stem): + keepHighest(wordPrefix, postings) + case searchSubword && strings.Contains(word, term): + keepHighest(subword, postings) } } - if len(wortanfang) > 0 { - ergebnis = append(ergebnis, variante{postings: alsPostings(wortanfang), gewicht: gewichtWortanfang}) + if len(wordPrefix) > 0 { + result = append(result, variant{postings: asPostings(wordPrefix), weight: weightWordPrefix}) } - if len(teilwort) > 0 { - ergebnis = append(ergebnis, variante{postings: alsPostings(teilwort), gewicht: gewichtTeilwort}) + if len(subword) > 0 { + result = append(result, variant{postings: asPostings(subword), weight: weightSubword}) } // Tippfehler nur als letzte Rückfallebene: Sie sind die unsicherste // Variante und kosten den meisten Rechenaufwand. - if tippfehler && len(ergebnis) == 0 { - if treffer := s.tippfehlerPostings(term); len(treffer) > 0 { - ergebnis = append(ergebnis, variante{postings: treffer, gewicht: gewichtTippfehler}) + if typo && len(result) == 0 { + if hits := s.typoPostings(term); len(hits) > 0 { + result = append(result, variant{postings: hits, weight: weightTypo}) } } - return ergebnis + return result } -// tippfehlerPostings sammelt die Fundstellen aller Wörter, die sich vom +// typoPostings sammelt die Fundstellen aller Wörter, die sich vom // Suchwort nur um wenige Buchstaben unterscheiden. -func (s *Index) tippfehlerPostings(term string) []posting { - suchwort := []rune(term) - erlaubt := erlaubteTippfehler(len(suchwort)) - if erlaubt == 0 { +func (s *Index) typoPostings(term string) []posting { + searchWord := []rune(term) + allowed := allowedTypos(len(searchWord)) + if allowed == 0 { return nil } - beste := make(map[string]int) - for wort, postings := range s.invertedIndex { + best := make(map[string]int) + for word, postings := range s.invertedIndex { // Schneller Vorfilter: Unterscheiden sich die Längen schon stärker als // erlaubt, kann der Abstand nicht kleiner sein. Das erspart die // eigentliche Rechnung für fast alle Wörter. - laenge := utf8.RuneCountInString(wort) - if abs(laenge-len(suchwort)) > erlaubt { + length := utf8.RuneCountInString(word) + if abs(length-len(searchWord)) > allowed { continue } - if levenshtein(suchwort, []rune(wort)) <= erlaubt { - merkeHoechste(beste, postings) + if levenshtein(searchWord, []rune(word)) <= allowed { + keepHighest(best, postings) } } - return alsPostings(beste) + return asPostings(best) } -// erlaubteTippfehler legt fest, wie viele Buchstaben bei einem Wort dieser +// allowedTypos legt fest, wie viele Buchstaben bei einem Wort dieser // Länge abweichen dürfen. Bei kurzen Wörtern wäre schon ein Fehler zu viel: // „host" und „post" sind verschiedene Dinge. -func erlaubteTippfehler(laenge int) int { +func allowedTypos(length int) int { switch { - case laenge < minTippfehler: + case length < minTypo: return 0 - case laenge < 8: + case length < 8: return 1 default: return 2 @@ -165,33 +165,33 @@ func erlaubteTippfehler(laenge int) int { // Feldern. Jede Zeile braucht aber nur die Zeile davor — deshalb genügen hier // zwei Zeilen, die abwechselnd überschrieben werden. func levenshtein(a, b []rune) int { - vorher := make([]int, len(b)+1) - aktuell := make([]int, len(b)+1) + before := make([]int, len(b)+1) + current := make([]int, len(b)+1) // Erste Zeile: aus dem leeren Wort wird b durch j-maliges Einfügen. - for j := range vorher { - vorher[j] = j + for j := range before { + before[j] = j } for i := 1; i <= len(a); i++ { - aktuell[0] = i // aus a[:i] wird das leere Wort durch i-maliges Löschen + current[0] = i // aus a[:i] wird das leere Wort durch i-maliges Löschen for j := 1; j <= len(b); j++ { - kosten := 1 + cost := 1 if a[i-1] == b[j-1] { - kosten = 0 + cost = 0 } - aktuell[j] = min( - vorher[j]+1, // Buchstabe aus a löschen - aktuell[j-1]+1, // Buchstabe aus b einfügen - vorher[j-1]+kosten, // Buchstabe ersetzen (oder gleich lassen) + current[j] = min( + before[j]+1, // Buchstabe aus a löschen + current[j-1]+1, // Buchstabe aus b einfügen + before[j-1]+cost, // Buchstabe ersetzen (oder gleich lassen) ) } - vorher, aktuell = aktuell, vorher + before, current = current, before } - return vorher[len(b)] + return before[len(b)] } -// endungen sind die Wortendungen, die kuerzeEndung abschneidet — die +// suffixes sind die Wortendungen, die trimSuffix abschneidet — die // häufigsten deutschen Plural- und Beugungsendungen, dazu das englische -s. // Längere Endungen stehen vorn, damit „prozesses" zu „prozess" wird und nicht // zu „prozesse". @@ -199,9 +199,9 @@ func levenshtein(a, b []rune) int { // Bewusst nicht dabei ist „-er": Im Deutschen bildet es meist ein eigenes Wort // („Rechner", „Benutzer"), und aus „Server" würde „Serv", das dann auch // „Service" fände. -var endungen = []string{"en", "es", "e", "n", "s"} +var suffixes = []string{"en", "es", "e", "n", "s"} -// kuerzeEndung schneidet eine Beugungsendung ab und gibt den Stamm zurück — +// trimSuffix schneidet eine Beugungsendung ab und gibt den Stamm zurück — // oder "", wenn es nichts zu kürzen gibt. // // deployen → deploy (findet dann „deployment") @@ -213,23 +213,23 @@ var endungen = []string{"en", "es", "e", "n", "s"} // Regeln und verfälschen dabei Fachbegriffe. Hier geht es nur darum, dass ein // Suchwort mit Endung auch das Wort ohne Endung findet. Da der Stamm nur als // Wortanfang gesucht wird, findet umgekehrt „zertifikat" auch „zertifikate". -func kuerzeEndung(term string) string { - for _, endung := range endungen { - if strings.HasSuffix(term, endung) && len(term)-len(endung) >= minStamm { - return term[:len(term)-len(endung)] +func trimSuffix(term string) string { + for _, suffix := range suffixes { + if strings.HasSuffix(term, suffix) && len(term)-len(suffix) >= minStem { + return term[:len(term)-len(suffix)] } } return "" } -// stoppwoerter sind Wörter, die in fast jeder Frage stehen, aber nichts über +// stopwords sind Wörter, die in fast jeder Frage stehen, aber nichts über // das Thema verraten. // // # Warum nur beim Suchen? // // Die Liste wird nur auf die Anfrage angewendet, nicht beim Indizieren. Der // Index bleibt also vollständig, und wer gezielt nach „was" sucht, findet es -// weiterhin (siehe ohneStoppwoerter). +// weiterhin (siehe withoutStopwords). // // # Warum überhaupt? // @@ -243,7 +243,7 @@ func kuerzeEndung(term string) string { // // Nicht dabei sind Wörter, die in der IT etwas bedeuten können, etwa „it" // oder „go". -var stoppwoerter = map[string]bool{ +var stopwords = map[string]bool{ // Deutsch "der": true, "die": true, "das": true, "den": true, "dem": true, "des": true, "ein": true, "eine": true, "einen": true, "einem": true, "einer": true, "eines": true, @@ -269,15 +269,15 @@ var stoppwoerter = map[string]bool{ "can": true, "this": true, "that": true, } -// ohneStoppwoerter entfernt die Stoppwörter aus einer Anfrage. +// withoutStopwords entfernt die Stoppwörter aus einer Anfrage. // // Besteht die Anfrage nur aus Stoppwörtern, bleibt sie unverändert — dann // meint jemand offenbar genau diese Wörter, und eine leere Anfrage hülfe // niemandem. -func ohneStoppwoerter(tokens []string) []string { +func withoutStopwords(tokens []string) []string { rest := make([]string, 0, len(tokens)) for _, t := range tokens { - if !stoppwoerter[t] { + if !stopwords[t] { rest = append(rest, t) } } @@ -287,22 +287,22 @@ func ohneStoppwoerter(tokens []string) []string { return rest } -// merkeHoechste trägt für jedes Dokument die höchste Häufigkeit ein. -func merkeHoechste(beste map[string]int, postings []posting) { +// keepHighest trägt für jedes Dokument die höchste Häufigkeit ein. +func keepHighest(best map[string]int, postings []posting) { for _, p := range postings { - if p.freq > beste[p.docID] { - beste[p.docID] = p.freq + if p.freq > best[p.docID] { + best[p.docID] = p.freq } } } -// alsPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um. -func alsPostings(beste map[string]int) []posting { - ergebnis := make([]posting, 0, len(beste)) - for docID, freq := range beste { - ergebnis = append(ergebnis, posting{docID: docID, freq: freq}) +// asPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um. +func asPostings(best map[string]int) []posting { + result := make([]posting, 0, len(best)) + for docID, freq := range best { + result = append(result, posting{docID: docID, freq: freq}) } - return ergebnis + return result } // abs gibt den Betrag einer ganzen Zahl zurück. @@ -313,7 +313,7 @@ func abs(n int) int { return n } -// Ueberdeckung gibt an, welcher Anteil der Wörter aus anfrage in text +// Coverage gibt an, welcher Anteil der Wörter aus query in text // vorkommt — als Zahl von 0 (keines) bis 1 (alle). Stoppwörter zählen nicht. // // Gedacht für kurze Texte wie Titel, um Dubletten zu erkennen (siehe remember @@ -332,15 +332,15 @@ func abs(n int) int { // der Größe des Projekts und der Länge der Einträge ab. Eine Schwelle darauf // müsste je Projekt neu eingestellt werden. Der Anteil gemeinsamer Wörter ist // dagegen in jedem Projekt gleich zu lesen. -func Ueberdeckung(anfrage, text string) float64 { - anteil, _ := UeberdeckungMitSuchbegriffen(anfrage, text, "") - return anteil +func Coverage(query, text string) float64 { + share, _ := CoverageWithSearchTerms(query, text, "") + return share } -// UeberdeckungMitSuchbegriffen ist Ueberdeckung für einen vorhandenen +// CoverageWithSearchTerms ist Ueberdeckung für einen vorhandenen // Eintrag, der aus zwei Teilen besteht: seinem Titel und seinen Suchbegriffen // (der Zeile „Auch gesucht als: …"). Zurück kommen der Anteil und die Wörter -// aus anfrage, die gefunden wurden — in der Form, die tokenize erzeugt. +// aus query, die gefunden wurden — in der Form, die tokenize erzeugt. // // # Warum zwei Teile? // @@ -353,65 +353,65 @@ func Ueberdeckung(anfrage, text string) float64 { // // „Release-Ablauf" vs. Suchbegriff „release" → zählt (wörtlich) // „Betrieb mit PostgreSQL" vs. Suchbegriff „postgres" → zählt nicht -func UeberdeckungMitSuchbegriffen(anfrage, titel, suchbegriffe string) (float64, []string) { - woerter := ohneStoppwoerter(tokenize(anfrage)) - if len(woerter) == 0 { +func CoverageWithSearchTerms(query, title, searchTerms string) (float64, []string) { + words := withoutStopwords(tokenize(query)) + if len(words) == 0 { return 0, nil } - imTitel := tokenize(titel) - wortgenau := make(map[string]bool) - for _, w := range tokenize(suchbegriffe) { - wortgenau[w] = true + inTitle := tokenize(title) + wordExact := make(map[string]bool) + for _, w := range tokenize(searchTerms) { + wordExact[w] = true } // Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht // zu zwei Dritteln „Server". - gesehen := make(map[string]bool, len(woerter)) - gesamt := 0 - var gefunden []string - for _, w := range woerter { - if gesehen[w] { + seen := make(map[string]bool, len(words)) + total := 0 + var found []string + for _, w := range words { + if seen[w] { continue } - gesehen[w] = true - gesamt++ + seen[w] = true + total++ - if wortgenau[w] { - gefunden = append(gefunden, w) + if wordExact[w] { + found = append(found, w) continue } - for _, v := range imTitel { - if passtZu(w, v) { - gefunden = append(gefunden, w) + for _, v := range inTitle { + if matches(w, v) { + found = append(found, w) break } } } - return float64(len(gefunden)) / float64(gesamt), gefunden + return float64(len(found)) / float64(total), found } -// passtZu prüft, ob zwei einzelne Wörter als dasselbe gelten. -func passtZu(a, b string) bool { +// matches prüft, ob zwei einzelne Wörter als dasselbe gelten. +func matches(a, b string) bool { if a == b { return true } // Wortanfang, in beide Richtungen: „deploy" ↔ „deployment", // „releaseprozess" ↔ „release". Beide müssen lang genug sein, sonst passt // „de" zu allem. - if len(a) >= minStamm && len(b) >= minStamm && + if len(a) >= minStem && len(b) >= minStem && (strings.HasPrefix(a, b) || strings.HasPrefix(b, a)) { return true } // Endung: „zertifikate" ↔ „zertifikat". - if stamm := kuerzeEndung(a); stamm != "" && strings.HasPrefix(b, stamm) { + if stem := trimSuffix(a); stem != "" && strings.HasPrefix(b, stem) { return true } - if stamm := kuerzeEndung(b); stamm != "" && strings.HasPrefix(a, stamm) { + if stem := trimSuffix(b); stem != "" && strings.HasPrefix(a, stem) { return true } // Teil eines zusammengesetzten Worts: „prozess" ↔ „releaseprozess". - if len(a) >= minTeilwort && strings.Contains(b, a) { + if len(a) >= minSubword && strings.Contains(b, a) { return true } - return len(b) >= minTeilwort && strings.Contains(a, b) + return len(b) >= minSubword && strings.Contains(a, b) } diff --git a/internal/search/varianten_test.go b/internal/search/variants_test.go similarity index 57% rename from internal/search/varianten_test.go rename to internal/search/variants_test.go index 5b23cfd..0e531f0 100644 --- a/internal/search/varianten_test.go +++ b/internal/search/variants_test.go @@ -6,124 +6,124 @@ import ( "testing" ) -// enthaelt prüft, ob ein Eintrag unter den Treffern ist. -func enthaelt(treffer []Result, docID string) bool { - return slices.Contains(ergebnisIDs(treffer), docID) +// contains prüft, ob ein Eintrag unter den Treffern ist. +func contains(hits []Result, docID string) bool { + return slices.Contains(resultIDs(hits), docID) } // ── Wortanfang ─────────────────────────────────────────────────────────────── -// TestWortanfangNebenExaktemTreffer prüft den Fehler, der zu diesem Umbau +// TestWordPrefixBesideExactHit prüft den Fehler, der zu diesem Umbau // geführt hat: Früher griff die Suche nach dem Wortanfang nur, wenn es keinen // exakten Treffer gab. Stand „deploy" in irgendeinem Eintrag, fand „deploy" // den „Deployment-Prozess" nicht mehr — ein stiller Fehltreffer, der umso // häufiger wird, je größer ein Projekt ist. -func TestWortanfangNebenExaktemTreffer(t *testing.T) { +func TestWordPrefixBesideExactHit(t *testing.T) { idx := NewIndex() idx.Index("skript", "Skript", "Das Skript heißt deploy und liegt im Repository.", nil) idx.Index("prozess", "Deployment-Prozess", "So kommt eine neue Fassung in Produktion.", nil) - treffer := idx.Search("deploy", 10) + hits := idx.Search("deploy", 10) - if !enthaelt(treffer, "prozess") { - t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", ergebnisIDs(treffer)) + if !contains(hits, "prozess") { + t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", resultIDs(hits)) } - if treffer[0].DocID != "skript" { - t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID) + if hits[0].DocID != "skript" { + t.Errorf("bester Treffer = %q, erwartet den exakten", hits[0].DocID) } } -// TestWortanfangZaehltNichtDoppelt prüft, dass ein Eintrag mit vielen Wörtern +// TestWordPrefixNotCountedTwice prüft, dass ein Eintrag mit vielen Wörtern // desselben Anfangs nicht vor einem exakten Treffer landet. -func TestWortanfangZaehltNichtDoppelt(t *testing.T) { +func TestWordPrefixNotCountedTwice(t *testing.T) { idx := NewIndex() idx.Index("exakt", "Hinweis", "Hier steht config.", nil) idx.Index("viele", "Hinweis", "configuration configure configured configs configurable", nil) - treffer := idx.Search("config", 10) + hits := idx.Search("config", 10) - if len(treffer) != 2 || treffer[0].DocID != "exakt" { - t.Errorf("Treffer = %v, erwartet exakt vor viele", ergebnisIDs(treffer)) + if len(hits) != 2 || hits[0].DocID != "exakt" { + t.Errorf("Treffer = %v, erwartet exakt vor viele", resultIDs(hits)) } } // ── Zusammengesetzte Wörter ────────────────────────────────────────────────── -// TestTeilwortFindetKomposita prüft zusammengeschriebene deutsche Wörter. Mit +// TestSubwordFindsCompounds prüft zusammengeschriebene deutsche Wörter. Mit // Bindestrich („Release-Prozess") genügte schon immer die Zerlegung in Wörter; // zusammengeschrieben ist „prozess" aber weder ein eigenes Wort noch ein // Wortanfang. -func TestTeilwortFindetKomposita(t *testing.T) { +func TestSubwordFindsCompounds(t *testing.T) { idx := NewIndex() idx.Index("release", "Releaseprozess", "Ablauf für neue Fassungen.", nil) idx.Index("zertifikat", "Wartung", "Der jährliche SSL-Zertifikatswechsel steht im Kalender.", nil) idx.Index("datenbank", "Datenbankzugang", "Zugriff nur über das VPN.", nil) - faelle := map[string]string{ + cases := map[string]string{ "prozess": "release", "wechsel": "zertifikat", "zugang": "datenbank", "datenbank": "datenbank", // Wortanfang, kein Teilwort — darf trotzdem nicht verloren gehen } - for anfrage, erwartet := range faelle { - if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) { - t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) + for query, want := range cases { + if hits := idx.Search(query, 10); !contains(hits, want) { + t.Errorf("Search(%q) = %v, erwartet %s", query, resultIDs(hits), want) } } } -// TestTeilwortBrauchtFuenfZeichen prüft die Untergrenze: Kurze Wörter stecken +// TestSubwordNeedsFiveChars prüft die Untergrenze: Kurze Wörter stecken // zufällig in zu vielen anderen. -func TestTeilwortBrauchtFuenfZeichen(t *testing.T) { +func TestSubwordNeedsFiveChars(t *testing.T) { idx := NewIndex() idx.Index("bericht", "Reporting", "Monatliche Auswertung.", nil) - if treffer := idx.Search("port", 10); len(treffer) > 0 { - t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", ergebnisIDs(treffer)) + if hits := idx.Search("port", 10); len(hits) > 0 { + t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", resultIDs(hits)) } } -// TestExaktVorTeilwort prüft die Gewichtung: Das eigenständige Wort steht vor +// TestExactBeforeSubword prüft die Gewichtung: Das eigenständige Wort steht vor // dem Wort, in dem es nur steckt. -func TestExaktVorTeilwort(t *testing.T) { +func TestExactBeforeSubword(t *testing.T) { idx := NewIndex() idx.Index("kompositum", "Releaseprozess", "Ablauf.", nil) idx.Index("eigenstaendig", "Prozess", "Ablauf.", nil) - treffer := idx.Search("prozess", 10) + hits := idx.Search("prozess", 10) - if len(treffer) != 2 || treffer[0].DocID != "eigenstaendig" { - t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", ergebnisIDs(treffer)) + if len(hits) != 2 || hits[0].DocID != "eigenstaendig" { + t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", resultIDs(hits)) } } // ── Endungen ───────────────────────────────────────────────────────────────── -// TestEndungenWerdenAusgeglichen prüft Suchwörter, die länger sind als das +// TestSuffixesAreNormalized prüft Suchwörter, die länger sind als das // Wort im Eintrag. Die Suche nach dem Wortanfang hilft hier nicht: „deployen" // ist kein Anfang von „deployment". -func TestEndungenWerdenAusgeglichen(t *testing.T) { +func TestSuffixesAreNormalized(t *testing.T) { idx := NewIndex() idx.Index("deployment", "Deployment", "Ablauf.", nil) idx.Index("zertifikat", "Zertifikat", "Erneuerung.", nil) idx.Index("datenbank", "Datenbank", "Sicherung.", nil) - faelle := map[string]string{ + cases := map[string]string{ "deployen": "deployment", "zertifikate": "zertifikat", "datenbanken": "datenbank", } - for anfrage, erwartet := range faelle { - if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) { - t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) + for query, want := range cases { + if hits := idx.Search(query, 10); !contains(hits, want) { + t.Errorf("Search(%q) = %v, erwartet %s", query, resultIDs(hits), want) } } } -func TestKuerzeEndung(t *testing.T) { - faelle := map[string]string{ +func TestTrimSuffix(t *testing.T) { + cases := map[string]string{ "deployen": "deploy", "zertifikate": "zertifikat", "datenbanken": "datenbank", @@ -134,96 +134,96 @@ func TestKuerzeEndung(t *testing.T) { "server": "", // „-er" wird bewusst nicht gekürzt } - for eingabe, erwartet := range faelle { - if got := kuerzeEndung(eingabe); got != erwartet { - t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", eingabe, got, erwartet) + for input, want := range cases { + if got := trimSuffix(input); got != want { + t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", input, got, want) } } } // ── Stoppwörter ────────────────────────────────────────────────────────────── -// TestStoppwoerterErzeugenKeinRauschen prüft eine typische Agenten-Frage. +// TestStopwordsCreateNoNoise prüft eine typische Agenten-Frage. // Ohne Stoppwörter fand „wir" über den Wortanfang den Eintrag zur „Wirkung". -func TestStoppwoerterErzeugenKeinRauschen(t *testing.T) { +func TestStopwordsCreateNoNoise(t *testing.T) { idx := NewIndex() idx.Index("deployment", "Deployment-Prozess", "So kommt eine Fassung in Produktion.", nil) idx.Index("kuehlung", "Kühlung", "Die Wirkung der Lüfter im Serverraum.", nil) - treffer := idx.Search("wie deployen wir", 10) + hits := idx.Search("wie deployen wir", 10) - if !enthaelt(treffer, "deployment") { - t.Errorf("Treffer = %v, erwartet deployment", ergebnisIDs(treffer)) + if !contains(hits, "deployment") { + t.Errorf("Treffer = %v, erwartet deployment", resultIDs(hits)) } - if enthaelt(treffer, "kuehlung") { - t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", ergebnisIDs(treffer)) + if contains(hits, "kuehlung") { + t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", resultIDs(hits)) } } -// TestNurStoppwoerterBleibtSuchbar prüft die Ausnahme: Besteht die Anfrage nur +// TestOnlyStopwordsStaysSearchable prüft die Ausnahme: Besteht die Anfrage nur // aus Stoppwörtern, wird trotzdem gesucht. -func TestNurStoppwoerterBleibtSuchbar(t *testing.T) { +func TestOnlyStopwordsStaysSearchable(t *testing.T) { idx := NewIndex() idx.Index("anleitung", "Wie man testet", "Schritt für Schritt.", nil) - if treffer := idx.Search("wie", 10); len(treffer) == 0 { + if hits := idx.Search("wie", 10); len(hits) == 0 { t.Error("eine Anfrage nur aus Stoppwörtern fand nichts") } } -// TestStoppwoerterSindGefaltet stellt sicher, dass jedes Stoppwort in der Form +// TestStopwordsAreFolded stellt sicher, dass jedes Stoppwort in der Form // in der Liste steht, die tokenize erzeugt. Stünde dort „für" statt „fur", // würde es nie greifen. -func TestStoppwoerterSindGefaltet(t *testing.T) { - for wort := range stoppwoerter { - if got := tokenize(wort); len(got) != 1 || got[0] != wort { - t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", wort, got) +func TestStopwordsAreFolded(t *testing.T) { + for word := range stopwords { + if got := tokenize(word); len(got) != 1 || got[0] != word { + t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", word, got) } } } // ── Tippfehler ─────────────────────────────────────────────────────────────── -// TestTippfehlerNurInSearchFuzzy prüft, dass Tippfehler nur dort ausgeglichen +// TestTypoOnlyInSearchFuzzy prüft, dass Tippfehler nur dort ausgeglichen // werden, wo es gewollt ist: in der Weboberfläche, nicht für Agenten. -func TestTippfehlerNurInSearchFuzzy(t *testing.T) { +func TestTypoOnlyInSearchFuzzy(t *testing.T) { idx := NewIndex() idx.Index("deployment", "Deployment", "Ablauf.", nil) idx.Index("server", "Server", "Wartung.", nil) - faelle := map[string]string{ + cases := map[string]string{ "deploymnet": "deployment", // zwei vertauschte Buchstaben "sever": "server", // ein Buchstabe fehlt } - for anfrage, erwartet := range faelle { - if treffer := idx.SearchFuzzy(anfrage, 10); !enthaelt(treffer, erwartet) { - t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) + for query, want := range cases { + if hits := idx.SearchFuzzy(query, 10); !contains(hits, want) { + t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", query, resultIDs(hits), want) } - if treffer := idx.Search(anfrage, 10); len(treffer) > 0 { - t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", anfrage, ergebnisIDs(treffer)) + if hits := idx.Search(query, 10); len(hits) > 0 { + t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", query, resultIDs(hits)) } } } -// TestTippfehlerNurAlsRueckfall prüft, dass die Tippfehler-Suche nicht greift, +// TestTypoOnlyAsFallback prüft, dass die Tippfehler-Suche nicht greift, // wenn das Suchwort schon etwas findet — sonst fände „host" auch „post". -func TestTippfehlerNurAlsRueckfall(t *testing.T) { +func TestTypoOnlyAsFallback(t *testing.T) { idx := NewIndex() idx.Index("host", "Host", "Rechner im Netz.", nil) idx.Index("cost", "Cost", "Kostenstelle.", nil) - treffer := idx.SearchFuzzy("host", 10) + hits := idx.SearchFuzzy("host", 10) - if len(treffer) != 1 || treffer[0].DocID != "host" { - t.Errorf("Treffer = %v, erwartet nur host", ergebnisIDs(treffer)) + if len(hits) != 1 || hits[0].DocID != "host" { + t.Errorf("Treffer = %v, erwartet nur host", resultIDs(hits)) } } func TestLevenshtein(t *testing.T) { - faelle := []struct { - a, b string - abstand int + cases := []struct { + a, b string + distance int }{ {"server", "server", 0}, {"sever", "server", 1}, // einfügen @@ -234,33 +234,33 @@ func TestLevenshtein(t *testing.T) { {"prüfung", "prufung", 1}, // Runen, nicht Bytes } - for _, f := range faelle { - if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.abstand { - t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.abstand) + for _, f := range cases { + if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.distance { + t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.distance) } } } -func TestErlaubteTippfehler(t *testing.T) { - faelle := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2} +func TestAllowedTypos(t *testing.T) { + cases := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2} - for laenge, erwartet := range faelle { - if got := erlaubteTippfehler(laenge); got != erwartet { - t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", laenge, got, erwartet) + for length, want := range cases { + if got := allowedTypos(length); got != want { + t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", length, got, want) } } } // ── Überdeckung (Dubletten) ────────────────────────────────────────────────── -// TestUeberdeckungMitSuchbegriffen: Im Titel zählen alle Varianten, ein +// TestCoverageWithSearchTerms: Im Titel zählen alle Varianten, ein // Suchbegriff nur wörtlich. Dazu kommen die gefundenen Wörter zurück. -func TestUeberdeckungMitSuchbegriffen(t *testing.T) { - faelle := []struct { - name string - anfrage, titel, suchbegriffe string - anteil float64 - gemeinsam string +func TestCoverageWithSearchTerms(t *testing.T) { + cases := []struct { + name string + query, title, searchTerms string + share float64 + shared string }{ {"Suchbegriff wörtlich", "Release-Ablauf", "Deployment-Prozess", "release, rollout", 0.5, "release"}, {"Suchbegriff nur als Wortanfang zählt nicht", "Betrieb mit PostgreSQL", "Ausbaustand", "roadmap, postgres-migration", 0, ""}, @@ -269,20 +269,20 @@ func TestUeberdeckungMitSuchbegriffen(t *testing.T) { {"Umlaute im Suchbegriff", "Übergabe", "Anderes", "uebergabe, übergabe", 1, "ubergabe"}, {"nichts", "VPN-Zugang", "Drucker", "", 0, ""}, } - for _, f := range faelle { + for _, f := range cases { t.Run(f.name, func(t *testing.T) { - anteil, gemeinsam := UeberdeckungMitSuchbegriffen(f.anfrage, f.titel, f.suchbegriffe) - if anteil != f.anteil || strings.Join(gemeinsam, " ") != f.gemeinsam { - t.Errorf("= %v %q, erwartet %v %q", anteil, gemeinsam, f.anteil, f.gemeinsam) + share, shared := CoverageWithSearchTerms(f.query, f.title, f.searchTerms) + if share != f.share || strings.Join(shared, " ") != f.shared { + t.Errorf("= %v %q, erwartet %v %q", share, shared, f.share, f.shared) } }) } } -func TestUeberdeckung(t *testing.T) { - faelle := []struct { - anfrage, text string - erwartet float64 +func TestCoverage(t *testing.T) { + cases := []struct { + query, text string + want float64 }{ {"Deployment-Ablauf", "Deployment-Prozess", 0.5}, {"Releaseprozess", "Release-Prozess", 1}, @@ -296,9 +296,9 @@ func TestUeberdeckung(t *testing.T) { {"Port", "Reporting", 0}, // zu kurz für ein Teilwort } - for _, f := range faelle { - if got := Ueberdeckung(f.anfrage, f.text); got != f.erwartet { - t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.anfrage, f.text, got, f.erwartet) + for _, f := range cases { + if got := Coverage(f.query, f.text); got != f.want { + t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.query, f.text, got, f.want) } } } diff --git a/internal/statistik/statistik.go b/internal/statistik/statistik.go deleted file mode 100644 index dd96966..0000000 --- a/internal/statistik/statistik.go +++ /dev/null @@ -1,290 +0,0 @@ -// Package statistik zählt die Kennzahlen aller Workspaces für die Systemseite -// zusammen (AUSBAUSTUFEN.md 5.16): Bestand, Platz, Stand des Schemas und die -// Aktivität der letzten 30 Tage. -// -// # Warum nicht bei jedem Seitenaufruf? -// -// Für die Zahlen fragt Munin jeden Workspace einzeln ab — bei 40 Workspaces -// rund 300 Abfragen. Das ist kein Problem, wenn es selten geschieht, aber -// unnötig bei jedem Neuladen der Seite. Der Sammler rechnet deshalb höchstens -// alle MindestAbstand neu und gibt dazwischen das gemerkte Ergebnis zurück. -// Die Seite nennt den Stand ("Stand 14:05"). -// -// Gerechnet wird beim Aufruf der Seite, nicht in einer eigenen Goroutine: Wer -// die Seite nie öffnet, kostet nichts, und es gibt keinen Hintergrundlauf, -// der beim Beenden aufgeräumt werden müsste. -package statistik - -import ( - "context" - "fmt" - "sort" - "sync" - "time" - - "munin/internal/store" - "munin/internal/workspace" -) - -// Tage ist der Zeitraum der Aktivität. Derselbe wie in der Nutzung eines -// Projekts (store.UsageWindow) — und nicht länger, weil das Suchprotokoll -// nach 30 Tagen aufgeräumt wird. Ältere Tage stünden sonst ohne Suchen da. -const Tage = store.UsageWindowDays - -// MindestAbstand ist die Zeit, die ein Ergebnis gilt, bevor neu gerechnet -// wird. -const MindestAbstand = 5 * time.Minute - -// Stand ist das Ergebnis einer Erhebung. -type Stand struct { - Erhoben time.Time - Dauer time.Duration // wie lange die Erhebung brauchte - - Workspaces []Workspace // nach Name sortiert - Summe store.Bestand - Belegung store.Belegung // aller Workspaces zusammen - - // Schemastand: die neueste Migration, die dieses Programm kennt. Ein - // Workspace, der davon abweicht, steht mit seinem Stand in Workspace.Schema. - Schemastand string - - Tage []Tag // genau Tage Einträge, der älteste zuerst, heute zuletzt - Projekte []Projekt // mit Aktivität, die aktivsten zuerst - Ruhig []Projekt // ohne jede Aktivität im Zeitraum, nach Workspace und Name - - // Gewartet zählt, wie oft Anfragen seit dem Start auf eine freie - // Datenbankverbindung warten mussten (alle Pools zusammen), zum Zeitpunkt - // der Erhebung. GewartetVorher und VorherErhoben sind dieselbe Zahl bei - // der vorigen Erhebung (0 und Nullzeitpunkt bei der ersten). Die - // Differenz zeigt, ob gerade gewartet wird — die Summe seit dem Start - // sagt das nach Tagen Laufzeit nicht mehr. - Gewartet int64 - GewartetVorher int64 - VorherErhoben time.Time - - // Fehler sind Workspaces, die sich nicht lesen ließen. Ihre Zahlen fehlen - // in den Summen; die übrigen sind trotzdem da. - Fehler []string -} - -// Workspace sind die Kennzahlen eines Workspace. -type Workspace struct { - ID, Name string - Ort string // Datei oder Schema (store.Speicher.Ort) - Bestand store.Bestand - Belegung store.Belegung - Schema string // zuletzt angewandte Migration -} - -// SchemaAbweichend sagt, ob der Workspace auf einem anderen Stand ist als das -// Programm. -func (w Workspace) SchemaAbweichend(soll string) bool { return w.Schema != soll } - -// Tag ist die Aktivität eines Tages über alle Workspaces. -type Tag struct { - Datum time.Time // 0 Uhr UTC - SchreibenAgent int - SchreibenMensch int - Suchen int -} - -// Schreiben sind alle Schreibvorgänge des Tages. -func (t Tag) Schreiben() int { return t.SchreibenAgent + t.SchreibenMensch } - -// Projekt ist die Aktivität eines Projekts im Zeitraum. -type Projekt struct { - Workspace string // Name - ID string - Slug string - Name string - - SchreibenAgent int - SchreibenMensch int - Suchen int -} - -// Schreiben sind alle Schreibvorgänge des Projekts im Zeitraum. -func (p Projekt) Schreiben() int { return p.SchreibenAgent + p.SchreibenMensch } - -// Summe ist alles zusammen — danach wird sortiert. -func (p Projekt) Summe() int { return p.Schreiben() + p.Suchen } - -// Sammler erhebt den Stand und merkt ihn sich für MindestAbstand. -type Sammler struct { - control *store.ControlStore - workspaces *workspace.Manager - - // mu schützt stand und sorgt zugleich dafür, dass bei zwei - // gleichzeitigen Aufrufen nur einer rechnet; der zweite wartet und - // bekommt dasselbe Ergebnis. - mu sync.Mutex - stand Stand -} - -// NewSammler legt einen Sammler an. -func NewSammler(control *store.ControlStore, workspaces *workspace.Manager) *Sammler { - return &Sammler{control: control, workspaces: workspaces} -} - -// Aktuell liefert einen Stand, der höchstens MindestAbstand alt ist, und -// erhebt dafür nötigenfalls neu. -func (s *Sammler) Aktuell(ctx context.Context, jetzt time.Time) Stand { - s.mu.Lock() - defer s.mu.Unlock() - - if !s.stand.Erhoben.IsZero() && jetzt.Sub(s.stand.Erhoben) < MindestAbstand { - return s.stand - } - neu := s.erhebe(ctx, jetzt) - neu.GewartetVorher, neu.VorherErhoben = s.stand.Gewartet, s.stand.Erhoben - s.stand = neu - return s.stand -} - -// Gewartet zählt die Wartevorgänge aller gerade offenen Pools zusammen: der -// Kontroll-Datenbank und jedes geöffneten Workspace. -func (s *Sammler) Gewartet() int64 { - summe := s.control.Verbindungen().Gewartet() - for _, w := range s.workspaces.Offen() { - summe += w.Store.Verbindungen().Gewartet() - } - return summe -} - -// FuerWorkspace erhebt den Stand eines einzelnen Workspace — für den -// Überblick des Workspace-Admins (AUSBAUSTUFEN.md 5.17). Ohne Zwischenspeicher: -// Ein Workspace sind nur eine Handvoll Abfragen, und der Admin soll nach -// einer Änderung (Key angelegt) sofort den neuen Stand sehen. -func (s *Sammler) FuerWorkspace(ctx context.Context, w store.Workspace, jetzt time.Time) Stand { - return s.erhebeListe(ctx, jetzt, []store.Workspace{w}) -} - -// erhebe fragt jeden Workspace ab und zählt zusammen. -// -// Dafür werden alle Workspaces geöffnet, auch lange nicht benutzte. Das tun -// das tägliche Aufräumen und der Export (main.go) ohnehin schon beim Start; -// zusätzliche Verbindungen kostet die Erhebung also nicht. -func (s *Sammler) erhebe(ctx context.Context, jetzt time.Time) Stand { - alle, err := s.control.ListWorkspaces(ctx) - if err != nil { - return Stand{Erhoben: jetzt, Fehler: []string{fmt.Sprintf("Workspaces konnten nicht gelesen werden: %v", err)}} - } - return s.erhebeListe(ctx, jetzt, alle) -} - -// erhebeListe zählt die gegebenen Workspaces zusammen. -func (s *Sammler) erhebeListe(ctx context.Context, jetzt time.Time, alle []store.Workspace) Stand { - beginn := time.Now() - stand := Stand{Erhoben: jetzt} - - // Die Tage stehen von vornherein fest, auch die ohne jede Aktivität — - // sonst hätte das Diagramm Lücken, die man nicht sieht. - heute := time.Date(jetzt.UTC().Year(), jetzt.UTC().Month(), jetzt.UTC().Day(), 0, 0, 0, 0, time.UTC) - erster := heute.AddDate(0, 0, -(Tage - 1)) - tagNr := map[string]int{} - for i := 0; i < Tage; i++ { - d := erster.AddDate(0, 0, i) - stand.Tage = append(stand.Tage, Tag{Datum: d}) - tagNr[d.Format("2006-01-02")] = i - } - - for _, w := range alle { - ws, projekte, err := s.erheheWorkspace(ctx, w, jetzt, erster, &stand, tagNr) - if err != nil { - stand.Fehler = append(stand.Fehler, fmt.Sprintf("%s: %v", w.Name, err)) - continue - } - stand.Workspaces = append(stand.Workspaces, ws) - stand.Summe = stand.Summe.Plus(ws.Bestand) - stand.Belegung.Daten += ws.Belegung.Daten - stand.Belegung.WAL += ws.Belegung.WAL - - for _, p := range projekte { - if p.Summe() > 0 { - stand.Projekte = append(stand.Projekte, p) - } else { - stand.Ruhig = append(stand.Ruhig, p) - } - } - } - - sort.Slice(stand.Workspaces, func(i, j int) bool { return stand.Workspaces[i].Name < stand.Workspaces[j].Name }) - // Die aktivsten zuerst; bei Gleichstand alphabetisch, damit die - // Reihenfolge beim Neuladen nicht springt. - sort.Slice(stand.Projekte, func(i, j int) bool { - a, b := stand.Projekte[i], stand.Projekte[j] - if a.Summe() != b.Summe() { - return a.Summe() > b.Summe() - } - return a.Workspace+a.Name < b.Workspace+b.Name - }) - sort.Slice(stand.Ruhig, func(i, j int) bool { - a, b := stand.Ruhig[i], stand.Ruhig[j] - return a.Workspace+a.Name < b.Workspace+b.Name - }) - - stand.Gewartet = s.Gewartet() - stand.Dauer = time.Since(beginn) - return stand -} - -// erheheWorkspace sammelt die Zahlen eines Workspace. Die Aktivität je Tag -// zählt sie direkt in stand.Tage ein; die je Projekt gibt sie zurück. -func (s *Sammler) erheheWorkspace(ctx context.Context, w store.Workspace, jetzt, seit time.Time, - stand *Stand, tagNr map[string]int) (Workspace, []Projekt, error) { - - ergebnis := Workspace{ID: w.ID, Name: w.Name, Ort: s.workspaces.Ort(w.ID)} - - offen, err := s.workspaces.Get(w.ID) - if err != nil { - return ergebnis, nil, err - } - st := offen.Store - - if ergebnis.Bestand, err = st.Bestand(ctx, jetzt); err != nil { - return ergebnis, nil, err - } - if ergebnis.Belegung, err = st.Belegung(ctx); err != nil { - return ergebnis, nil, err - } - ist, soll, err := st.Schemastand(ctx) - if err != nil { - return ergebnis, nil, err - } - ergebnis.Schema = ist - stand.Schemastand = soll // für alle Workspaces derselbe: er kommt aus dem Programm - - // Jedes Projekt bekommt einen Eintrag, auch ohne Aktivität — gerade die - // ruhigen sind interessant. - liste, err := st.ListProjects(ctx, w.ID) - if err != nil { - return ergebnis, nil, err - } - projekte := make([]Projekt, len(liste)) - nachID := map[string]*Projekt{} - for i, p := range liste { - projekte[i] = Projekt{Workspace: w.Name, ID: p.ID, Slug: p.Slug, Name: p.Name} - nachID[p.ID] = &projekte[i] - } - - aktivitaet, err := st.Aktivitaet(ctx, seit) - if err != nil { - return ergebnis, nil, err - } - for _, a := range aktivitaet { - if i, ok := tagNr[a.Tag]; ok { - stand.Tage[i].SchreibenAgent += a.SchreibenAgent - stand.Tage[i].SchreibenMensch += a.SchreibenMensch - stand.Tage[i].Suchen += a.Suchen - } - // Zeilen eines inzwischen gelöschten Projekts zählen für den Tag, - // haben aber kein Projekt mehr, dem sie gehören. - if p := nachID[a.ProjektID]; p != nil { - p.SchreibenAgent += a.SchreibenAgent - p.SchreibenMensch += a.SchreibenMensch - p.Suchen += a.Suchen - } - } - - return ergebnis, projekte, nil -} diff --git a/internal/statistik/statistik_test.go b/internal/statistik/statistik_test.go deleted file mode 100644 index 15fca73..0000000 --- a/internal/statistik/statistik_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package statistik - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "munin/internal/store" - "munin/internal/workspace" -) - -// testUmgebung legt eine Kontroll-Datenbank mit zwei Workspaces an, je einem -// Projekt darin. -func testUmgebung(t *testing.T) (*store.ControlStore, *workspace.Manager, []store.Workspace) { - t.Helper() - ctx := context.Background() - - ordner := t.TempDir() - wsOrdner := filepath.Join(ordner, "workspaces") - if err := os.MkdirAll(wsOrdner, 0750); err != nil { - t.Fatal(err) - } - speicher := store.NewSQLite(filepath.Join(ordner, "control.db"), wsOrdner) - control, err := speicher.OpenControl() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { control.Close() }) - manager := workspace.NewManager(speicher) - t.Cleanup(func() { manager.Close() }) - - var liste []store.Workspace - for _, name := range []string{"Vertrieb", "Entwicklung"} { - ws, err := control.CreateWorkspace(ctx, name) - if err != nil { - t.Fatal(err) - } - offen, err := manager.Get(ws.ID) - if err != nil { - t.Fatal(err) - } - if _, err := offen.Store.CreateProject(ctx, ws.ID, "allgemein", "Allgemein "+name, ""); err != nil { - t.Fatal(err) - } - liste = append(liste, ws) - } - return control, manager, liste -} - -// TestErhebung zählt über alle Workspaces zusammen, füllt jeden Tag des -// Zeitraums und trennt aktive von ruhigen Projekten. -func TestErhebung(t *testing.T) { - control, manager, liste := testUmgebung(t) - ctx := context.Background() - - // Im Workspace "Vertrieb" schreibt ein Agent einen Eintrag. - offen, _ := manager.Get(liste[0].ID) - projekte, _ := offen.Store.ListProjects(ctx, liste[0].ID) - if _, err := offen.Store.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekte[0].ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent, - }); err != nil { - t.Fatal(err) - } - offen.Store.LogAudit(ctx, store.AuditEvent{ - ProjectID: projekte[0].ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP, - }) - - stand := NewSammler(control, manager).Aktuell(ctx, time.Now()) - - if len(stand.Fehler) > 0 { - t.Fatalf("Fehler: %v", stand.Fehler) - } - if len(stand.Workspaces) != 2 || stand.Workspaces[0].Name != "Entwicklung" { - t.Errorf("Workspaces %+v, erwartet zwei, nach Name sortiert", stand.Workspaces) - } - if stand.Summe.Projekte != 2 || stand.Summe.Eintraege != 1 { - t.Errorf("Summe %+v, erwartet 2 Projekte und 1 Eintrag", stand.Summe) - } - if stand.Belegung.Daten <= 0 { - t.Error("keine Belegung gezählt") - } - if stand.Schemastand == "" { - t.Error("Schemastand fehlt") - } - - if len(stand.Tage) != Tage { - t.Fatalf("%d Tage, erwartet %d", len(stand.Tage), Tage) - } - heute := stand.Tage[len(stand.Tage)-1] - if heute.SchreibenAgent != 1 || heute.Datum.Format("2006-01-02") != time.Now().UTC().Format("2006-01-02") { - t.Errorf("letzter Tag %+v, erwartet heute mit einem Schreibvorgang", heute) - } - - if len(stand.Projekte) != 1 || stand.Projekte[0].Workspace != "Vertrieb" { - t.Errorf("aktive Projekte %+v, erwartet nur das im Vertrieb", stand.Projekte) - } - if len(stand.Ruhig) != 1 || stand.Ruhig[0].Workspace != "Entwicklung" { - t.Errorf("ruhige Projekte %+v, erwartet nur das in der Entwicklung", stand.Ruhig) - } -} - -// TestMindestAbstand: Innerhalb von MindestAbstand kommt der gemerkte Stand -// zurück, danach ein neuer — der sich die Wartezahl des vorigen merkt. -func TestMindestAbstand(t *testing.T) { - control, manager, _ := testUmgebung(t) - ctx := context.Background() - s := NewSammler(control, manager) - - jetzt := time.Now() - erster := s.Aktuell(ctx, jetzt) - if zweiter := s.Aktuell(ctx, jetzt.Add(time.Minute)); !zweiter.Erhoben.Equal(erster.Erhoben) { - t.Error("nach einer Minute neu gezählt, erwartet den gemerkten Stand") - } - - dritter := s.Aktuell(ctx, jetzt.Add(MindestAbstand+time.Second)) - if dritter.Erhoben.Equal(erster.Erhoben) { - t.Fatal("nach MindestAbstand nicht neu gezählt") - } - if !dritter.VorherErhoben.Equal(erster.Erhoben) { - t.Errorf("VorherErhoben = %v, erwartet den ersten Stand %v", dritter.VorherErhoben, erster.Erhoben) - } -} diff --git a/internal/stats/stats.go b/internal/stats/stats.go new file mode 100644 index 0000000..4918f3a --- /dev/null +++ b/internal/stats/stats.go @@ -0,0 +1,290 @@ +// Package stats zählt die Kennzahlen aller Workspaces für die Systemseite +// zusammen (AUSBAUSTUFEN.md 5.16): Bestand, Platz, Stand des Schemas und die +// Aktivität der letzten 30 Tage. +// +// # Warum nicht bei jedem Seitenaufruf? +// +// Für die Zahlen fragt Munin jeden Workspace einzeln ab — bei 40 Workspaces +// rund 300 Abfragen. Das ist kein Problem, wenn es selten geschieht, aber +// unnötig bei jedem Neuladen der Seite. Der Sammler rechnet deshalb höchstens +// alle MinInterval neu und gibt dazwischen das gemerkte Ergebnis zurück. +// Die Seite nennt den Stand ("Stand 14:05"). +// +// Gerechnet wird beim Aufruf der Seite, nicht in einer eigenen Goroutine: Wer +// die Seite nie öffnet, kostet nichts, und es gibt keinen Hintergrundlauf, +// der beim Beenden aufgeräumt werden müsste. +package stats + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "munin/internal/store" + "munin/internal/workspace" +) + +// Days ist der Zeitraum der Aktivität. Derselbe wie in der Nutzung eines +// Projekts (store.UsageWindow) — und nicht länger, weil das Suchprotokoll +// nach 30 Tagen aufgeräumt wird. Ältere Tage stünden sonst ohne Suchen da. +const Days = store.UsageWindowDays + +// MinInterval ist die Zeit, die ein Ergebnis gilt, bevor neu gerechnet +// wird. +const MinInterval = 5 * time.Minute + +// Snapshot ist das Ergebnis einer Erhebung. +type Snapshot struct { + CollectedAt time.Time + Duration time.Duration // wie lange die Erhebung brauchte + + Workspaces []Workspace // nach Name sortiert + Total store.Inventory + DiskUsage store.DiskUsage // aller Workspaces zusammen + + // SchemaVersion: die neueste Migration, die dieses Programm kennt. Ein + // Workspace, der davon abweicht, steht mit seinem Stand in Workspace.Schema. + SchemaVersion string + + Days []Tag // genau Tage Einträge, der älteste zuerst, heute zuletzt + Projects []Project // mit Aktivität, die aktivsten zuerst + Idle []Project // ohne jede Aktivität im Zeitraum, nach Workspace und Name + + // WaitCount zählt, wie oft Anfragen seit dem Start auf eine freie + // Datenbankverbindung warten mussten (alle Pools zusammen), zum Zeitpunkt + // der Erhebung. PrevWaitCount und PrevCollectedAt sind dieselbe Zahl bei + // der vorigen Erhebung (0 und Nullzeitpunkt bei der ersten). Die + // Differenz zeigt, ob gerade gewartet wird — die Summe seit dem Start + // sagt das nach Tagen Laufzeit nicht mehr. + WaitCount int64 + PrevWaitCount int64 + PrevCollectedAt time.Time + + // Errors sind Workspaces, die sich nicht lesen ließen. Ihre Zahlen fehlen + // in den Summen; die übrigen sind trotzdem da. + Errors []string +} + +// Workspace sind die Kennzahlen eines Workspace. +type Workspace struct { + ID, Name string + Location string // Datei oder Schema (store.Backend.Ort) + Inventory store.Inventory + DiskUsage store.DiskUsage + Schema string // zuletzt angewandte Migration +} + +// SchemaMismatch sagt, ob der Workspace auf einem anderen Stand ist als das +// Programm. +func (w Workspace) SchemaMismatch(expected string) bool { return w.Schema != expected } + +// Tag ist die Aktivität eines Tages über alle Workspaces. +type Tag struct { + Date time.Time // 0 Uhr UTC + AgentWrites int + HumanWrites int + Searches int +} + +// Writes sind alle Schreibvorgänge des Tages. +func (t Tag) Writes() int { return t.AgentWrites + t.HumanWrites } + +// Project ist die Aktivität eines Projekts im Zeitraum. +type Project struct { + Workspace string // Name + ID string + Slug string + Name string + + AgentWrites int + HumanWrites int + Searches int +} + +// Writes sind alle Schreibvorgänge des Projekts im Zeitraum. +func (p Project) Writes() int { return p.AgentWrites + p.HumanWrites } + +// Total ist alles zusammen — danach wird sortiert. +func (p Project) Total() int { return p.Writes() + p.Searches } + +// Collector erhebt den Stand und merkt ihn sich für MinInterval. +type Collector struct { + control *store.ControlStore + workspaces *workspace.Manager + + // mu schützt stand und sorgt zugleich dafür, dass bei zwei + // gleichzeitigen Aufrufen nur einer rechnet; der zweite wartet und + // bekommt dasselbe Ergebnis. + mu sync.Mutex + snapshot Snapshot +} + +// NewCollector legt einen Sammler an. +func NewCollector(control *store.ControlStore, workspaces *workspace.Manager) *Collector { + return &Collector{control: control, workspaces: workspaces} +} + +// Current liefert einen Stand, der höchstens MinInterval alt ist, und +// erhebt dafür nötigenfalls neu. +func (s *Collector) Current(ctx context.Context, now time.Time) Snapshot { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.snapshot.CollectedAt.IsZero() && now.Sub(s.snapshot.CollectedAt) < MinInterval { + return s.snapshot + } + newer := s.collect(ctx, now) + newer.PrevWaitCount, newer.PrevCollectedAt = s.snapshot.WaitCount, s.snapshot.CollectedAt + s.snapshot = newer + return s.snapshot +} + +// WaitCount zählt die Wartevorgänge aller gerade offenen Pools zusammen: der +// Kontroll-Datenbank und jedes geöffneten Workspace. +func (s *Collector) WaitCount() int64 { + total := s.control.PoolStats().WaitCount() + for _, w := range s.workspaces.OpenWorkspaces() { + total += w.Store.PoolStats().WaitCount() + } + return total +} + +// ForWorkspace erhebt den Stand eines einzelnen Workspace — für den +// Überblick des Workspace-Admins (AUSBAUSTUFEN.md 5.17). Ohne Zwischenspeicher: +// Ein Workspace sind nur eine Handvoll Abfragen, und der Admin soll nach +// einer Änderung (Key angelegt) sofort den neuen Stand sehen. +func (s *Collector) ForWorkspace(ctx context.Context, w store.Workspace, now time.Time) Snapshot { + return s.collectAll(ctx, now, []store.Workspace{w}) +} + +// collect fragt jeden Workspace ab und zählt zusammen. +// +// Dafür werden alle Workspaces geöffnet, auch lange nicht benutzte. Das tun +// das tägliche Aufräumen und der Export (main.go) ohnehin schon beim Start; +// zusätzliche Verbindungen kostet die Erhebung also nicht. +func (s *Collector) collect(ctx context.Context, now time.Time) Snapshot { + all, err := s.control.ListWorkspaces(ctx) + if err != nil { + return Snapshot{CollectedAt: now, Errors: []string{fmt.Sprintf("Workspaces konnten nicht gelesen werden: %v", err)}} + } + return s.collectAll(ctx, now, all) +} + +// collectAll zählt die gegebenen Workspaces zusammen. +func (s *Collector) collectAll(ctx context.Context, now time.Time, all []store.Workspace) Snapshot { + begin := time.Now() + snapshot := Snapshot{CollectedAt: now} + + // Die Tage stehen von vornherein fest, auch die ohne jede Aktivität — + // sonst hätte das Diagramm Lücken, die man nicht sieht. + today := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC) + first := today.AddDate(0, 0, -(Days - 1)) + tagNum := map[string]int{} + for i := 0; i < Days; i++ { + d := first.AddDate(0, 0, i) + snapshot.Days = append(snapshot.Days, Tag{Date: d}) + tagNum[d.Format("2006-01-02")] = i + } + + for _, w := range all { + ws, projects, err := s.collectWorkspace(ctx, w, now, first, &snapshot, tagNum) + if err != nil { + snapshot.Errors = append(snapshot.Errors, fmt.Sprintf("%s: %v", w.Name, err)) + continue + } + snapshot.Workspaces = append(snapshot.Workspaces, ws) + snapshot.Total = snapshot.Total.Plus(ws.Inventory) + snapshot.DiskUsage.Data += ws.DiskUsage.Data + snapshot.DiskUsage.WAL += ws.DiskUsage.WAL + + for _, p := range projects { + if p.Total() > 0 { + snapshot.Projects = append(snapshot.Projects, p) + } else { + snapshot.Idle = append(snapshot.Idle, p) + } + } + } + + sort.Slice(snapshot.Workspaces, func(i, j int) bool { return snapshot.Workspaces[i].Name < snapshot.Workspaces[j].Name }) + // Die aktivsten zuerst; bei Gleichstand alphabetisch, damit die + // Reihenfolge beim Neuladen nicht springt. + sort.Slice(snapshot.Projects, func(i, j int) bool { + a, b := snapshot.Projects[i], snapshot.Projects[j] + if a.Total() != b.Total() { + return a.Total() > b.Total() + } + return a.Workspace+a.Name < b.Workspace+b.Name + }) + sort.Slice(snapshot.Idle, func(i, j int) bool { + a, b := snapshot.Idle[i], snapshot.Idle[j] + return a.Workspace+a.Name < b.Workspace+b.Name + }) + + snapshot.WaitCount = s.WaitCount() + snapshot.Duration = time.Since(begin) + return snapshot +} + +// collectWorkspace sammelt die Zahlen eines Workspace. Die Aktivität je Tag +// zählt sie direkt in snapshot.Days ein; die je Projekt gibt sie zurück. +func (s *Collector) collectWorkspace(ctx context.Context, w store.Workspace, now, since time.Time, + snapshot *Snapshot, tagNum map[string]int) (Workspace, []Project, error) { + + result := Workspace{ID: w.ID, Name: w.Name, Location: s.workspaces.Location(w.ID)} + + opened, err := s.workspaces.Get(w.ID) + if err != nil { + return result, nil, err + } + st := opened.Store + + if result.Inventory, err = st.Inventory(ctx, now); err != nil { + return result, nil, err + } + if result.DiskUsage, err = st.DiskUsage(ctx); err != nil { + return result, nil, err + } + actual, expected, err := st.SchemaVersion(ctx) + if err != nil { + return result, nil, err + } + result.Schema = actual + snapshot.SchemaVersion = expected // für alle Workspaces derselbe: er kommt aus dem Programm + + // Jedes Projekt bekommt einen Eintrag, auch ohne Aktivität — gerade die + // ruhigen sind interessant. + list, err := st.ListProjects(ctx, w.ID) + if err != nil { + return result, nil, err + } + projects := make([]Project, len(list)) + toID := map[string]*Project{} + for i, p := range list { + projects[i] = Project{Workspace: w.Name, ID: p.ID, Slug: p.Slug, Name: p.Name} + toID[p.ID] = &projects[i] + } + + activity, err := st.Activity(ctx, since) + if err != nil { + return result, nil, err + } + for _, a := range activity { + if i, ok := tagNum[a.Tag]; ok { + snapshot.Days[i].AgentWrites += a.AgentWrites + snapshot.Days[i].HumanWrites += a.HumanWrites + snapshot.Days[i].Searches += a.Searches + } + // Zeilen eines inzwischen gelöschten Projekts zählen für den Tag, + // haben aber kein Projekt mehr, dem sie gehören. + if p := toID[a.ProjectID]; p != nil { + p.AgentWrites += a.AgentWrites + p.HumanWrites += a.HumanWrites + p.Searches += a.Searches + } + } + + return result, projects, nil +} diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go new file mode 100644 index 0000000..8abffce --- /dev/null +++ b/internal/stats/stats_test.go @@ -0,0 +1,124 @@ +package stats + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "munin/internal/store" + "munin/internal/workspace" +) + +// testEnv legt eine Kontroll-Datenbank mit zwei Workspaces an, je einem +// Projekt darin. +func testEnv(t *testing.T) (*store.ControlStore, *workspace.Manager, []store.Workspace) { + t.Helper() + ctx := context.Background() + + dir := t.TempDir() + wsDir := filepath.Join(dir, "workspaces") + if err := os.MkdirAll(wsDir, 0750); err != nil { + t.Fatal(err) + } + backend := store.NewSQLite(filepath.Join(dir, "control.db"), wsDir) + control, err := backend.OpenControl() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { control.Close() }) + manager := workspace.NewManager(backend) + t.Cleanup(func() { manager.Close() }) + + var list []store.Workspace + for _, name := range []string{"Vertrieb", "Entwicklung"} { + ws, err := control.CreateWorkspace(ctx, name) + if err != nil { + t.Fatal(err) + } + opened, err := manager.Get(ws.ID) + if err != nil { + t.Fatal(err) + } + if _, err := opened.Store.CreateProject(ctx, ws.ID, "allgemein", "Allgemein "+name, ""); err != nil { + t.Fatal(err) + } + list = append(list, ws) + } + return control, manager, list +} + +// TestCollect zählt über alle Workspaces zusammen, füllt jeden Tag des +// Zeitraums und trennt aktive von ruhigen Projekten. +func TestCollect(t *testing.T) { + control, manager, list := testEnv(t) + ctx := context.Background() + + // Im Workspace "Vertrieb" schreibt ein Agent einen Eintrag. + opened, _ := manager.Get(list[0].ID) + projects, _ := opened.Store.ListProjects(ctx, list[0].ID) + if _, err := opened.Store.PutEntry(ctx, store.EntryWrite{ + ProjectID: projects[0].ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent, + }); err != nil { + t.Fatal(err) + } + opened.Store.LogAudit(ctx, store.AuditEvent{ + ProjectID: projects[0].ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP, + }) + + snapshot := NewCollector(control, manager).Current(ctx, time.Now()) + + if len(snapshot.Errors) > 0 { + t.Fatalf("Fehler: %v", snapshot.Errors) + } + if len(snapshot.Workspaces) != 2 || snapshot.Workspaces[0].Name != "Entwicklung" { + t.Errorf("Workspaces %+v, erwartet zwei, nach Name sortiert", snapshot.Workspaces) + } + if snapshot.Total.Projects != 2 || snapshot.Total.Entries != 1 { + t.Errorf("Summe %+v, erwartet 2 Projekte und 1 Eintrag", snapshot.Total) + } + if snapshot.DiskUsage.Data <= 0 { + t.Error("keine Belegung gezählt") + } + if snapshot.SchemaVersion == "" { + t.Error("Schemastand fehlt") + } + + if len(snapshot.Days) != Days { + t.Fatalf("%d Tage, erwartet %d", len(snapshot.Days), Days) + } + today := snapshot.Days[len(snapshot.Days)-1] + if today.AgentWrites != 1 || today.Date.Format("2006-01-02") != time.Now().UTC().Format("2006-01-02") { + t.Errorf("letzter Tag %+v, erwartet heute mit einem Schreibvorgang", today) + } + + if len(snapshot.Projects) != 1 || snapshot.Projects[0].Workspace != "Vertrieb" { + t.Errorf("aktive Projekte %+v, erwartet nur das im Vertrieb", snapshot.Projects) + } + if len(snapshot.Idle) != 1 || snapshot.Idle[0].Workspace != "Entwicklung" { + t.Errorf("ruhige Projekte %+v, erwartet nur das in der Entwicklung", snapshot.Idle) + } +} + +// TestMinInterval: Innerhalb von MinInterval kommt der gemerkte Stand +// zurück, danach ein neuer — der sich die Wartezahl des vorigen merkt. +func TestMinInterval(t *testing.T) { + control, manager, _ := testEnv(t) + ctx := context.Background() + s := NewCollector(control, manager) + + now := time.Now() + first := s.Current(ctx, now) + if second := s.Current(ctx, now.Add(time.Minute)); !second.CollectedAt.Equal(first.CollectedAt) { + t.Error("nach einer Minute neu gezählt, erwartet den gemerkten Stand") + } + + third := s.Current(ctx, now.Add(MinInterval+time.Second)) + if third.CollectedAt.Equal(first.CollectedAt) { + t.Fatal("nach MindestAbstand nicht neu gezählt") + } + if !third.PrevCollectedAt.Equal(first.CollectedAt) { + t.Errorf("VorherErhoben = %v, erwartet den ersten Stand %v", third.PrevCollectedAt, first.CollectedAt) + } +} diff --git a/internal/store/audit.go b/internal/store/audit.go index 1c38301..9db60b5 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -66,14 +66,14 @@ const ( // festen Werte oben, etwa mit `detail LIKE 'wiederhergestellt%'`. Eine // Wiederherstellung ist fachlich immer eine Aktualisierung: Der Eintrag // existiert ja schon, sonst gäbe es keine alte Fassung. -func EntryRestoreDetail(vonFassung int) string { +func EntryRestoreDetail(fromVersion int) string { // fmt.Sprintf setzt die Zahl in den Text ein — %d steht für eine ganze Zahl. - return fmt.Sprintf("wiederhergestellt: Fassung %d", vonFassung) + return fmt.Sprintf("wiederhergestellt: Fassung %d", fromVersion) } // DetailCuratedOverridden leitet im Protokoll einen Schreibvorgang ein, mit dem // ein Agent einen kuratierten Eintrag geändert hat — und damit die Markierung -// aufgehoben (siehe internal/mcpserver/kuratiert.go). Danach folgt die +// aufgehoben (siehe internal/mcpserver/curated.go). Danach folgt die // Begründung des Agenten. // // Fachlich ist das eine Aktualisierung und zählt in der Nutzungsübersicht auch @@ -81,19 +81,19 @@ func EntryRestoreDetail(vonFassung int) string { // auffindbar: CuratedOverrides sucht mit LIKE danach. const DetailCuratedOverridden = "kuratiert aufgehoben" -// maxBegruendung begrenzt die Begründung im Protokoll. Sie soll einordnen, +// maxJustification begrenzt die Begründung im Protokoll. Sie soll einordnen, // nicht den Eintrag noch einmal enthalten. -const maxBegruendung = 300 +const maxJustification = 300 // CuratedOverrideDetail baut den Protokolltext für DetailCuratedOverridden. -func CuratedOverrideDetail(begruendung string) string { - begruendung = strings.TrimSpace(begruendung) +func CuratedOverrideDetail(justification string) string { + justification = strings.TrimSpace(justification) // In Runen zählen, nicht in Bytes — sonst könnte der Schnitt mitten in // einem Umlaut landen. - if r := []rune(begruendung); len(r) > maxBegruendung { - begruendung = string(r[:maxBegruendung]) + "…" + if r := []rune(justification); len(r) > maxJustification { + justification = string(r[:maxJustification]) + "…" } - return DetailCuratedOverridden + ": " + begruendung + return DetailCuratedOverridden + ": " + justification } // EntryWriteDetail sagt anhand des gespeicherten Eintrags, ob der @@ -129,7 +129,7 @@ func (s *Store) LogAudit(ctx context.Context, e AuditEvent) { e.ID = NewID() } - _, err := s.schreiben.ExecContext(ctx, + _, err := s.write.ExecContext(ctx, `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.ID, NowString(), e.ProjectID, e.APIKeyID, e.Actor, e.Action, e.EntrySlug, e.Source, e.IP, e.Detail, @@ -184,45 +184,45 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, err // Die Bedingungen werden gesammelt und mit AND verbunden. Werte gehen // immer als Platzhalter in die Abfrage, nie als Text. - var bedingungen []string - werte := []any{} + var conditions []string + values := []any{} if f.ProjectID != "" { - bedingungen = append(bedingungen, `project_id = ?`) - werte = append(werte, f.ProjectID) + conditions = append(conditions, `project_id = ?`) + values = append(values, f.ProjectID) } if f.Action != "" { - bedingungen = append(bedingungen, `action = ?`) - werte = append(werte, f.Action) + conditions = append(conditions, `action = ?`) + values = append(values, f.Action) } if f.Actor != "" { - bedingungen = append(bedingungen, `actor = ?`) - werte = append(werte, f.Actor) + conditions = append(conditions, `actor = ?`) + values = append(values, f.Actor) } if !f.Since.IsZero() { - bedingungen = append(bedingungen, `ts >= ?`) - werte = append(werte, formatTime(f.Since)) + conditions = append(conditions, `ts >= ?`) + values = append(values, formatTime(f.Since)) } if !f.Until.IsZero() { - bedingungen = append(bedingungen, `ts < ?`) - werte = append(werte, formatTime(f.Until)) + conditions = append(conditions, `ts < ?`) + values = append(values, formatTime(f.Until)) } - abfrage := `SELECT id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail + query := `SELECT id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail FROM audit_events` - if len(bedingungen) > 0 { - abfrage += ` WHERE ` + strings.Join(bedingungen, ` AND `) + if len(conditions) > 0 { + query += ` WHERE ` + strings.Join(conditions, ` AND `) } - abfrage += ` ORDER BY ts DESC LIMIT ?` - werte = append(werte, limit) + query += ` ORDER BY ts DESC LIMIT ?` + values = append(values, limit) - rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) + rows, err := s.read.QueryContext(ctx, query, values...) if err != nil { return nil, fmt.Errorf("Audit-Log konnte nicht gelesen werden: %w", err) } defer rows.Close() - var ereignisse []AuditEvent + var events []AuditEvent for rows.Next() { var e AuditEvent var ts string @@ -231,39 +231,39 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, err return nil, err } e.Timestamp = parseTime(ts) - ereignisse = append(ereignisse, e) + events = append(events, e) } - return ereignisse, rows.Err() + return events, rows.Err() } // AuditActors nennt alle Zugangsnamen, die im Protokoll vorkommen, sortiert — // für die Auswahlliste der Protokollseite. Mit projectID nur die eines // Projekts. Höchstens auditActorLimit, damit die Liste bedienbar bleibt. func (s *Store) AuditActors(ctx context.Context, projectID string) ([]string, error) { - abfrage := `SELECT DISTINCT actor FROM audit_events WHERE actor <> ''` - werte := []any{} + query := `SELECT DISTINCT actor FROM audit_events WHERE actor <> ''` + values := []any{} if projectID != "" { - abfrage += ` AND project_id = ?` - werte = append(werte, projectID) + query += ` AND project_id = ?` + values = append(values, projectID) } - abfrage += ` ORDER BY actor LIMIT ?` - werte = append(werte, auditActorLimit) + query += ` ORDER BY actor LIMIT ?` + values = append(values, auditActorLimit) - rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) + rows, err := s.read.QueryContext(ctx, query, values...) if err != nil { return nil, fmt.Errorf("Zugänge im Protokoll konnten nicht gelesen werden: %w", err) } defer rows.Close() - var namen []string + var names []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } - namen = append(namen, n) + names = append(names, n) } - return namen, rows.Err() + return names, rows.Err() } // auditActorLimit begrenzt die Auswahlliste der Zugänge. @@ -282,8 +282,8 @@ const auditActorLimit = 500 // keine Kür, sondern die Voraussetzung dafür, das Protokoll überhaupt führen zu dürfen. // // Übergeben wird die Grenze als Zeitstempel im Datenbankformat; alles davor fällt weg. -func (s *Store) PruneAudit(ctx context.Context, aelterAls string) (int64, error) { - res, err := s.schreiben.ExecContext(ctx, `DELETE FROM audit_events WHERE ts < ?`, aelterAls) +func (s *Store) PruneAudit(ctx context.Context, olderThan string) (int64, error) { + res, err := s.write.ExecContext(ctx, `DELETE FROM audit_events WHERE ts < ?`, olderThan) if err != nil { return 0, fmt.Errorf("Audit-Log konnte nicht aufgeräumt werden: %w", err) } @@ -293,16 +293,16 @@ func (s *Store) PruneAudit(ctx context.Context, aelterAls string) (int64, error) // ProjectNames gibt eine Zuordnung von Projekt-ID zu Anzeigename zurück. // Wird in der Audit-Ansicht gebraucht, damit dort nicht rohe IDs stehen. func (s *Store) ProjectNames(ctx context.Context, workspaceID string) (map[string]string, error) { - projekte, err := s.ListProjects(ctx, workspaceID) + projects, err := s.ListProjects(ctx, workspaceID) if err != nil { return nil, err } - namen := make(map[string]string, len(projekte)) - for _, p := range projekte { - namen[p.ID] = p.Name + names := make(map[string]string, len(projects)) + for _, p := range projects { + names[p.ID] = p.Name } - return namen, nil + return names, nil } // KeyNames gibt eine Zuordnung von Key-ID zu Key-Name zurück — analog zu @@ -312,26 +312,26 @@ func (s *Store) KeyNames(ctx context.Context, projectIDs []string) (map[string]s return map[string]string{}, nil } - platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(projectIDs)), ",") - werte := make([]any, len(projectIDs)) + placeholder := strings.TrimSuffix(strings.Repeat("?,", len(projectIDs)), ",") + values := make([]any, len(projectIDs)) for i, id := range projectIDs { - werte[i] = id + values[i] = id } - rows, err := s.lesen.QueryContext(ctx, - `SELECT id, name FROM api_keys WHERE project_id IN (`+platzhalter+`)`, werte...) + rows, err := s.read.QueryContext(ctx, + `SELECT id, name FROM api_keys WHERE project_id IN (`+placeholder+`)`, values...) if err != nil { return nil, fmt.Errorf("Key-Namen konnten nicht gelesen werden: %w", err) } defer rows.Close() - namen := make(map[string]string) + names := make(map[string]string) for rows.Next() { var id, name string if err := rows.Scan(&id, &name); err != nil { return nil, err } - namen[id] = name + names[id] = name } - return namen, rows.Err() + return names, rows.Err() } diff --git a/internal/store/speicher.go b/internal/store/backend.go similarity index 62% rename from internal/store/speicher.go rename to internal/store/backend.go index 05c2248..fd22bae 100644 --- a/internal/store/speicher.go +++ b/internal/store/backend.go @@ -17,26 +17,26 @@ import "context" // // In beiden Fällen bekommt jeder Workspace seinen eigenen, abgeschotteten // Bereich mit denselben Tabellen. -// Kapazitaet beschreibt, wie viele Datenbankverbindungen Munin belegen darf. -// Die Rechnung dahinter steht bei PruefeKapazitaet (speicher_postgres.go). -type Kapazitaet struct { - Begrenzt bool // false: keine gemeinsame Grenze (SQLite) - Erlaubt int // max_connections des Servers - Reserve int // bleibt frei für psql, pg_dump, Superuser - JeWorkspace int // so viele belegt ein geöffneter Workspace höchstens - Fest int // Kontroll-Datenbank und Verwaltungsverbindung +// Capacity beschreibt, wie viele Datenbankverbindungen Munin belegen darf. +// Die Rechnung dahinter steht bei CheckCapacity (backend_postgres.go). +type Capacity struct { + Limited bool // false: keine gemeinsame Grenze (SQLite) + Allowed int // max_connections des Servers + Reserve int // bleibt frei für psql, pg_dump, Superuser + PerWorkspace int // so viele belegt ein geöffneter Workspace höchstens + Fixed int // Kontroll-Datenbank und Verwaltungsverbindung } // MaxWorkspaces ist die Zahl der Workspaces, die mit dieser Grenze höchstens // gleichzeitig geöffnet sein können. -func (k Kapazitaet) MaxWorkspaces() int { - if !k.Begrenzt || k.JeWorkspace == 0 { +func (k Capacity) MaxWorkspaces() int { + if !k.Limited || k.PerWorkspace == 0 { return 0 } - return (k.Erlaubt - k.Reserve - k.Fest) / k.JeWorkspace + return (k.Allowed - k.Reserve - k.Fixed) / k.PerWorkspace } -type Speicher interface { +type Backend interface { // OpenControl öffnet die Kontroll-Datenbank und bringt ihr Schema auf den // aktuellen Stand. OpenControl() (*ControlStore, error) @@ -49,29 +49,29 @@ type Speicher interface { // Der Store dazu muss vorher geschlossen sein (siehe workspace.Manager). DeleteWorkspace(workspaceID string) error - // PruefeKapazitaet meldet einen Fehler, wenn anzahl Workspaces mehr + // CheckCapacity meldet einen Fehler, wenn anzahl Workspaces mehr // Verbindungen bräuchten, als die Datenbank erlaubt. Bei SQLite gibt es // diese Grenze nicht. - PruefeKapazitaet(ctx context.Context, anzahl int) error + CheckCapacity(ctx context.Context, count int) error - // Kapazitaet beschreibt die Grenze der Datenbankverbindungen — für die + // Capacity beschreibt die Grenze der Datenbankverbindungen — für die // Systemseite. Bei SQLite gibt es keine (Begrenzt = false). - Kapazitaet(ctx context.Context) (Kapazitaet, error) + Capacity(ctx context.Context) (Capacity, error) - // Ort nennt, wo die Daten eines Workspace liegen: den Schemanamen + // Location nennt, wo die Daten eines Workspace liegen: den Schemanamen // (PostgreSQL) bzw. die Datei (SQLite). Für die Workspace-Übersicht des // Plattform-Admins, damit sich ein Workspace in der Datenbank wiederfinden // lässt. - Ort(workspaceID string) string + Location(workspaceID string) string - // Hinweise nennt Auffälligkeiten des Speichers, die den Betrieb nicht + // Hints nennt Auffälligkeiten des Speichers, die den Betrieb nicht // verhindern, aber behoben werden sollten — für die Systemseite. Leer, // wenn alles in Ordnung ist. - Hinweise() []string + Hints() []string - // Beschreibung sagt für das Startprotokoll, wo die Daten liegen — ohne + // Description sagt für das Startprotokoll, wo die Daten liegen — ohne // Passwort. - Beschreibung() string + Description() string // Close gibt alles frei, was der Speicher selbst offen hält (nicht die // geöffneten Stores — die schließt ihr jeweiliger Besitzer). diff --git a/internal/store/speicher_postgres.go b/internal/store/backend_postgres.go similarity index 64% rename from internal/store/speicher_postgres.go rename to internal/store/backend_postgres.go index c374b18..b032a29 100644 --- a/internal/store/speicher_postgres.go +++ b/internal/store/backend_postgres.go @@ -21,7 +21,7 @@ import ( // (max_connections, Standard 100). Würde Munin mehr öffnen, lehnte PostgreSQL // neue Verbindungen ab und Anfragen schlügen fehl. Deshalb gelten hier feste, // kleine Pools, und Munin rechnet vor dem Start und vor jedem neuen Workspace -// nach, ob sie reichen (PruefeKapazitaet). Hintergrund und Rechnung: +// nach, ob sie reichen (CheckCapacity). Hintergrund und Rechnung: // AUSBAUSTUFEN.md 4.10, „Verbindungen fest begrenzt“. // // Kleine Pools reichen, weil eine Verbindung nur für die Dauer einer Abfrage @@ -29,35 +29,35 @@ import ( // Ist ein Pool voll, wartet die Anfrage in Go auf eine freie Verbindung (bis // zum Ende ihres Kontexts), statt in PostgreSQL zu scheitern. const ( - // pgLeseverbindungen je Workspace (und für die Kontroll-Datenbank). - pgLeseverbindungen = 2 + // pgReadConns je Workspace (und für die Kontroll-Datenbank). + pgReadConns = 2 - // pgSchreibverbindungen je Workspace. Vorerst 1, also dasselbe Verhalten + // pgWriteConns je Workspace. Vorerst 1, also dasselbe Verhalten // wie unter SQLite: Schreibvorgänge eines Workspace laufen nacheinander. // Mehr erst, wenn die schreibenden Methoden gegen gleichzeitiges Schreiben // abgesichert sind (AUSBAUSTUFEN.md 4.10, Schritt 4) — sonst kann z.B. // PutEntry unter PostgreSQL Änderungen verlieren. - pgSchreibverbindungen = 1 + pgWriteConns = 1 - // pgVerbindungenJeWorkspace ist, was ein geöffneter Workspace höchstens + // pgConnsPerWorkspace ist, was ein geöffneter Workspace höchstens // belegt. - pgVerbindungenJeWorkspace = pgLeseverbindungen + pgSchreibverbindungen + pgConnsPerWorkspace = pgReadConns + pgWriteConns - // pgFesteVerbindungen: die Kontroll-Datenbank (wie ein Workspace) plus + // pgFixedConns: die Kontroll-Datenbank (wie ein Workspace) plus // eine Verwaltungsverbindung zum Anlegen und Löschen von Schemas. - pgFesteVerbindungen = pgVerbindungenJeWorkspace + 1 + pgFixedConns = pgConnsPerWorkspace + 1 // pgReserve bleibt frei — für psql, Backups (pg_dump) und die // Verbindungen, die PostgreSQL selbst für Superuser zurückhält. pgReserve = 10 - // pgLeerlauf: Nach so langer Ruhe schließt ein Pool seine Verbindungen. + // pgIdleTime: Nach so langer Ruhe schließt ein Pool seine Verbindungen. // Ein Workspace, den gerade niemand benutzt, belegt dann keine. - pgLeerlauf = 2 * time.Minute + pgIdleTime = 2 * time.Minute // Beim Start ist PostgreSQL vielleicht noch nicht bereit (der Container // fährt gerade hoch). Munin versucht es so oft, mit dieser Pause dazwischen. - pgStartVersuche = 15 + pgStartAttempts = 15 pgStartPause = 2 * time.Second ) @@ -68,14 +68,14 @@ const ( // Anwendungen liegen. Begründung: AUSBAUSTUFEN.md 4.10, „Schemanamen aus der // Workspace-ID“. const ( - ControlSchema = "munin_control" - workspaceSchemaPraefix = "munin_ws_" + ControlSchema = "munin_control" + workspaceSchemaPrefix = "munin_ws_" ) -// erlaubteWorkspaceID passt auf IDs, wie NewID sie erzeugt (Kleinbuchstaben, +// validWorkspaceID passt auf IDs, wie NewID sie erzeugt (Kleinbuchstaben, // Ziffern, Bindestrich). Alles andere wird abgelehnt, bevor es in einen // Schemanamen wandert. -var erlaubteWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`) +var validWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`) // WorkspaceSchema liefert den Schemanamen eines Workspace, z.B. // "munin_ws_mue9bh3x_1c0e1120". @@ -83,12 +83,12 @@ var erlaubteWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`) // Die ID statt des Namens, weil sich Namen ändern können und freier Text sind. // "-" wird zu "_", weil ein Bindestrich in PostgreSQL überall Anführungszeichen // erzwingen würde. Den lesbaren Namen trägt ein Kommentar am Schema (siehe -// nachDemBenennen). +// afterRename). func WorkspaceSchema(workspaceID string) (string, error) { - if !erlaubteWorkspaceID.MatchString(workspaceID) { + if !validWorkspaceID.MatchString(workspaceID) { return "", fmt.Errorf("Workspace-ID %q ist als Schemaname nicht geeignet", workspaceID) } - name := workspaceSchemaPraefix + strings.ReplaceAll(workspaceID, "-", "_") + name := workspaceSchemaPrefix + strings.ReplaceAll(workspaceID, "-", "_") // PostgreSQL kürzt längere Namen stillschweigend auf 63 Byte. Zwei lange // IDs könnten dann im selben Schema landen — lieber gleich ablehnen. @@ -100,7 +100,7 @@ func WorkspaceSchema(workspaceID string) (string, error) { // ── Speicher ───────────────────────────────────────────────────────────────── -// PostgresSpeicher legt die Kontroll-Datenbank und jeden Workspace als eigenes +// PostgresBackend legt die Kontroll-Datenbank und jeden Workspace als eigenes // Schema in einer PostgreSQL-Datenbank ab. // // Jedes Schema bekommt eigene Pools, deren Verbindungen mit @@ -108,8 +108,8 @@ func WorkspaceSchema(workspaceID string) (string, error) { // SQLite unverändert — "SELECT … FROM entries" trifft automatisch die Tabelle // im richtigen Schema, und ein Workspace kann die Tabellen eines anderen gar // nicht erst sehen. -type PostgresSpeicher struct { - basis *pgx.ConnConfig // aus DATABASE_URL; je Schema kopiert und ergänzt +type PostgresBackend struct { + base *pgx.ConnConfig // aus DATABASE_URL; je Schema kopiert und ergänzt admin *sql.DB // eine Verbindung ohne search_path, für CREATE/DROP SCHEMA // controlSchema ist im Betrieb immer ControlSchema. Nur Tests setzen ein @@ -117,58 +117,58 @@ type PostgresSpeicher struct { // anfassen (siehe postgres_test.go). controlSchema string - // hinweise sammelt beim Start, was nicht zum Abbruch reicht, aber auf die + // hints sammelt beim Start, was nicht zum Abbruch reicht, aber auf die // Systemseite gehört (siehe Hinweise). - hinweise []string + hints []string } // NewPostgres verbindet sich mit der Datenbank aus url, wartet nötigenfalls, // bis sie bereit ist, und legt das Schema der Kontroll-Datenbank an. -func NewPostgres(ctx context.Context, url string) (*PostgresSpeicher, error) { - basis, err := pgx.ParseConfig(url) +func NewPostgres(ctx context.Context, url string) (*PostgresBackend, error) { + base, err := pgx.ParseConfig(url) if err != nil { // pgx ersetzt das Passwort in der Fehlermeldung durch "xxxxx". return nil, fmt.Errorf("DATABASE_URL ist ungültig: %w", err) } - admin := stdlib.OpenDB(*basis) + admin := stdlib.OpenDB(*base) admin.SetMaxOpenConns(1) - admin.SetConnMaxIdleTime(pgLeerlauf) + admin.SetConnMaxIdleTime(pgIdleTime) - p := &PostgresSpeicher{basis: basis, admin: admin, controlSchema: ControlSchema} + p := &PostgresBackend{base: base, admin: admin, controlSchema: ControlSchema} - if err := p.warteAufDatenbank(ctx); err != nil { + if err := p.waitForDatabase(ctx); err != nil { admin.Close() return nil, err } - p.pruefeSortierung(ctx) + p.checkCollation(ctx) - if err := p.legeSchemaAn(ctx, p.controlSchema); err != nil { + if err := p.createSchema(ctx, p.controlSchema); err != nil { admin.Close() return nil, err } return p, nil } -// warteAufDatenbank versucht mehrmals, die Datenbank zu erreichen. +// waitForDatabase versucht mehrmals, die Datenbank zu erreichen. // // Mit SQLite ist die Datei immer da. PostgreSQL läuft dagegen als eigener // Dienst und ist nach einem gemeinsamen Start (docker compose up) oft ein paar // Sekunden später bereit als Munin. Ohne Wiederholung bräche Munin dann sofort // ab. -func (p *PostgresSpeicher) warteAufDatenbank(ctx context.Context) error { - var letzterFehler error - for versuch := 1; versuch <= pgStartVersuche; versuch++ { +func (p *PostgresBackend) waitForDatabase(ctx context.Context) error { + var lastErr error + for attempt := 1; attempt <= pgStartAttempts; attempt++ { pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - letzterFehler = p.admin.PingContext(pingCtx) + lastErr = p.admin.PingContext(pingCtx) cancel() - if letzterFehler == nil { + if lastErr == nil { return nil } - if versuch < pgStartVersuche { - log.Printf("PostgreSQL ist noch nicht erreichbar (Versuch %d von %d): %v", versuch, pgStartVersuche, letzterFehler) + if attempt < pgStartAttempts { + log.Printf("PostgreSQL ist noch nicht erreichbar (Versuch %d von %d): %v", attempt, pgStartAttempts, lastErr) select { case <-ctx.Done(): return ctx.Err() @@ -176,10 +176,10 @@ func (p *PostgresSpeicher) warteAufDatenbank(ctx context.Context) error { } } } - return fmt.Errorf("PostgreSQL ist nicht erreichbar (%s): %w", p.Beschreibung(), letzterFehler) + return fmt.Errorf("PostgreSQL ist nicht erreichbar (%s): %w", p.Description(), lastErr) } -// pruefeSortierung warnt, wenn die Datenbank nicht mit der Sortierung "C" +// checkCollation warnt, wenn die Datenbank nicht mit der Sortierung "C" // angelegt wurde. // // SQLite sortiert Text Byte für Byte. PostgreSQL sortiert nach der @@ -187,62 +187,62 @@ func (p *PostgresSpeicher) warteAufDatenbank(ctx context.Context) error { // zunächst übergangen — "a-c" stünde dann vor "ab". Listen wären anders // sortiert als unter SQLite. Kein Grund, den Start zu verweigern, aber einer // für einen deutlichen Hinweis. -func (p *PostgresSpeicher) pruefeSortierung(ctx context.Context) { - var sortierung string +func (p *PostgresBackend) checkCollation(ctx context.Context) { + var sortOrder string err := p.admin.QueryRowContext(ctx, `SELECT datcollate FROM pg_database WHERE datname = current_database()`, - ).Scan(&sortierung) + ).Scan(&sortOrder) if err != nil { log.Printf("Hinweis: Sortierung der Datenbank konnte nicht geprüft werden: %v", err) return } - if sortierung != "C" && sortierung != "POSIX" { - hinweis := fmt.Sprintf("Die Datenbank sortiert nach %q statt \"C\". Listen sind dann anders sortiert als unter SQLite. "+ - "Abhilfe: Datenbank mit --locale=C anlegen (siehe AUSBAUSTUFEN.md 4.10).", sortierung) - log.Printf("Warnung: %s", hinweis) - p.hinweise = append(p.hinweise, hinweis) + if sortOrder != "C" && sortOrder != "POSIX" { + hint := fmt.Sprintf("Die Datenbank sortiert nach %q statt \"C\". Listen sind dann anders sortiert als unter SQLite. "+ + "Abhilfe: Datenbank mit --locale=C anlegen (siehe AUSBAUSTUFEN.md 4.10).", sortOrder) + log.Printf("Warnung: %s", hint) + p.hints = append(p.hints, hint) } } -// Hinweise nennt, was beim Start auffiel, etwa eine falsche Sortierung. Die +// Hints nennt, was beim Start auffiel, etwa eine falsche Sortierung. Die // Liste entsteht einmal in NewPostgres: Die Sortierung einer Datenbank lässt // sich nachträglich nicht ändern, ein erneutes Nachfragen brächte nichts. -func (p *PostgresSpeicher) Hinweise() []string { return p.hinweise } +func (p *PostgresBackend) Hints() []string { return p.hints } // OpenControl öffnet die Kontroll-Datenbank im Schema munin_control. // // Der ControlStore bekommt den Speicher als Haken mit: Er prüft vor jedem neuen // Workspace die Verbindungen und legt danach dessen Schema samt lesbarem Namen -// an (siehe vorDemAnlegen, nachDemBenennen). -func (p *PostgresSpeicher) OpenControl() (*ControlStore, error) { - lesen, schreiben, err := p.oeffnePools(p.controlSchema, controlMigrationsFS, "migrations_control") +// an (siehe beforeCreate, afterRename). +func (p *PostgresBackend) OpenControl() (*ControlStore, error) { + read, write, err := p.openPools(p.controlSchema, controlMigrationsFS, "migrations_control") if err != nil { return nil, err } - return &ControlStore{lesen: lesen, schreiben: schreiben, haken: p}, nil + return &ControlStore{read: read, write: write, hooks: p}, nil } // OpenWorkspace öffnet das Schema eines Workspace und legt es an, falls es // noch fehlt. -func (p *PostgresSpeicher) OpenWorkspace(workspaceID string) (*Store, error) { +func (p *PostgresBackend) OpenWorkspace(workspaceID string) (*Store, error) { schema, err := WorkspaceSchema(workspaceID) if err != nil { return nil, err } - if err := p.legeSchemaAn(context.Background(), schema); err != nil { + if err := p.createSchema(context.Background(), schema); err != nil { return nil, err } - lesen, schreiben, err := p.oeffnePools(schema, migrationsFS, "migrations") + read, write, err := p.openPools(schema, migrationsFS, "migrations") if err != nil { return nil, err } - return &Store{lesen: lesen, schreiben: schreiben}, nil + return &Store{read: read, write: write}, nil } // DeleteWorkspace löscht das Schema eines Workspace samt aller Tabellen und // Daten darin. -func (p *PostgresSpeicher) DeleteWorkspace(workspaceID string) error { +func (p *PostgresBackend) DeleteWorkspace(workspaceID string) error { schema, err := WorkspaceSchema(workspaceID) if err != nil { return err @@ -255,58 +255,58 @@ func (p *PostgresSpeicher) DeleteWorkspace(workspaceID string) error { return nil } -// Kapazitaet liest max_connections und nennt die Größen, mit denen -// PruefeKapazitaet rechnet. -func (p *PostgresSpeicher) Kapazitaet(ctx context.Context) (Kapazitaet, error) { - erlaubt, err := p.maxConnections(ctx) +// Capacity liest max_connections und nennt die Größen, mit denen +// CheckCapacity rechnet. +func (p *PostgresBackend) Capacity(ctx context.Context) (Capacity, error) { + allowed, err := p.maxConnections(ctx) if err != nil { - return Kapazitaet{}, err + return Capacity{}, err } - return Kapazitaet{ - Begrenzt: true, Erlaubt: erlaubt, Reserve: pgReserve, - JeWorkspace: pgVerbindungenJeWorkspace, Fest: pgFesteVerbindungen, + return Capacity{ + Limited: true, Allowed: allowed, Reserve: pgReserve, + PerWorkspace: pgConnsPerWorkspace, Fixed: pgFixedConns, }, nil } // maxConnections liest max_connections des Servers. -func (p *PostgresSpeicher) maxConnections(ctx context.Context) (int, error) { +func (p *PostgresBackend) maxConnections(ctx context.Context) (int, error) { var text string if err := p.admin.QueryRowContext(ctx, `SHOW max_connections`).Scan(&text); err != nil { return 0, fmt.Errorf("max_connections konnte nicht gelesen werden: %w", err) } - erlaubt, err := strconv.Atoi(text) + allowed, err := strconv.Atoi(text) if err != nil { return 0, fmt.Errorf("max_connections hat einen unerwarteten Wert %q", text) } - return erlaubt, nil + return allowed, nil } -// PruefeKapazitaet rechnet nach, ob anzahl Workspaces mit ihren Pools unter +// CheckCapacity rechnet nach, ob count Workspaces mit ihren Pools unter // max_connections bleiben (abzüglich Reserve). // // max_connections wird jedes Mal frisch gelesen: Wer es nach einer Meldung // erhöht und PostgreSQL neu startet, soll Munin dafür nicht auch neu starten // müssen. -func (p *PostgresSpeicher) PruefeKapazitaet(ctx context.Context, anzahl int) error { - erlaubt, err := p.maxConnections(ctx) +func (p *PostgresBackend) CheckCapacity(ctx context.Context, count int) error { + allowed, err := p.maxConnections(ctx) if err != nil { return err } - bedarf := anzahl*pgVerbindungenJeWorkspace + pgFesteVerbindungen - if bedarf > erlaubt-pgReserve { + demand := count*pgConnsPerWorkspace + pgFixedConns + if demand > allowed-pgReserve { return fmt.Errorf("zu wenige PostgreSQL-Verbindungen: %d Workspaces brauchen bis zu %d Verbindungen "+ "(%d je Workspace + %d fest), max_connections ist %d (davon %d Reserve). "+ "max_connections auf mindestens %d erhöhen", - anzahl, bedarf, pgVerbindungenJeWorkspace, pgFesteVerbindungen, erlaubt, pgReserve, bedarf+pgReserve) + count, demand, pgConnsPerWorkspace, pgFixedConns, allowed, pgReserve, demand+pgReserve) } return nil } -// Ort nennt das Schema des Workspace, z.B. "munin_ws_mue9bh3x_1c0e1120". +// Location nennt das Schema des Workspace, z.B. "munin_ws_mue9bh3x_1c0e1120". // Eine ID, die keinen gültigen Schemanamen ergibt, kann es hier nicht geben // (OpenWorkspace hätte sie abgelehnt); falls doch, steht der Grund da. -func (p *PostgresSpeicher) Ort(workspaceID string) string { +func (p *PostgresBackend) Location(workspaceID string) string { schema, err := WorkspaceSchema(workspaceID) if err != nil { return err.Error() @@ -314,30 +314,30 @@ func (p *PostgresSpeicher) Ort(workspaceID string) string { return schema } -// Beschreibung nennt Server und Datenbank, nie das Passwort. -func (p *PostgresSpeicher) Beschreibung() string { +// Description nennt Server und Datenbank, nie das Passwort. +func (p *PostgresBackend) Description() string { return fmt.Sprintf("PostgreSQL — %s:%d/%s (Schemas %s, %s…)", - p.basis.Host, p.basis.Port, p.basis.Database, ControlSchema, workspaceSchemaPraefix) + p.base.Host, p.base.Port, p.base.Database, ControlSchema, workspaceSchemaPrefix) } // Close schließt die Verwaltungsverbindung. -func (p *PostgresSpeicher) Close() error { return p.admin.Close() } +func (p *PostgresBackend) Close() error { return p.admin.Close() } // ── Haken für den ControlStore ─────────────────────────────────────────────── -func (p *PostgresSpeicher) vorDemAnlegen(ctx context.Context, anzahl int) error { - return p.PruefeKapazitaet(ctx, anzahl) +func (p *PostgresBackend) beforeCreate(ctx context.Context, count int) error { + return p.CheckCapacity(ctx, count) } -// nachDemBenennen legt das Schema des Workspace an (falls noch nicht da) und +// afterRename legt das Schema des Workspace an (falls noch nicht da) und // schreibt den lesbaren Namen als Kommentar daran. In psql zeigt \dn+ ihn // neben dem Schemanamen. -func (p *PostgresSpeicher) nachDemBenennen(ctx context.Context, workspaceID, name string) error { +func (p *PostgresBackend) afterRename(ctx context.Context, workspaceID, name string) error { schema, err := WorkspaceSchema(workspaceID) if err != nil { return err } - if err := p.legeSchemaAn(ctx, schema); err != nil { + if err := p.createSchema(ctx, schema); err != nil { return err } @@ -354,7 +354,7 @@ func (p *PostgresSpeicher) nachDemBenennen(ctx context.Context, workspaceID, nam // ── Hilfsfunktionen ────────────────────────────────────────────────────────── -func (p *PostgresSpeicher) legeSchemaAn(ctx context.Context, schema string) error { +func (p *PostgresBackend) createSchema(ctx context.Context, schema string) error { if _, err := p.admin.ExecContext(ctx, `CREATE SCHEMA IF NOT EXISTS `+pgx.Identifier{schema}.Sanitize(), ); err != nil { @@ -363,33 +363,33 @@ func (p *PostgresSpeicher) legeSchemaAn(ctx context.Context, schema string) erro return nil } -// oeffnePools öffnet Lese- und Schreibpool für ein Schema und bringt dessen +// openPools öffnet Lese- und Schreibpool für ein Schema und bringt dessen // Tabellen auf den aktuellen Stand — das Gegenstück zu openSQLite. -func (p *PostgresSpeicher) oeffnePools(schema string, fsys embed.FS, migrationsDir string) (lesen, schreiben pool, err error) { - d := postgresDialekt{} +func (p *PostgresBackend) openPools(schema string, fsys embed.FS, migrationsDir string) (read, write pool, err error) { + d := postgresDialect{} // Jede Verbindung dieser Pools startet mit search_path=<schema>. Kopie, // damit die Grundeinstellung für die anderen Schemas unverändert bleibt. - cfg := p.basis.Copy() + cfg := p.base.Copy() cfg.RuntimeParams["search_path"] = schema - oeffne := func(maxVerbindungen int) *sql.DB { + open := func(maxConns int) *sql.DB { db := stdlib.OpenDB(*cfg) - db.SetMaxOpenConns(maxVerbindungen) - db.SetMaxIdleConns(maxVerbindungen) - db.SetConnMaxIdleTime(pgLeerlauf) + db.SetMaxOpenConns(maxConns) + db.SetMaxIdleConns(maxConns) + db.SetConnMaxIdleTime(pgIdleTime) return db } - schreibDB := oeffne(pgSchreibverbindungen) - schreiben = pool{db: schreibDB, d: d} - if err := runMigrations(schreiben, fsys, migrationsDir); err != nil { - schreibDB.Close() + writeDB := open(pgWriteConns) + write = pool{db: writeDB, d: d} + if err := runMigrations(write, fsys, migrationsDir); err != nil { + writeDB.Close() return pool{}, pool{}, fmt.Errorf("Schema %s: %w", schema, err) } - lesen = pool{db: oeffne(pgLeseverbindungen), d: d} - return lesen, schreiben, nil + read = pool{db: open(pgReadConns), d: d} + return read, write, nil } // textLiteral schreibt s als PostgreSQL-Textkonstante, z.B. E'Vertrieb & Co'. diff --git a/internal/store/backend_sqlite.go b/internal/store/backend_sqlite.go new file mode 100644 index 0000000..326f129 --- /dev/null +++ b/internal/store/backend_sqlite.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" +) + +// SQLiteBackend legt die Kontroll-Datenbank und jeden Workspace als eigene +// SQLite-Datei ab. Das ist der Standard und der bisherige Aufbau +// (AUSBAUSTUFEN.md 4.4). +type SQLiteBackend struct { + controlPath string // z.B. "<DATA_DIR>/control.db" + workspaceDir string // z.B. "<DATA_DIR>/workspaces", muss existieren +} + +// NewSQLite legt einen SQLite-Speicher an. Der Workspace-Ordner muss bereits +// existieren — das übernimmt main.go beim Start. +// +// Wer nur Workspaces braucht (etwa ein Test des workspace.Manager), kann +// controlPath leer lassen; OpenControl meldet dann einen Fehler. +func NewSQLite(controlPath, workspaceDir string) *SQLiteBackend { + return &SQLiteBackend{controlPath: controlPath, workspaceDir: workspaceDir} +} + +func (s *SQLiteBackend) OpenControl() (*ControlStore, error) { + if s.controlPath == "" { + return nil, errors.New("SQLite-Speicher ohne Pfad zur Kontroll-Datenbank") + } + return OpenControl(s.controlPath) +} + +func (s *SQLiteBackend) OpenWorkspace(workspaceID string) (*Store, error) { + return Open(s.workspaceFile(workspaceID)) +} + +// DeleteWorkspace entfernt die Workspace-Datei unwiederbringlich von der +// Platte — samt WAL/SHM-Nebendateien, die SQLite im laufenden Betrieb +// danebenlegt. +func (s *SQLiteBackend) DeleteWorkspace(workspaceID string) error { + filePath := s.workspaceFile(workspaceID) + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.Remove(filePath + suffix); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("Workspace-Datei konnte nicht gelöscht werden: %w", err) + } + } + return nil +} + +// CheckCapacity: Jede SQLite-Datei hat ihre eigenen Verbindungen, eine +// gemeinsame Obergrenze gibt es nicht. +func (s *SQLiteBackend) CheckCapacity(context.Context, int) error { return nil } + +// Capacity: SQLite kennt keine gemeinsame Grenze. +func (s *SQLiteBackend) Capacity(context.Context) (Capacity, error) { + return Capacity{}, nil +} + +// Location nennt die Datei relativ zum Datenverzeichnis, z.B. +// "workspaces/mue9bh3x-1c0e1120.db" — der volle Pfad stünde schon im +// Startprotokoll und wäre in der Tabelle nur lang. +func (s *SQLiteBackend) Location(workspaceID string) string { + return filepath.Join(filepath.Base(s.workspaceDir), workspaceID+".db") +} + +func (s *SQLiteBackend) Description() string { + return fmt.Sprintf("SQLite — Workspaces: %s, Kontrolle: %s", s.workspaceDir, s.controlPath) +} + +// Hints: Beim SQLite-Speicher gibt es beim Start nichts zu prüfen. Eine +// zu große WAL-Datei zeigt die Systemseite je Workspace (Store.DiskUsage). +func (s *SQLiteBackend) Hints() []string { return nil } + +// Close: Der SQLite-Speicher hält selbst nichts offen. +func (s *SQLiteBackend) Close() error { return nil } + +func (s *SQLiteBackend) workspaceFile(workspaceID string) string { + return filepath.Join(s.workspaceDir, workspaceID+".db") +} diff --git a/internal/store/control.go b/internal/store/control.go index 5d36e00..1a6e4f7 100644 --- a/internal/store/control.go +++ b/internal/store/control.go @@ -23,57 +23,57 @@ var controlMigrationsFS embed.FS // eines zweiten Satzes Methoden auf Store, damit nie aus Versehen eine // Workspace-Abfrage gegen die falsche Datei läuft. type ControlStore struct { - lesen pool - schreiben pool + read pool + write pool - // haken sind die zusätzlichen Schritte, die der Speicher beim Anlegen und + // hooks sind die zusätzlichen Schritte, die der Speicher beim Anlegen und // Umbenennen eines Workspace braucht. Bei SQLite gibt es keine (nil): // Eine Workspace-Datei entsteht beim ersten Öffnen von selbst. Bei // PostgreSQL wird geprüft, ob die Verbindungen reichen, und das Schema - // samt lesbarem Namen angelegt (siehe speicher_postgres.go). - haken workspaceHaken + // samt lesbarem Namen angelegt (siehe backend_postgres.go). + hooks workspaceHooks } -// workspaceHaken wird vom Speicher gesetzt, siehe ControlStore.haken. -type workspaceHaken interface { - // vorDemAnlegen prüft, ob ein weiterer Workspace möglich ist. anzahl ist +// workspaceHooks wird vom Speicher gesetzt, siehe ControlStore.haken. +type workspaceHooks interface { + // beforeCreate prüft, ob ein weiterer Workspace möglich ist. anzahl ist // die Zahl der Workspaces MIT dem neuen. - vorDemAnlegen(ctx context.Context, anzahl int) error + beforeCreate(ctx context.Context, count int) error - // nachDemBenennen läuft nach dem Anlegen und nach jedem Umbenennen. - nachDemBenennen(ctx context.Context, workspaceID, name string) error + // afterRename läuft nach dem Anlegen und nach jedem Umbenennen. + afterRename(ctx context.Context, workspaceID, name string) error } // OpenControl öffnet die Kontroll-Datenbank als SQLite-Datei und bringt ihr -// Schema auf den aktuellen Stand. Für PostgreSQL siehe Speicher.OpenControl. +// Schema auf den aktuellen Stand. Für PostgreSQL siehe Backend.OpenControl. func OpenControl(path string) (*ControlStore, error) { - lesen, schreiben, err := openSQLite(path, controlMigrationsFS, "migrations_control") + read, write, err := openSQLite(path, controlMigrationsFS, "migrations_control") if err != nil { return nil, err } - return &ControlStore{lesen: lesen, schreiben: schreiben}, nil + return &ControlStore{read: read, write: write}, nil } -// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen). -func (c *ControlStore) Verbindungen() Verbindungen { - return Verbindungen{Lesen: c.lesen.db.Stats(), Schreiben: c.schreiben.db.Stats()} +// PoolStats liefert die Zahlen beider Pools (siehe Verbindungen). +func (c *ControlStore) PoolStats() PoolStats { + return PoolStats{Read: c.read.db.Stats(), Write: c.write.db.Stats()} } // Close schließt beide Verbindungspools. func (c *ControlStore) Close() error { - fehlerLesen := c.lesen.Close() - if err := c.schreiben.Close(); err != nil { + readErr := c.read.Close() + if err := c.write.Close(); err != nil { return err } - return fehlerLesen + return readErr } // Ping prüft mit einer echten Abfrage, ob die Kontroll-Datenbank antwortet. // Für /ready (siehe internal/web/server.go). Bei SQLite ist das die Datei, // bei PostgreSQL der Datenbankserver — und damit auch der Weg dorthin. func (c *ControlStore) Ping(ctx context.Context) error { - var eins int - return c.lesen.QueryRowContext(ctx, `SELECT 1`).Scan(&eins) + var one int + return c.read.QueryRowContext(ctx, `SELECT 1`).Scan(&one) } // ── Workspaces ─────────────────────────────────────────────────────────────── @@ -90,7 +90,7 @@ func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string) var w Workspace var createdAt string - err := c.lesen.QueryRowContext(ctx, + err := c.read.QueryRowContext(ctx, `SELECT id, name, created_at FROM workspaces ORDER BY created_at LIMIT 1`, ).Scan(&w.ID, &w.Name, &createdAt) @@ -107,39 +107,39 @@ func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string) // CreateWorkspace legt einen neuen Workspace an. func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Workspace, error) { - if c.haken != nil { - var bisher int - if err := c.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspaces`).Scan(&bisher); err != nil { + if c.hooks != nil { + var sofar int + if err := c.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspaces`).Scan(&sofar); err != nil { return Workspace{}, fmt.Errorf("Workspaces konnten nicht gezählt werden: %w", err) } - if err := c.haken.vorDemAnlegen(ctx, bisher+1); err != nil { + if err := c.hooks.beforeCreate(ctx, sofar+1); err != nil { return Workspace{}, err } } w := Workspace{ID: NewID(), Name: name} - jetzt := NowString() + now := NowString() - if _, err := c.schreiben.ExecContext(ctx, + if _, err := c.write.ExecContext(ctx, `INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)`, - w.ID, w.Name, jetzt, + w.ID, w.Name, now, ); err != nil { return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err) } - if c.haken != nil { - if err := c.haken.nachDemBenennen(ctx, w.ID, w.Name); err != nil { + if c.hooks != nil { + if err := c.hooks.afterRename(ctx, w.ID, w.Name); err != nil { return Workspace{}, err } } - w.CreatedAt = parseTime(jetzt) + w.CreatedAt = parseTime(now) return w, nil } // ListWorkspaces gibt alle Workspaces zurück, älteste zuerst. func (c *ControlStore) ListWorkspaces(ctx context.Context) ([]Workspace, error) { - rows, err := c.lesen.QueryContext(ctx, + rows, err := c.read.QueryContext(ctx, `SELECT id, name, created_at FROM workspaces ORDER BY created_at`, ) if err != nil { @@ -165,7 +165,7 @@ func (c *ControlStore) WorkspaceByID(ctx context.Context, id string) (Workspace, var w Workspace var createdAt string - err := c.lesen.QueryRowContext(ctx, + err := c.read.QueryRowContext(ctx, `SELECT id, name, created_at FROM workspaces WHERE id = ?`, id, ).Scan(&w.ID, &w.Name, &createdAt) @@ -182,22 +182,22 @@ func (c *ControlStore) WorkspaceByID(ctx context.Context, id string) (Workspace, // UpdateWorkspace benennt einen Workspace um. func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) error { - res, err := c.schreiben.ExecContext(ctx, + res, err := c.write.ExecContext(ctx, `UPDATE workspaces SET name = ? WHERE id = ?`, name, id, ) if err != nil { return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err) } - betroffen, err := res.RowsAffected() + affected, err := res.RowsAffected() if err != nil { return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err) } - if betroffen == 0 { + if affected == 0 { return ErrNotFound } - if c.haken != nil { - return c.haken.nachDemBenennen(ctx, id, name) + if c.hooks != nil { + return c.hooks.afterRename(ctx, id, name) } return nil } @@ -211,15 +211,15 @@ func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) err // wenn beides passiert ist, ist der Workspace wirklich weg (siehe // handleWorkspaceDelete in internal/web/handlers_workspaces.go). func (c *ControlStore) DeleteWorkspace(ctx context.Context, id string) error { - res, err := c.schreiben.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id) + res, err := c.write.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id) if err != nil { return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err) } - betroffen, err := res.RowsAffected() + affected, err := res.RowsAffected() if err != nil { return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err) } - if betroffen == 0 { + if affected == 0 { return ErrNotFound } return nil @@ -232,7 +232,7 @@ func (c *ControlStore) DeleteWorkspace(ctx context.Context, id string) error { // diesen Eintrag kann der Key künftig nicht mehr seinem Workspace zugeordnet // werden, bevor dessen Datei bekannt ist. func (c *ControlStore) IndexKey(ctx context.Context, hash, workspaceID string) error { - _, err := c.schreiben.ExecContext(ctx, + _, err := c.write.ExecContext(ctx, `INSERT INTO key_index (hash, workspace_id, created_at) VALUES (?, ?, ?)`, hash, workspaceID, NowString(), ) @@ -245,7 +245,7 @@ func (c *ControlStore) IndexKey(ctx context.Context, hash, workspaceID string) e // RemoveKeyIndex entfernt einen Key-Hash aus dem Routing-Index. Aufzurufen im // selben Zug wie Store.RevokeAPIKey. func (c *ControlStore) RemoveKeyIndex(ctx context.Context, hash string) error { - _, err := c.schreiben.ExecContext(ctx, + _, err := c.write.ExecContext(ctx, `DELETE FROM key_index WHERE hash = ?`, hash, ) if err != nil { @@ -261,7 +261,7 @@ func (c *ControlStore) RemoveKeyIndex(ctx context.Context, hash string) error { // WP4/WP5): erst hier lässt sich feststellen, welche Datei überhaupt // angefasst werden muss, bevor Store.ResolveKeyHash dort wie gewohnt läuft. func (c *ControlStore) ResolveKeyWorkspace(ctx context.Context, hash string) (workspaceID string, err error) { - err = c.lesen.QueryRowContext(ctx, + err = c.read.QueryRowContext(ctx, `SELECT workspace_id FROM key_index WHERE hash = ?`, hash, ).Scan(&workspaceID) diff --git a/internal/store/control_test.go b/internal/store/control_test.go index 5538c21..12599ba 100644 --- a/internal/store/control_test.go +++ b/internal/store/control_test.go @@ -11,7 +11,7 @@ import ( // postgres_test.go). func testControl(t *testing.T) *ControlStore { t.Helper() - if pgTestAktiv() { + if pgTestEnabled() { return testPostgresControl(t) } @@ -27,16 +27,16 @@ func TestEnsureDefaultWorkspaceIsStable(t *testing.T) { c := testControl(t) ctx := context.Background() - erst, err := c.EnsureDefaultWorkspace(ctx, "Intern") + first, err := c.EnsureDefaultWorkspace(ctx, "Intern") if err != nil { t.Fatal(err) } - zweit, err := c.EnsureDefaultWorkspace(ctx, "Anderer Name") + second, err := c.EnsureDefaultWorkspace(ctx, "Anderer Name") if err != nil { t.Fatal(err) } - if erst.ID != zweit.ID { + if first.ID != second.ID { t.Error("EnsureDefaultWorkspace hat einen zweiten Workspace angelegt statt den bestehenden zu nehmen") } } @@ -54,12 +54,12 @@ func TestKeyIndexRouting(t *testing.T) { t.Fatal(err) } - gefunden, err := c.ResolveKeyWorkspace(ctx, "hash-1") + found, err := c.ResolveKeyWorkspace(ctx, "hash-1") if err != nil { t.Fatal(err) } - if gefunden != ws.ID { - t.Errorf("ResolveKeyWorkspace = %q, erwartet %q", gefunden, ws.ID) + if found != ws.ID { + t.Errorf("ResolveKeyWorkspace = %q, erwartet %q", found, ws.ID) } if err := c.RemoveKeyIndex(ctx, "hash-1"); err != nil { @@ -91,12 +91,12 @@ func TestListWorkspaces(t *testing.T) { t.Errorf("ListWorkspaces = %+v, erwartet [%q, %q] in Anlegereihenfolge", ws, a.ID, b.ID) } - gelesen, err := c.WorkspaceByID(ctx, a.ID) + read, err := c.WorkspaceByID(ctx, a.ID) if err != nil { t.Fatal(err) } - if gelesen.Name != "Team A" { - t.Errorf("WorkspaceByID.Name = %q, erwartet %q", gelesen.Name, "Team A") + if read.Name != "Team A" { + t.Errorf("WorkspaceByID.Name = %q, erwartet %q", read.Name, "Team A") } if _, err := c.WorkspaceByID(ctx, "unbekannt"); err != ErrNotFound { @@ -129,9 +129,9 @@ func TestWorkspaceAdmins(t *testing.T) { t.Fatal(err) } - gefunden, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1") - if err != nil || gefunden.ID != admin.ID || gefunden.WorkspaceID != wsA.ID { - t.Fatalf("ResolveWorkspaceAdminHash = %+v, %v", gefunden, err) + found, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1") + if err != nil || found.ID != admin.ID || found.WorkspaceID != wsA.ID { + t.Fatalf("ResolveWorkspaceAdminHash = %+v, %v", found, err) } if err := c.RevokeWorkspaceAdmin(ctx, wsB.ID, admin.ID); err != ErrNotFound { @@ -146,9 +146,9 @@ func TestWorkspaceAdmins(t *testing.T) { } // Die Zeile bleibt für die Verwaltung und das Protokoll erhalten. - gelesen, err := c.WorkspaceAdminByID(ctx, admin.ID) - if err != nil || !gelesen.Revoked() { - t.Errorf("WorkspaceAdminByID = %+v, %v — erwartet zurückgezogen", gelesen, err) + read, err := c.WorkspaceAdminByID(ctx, admin.ID) + if err != nil || !read.Revoked() { + t.Errorf("WorkspaceAdminByID = %+v, %v — erwartet zurückgezogen", read, err) } } diff --git a/internal/store/dialekt.go b/internal/store/dialect.go similarity index 58% rename from internal/store/dialekt.go rename to internal/store/dialect.go index f7fee66..57c4e20 100644 --- a/internal/store/dialekt.go +++ b/internal/store/dialect.go @@ -6,7 +6,7 @@ import ( "strings" ) -// dialekt beschreibt, worin sich SQLite und PostgreSQL unterscheiden. +// dialect beschreibt, worin sich SQLite und PostgreSQL unterscheiden. // // # Warum so klein? // @@ -15,41 +15,41 @@ import ( // nur, was sich wirklich unterscheidet. Jede Methode, die hier dazukommt, muss // für beide Datenbanken umgesetzt werden — fehlt eine, baut das Programm nicht. // Der Compiler passt also auf, dass keine Seite vergessen wird. -type dialekt interface { +type dialect interface { // rebind wandelt die Platzhalter einer Abfrage in die Schreibweise der // Datenbank um. Geschrieben werden alle Abfragen mit "?" (SQLite); // PostgreSQL erwartet "$1, $2, …". - rebind(abfrage string) string + rebind(query string) string - // migrationsUnterordner nennt den Ordner innerhalb von migrations/ bzw. + // migrationsSubdir nennt den Ordner innerhalb von migrations/ bzw. // migrations_control/, aus dem die Migrationen dieser Datenbank kommen. - migrationsUnterordner() string + migrationsSubdir() string - // belegung misst, wie viel Platz ein Workspace belegt: bei SQLite die + // diskUsage misst, wie viel Platz ein Workspace belegt: bei SQLite die // Datei samt WAL, bei PostgreSQL die Tabellen des Schemas. Die - // Umsetzungen stehen in systemstand.go. - belegung(ctx context.Context, p pool) (Belegung, error) + // Umsetzungen stehen in sysinfo.go. + diskUsage(ctx context.Context, p pool) (DiskUsage, error) } // ── SQLite ─────────────────────────────────────────────────────────────────── -type sqliteDialekt struct{} +type sqliteDialect struct{} // rebind ist bei SQLite nichts zu tun: Die Abfragen sind bereits in der // SQLite-Schreibweise geschrieben. -func (sqliteDialekt) rebind(abfrage string) string { return abfrage } +func (sqliteDialect) rebind(query string) string { return query } -func (sqliteDialekt) migrationsUnterordner() string { return "sqlite" } +func (sqliteDialect) migrationsSubdir() string { return "sqlite" } // ── PostgreSQL ─────────────────────────────────────────────────────────────── -type postgresDialekt struct{} +type postgresDialect struct{} -func (postgresDialekt) rebind(abfrage string) string { return platzhalterNummerieren(abfrage) } +func (postgresDialect) rebind(query string) string { return numberPlaceholders(query) } -func (postgresDialekt) migrationsUnterordner() string { return "postgres" } +func (postgresDialect) migrationsSubdir() string { return "postgres" } -// platzhalterNummerieren ersetzt jedes "?" durch "$1", "$2", … in der +// numberPlaceholders ersetzt jedes "?" durch "$1", "$2", … in der // Reihenfolge des Auftretens. // // Ein "?" innerhalb eines Textes in Hochkommas ('Wie geht das?'), eines @@ -60,55 +60,55 @@ func (postgresDialekt) migrationsUnterordner() string { return "postgres" } // Ein verdoppeltes Hochkomma (so schreibt SQL ein Hochkomma innerhalb eines // Textes) braucht keine Sonderbehandlung: Das erste beendet den Text, das // zweite beginnt sofort einen neuen — das Ergebnis ist dasselbe. -func platzhalterNummerieren(abfrage string) string { +func numberPlaceholders(query string) string { // Schneller Weg: Die meisten Abfragen sind kurz, aber ohne "?" gibt es // gar nichts zu tun. - if !strings.Contains(abfrage, "?") { - return abfrage + if !strings.Contains(query, "?") { + return query } var b strings.Builder - b.Grow(len(abfrage) + 16) + b.Grow(len(query) + 16) - nummer := 0 - for i := 0; i < len(abfrage); i++ { - c := abfrage[i] + number := 0 + for i := 0; i < len(query); i++ { + c := query[i] switch { case c == '\'' || c == '"': // Text bzw. Bezeichner: bis zum passenden Gegenstück unverändert // übernehmen. - ende := strings.IndexByte(abfrage[i+1:], c) - if ende < 0 { - b.WriteString(abfrage[i:]) + end := strings.IndexByte(query[i+1:], c) + if end < 0 { + b.WriteString(query[i:]) return b.String() } - b.WriteString(abfrage[i : i+1+ende+1]) - i += ende + 1 + b.WriteString(query[i : i+1+end+1]) + i += end + 1 - case c == '-' && i+1 < len(abfrage) && abfrage[i+1] == '-': + case c == '-' && i+1 < len(query) && query[i+1] == '-': // Kommentar bis zum Zeilenende. - ende := strings.IndexByte(abfrage[i:], '\n') - if ende < 0 { - b.WriteString(abfrage[i:]) + end := strings.IndexByte(query[i:], '\n') + if end < 0 { + b.WriteString(query[i:]) return b.String() } - b.WriteString(abfrage[i : i+ende]) - i += ende - 1 + b.WriteString(query[i : i+end]) + i += end - 1 - case c == '/' && i+1 < len(abfrage) && abfrage[i+1] == '*': + case c == '/' && i+1 < len(query) && query[i+1] == '*': // Blockkommentar bis "*/". - ende := strings.Index(abfrage[i+2:], "*/") - if ende < 0 { - b.WriteString(abfrage[i:]) + end := strings.Index(query[i+2:], "*/") + if end < 0 { + b.WriteString(query[i:]) return b.String() } - b.WriteString(abfrage[i : i+2+ende+2]) - i += 2 + ende + 1 + b.WriteString(query[i : i+2+end+2]) + i += 2 + end + 1 case c == '?': - nummer++ + number++ b.WriteByte('$') - b.WriteString(strconv.Itoa(nummer)) + b.WriteString(strconv.Itoa(number)) default: b.WriteByte(c) diff --git a/internal/store/dialekt_test.go b/internal/store/dialect_test.go similarity index 65% rename from internal/store/dialekt_test.go rename to internal/store/dialect_test.go index 107dea0..098168c 100644 --- a/internal/store/dialekt_test.go +++ b/internal/store/dialect_test.go @@ -9,9 +9,9 @@ import ( // Diese Tests laufen immer, auch ohne PostgreSQL. Die Tests gegen eine echte // PostgreSQL-Datenbank stehen in postgres_test.go. -func TestPlatzhalterNummerieren(t *testing.T) { - faelle := []struct { - name, ein, aus string +func TestNumberPlaceholders(t *testing.T) { + cases := []struct { + name, in, out string }{ {"ohne Platzhalter", `SELECT 1`, `SELECT 1`}, {"der Reihe nach", `SELECT a FROM t WHERE x = ? AND y = ?`, `SELECT a FROM t WHERE x = $1 AND y = $2`}, @@ -25,56 +25,56 @@ func TestPlatzhalterNummerieren(t *testing.T) { {"Kommentar am Ende ohne Zeilenumbruch", `WHERE a = ? -- fertig?`, `WHERE a = $1 -- fertig?`}, {"zweistellig", `(?,?,?,?,?,?,?,?,?,?,?)`, `($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`}, } - for _, f := range faelle { + for _, f := range cases { t.Run(f.name, func(t *testing.T) { - if got := platzhalterNummerieren(f.ein); got != f.aus { - t.Errorf("\nein: %s\nist: %s\nsoll: %s", f.ein, got, f.aus) + if got := numberPlaceholders(f.in); got != f.out { + t.Errorf("\nein: %s\nist: %s\nsoll: %s", f.in, got, f.out) } }) } } -func TestSQLiteRebindLaesstAbfrageUnveraendert(t *testing.T) { - abfrage := `SELECT a FROM t WHERE x = ?` - if got := (sqliteDialekt{}).rebind(abfrage); got != abfrage { +func TestSQLiteRebindLeavesQueryUnchanged(t *testing.T) { + query := `SELECT a FROM t WHERE x = ?` + if got := (sqliteDialect{}).rebind(query); got != query { t.Errorf("SQLite hat die Abfrage verändert: %s", got) } } -// TestMigrationsordnerGleich prüft, dass sqlite/ und postgres/ dieselben +// TestMigrationDirsMatch prüft, dass sqlite/ und postgres/ dieselben // Migrationen enthalten. Eine Migration, die nur in einem Ordner liegt, würde // die beiden Datenbanken unbemerkt auseinanderlaufen lassen — siehe // AUSBAUSTUFEN.md 4.10. // -// Ob die Migrationen auch dasselbe SCHEMA ergeben, prüft TestSchemaGleich +// Ob die Migrationen auch dasselbe SCHEMA ergeben, prüft TestSchemaMatches // (braucht PostgreSQL). -func TestMigrationsordnerGleich(t *testing.T) { - for _, quelle := range []struct { +func TestMigrationDirsMatch(t *testing.T) { + for _, source := range []struct { fsys fs.FS dir string }{ {migrationsFS, "migrations"}, {controlMigrationsFS, "migrations_control"}, } { - dateien := func(unterordner string) []string { - eintraege, err := fs.ReadDir(quelle.fsys, quelle.dir+"/"+unterordner) + files := func(subdir string) []string { + entries, err := fs.ReadDir(source.fsys, source.dir+"/"+subdir) if err != nil { - t.Fatalf("%s/%s: %v", quelle.dir, unterordner, err) + t.Fatalf("%s/%s: %v", source.dir, subdir, err) } - var namen []string - for _, e := range eintraege { - namen = append(namen, e.Name()) + var names []string + for _, e := range entries { + names = append(names, e.Name()) } - return namen + return names } - sqlite := dateien((sqliteDialekt{}).migrationsUnterordner()) - postgres := dateien((postgresDialekt{}).migrationsUnterordner()) + sqlite := files((sqliteDialect{}).migrationsSubdir()) + postgres := files((postgresDialect{}).migrationsSubdir()) if len(sqlite) == 0 { - t.Errorf("%s: keine Migrationen gefunden", quelle.dir) + t.Errorf("%s: keine Migrationen gefunden", source.dir) } if !slices.Equal(sqlite, postgres) { - t.Errorf("%s: Ordner unterscheiden sich\nsqlite: %v\npostgres: %v", quelle.dir, sqlite, postgres) + t.Errorf("%s: Ordner unterscheiden sich\nsqlite: %v\npostgres: %v", source.dir, sqlite, postgres) } } } @@ -94,24 +94,24 @@ func TestWorkspaceSchema(t *testing.T) { } // Alles, was nicht wie eine ID aussieht, darf nie in einen Schemanamen. - for _, kaputt := range []string{"", "Groß", "a b", "a;DROP", `a"b`, "../x", "ä"} { - if _, err := WorkspaceSchema(kaputt); err == nil { - t.Errorf("WorkspaceSchema(%q) hätte abgelehnt werden müssen", kaputt) + for _, broken := range []string{"", "Groß", "a b", "a;DROP", `a"b`, "../x", "ä"} { + if _, err := WorkspaceSchema(broken); err == nil { + t.Errorf("WorkspaceSchema(%q) hätte abgelehnt werden müssen", broken) } } // PostgreSQL würde längere Namen stillschweigend kürzen. - lang := "" - for len(lang) < 60 { - lang += "abcdefghij" + long := "" + for len(long) < 60 { + long += "abcdefghij" } - if _, err := WorkspaceSchema(lang); err == nil { + if _, err := WorkspaceSchema(long); err == nil { t.Error("zu lange ID wurde nicht abgelehnt") } } func TestTextLiteral(t *testing.T) { - faelle := map[string]string{ + cases := map[string]string{ "Vertrieb": `E'Vertrieb'`, "O'Brien": `E'O''Brien'`, `C:\temp`: `E'C:\\temp'`, @@ -119,9 +119,9 @@ func TestTextLiteral(t *testing.T) { "mit\x00Null": `E'mitNull'`, "Ärger & Öl": `E'Ärger & Öl'`, } - for ein, soll := range faelle { - if got := textLiteral(ein); got != soll { - t.Errorf("textLiteral(%q) = %s, erwartet %s", ein, got, soll) + for in, expected := range cases { + if got := textLiteral(in); got != expected { + t.Errorf("textLiteral(%q) = %s, erwartet %s", in, got, expected) } } } diff --git a/internal/store/entries.go b/internal/store/entries.go index e6eaef3..9166fc8 100644 --- a/internal/store/entries.go +++ b/internal/store/entries.go @@ -48,15 +48,15 @@ type EntryWrite struct { UpdatedAt time.Time } -// nichtImPapierkorb gehört in JEDE lesende Abfrage auf entries (siehe -// migrations/005_papierkorb.sql). Als Konstante, damit sich alle Stellen mit +// notInTrash gehört in JEDE lesende Abfrage auf entries (siehe +// migrations/005_trash.sql). Als Konstante, damit sich alle Stellen mit // einer Suche finden lassen — und damit eine neue Abfrage ohne diese Bedingung -// beim Lesen auffällt. TestPapierkorbUnsichtbar (papierkorb_test.go) prüft +// beim Lesen auffällt. TestTrashInvisible (trash_test.go) prüft // jeden lesenden Weg. // // Wo die Tabelle in einer Verknüpfung den Kurznamen "e" trägt, steht die // Bedingung ausgeschrieben als "e.deleted_at IS NULL". -const nichtImPapierkorb = `deleted_at IS NULL` +const notInTrash = `deleted_at IS NULL` // entryColumns steht in jeder Eintrags-Abfrage. const entryColumns = `id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at` @@ -86,20 +86,20 @@ func scanEntry(scan func(...any) error) (Entry, error) { // verschiedene Schlagworte in der Datenbank — und keine Filterung würde je // vollständig treffen. func NormalizeTags(tags []string) []string { - gesehen := make(map[string]bool, len(tags)) - var sauber []string + seen := make(map[string]bool, len(tags)) + var clean []string for _, t := range tags { t = strings.ToLower(strings.TrimSpace(t)) - if t == "" || gesehen[t] { + if t == "" || seen[t] { continue } - gesehen[t] = true - sauber = append(sauber, t) + seen[t] = true + clean = append(clean, t) } - sort.Strings(sauber) - return sauber + sort.Strings(clean) + return clean } // PutEntry legt einen Eintrag an oder aktualisiert ihn — der Slug entscheidet. @@ -123,14 +123,14 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { tags := NormalizeTags(w.Tags) links := ExtractLinks(w.Content) - jetzt := NowString() + now := NowString() - var ergebnis Entry + var result Entry - err := s.tx(ctx, func(tx *transaktion) error { + err := s.tx(ctx, func(tx *transaction) error { // Bestehenden Eintrag suchen, um zwischen Anlegen und Ändern zu unterscheiden. // - // Bewusst OHNE nichtImPapierkorb: Ein Eintrag im Papierkorb belegt seinen + // Bewusst OHNE notInTrash: Ein Eintrag im Papierkorb belegt seinen // Bezeichner weiter. Wer ihn neu beschreibt, holt ihn zurück (siehe // unten) — statt an einer UNIQUE-Verletzung zu scheitern oder // stillschweigend einen zweiten Eintrag daneben zu legen. @@ -140,12 +140,12 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { // "liegt im Papierkorb" (Zeitpunkt gesetzt) von "normal" (NULL). var id, createdAt string var version int - var geloeschtAm sql.NullString + var deletedAt sql.NullString err := tx.QueryRowContext(ctx, `SELECT id, version, created_at, deleted_at FROM entries WHERE project_id = ? AND slug = ?`, w.ProjectID, w.Slug, - ).Scan(&id, &version, &createdAt, &geloeschtAm) - imPapierkorb := geloeschtAm.Valid + ).Scan(&id, &version, &createdAt, &deletedAt) + inTrash := deletedAt.Valid switch { case errors.Is(err, sql.ErrNoRows): @@ -160,19 +160,19 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { id = NewID() version = 1 - createdAt = jetzt + createdAt = now if !w.CreatedAt.IsZero() { createdAt = formatTime(w.CreatedAt) } if !w.UpdatedAt.IsZero() { - jetzt = formatTime(w.UpdatedAt) + now = formatTime(w.UpdatedAt) } if _, err := tx.ExecContext(ctx, `INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, w.ProjectID, w.Slug, w.Title, w.Content, w.Source, w.Author, - boolToInt(w.Curated), version, createdAt, jetzt, + boolToInt(w.Curated), version, createdAt, now, ); err != nil { return fmt.Errorf("Eintrag konnte nicht angelegt werden: %w", err) } @@ -189,7 +189,7 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { // Agent "forget" aufrief. Das ist ein Konflikt, genau wie beim // zwischenzeitlich geänderten Eintrag: Die Löschung soll nicht // unbemerkt rückgängig gemacht werden. - if imPapierkorb && w.ExpectedVersion > 0 { + if inTrash && w.ExpectedVersion > 0 { return fmt.Errorf("%w: der Eintrag wurde inzwischen gelöscht und liegt im Papierkorb", ErrConflict) } if w.ExpectedVersion > 0 && w.ExpectedVersion != version { @@ -213,7 +213,7 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { `UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?, deleted_at = NULL, deleted_by = '' WHERE id = ?`, - w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, jetzt, id, + w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, now, id, ); err != nil { return fmt.Errorf("Eintrag konnte nicht geändert werden: %w", err) } @@ -229,7 +229,7 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { return err } - ergebnis = Entry{ + result = Entry{ ID: id, ProjectID: w.ProjectID, Slug: w.Slug, @@ -241,9 +241,9 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { Curated: w.Curated, Version: version, CreatedAt: parseTime(createdAt), - UpdatedAt: parseTime(jetzt), + UpdatedAt: parseTime(now), - RestoredFromTrash: imPapierkorb, + RestoredFromTrash: inTrash, } return nil }) @@ -251,17 +251,17 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { if err != nil { return Entry{}, err } - return ergebnis, nil + return result, nil } // snapshotEntryVersion sichert den aktuellen Stand eines Eintrags in // entry_versions, bevor er überschrieben wird — die einzige Stelle, an der -// diese Tabelle beschrieben wird (siehe migrations/004_aenderungshistorie.sql). +// diese Tabelle beschrieben wird (siehe migrations/004_change_history.sql). // // Läuft immer als Teil der Transaktion, die auch die eigentliche Änderung // vornimmt: Entweder werden alte Fassung und neue Fassung beide geschrieben, // oder keine von beiden. -func snapshotEntryVersion(ctx context.Context, tx *transaktion, projectID, entryID string) error { +func snapshotEntryVersion(ctx context.Context, tx *transaction, projectID, entryID string) error { var version int var title, content, source, author string var curated int @@ -326,7 +326,7 @@ const entryVersionColumns = `id, entry_id, version, title, content, tags, author // EntryVersions gibt die abgelösten Fassungen eines Eintrags zurück, neueste // zuerst. Die aktuelle Fassung steht nicht darin — die liefert EntryBySlug. func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersion, error) { - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? ORDER BY version DESC`, entryID, ) @@ -335,20 +335,20 @@ func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersi } defer rows.Close() - var versionen []EntryVersion + var versions []EntryVersion for rows.Next() { v, err := scanEntryVersion(rows.Scan) if err != nil { return nil, err } - versionen = append(versionen, v) + versions = append(versions, v) } - return versionen, rows.Err() + return versions, rows.Err() } // EntryVersionByNumber liest eine einzelne abgelöste Fassung. func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, version int) (EntryVersion, error) { - row := s.lesen.QueryRowContext(ctx, + row := s.read.QueryRowContext(ctx, `SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? AND version = ?`, entryID, version, ) @@ -365,11 +365,11 @@ func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, versio // replaceTags ersetzt die Schlagworte eines Eintrags. // // project_id wird mitgeschrieben, obwohl es schon am Eintrag steht — siehe die -// Begründung in migrations/002_projekt_in_nebentabellen.sql. Diese Funktion und +// Begründung in migrations/002_project_in_child_tables.sql. Diese Funktion und // replaceLinks sind die einzigen Schreibwege in diese Tabellen, und beide laufen // in derselben Transaktion wie der Eintrag. Die Redundanz kann deshalb nicht // auseinanderlaufen. -func replaceTags(ctx context.Context, tx *transaktion, projectID, entryID string, tags []string) error { +func replaceTags(ctx context.Context, tx *transaction, projectID, entryID string, tags []string) error { if _, err := tx.ExecContext(ctx, `DELETE FROM entry_tags WHERE entry_id = ?`, entryID); err != nil { return fmt.Errorf("Schlagworte konnten nicht ersetzt werden: %w", err) } @@ -384,16 +384,16 @@ func replaceTags(ctx context.Context, tx *transaktion, projectID, entryID string } // replaceLinks ersetzt die ausgehenden Verweise eines Eintrags. -func replaceLinks(ctx context.Context, tx *transaktion, projectID, entryID string, links []string) error { +func replaceLinks(ctx context.Context, tx *transaction, projectID, entryID string, links []string) error { if _, err := tx.ExecContext(ctx, `DELETE FROM entry_links WHERE from_entry_id = ?`, entryID); err != nil { return fmt.Errorf("Verweise konnten nicht ersetzt werden: %w", err) } - for _, ziel := range links { + for _, target := range links { if _, err := tx.ExecContext(ctx, `INSERT INTO entry_links (from_entry_id, project_id, to_slug) VALUES (?, ?, ?)`, - entryID, projectID, ziel, + entryID, projectID, target, ); err != nil { - return fmt.Errorf("Verweis auf %q konnte nicht gespeichert werden: %w", ziel, err) + return fmt.Errorf("Verweis auf %q konnte nicht gespeichert werden: %w", target, err) } } return nil @@ -401,8 +401,8 @@ func replaceLinks(ctx context.Context, tx *transaktion, projectID, entryID strin // EntryBySlug liest einen Eintrag samt Schlagworten. func (s *Store) EntryBySlug(ctx context.Context, projectID, slug string) (Entry, error) { - row := s.lesen.QueryRowContext(ctx, - `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, + row := s.read.QueryRowContext(ctx, + `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+notInTrash, projectID, slug, ) @@ -445,48 +445,48 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter // Die Abfrage wird stückweise zusammengesetzt. Wichtig: Werte gehen immer als // Parameter (?) in die Abfrage, nie per String-Verkettung — sonst wäre das // eine SQL-Injection-Lücke. - abfrage := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + nichtImPapierkorb - werte := []any{projectID} + query := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + notInTrash + values := []any{projectID} if f.Tag != "" { // project_id gehört mit in die Unterabfrage, damit der Index // (project_id, tag) greift statt nur über das Schlagwort zu suchen. - abfrage += ` AND id IN (SELECT entry_id FROM entry_tags WHERE project_id = ? AND tag = ?)` - werte = append(werte, projectID, strings.ToLower(strings.TrimSpace(f.Tag))) + query += ` AND id IN (SELECT entry_id FROM entry_tags WHERE project_id = ? AND tag = ?)` + values = append(values, projectID, strings.ToLower(strings.TrimSpace(f.Tag))) } if f.Curated { - abfrage += ` AND curated = 1` + query += ` AND curated = 1` } if !f.ChangedBefore.IsZero() { // Textvergleich auf RFC3339-Zeitstempeln, wie überall im Store (siehe // timeFormat): Das sortiert richtig und braucht keine Datumsfunktion // der Datenbank. - abfrage += ` AND updated_at < ?` - werte = append(werte, formatTime(f.ChangedBefore)) + query += ` AND updated_at < ?` + values = append(values, formatTime(f.ChangedBefore)) } - abfrage += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?` - werte = append(werte, limit, f.Offset) + query += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?` + values = append(values, limit, f.Offset) - rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) + rows, err := s.read.QueryContext(ctx, query, values...) if err != nil { return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err) } defer rows.Close() - var eintraege []Entry + var entries []Entry for rows.Next() { e, err := scanEntry(rows.Scan) if err != nil { return nil, err } - eintraege = append(eintraege, e) + entries = append(entries, e) } if err := rows.Err(); err != nil { return nil, err } - return eintraege, s.attachTagsToSlice(ctx, eintraege) + return entries, s.attachTagsToSlice(ctx, entries) } // AllEntries gibt alle Einträge eines Projekts zurück — für den Aufbau des @@ -494,8 +494,8 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter // nicht dazu: Sie sollen weder gefunden werden noch im Export wieder // auftauchen. func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, error) { - rows, err := s.lesen.QueryContext(ctx, - `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+nichtImPapierkorb+` ORDER BY slug`, + rows, err := s.read.QueryContext(ctx, + `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+notInTrash+` ORDER BY slug`, projectID, ) if err != nil { @@ -503,26 +503,26 @@ func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, erro } defer rows.Close() - var eintraege []Entry + var entries []Entry for rows.Next() { e, err := scanEntry(rows.Scan) if err != nil { return nil, err } - eintraege = append(eintraege, e) + entries = append(entries, e) } if err := rows.Err(); err != nil { return nil, err } - return eintraege, s.attachTagsToSlice(ctx, eintraege) + return entries, s.attachTagsToSlice(ctx, entries) } // CountEntries zählt die Einträge eines Projekts (ohne Papierkorb). func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error) { var n int - err := s.lesen.QueryRowContext(ctx, - `SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID, + err := s.read.QueryRowContext(ctx, + `SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+notInTrash, projectID, ).Scan(&n) if err != nil { return 0, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err) @@ -533,11 +533,11 @@ func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error) // CountStaleEntries zählt die Einträge eines Projekts, die seit StaleAfter // nicht geändert wurden (siehe Entry.Stale). Für die Weboberfläche: Liste und // Nutzungsseite nennen die Zahl, damit ein Team gezielt aufräumen kann. -func (s *Store) CountStaleEntries(ctx context.Context, projectID string, jetzt time.Time) (int, error) { +func (s *Store) CountStaleEntries(ctx context.Context, projectID string, now time.Time) (int, error) { var n int - err := s.lesen.QueryRowContext(ctx, - `SELECT COUNT(*) FROM entries WHERE project_id = ? AND updated_at < ? AND `+nichtImPapierkorb, - projectID, formatTime(StaleCutoff(jetzt)), + err := s.read.QueryRowContext(ctx, + `SELECT COUNT(*) FROM entries WHERE project_id = ? AND updated_at < ? AND `+notInTrash, + projectID, formatTime(StaleCutoff(now)), ).Scan(&n) if err != nil { return 0, fmt.Errorf("veraltete Einträge konnten nicht gezählt werden: %w", err) @@ -558,9 +558,9 @@ func (s *Store) ListTags(ctx context.Context, projectID string) ([]TagCount, err // Die Schlagworte gelöschter Einträge bleiben in entry_tags stehen — sie // werden beim Wiederherstellen gebraucht. Damit sie hier nicht mitgezählt // werden, schließt die Unterabfrage die Einträge im Papierkorb aus. Die - // läuft über den kleinen Teilindex idx_entries_papierkorb (migrations/005) + // läuft über den kleinen Teilindex idx_entries_trash (migrations/005) // und kostet deshalb praktisch nichts, egal wie groß das Projekt ist. - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT tag, COUNT(*) AS anzahl FROM entry_tags WHERE project_id = ? @@ -592,12 +592,12 @@ type TagCount struct { } // attachTagsToSlice lädt die Schlagworte für eine Ergebnisliste nach. -func (s *Store) attachTagsToSlice(ctx context.Context, eintraege []Entry) error { - zeiger := make([]*Entry, len(eintraege)) - for i := range eintraege { - zeiger[i] = &eintraege[i] +func (s *Store) attachTagsToSlice(ctx context.Context, entries []Entry) error { + cursor := make([]*Entry, len(entries)) + for i := range entries { + cursor[i] = &entries[i] } - return s.attachTags(ctx, zeiger) + return s.attachTags(ctx, cursor) } // attachTags lädt die Schlagworte mehrerer Einträge in EINER Abfrage nach. @@ -605,24 +605,24 @@ func (s *Store) attachTagsToSlice(ctx context.Context, eintraege []Entry) error // Die naheliegende Lösung wäre, pro Eintrag einmal nachzufragen — das ist das // klassische "N+1-Problem": bei 50 Einträgen 51 Abfragen. Stattdessen wird eine // Abfrage mit einer IN-Liste gestellt und das Ergebnis in Go zugeordnet. -func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error { - if len(eintraege) == 0 { +func (s *Store) attachTags(ctx context.Context, entries []*Entry) error { + if len(entries) == 0 { return nil } // Platzhalter "?, ?, ?" in der passenden Anzahl bauen. - platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(eintraege)), ",") - werte := make([]any, len(eintraege)) - nachID := make(map[string]*Entry, len(eintraege)) + placeholder := strings.TrimSuffix(strings.Repeat("?,", len(entries)), ",") + values := make([]any, len(entries)) + toID := make(map[string]*Entry, len(entries)) - for i, e := range eintraege { - werte[i] = e.ID - nachID[e.ID] = e + for i, e := range entries { + values[i] = e.ID + toID[e.ID] = e } - rows, err := s.lesen.QueryContext(ctx, - `SELECT entry_id, tag FROM entry_tags WHERE entry_id IN (`+platzhalter+`) ORDER BY tag`, - werte..., + rows, err := s.read.QueryContext(ctx, + `SELECT entry_id, tag FROM entry_tags WHERE entry_id IN (`+placeholder+`) ORDER BY tag`, + values..., ) if err != nil { return fmt.Errorf("Schlagworte konnten nicht geladen werden: %w", err) @@ -634,7 +634,7 @@ func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error { if err := rows.Scan(&entryID, &tag); err != nil { return err } - if e, ok := nachID[entryID]; ok { + if e, ok := toID[entryID]; ok { e.Tags = append(e.Tags, tag) } } @@ -656,20 +656,20 @@ func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error { func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, add, remove []string) ([]Entry, error) { add = NormalizeTags(add) remove = NormalizeTags(remove) - entferneMenge := make(map[string]bool, len(remove)) + removeSet := make(map[string]bool, len(remove)) for _, t := range remove { - entferneMenge[t] = true + removeSet[t] = true } - var geaendert []Entry - jetzt := NowString() + var changed []Entry + now := NowString() - err := s.tx(ctx, func(tx *transaktion) error { + err := s.tx(ctx, func(tx *transaction) error { for _, id := range entryIDs { - var vorhandenID string + var existingID string err := tx.QueryRowContext(ctx, - `SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb, id, projectID, - ).Scan(&vorhandenID) + `SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+notInTrash, id, projectID, + ).Scan(&existingID) if errors.Is(err, sql.ErrNoRows) { continue // gehört nicht zu diesem Projekt, existiert nicht mehr oder liegt im Papierkorb } @@ -681,14 +681,14 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, if err != nil { return fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err) } - var bisherige []string + var previous []string for rows.Next() { var tag string if err := rows.Scan(&tag); err != nil { rows.Close() return err } - bisherige = append(bisherige, tag) + previous = append(previous, tag) } if err := rows.Err(); err != nil { rows.Close() @@ -696,18 +696,18 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, } rows.Close() - neu := NormalizeTags(append(bisherige, add...)) - gefiltert := neu[:0] - for _, t := range neu { - if !entferneMenge[t] { - gefiltert = append(gefiltert, t) + newer := NormalizeTags(append(previous, add...)) + filtered := newer[:0] + for _, t := range newer { + if !removeSet[t] { + filtered = append(filtered, t) } } // Nur schreiben, wenn sich wirklich etwas ändert — sonst würde ein // Sammelaufruf über eine gemischte Auswahl auch die Einträge // "berühren", bei denen add/remove gar nichts bewirkt haben. - if stringSlicesEqual(bisherige, gefiltert) { + if stringSlicesEqual(previous, filtered) { continue } @@ -718,11 +718,11 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, return err } - if err := replaceTags(ctx, tx, projectID, id, gefiltert); err != nil { + if err := replaceTags(ctx, tx, projectID, id, filtered); err != nil { return err } if _, err := tx.ExecContext(ctx, - `UPDATE entries SET version = version + 1, updated_at = ? WHERE id = ?`, jetzt, id, + `UPDATE entries SET version = version + 1, updated_at = ? WHERE id = ?`, now, id, ); err != nil { return fmt.Errorf("Eintrag konnte nicht aktualisiert werden: %w", err) } @@ -732,15 +732,15 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, if err != nil { return fmt.Errorf("Eintrag konnte nicht neu gelesen werden: %w", err) } - e.Tags = gefiltert - geaendert = append(geaendert, e) + e.Tags = filtered + changed = append(changed, e) } return nil }) if err != nil { return nil, err } - return geaendert, nil + return changed, nil } // stringSlicesEqual vergleicht zwei (bereits sortierte) Zeichenketten-Listen. @@ -778,7 +778,7 @@ func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backli // // Sortiert wird deshalb unten in Go. Rückverweise sind erfahrungsgemäß eine // Handvoll — dort kostet das Sortieren nichts. - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT e.slug, e.title FROM entry_links l JOIN entries e ON e.id = l.from_entry_id @@ -828,20 +828,20 @@ type GraphEdge struct { // Graph-Ansicht sichtbar, wohin schon verwiesen wird, was aber noch fehlt, // statt solche Verweise stillschweigend zu unterschlagen. func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, []GraphEdge, error) { - entryRows, err := s.lesen.QueryContext(ctx, - `SELECT slug, title FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID, + entryRows, err := s.read.QueryContext(ctx, + `SELECT slug, title FROM entries WHERE project_id = ? AND `+notInTrash, projectID, ) if err != nil { return nil, nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err) } - knoten := make(map[string]*GraphNode) + nodes := make(map[string]*GraphNode) for entryRows.Next() { var slug, title string if err := entryRows.Scan(&slug, &title); err != nil { entryRows.Close() return nil, nil, err } - knoten[slug] = &GraphNode{Slug: slug, Title: title, Exists: true} + nodes[slug] = &GraphNode{Slug: slug, Title: title, Exists: true} } if err := entryRows.Err(); err != nil { entryRows.Close() @@ -849,7 +849,7 @@ func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, [ } entryRows.Close() - linkRows, err := s.lesen.QueryContext(ctx, + linkRows, err := s.read.QueryContext(ctx, `SELECT e.slug, l.to_slug FROM entry_links l JOIN entries e ON e.id = l.from_entry_id @@ -861,28 +861,28 @@ func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, [ } defer linkRows.Close() - var kanten []GraphEdge + var edges []GraphEdge for linkRows.Next() { - var von, nach string - if err := linkRows.Scan(&von, &nach); err != nil { + var from, to string + if err := linkRows.Scan(&from, &to); err != nil { return nil, nil, err } - if _, ok := knoten[nach]; !ok { - knoten[nach] = &GraphNode{Slug: nach, Title: nach, Exists: false} + if _, ok := nodes[to]; !ok { + nodes[to] = &GraphNode{Slug: to, Title: to, Exists: false} } - kanten = append(kanten, GraphEdge{From: von, To: nach}) + edges = append(edges, GraphEdge{From: from, To: to}) } if err := linkRows.Err(); err != nil { return nil, nil, err } - knotenListe := make([]GraphNode, 0, len(knoten)) - for _, k := range knoten { - knotenListe = append(knotenListe, *k) + nodeList := make([]GraphNode, 0, len(nodes)) + for _, k := range nodes { + nodeList = append(nodeList, *k) } - sort.Slice(knotenListe, func(i, j int) bool { return knotenListe[i].Slug < knotenListe[j].Slug }) + sort.Slice(nodeList, func(i, j int) bool { return nodeList[i].Slug < nodeList[j].Slug }) - return knotenListe, kanten, nil + return nodeList, edges, nil } // boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung. diff --git a/internal/store/keys.go b/internal/store/keys.go index e080f03..9270478 100644 --- a/internal/store/keys.go +++ b/internal/store/keys.go @@ -45,13 +45,13 @@ func (s *Store) CreateAPIKey(ctx context.Context, projectID, name, hash, role st return s.CreateAPIKeyWithExpiry(ctx, projectID, name, hash, role, time.Time{}) } -// CreateAPIKeyWithExpiry legt einen Key an, der ab ablauf nicht mehr gilt +// CreateAPIKeyWithExpiry legt einen Key an, der ab expiry nicht mehr gilt // (AUSBAUSTUFEN.md 4.7, Stufe 2). Der Nullzeitpunkt heißt: läuft nie ab. -func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, hash, role string, ablauf time.Time) (APIKey, error) { +func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, hash, role string, expiry time.Time) (APIKey, error) { if strings.TrimSpace(name) == "" { return APIKey{}, fmt.Errorf("der Key braucht einen Namen — daran erkennt man später, wer oder was ihn benutzt") } - if !ablauf.IsZero() && !ablauf.After(time.Now()) { + if !expiry.IsZero() && !expiry.After(time.Now()) { return APIKey{}, fmt.Errorf("das Ablaufdatum liegt in der Vergangenheit — so wäre der Key nie gültig") } @@ -61,24 +61,24 @@ func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, has Name: strings.TrimSpace(name), Hash: hash, Role: role, - ExpiresAt: ablauf, + ExpiresAt: expiry, } - jetzt := NowString() - ablaufText := "" - if !ablauf.IsZero() { - ablaufText = formatTime(ablauf) + now := NowString() + expiryText := "" + if !expiry.IsZero() { + expiryText = formatTime(expiry) } - _, err := s.schreiben.ExecContext(ctx, + _, err := s.write.ExecContext(ctx, `INSERT INTO api_keys (id, project_id, name, hash, role, created_at, last_used_at, revoked_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, '', '', ?)`, - k.ID, k.ProjectID, k.Name, k.Hash, k.Role, jetzt, ablaufText, + k.ID, k.ProjectID, k.Name, k.Hash, k.Role, now, expiryText, ) if err != nil { return APIKey{}, fmt.Errorf("Key konnte nicht angelegt werden: %w", err) } - k.CreatedAt = parseTime(jetzt) + k.CreatedAt = parseTime(now) return k, nil } @@ -91,7 +91,7 @@ func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, has // Zurückgezogene und abgelaufene Keys gelten als nicht vorhanden: der // Aufrufer erfährt nicht, ob es den Key nie gab oder ob er gesperrt wurde. func (s *Store) ResolveKeyHash(ctx context.Context, hash string) (ResolvedKey, error) { - row := s.lesen.QueryRowContext(ctx, + row := s.read.QueryRowContext(ctx, `SELECT k.id, k.project_id, k.name, k.hash, k.role, k.created_at, k.last_used_at, k.revoked_at, k.expires_at, p.id, p.workspace_id, p.slug, p.name, p.description, p.created_at FROM api_keys k @@ -127,7 +127,7 @@ func (s *Store) ResolveKeyHash(ctx context.Context, hash string) (ResolvedKey, e // APIKeyByID liest einen Key anhand seiner ID — auch einen zurückgezogenen, // damit die Verwaltungsoberfläche ihn noch anzeigen kann. func (s *Store) APIKeyByID(ctx context.Context, id string) (APIKey, error) { - row := s.lesen.QueryRowContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE id = ?`, id) + row := s.read.QueryRowContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE id = ?`, id) k, err := scanKey(row.Scan) if errors.Is(err, sql.ErrNoRows) { @@ -142,7 +142,7 @@ func (s *Store) APIKeyByID(ctx context.Context, id string) (APIKey, error) { // ListAPIKeys gibt alle Keys eines Projekts zurück, neueste zuerst. // Zurückgezogene sind dabei — sie bleiben als Beleg im Audit-Log referenziert. func (s *Store) ListAPIKeys(ctx context.Context, projectID string) ([]APIKey, error) { - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE project_id = ? ORDER BY created_at DESC`, projectID, ) @@ -167,7 +167,7 @@ func (s *Store) ListAPIKeys(ctx context.Context, projectID string) ([]APIKey, er // Der Datensatz bleibt bestehen und wird nur markiert — sonst verlöre das // Audit-Log die Zuordnung zu allem, was dieser Key jemals geschrieben hat. func (s *Store) RevokeAPIKey(ctx context.Context, id string) error { - res, err := s.schreiben.ExecContext(ctx, + res, err := s.write.ExecContext(ctx, `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at = ''`, NowString(), id, ) @@ -190,6 +190,6 @@ func (s *Store) TouchAPIKey(ctx context.Context, id string, lastUsed time.Time) if time.Since(lastUsed) < time.Hour { return nil } - _, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, NowString(), id) + _, err := s.write.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, NowString(), id) return err } diff --git a/internal/store/links.go b/internal/store/links.go index 422cbf4..59b6bee 100644 --- a/internal/store/links.go +++ b/internal/store/links.go @@ -22,53 +22,53 @@ var linkPattern = regexp.MustCompile(`\[\[([a-z0-9][a-z0-9_-]{0,63})\]\]`) // Das Ergebnis ist sortiert und ohne Dubletten — ein zweiter Verweis auf dasselbe // Ziel ist keine zusätzliche Information. // -// Was in Code steht, zählt nicht (siehe verweiseAusserhalbVonCode). +// Was in Code steht, zählt nicht (siehe refsOutsideCode). func ExtractLinks(content string) []string { - treffer := verweiseAusserhalbVonCode(content) - if len(treffer) == 0 { + hits := refsOutsideCode(content) + if len(hits) == 0 { return nil } - gesehen := make(map[string]bool, len(treffer)) - var ziele []string + seen := make(map[string]bool, len(hits)) + var targets []string - for _, t := range treffer { - ziel := content[t[2]:t[3]] - if gesehen[ziel] { + for _, t := range hits { + target := content[t[2]:t[3]] + if seen[target] { continue } - gesehen[ziel] = true - ziele = append(ziele, ziel) + seen[target] = true + targets = append(targets, target) } - sort.Strings(ziele) - return ziele + sort.Strings(targets) + return targets } // ReplaceLinks ersetzt jeden [[slug]]-Verweis durch das Ergebnis von fn. // // Wird beim Rendern verwendet, um aus den Verweisen echte Links zu machen. Die // Ersetzungsfunktion bekommt den Slug und liefert den Ersatztext. Was in Code -// steht, bleibt unverändert (siehe verweiseAusserhalbVonCode). +// steht, bleibt unverändert (siehe refsOutsideCode). func ReplaceLinks(content string, fn func(slug string) string) string { - treffer := verweiseAusserhalbVonCode(content) - if len(treffer) == 0 { + hits := refsOutsideCode(content) + if len(hits) == 0 { return content } var b strings.Builder b.Grow(len(content)) - bisher := 0 - for _, t := range treffer { - b.WriteString(content[bisher:t[0]]) + sofar := 0 + for _, t := range hits { + b.WriteString(content[sofar:t[0]]) b.WriteString(fn(content[t[2]:t[3]])) - bisher = t[1] + sofar = t[1] } - b.WriteString(content[bisher:]) + b.WriteString(content[sofar:]) return b.String() } -// verweiseAusserhalbVonCode findet alle [[slug]]-Verweise, die NICHT in Code +// refsOutsideCode findet alle [[slug]]-Verweise, die NICHT in Code // stehen. Jeder Treffer ist wie bei regexp.FindAllStringSubmatchIndex // aufgebaut: [Anfang, Ende, Anfang des Slugs, Ende des Slugs]. // @@ -81,28 +81,28 @@ func ReplaceLinks(content string, fn func(slug string) string) string { // // Als Code gilt, was Markdown als Code darstellt: Codeblöcke zwischen ``` // oder ~~~ und Code im Fließtext zwischen Backticks. -func verweiseAusserhalbVonCode(content string) [][]int { - alle := linkPattern.FindAllStringSubmatchIndex(content, -1) - if len(alle) == 0 { +func refsOutsideCode(content string) [][]int { + all := linkPattern.FindAllStringSubmatchIndex(content, -1) + if len(all) == 0 { return nil } - code := codeBereiche(content) + code := codeRanges(content) if len(code) == 0 { - return alle + return all } - var draussen [][]int - for _, t := range alle { - if !inBereich(t[0], code) { - draussen = append(draussen, t) + var outside [][]int + for _, t := range all { + if !inRange(t[0], code) { + outside = append(outside, t) } } - return draussen + return outside } -// inBereich sagt, ob die Stelle pos in einem der Bereiche liegt. -func inBereich(pos int, bereiche [][2]int) bool { - for _, b := range bereiche { +// inRange sagt, ob die Stelle pos in einem der Bereiche liegt. +func inRange(pos int, ranges [][2]int) bool { + for _, b := range ranges { if pos >= b[0] && pos < b[1] { return true } @@ -110,7 +110,7 @@ func inBereich(pos int, bereiche [][2]int) bool { return false } -// codeBereiche liefert die Stellen eines Markdown-Texts, die Code sind, als +// codeRanges liefert die Stellen eines Markdown-Texts, die Code sind, als // [Anfang, Ende) in Bytes. // // Das ist bewusst kein vollständiger Markdown-Parser, sondern deckt die zwei @@ -126,85 +126,85 @@ func inBereich(pos int, bereiche [][2]int) bool { // Eingerückte Codeblöcke (vier Leerzeichen) fehlen absichtlich: Sie sind von // eingerückten Listenzeilen nicht sicher zu unterscheiden, und ein // übersehener Verweis wäre schlimmer als ein überflüssiger. -func codeBereiche(content string) [][2]int { - var bereiche [][2]int +func codeRanges(content string) [][2]int { + var ranges [][2]int // ── 1. Codeblöcke, Zeile für Zeile ─────────────────────────────────── - var fliesstext [][2]int // was außerhalb von Codeblöcken liegt - blockAnfang := -1 - var zaun string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks - zeilenAnfang := 0 - textAnfang := 0 - for zeilenAnfang < len(content) { - zeilenEnde := strings.IndexByte(content[zeilenAnfang:], '\n') - if zeilenEnde < 0 { - zeilenEnde = len(content) + var prose [][2]int // was außerhalb von Codeblöcken liegt + blockStart := -1 + var fence string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks + lineStart := 0 + textStart := 0 + for lineStart < len(content) { + lineEnd := strings.IndexByte(content[lineStart:], '\n') + if lineEnd < 0 { + lineEnd = len(content) } else { - zeilenEnde += zeilenAnfang + 1 // samt Zeilenumbruch + lineEnd += lineStart + 1 // samt Zeilenumbruch } - zeile := content[zeilenAnfang:zeilenEnde] + line := content[lineStart:lineEnd] - if blockAnfang < 0 { - if z := codeZaun(zeile); z != "" { - fliesstext = append(fliesstext, [2]int{textAnfang, zeilenAnfang}) - blockAnfang, zaun = zeilenAnfang, z + if blockStart < 0 { + if z := codeFence(line); z != "" { + prose = append(prose, [2]int{textStart, lineStart}) + blockStart, fence = lineStart, z } - } else if z := codeZaun(zeile); z != "" && z[0] == zaun[0] && len(z) >= len(zaun) && - strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(zeile), z[:1])) == "" { + } else if z := codeFence(line); z != "" && z[0] == fence[0] && len(z) >= len(fence) && + strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), z[:1])) == "" { // Schließender Zaun: nur Zaunzeichen, nichts dahinter. - bereiche = append(bereiche, [2]int{blockAnfang, zeilenEnde}) - blockAnfang = -1 - textAnfang = zeilenEnde + ranges = append(ranges, [2]int{blockStart, lineEnd}) + blockStart = -1 + textStart = lineEnd } - zeilenAnfang = zeilenEnde + lineStart = lineEnd } - if blockAnfang >= 0 { - bereiche = append(bereiche, [2]int{blockAnfang, len(content)}) + if blockStart >= 0 { + ranges = append(ranges, [2]int{blockStart, len(content)}) } else { - fliesstext = append(fliesstext, [2]int{textAnfang, len(content)}) + prose = append(prose, [2]int{textStart, len(content)}) } // ── 2. Code im Fließtext, nur außerhalb der Codeblöcke ─────────────── - for _, f := range fliesstext { + for _, f := range prose { text := content[f[0]:f[1]] for i := 0; i < len(text); { if text[i] != '`' { i++ continue } - laenge := laufLaenge(text, i, '`') - ende := schliessendeBackticks(text, i+laenge, laenge) - if ende < 0 { - i += laenge // kein Gegenstück: kein Code + length := runLength(text, i, '`') + end := closingBackticks(text, i+length, length) + if end < 0 { + i += length // kein Gegenstück: kein Code continue } - bereiche = append(bereiche, [2]int{f[0] + i, f[0] + ende + laenge}) - i = ende + laenge + ranges = append(ranges, [2]int{f[0] + i, f[0] + end + length}) + i = end + length } } - return bereiche + return ranges } -// codeZaun liefert den Zaun (z.B. "```"), wenn die Zeile einen Codeblock +// codeFence liefert den Zaun (z.B. "```"), wenn die Zeile einen Codeblock // öffnet oder schließt, sonst "". -func codeZaun(zeile string) string { - eingerueckt := len(zeile) - len(strings.TrimLeft(zeile, " ")) - if eingerueckt > 3 { +func codeFence(line string) string { + indented := len(line) - len(strings.TrimLeft(line, " ")) + if indented > 3 { return "" } - rest := zeile[eingerueckt:] + rest := line[indented:] if rest == "" || (rest[0] != '`' && rest[0] != '~') { return "" } - n := laufLaenge(rest, 0, rest[0]) + n := runLength(rest, 0, rest[0]) if n < 3 { return "" } return rest[:n] } -// laufLaenge zählt, wie oft das Zeichen c ab Stelle i hintereinander steht. -func laufLaenge(text string, i int, c byte) int { +// runLength zählt, wie oft das Zeichen c ab Stelle i hintereinander steht. +func runLength(text string, i int, c byte) int { n := 0 for i+n < len(text) && text[i+n] == c { n++ @@ -212,16 +212,16 @@ func laufLaenge(text string, i int, c byte) int { return n } -// schliessendeBackticks sucht ab Stelle ab eine Folge von genau laenge +// closingBackticks sucht ab Stelle from eine Folge von genau length // Backticks und gibt ihre Stelle zurück, sonst -1. -func schliessendeBackticks(text string, ab, laenge int) int { - for i := ab; i < len(text); { +func closingBackticks(text string, from, length int) int { + for i := from; i < len(text); { if text[i] != '`' { i++ continue } - n := laufLaenge(text, i, '`') - if n == laenge { + n := runLength(text, i, '`') + if n == length { return i } i += n diff --git a/internal/store/migrations/postgres/002_projekt_in_nebentabellen.sql b/internal/store/migrations/postgres/002_project_in_child_tables.sql similarity index 95% rename from internal/store/migrations/postgres/002_projekt_in_nebentabellen.sql rename to internal/store/migrations/postgres/002_project_in_child_tables.sql index 3724896..c1f766e 100644 --- a/internal/store/migrations/postgres/002_projekt_in_nebentabellen.sql +++ b/internal/store/migrations/postgres/002_project_in_child_tables.sql @@ -43,5 +43,5 @@ DROP INDEX idx_entry_links_to; -- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index -- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort -- in einem Projekt" ab. -CREATE INDEX idx_entry_tags_projekt ON entry_tags (project_id, tag); -CREATE INDEX idx_entry_links_projekt ON entry_links (project_id, to_slug); +CREATE INDEX idx_entry_tags_project ON entry_tags (project_id, tag); +CREATE INDEX idx_entry_links_project ON entry_links (project_id, to_slug); diff --git a/internal/store/migrations/postgres/003_schreibbudget_je_projekt.sql b/internal/store/migrations/postgres/003_write_budget_per_project.sql similarity index 100% rename from internal/store/migrations/postgres/003_schreibbudget_je_projekt.sql rename to internal/store/migrations/postgres/003_write_budget_per_project.sql diff --git a/internal/store/migrations/postgres/004_aenderungshistorie.sql b/internal/store/migrations/postgres/004_change_history.sql similarity index 100% rename from internal/store/migrations/postgres/004_aenderungshistorie.sql rename to internal/store/migrations/postgres/004_change_history.sql diff --git a/internal/store/migrations/sqlite/005_papierkorb.sql b/internal/store/migrations/postgres/005_trash.sql similarity index 95% rename from internal/store/migrations/sqlite/005_papierkorb.sql rename to internal/store/migrations/postgres/005_trash.sql index fbe3239..76d6d1e 100644 --- a/internal/store/migrations/sqlite/005_papierkorb.sql +++ b/internal/store/migrations/postgres/005_trash.sql @@ -38,4 +38,4 @@ ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT ''; -- -- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg -- dorthin bleibt offen. -CREATE INDEX idx_entries_papierkorb ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL; +CREATE INDEX idx_entries_trash ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/internal/store/migrations/postgres/006_suchprotokoll.sql b/internal/store/migrations/postgres/006_search_log.sql similarity index 100% rename from internal/store/migrations/postgres/006_suchprotokoll.sql rename to internal/store/migrations/postgres/006_search_log.sql diff --git a/internal/store/migrations/postgres/007_key_ablauf.sql b/internal/store/migrations/postgres/007_key_expiry.sql similarity index 100% rename from internal/store/migrations/postgres/007_key_ablauf.sql rename to internal/store/migrations/postgres/007_key_expiry.sql diff --git a/internal/store/migrations/sqlite/002_projekt_in_nebentabellen.sql b/internal/store/migrations/sqlite/002_project_in_child_tables.sql similarity index 95% rename from internal/store/migrations/sqlite/002_projekt_in_nebentabellen.sql rename to internal/store/migrations/sqlite/002_project_in_child_tables.sql index 3724896..c1f766e 100644 --- a/internal/store/migrations/sqlite/002_projekt_in_nebentabellen.sql +++ b/internal/store/migrations/sqlite/002_project_in_child_tables.sql @@ -43,5 +43,5 @@ DROP INDEX idx_entry_links_to; -- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index -- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort -- in einem Projekt" ab. -CREATE INDEX idx_entry_tags_projekt ON entry_tags (project_id, tag); -CREATE INDEX idx_entry_links_projekt ON entry_links (project_id, to_slug); +CREATE INDEX idx_entry_tags_project ON entry_tags (project_id, tag); +CREATE INDEX idx_entry_links_project ON entry_links (project_id, to_slug); diff --git a/internal/store/migrations/sqlite/003_schreibbudget_je_projekt.sql b/internal/store/migrations/sqlite/003_write_budget_per_project.sql similarity index 100% rename from internal/store/migrations/sqlite/003_schreibbudget_je_projekt.sql rename to internal/store/migrations/sqlite/003_write_budget_per_project.sql diff --git a/internal/store/migrations/sqlite/004_aenderungshistorie.sql b/internal/store/migrations/sqlite/004_change_history.sql similarity index 100% rename from internal/store/migrations/sqlite/004_aenderungshistorie.sql rename to internal/store/migrations/sqlite/004_change_history.sql diff --git a/internal/store/migrations/postgres/005_papierkorb.sql b/internal/store/migrations/sqlite/005_trash.sql similarity index 95% rename from internal/store/migrations/postgres/005_papierkorb.sql rename to internal/store/migrations/sqlite/005_trash.sql index fbe3239..76d6d1e 100644 --- a/internal/store/migrations/postgres/005_papierkorb.sql +++ b/internal/store/migrations/sqlite/005_trash.sql @@ -38,4 +38,4 @@ ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT ''; -- -- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg -- dorthin bleibt offen. -CREATE INDEX idx_entries_papierkorb ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL; +CREATE INDEX idx_entries_trash ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/internal/store/migrations/sqlite/006_suchprotokoll.sql b/internal/store/migrations/sqlite/006_search_log.sql similarity index 100% rename from internal/store/migrations/sqlite/006_suchprotokoll.sql rename to internal/store/migrations/sqlite/006_search_log.sql diff --git a/internal/store/migrations/sqlite/007_key_ablauf.sql b/internal/store/migrations/sqlite/007_key_expiry.sql similarity index 100% rename from internal/store/migrations/sqlite/007_key_ablauf.sql rename to internal/store/migrations/sqlite/007_key_expiry.sql diff --git a/internal/store/models.go b/internal/store/models.go index 9cbe579..c183d69 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -69,13 +69,13 @@ func (k APIKey) Revoked() bool { // Expired sagt, ob der Key abgelaufen ist. Ein abgelaufener Key gilt wie ein // zurückgezogener (siehe ResolveKeyHash und sessionFromSubject). -func (k APIKey) Expired(jetzt time.Time) bool { - return !k.ExpiresAt.IsZero() && !jetzt.Before(k.ExpiresAt) +func (k APIKey) Expired(now time.Time) bool { + return !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt) } // Valid sagt, ob der Key gerade gilt: weder zurückgezogen noch abgelaufen. -func (k APIKey) Valid(jetzt time.Time) bool { - return !k.Revoked() && !k.Expired(jetzt) +func (k APIKey) Valid(now time.Time) bool { + return !k.Revoked() && !k.Expired(now) } // ExpiresSoonWithin ist die Zeitspanne vor dem Ablauf, in der die Oberfläche @@ -85,8 +85,8 @@ const ExpiresSoonWithin = 14 * 24 * time.Hour // ExpiresSoon sagt, ob ein gültiger Key innerhalb von ExpiresSoonWithin // abläuft. -func (k APIKey) ExpiresSoon(jetzt time.Time) bool { - return k.Valid(jetzt) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(jetzt) <= ExpiresSoonWithin +func (k APIKey) ExpiresSoon(now time.Time) bool { + return k.Valid(now) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(now) <= ExpiresSoonWithin } // UnusedAfter ist die Zeitspanne, nach der ein aktiver Key ohne Benutzung als @@ -103,26 +103,26 @@ const UnusedAfter = 90 * 24 * time.Hour // Zurückgezogene Keys sind nie „ungenutzt“, denn sie sind ohnehin kein offener // Zugang mehr. // -// jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen +// now wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen // Zeitpunkt vorgeben können. // // Abgelaufene Keys sind ebenso wenig „ungenutzt" — auch sie öffnen nichts mehr. -func (k APIKey) Unused(jetzt time.Time) bool { - return ungenutzt(!k.Valid(jetzt), k.LastUsedAt, k.CreatedAt, jetzt) +func (k APIKey) Unused(now time.Time) bool { + return unused(!k.Valid(now), k.LastUsedAt, k.CreatedAt, now) } -// ungenutzt ist die gemeinsame Regel hinter APIKey.Unused und +// unused ist die gemeinsame Regel hinter APIKey.Unused und // WorkspaceAdmin.Unused — beide Zugangsarten sollen nach demselben Maßstab // als „ungenutzt“ gelten. -func ungenutzt(zurueckgezogen bool, zuletztBenutzt, angelegt, jetzt time.Time) bool { - if zurueckgezogen { +func unused(revoked bool, lastUsed, created, now time.Time) bool { + if revoked { return false } - zuletzt := zuletztBenutzt - if zuletzt.IsZero() { - zuletzt = angelegt + latest := lastUsed + if latest.IsZero() { + latest = created } - return jetzt.Sub(zuletzt) > UnusedAfter + return now.Sub(latest) > UnusedAfter } // Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben. @@ -166,21 +166,21 @@ const StaleAfter = 365 * 24 * time.Hour // // Auch geprüfte Einträge (Curated) können veralten. Das Häkchen sagt nur, dass // ein Mensch den Eintrag irgendwann bestätigt hat, nicht wann. -func (e Entry) Stale(jetzt time.Time) bool { - return StaleSince(e.UpdatedAt, jetzt) +func (e Entry) Stale(now time.Time) bool { + return StaleSince(e.UpdatedAt, now) } // StaleSince ist dieselbe Regel für Stellen, die nur den Änderungszeitpunkt // kennen, aber keinen ganzen Entry, etwa verwandte Einträge (RelatedEntry). -func StaleSince(geaendert, jetzt time.Time) bool { - return !geaendert.IsZero() && jetzt.Sub(geaendert) > StaleAfter +func StaleSince(changed, now time.Time) bool { + return !changed.IsZero() && now.Sub(changed) > StaleAfter } // StaleCutoff ist der Zeitpunkt, vor dem ein Eintrag zuletzt geändert worden // sein muss, um als veraltet zu gelten — dieselbe Grenze wie StaleSince, nur // als Wert für eine Abfrage. -func StaleCutoff(jetzt time.Time) time.Time { - return jetzt.Add(-StaleAfter) +func StaleCutoff(now time.Time) time.Time { + return now.Add(-StaleAfter) } // TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite @@ -247,16 +247,16 @@ type AuditEvent struct { // zugleich chronologisch sind — praktisch fürs Audit-Log und für Indizes, weil // neue Werte immer hinten angehängt werden statt den Baum zu zerstreuen. func NewID() string { - zeit := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36) + ts := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36) - zufall := make([]byte, 4) - if _, err := rand.Read(zufall); err != nil { + rng := make([]byte, 4) + if _, err := rand.Read(rng); err != nil { // crypto/rand schlägt praktisch nie fehl. Falls doch, reicht der // Zeitanteil zur Not aus — er ist auf die Millisekunde genau. - return zeit + return ts } - return zeit + "-" + hex.EncodeToString(zufall) + return ts + "-" + hex.EncodeToString(rng) } // timeFormat ist das Format aller Zeitstempel in der Datenbank. diff --git a/internal/store/pool.go b/internal/store/pool.go new file mode 100644 index 0000000..e4f8e98 --- /dev/null +++ b/internal/store/pool.go @@ -0,0 +1,90 @@ +package store + +import ( + "context" + "database/sql" +) + +// pool ist ein Verbindungspool samt dem Dialekt seiner Datenbank. +// +// # Warum nicht direkt *sql.DB? +// +// Jede Abfrage muss durch dialect.rebind, bevor sie an die Datenbank geht — +// sonst bekommt PostgreSQL ein "?", mit dem es nichts anfangen kann. Würde der +// Store *sql.DB direkt benutzen, wäre das bei jeder der rund 100 Abfragen eine +// Stelle, an der man es vergessen kann. +// +// pool bietet deshalb nur die Methoden an, die der Store braucht, und jede +// davon wandelt um. An *sql.DB selbst kommt außerhalb dieser Datei niemand +// heran — eine Abfrage am Dialekt vorbei lässt sich gar nicht erst schreiben. +type pool struct { + db *sql.DB + d dialect +} + +func (p pool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return p.db.ExecContext(ctx, p.d.rebind(query), args...) +} + +func (p pool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return p.db.QueryContext(ctx, p.d.rebind(query), args...) +} + +func (p pool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return p.db.QueryRowContext(ctx, p.d.rebind(query), args...) +} + +// beginTx startet eine Transaktion, deren Abfragen ebenfalls umgewandelt +// werden. +func (p pool) beginTx(ctx context.Context) (*transaction, error) { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + return &transaction{tx: tx, d: p.d}, nil +} + +func (p pool) Close() error { return p.db.Close() } + +// PoolStats sind die Zahlen eines Lese- und eines Schreibpools, wie +// database/sql sie führt — für die Systemseite (AUSBAUSTUFEN.md 5.12). +type PoolStats struct { + Read, Write sql.DBStats +} + +// OpenConns zählt die offenen Verbindungen beider Pools zusammen. Genau so viele +// belegt Munin gerade beim Datenbankserver. +func (v PoolStats) OpenConns() int { return v.Read.OpenConnections + v.Write.OpenConnections } + +// InUse zählt die Verbindungen, die gerade eine Abfrage ausführen. +func (v PoolStats) InUse() int { return v.Read.InUse + v.Write.InUse } + +// WaitCount zählt, wie oft eine Anfrage auf eine freie Verbindung warten +// musste, seit der Pool geöffnet wurde. Wächst die Zahl stetig, sind die +// Pools zu klein für die Last. +func (v PoolStats) WaitCount() int64 { return v.Read.WaitCount + v.Write.WaitCount } + +// transaction ist das Gegenstück zu pool für *sql.Tx: dieselben Methoden, +// dieselbe Umwandlung. +type transaction struct { + tx *sql.Tx + d dialect +} + +func (t *transaction) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return t.tx.ExecContext(ctx, t.d.rebind(query), args...) +} + +func (t *transaction) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return t.tx.QueryContext(ctx, t.d.rebind(query), args...) +} + +func (t *transaction) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return t.tx.QueryRowContext(ctx, t.d.rebind(query), args...) +} + +func (t *transaction) Commit() error { return t.tx.Commit() } + +// Rollback nach einem erfolgreichen Commit ist wirkungslos — deshalb kann es +// bedenkenlos per defer stehen. +func (t *transaction) Rollback() error { return t.tx.Rollback() } diff --git a/internal/store/postgres_test.go b/internal/store/postgres_test.go index c8df0a2..6656175 100644 --- a/internal/store/postgres_test.go +++ b/internal/store/postgres_test.go @@ -32,13 +32,13 @@ import ( const pgTestVariable = "MUNIN_TEST_PG" -// pgTestAktiv sagt, ob die Tests gegen PostgreSQL laufen sollen. -func pgTestAktiv() bool { return os.Getenv(pgTestVariable) != "" } +// pgTestEnabled sagt, ob die Tests gegen PostgreSQL laufen sollen. +func pgTestEnabled() bool { return os.Getenv(pgTestVariable) != "" } var ( - pgTestOnce sync.Once - pgTestSpeicher *PostgresSpeicher - pgTestFehler error + pgTestOnce sync.Once + pgTestBackend *PostgresBackend + pgTestErr error ) // testPostgres liefert den gemeinsamen Speicher aller PostgreSQL-Tests dieses @@ -46,18 +46,18 @@ var ( // // Ein gemeinsamer Speicher statt einem je Test: Er hält eine // Verwaltungsverbindung offen, und die braucht es nur einmal. -func testPostgres(t *testing.T) *PostgresSpeicher { +func testPostgres(t *testing.T) *PostgresBackend { t.Helper() - if !pgTestAktiv() { + if !pgTestEnabled() { t.Skipf("%s ist nicht gesetzt — PostgreSQL-Test übersprungen", pgTestVariable) } pgTestOnce.Do(func() { - pgTestSpeicher, pgTestFehler = NewPostgres(context.Background(), os.Getenv(pgTestVariable)) + pgTestBackend, pgTestErr = NewPostgres(context.Background(), os.Getenv(pgTestVariable)) }) - if pgTestFehler != nil { - t.Fatalf("PostgreSQL für die Tests nicht erreichbar: %v", pgTestFehler) + if pgTestErr != nil { + t.Fatalf("PostgreSQL für die Tests nicht erreichbar: %v", pgTestErr) } - return pgTestSpeicher + return pgTestBackend } // testPostgresStore öffnet einen Workspace in einem frischen Schema und löscht @@ -90,14 +90,14 @@ func testPostgresControl(t *testing.T) *ControlStore { // Eigener Speicher mit eigenem Kontroll-Schema, aber derselben // Verwaltungsverbindung. schema := "munin_test_control_" + strings.ReplaceAll(NewID(), "-", "_") - eigener := *sp - eigener.controlSchema = schema + own := *sp + own.controlSchema = schema ctx := context.Background() - if err := eigener.legeSchemaAn(ctx, schema); err != nil { + if err := own.createSchema(ctx, schema); err != nil { t.Fatal(err) } - c, err := eigener.OpenControl() + c, err := own.OpenControl() if err != nil { t.Fatalf("Kontroll-Schema konnte nicht angelegt werden: %v", err) } @@ -105,11 +105,11 @@ func testPostgresControl(t *testing.T) *ControlStore { // Jeden angelegten Workspace mitschreiben. Die Liste aus der // Kontroll-Datenbank reicht zum Aufräumen nicht: Ein Test, der dort einen // Workspace löscht, ließe sonst dessen Schema liegen. - merker := &angelegteWorkspaces{PostgresSpeicher: &eigener} - c.haken = merker + tracker := &createdWorkspaces{PostgresBackend: &own} + c.hooks = tracker t.Cleanup(func() { - for _, id := range merker.ids { + for _, id := range tracker.ids { if err := sp.DeleteWorkspace(id); err != nil { t.Errorf("Aufräumen: %v", err) } @@ -122,32 +122,32 @@ func testPostgresControl(t *testing.T) *ControlStore { return c } -// angelegteWorkspaces reicht die Haken an den Speicher weiter und merkt sich +// createdWorkspaces reicht die Haken an den Speicher weiter und merkt sich // dabei jeden Workspace, für den ein Schema angelegt wurde. -type angelegteWorkspaces struct { - *PostgresSpeicher +type createdWorkspaces struct { + *PostgresBackend mu sync.Mutex ids []string } -func (a *angelegteWorkspaces) nachDemBenennen(ctx context.Context, workspaceID, name string) error { +func (a *createdWorkspaces) afterRename(ctx context.Context, workspaceID, name string) error { a.mu.Lock() if !slices.Contains(a.ids, workspaceID) { a.ids = append(a.ids, workspaceID) } a.mu.Unlock() - return a.PostgresSpeicher.nachDemBenennen(ctx, workspaceID, name) + return a.PostgresBackend.afterRename(ctx, workspaceID, name) } // ── Tests, die nur mit PostgreSQL laufen ───────────────────────────────────── -// TestSchemaGleich prüft, dass die Migrationen beider Ordner dasselbe Schema +// TestSchemaMatches prüft, dass die Migrationen beider Ordner dasselbe Schema // ergeben: dieselben Tabellen, Spalten, NOT NULL, Standardwerte und Indizes. // -// TestMigrationsordnerGleich stellt nur sicher, dass beide Ordner dieselben +// TestMigrationDirsMatch stellt nur sicher, dass beide Ordner dieselben // Dateien haben. Erst dieser Test merkt, wenn eine Spalte auf einer Seite // anders angelegt wurde — siehe AUSBAUSTUFEN.md 4.10. -func TestSchemaGleich(t *testing.T) { +func TestSchemaMatches(t *testing.T) { ctx := context.Background() t.Run("Workspace", func(t *testing.T) { @@ -158,7 +158,7 @@ func TestSchemaGleich(t *testing.T) { defer sqlite.Close() pg := testPostgresStore(t) - vergleicheSchema(t, schemaSQLite(ctx, t, sqlite.lesen), schemaPostgres(ctx, t, pg.lesen)) + compareSchema(t, schemaSQLite(ctx, t, sqlite.read), schemaPostgres(ctx, t, pg.read)) }) t.Run("Kontrolle", func(t *testing.T) { @@ -169,19 +169,19 @@ func TestSchemaGleich(t *testing.T) { defer sqlite.Close() pg := testPostgresControl(t) - vergleicheSchema(t, schemaSQLite(ctx, t, sqlite.lesen), schemaPostgres(ctx, t, pg.lesen)) + compareSchema(t, schemaSQLite(ctx, t, sqlite.read), schemaPostgres(ctx, t, pg.read)) }) } -// TestPostgresSchemaJeWorkspace prüft den Weg eines Workspace durch +// TestPostgresSchemaPerWorkspace prüft den Weg eines Workspace durch // PostgreSQL: Anlegen erzeugt ein Schema mit dem Namen als Kommentar, // Umbenennen zieht den Kommentar nach, Löschen entfernt das Schema. -func TestPostgresSchemaJeWorkspace(t *testing.T) { +func TestPostgresSchemaPerWorkspace(t *testing.T) { sp := testPostgres(t) c := testPostgresControl(t) ctx := context.Background() - kommentar := func(schema string) (string, bool) { + comment := func(schema string) (string, bool) { var text sql.NullString err := sp.admin.QueryRowContext(ctx, `SELECT obj_description(oid, 'pg_namespace') FROM pg_namespace WHERE nspname = $1`, schema, @@ -200,18 +200,18 @@ func TestPostgresSchemaJeWorkspace(t *testing.T) { t.Fatal(err) } schema, _ := WorkspaceSchema(w.ID) - if ort := sp.Ort(w.ID); ort != schema { - t.Errorf("Ort = %q, erwartet das Schema %q", ort, schema) + if location := sp.Location(w.ID); location != schema { + t.Errorf("Ort = %q, erwartet das Schema %q", location, schema) } - if text, da := kommentar(schema); !da || text != "Vertrieb & Co's" { - t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", da, text) + if text, present := comment(schema); !present || text != "Vertrieb & Co's" { + t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", present, text) } if err := c.UpdateWorkspace(ctx, w.ID, "Vertrieb Nord"); err != nil { t.Fatal(err) } - if text, _ := kommentar(schema); text != "Vertrieb Nord" { + if text, _ := comment(schema); text != "Vertrieb Nord" { t.Errorf("nach dem Umbenennen: Kommentar=%q", text) } @@ -228,14 +228,14 @@ func TestPostgresSchemaJeWorkspace(t *testing.T) { if err := sp.DeleteWorkspace(w.ID); err != nil { t.Fatal(err) } - if _, da := kommentar(schema); da { + if _, present := comment(schema); present { t.Error("Schema ist nach DeleteWorkspace noch da") } } -// TestPostgresWorkspacesGetrennt: Was in einem Workspace-Schema steht, ist im +// TestPostgresWorkspacesSeparated: Was in einem Workspace-Schema steht, ist im // anderen nicht zu sehen — dieselbe Abschottung wie mit getrennten Dateien. -func TestPostgresWorkspacesGetrennt(t *testing.T) { +func TestPostgresWorkspacesSeparated(t *testing.T) { a := testPostgresStore(t) b := testPostgresStore(t) ctx := context.Background() @@ -248,16 +248,16 @@ func TestPostgresWorkspacesGetrennt(t *testing.T) { } } -// TestPostgresKapazitaet: Zu viele Workspaces für max_connections werden mit +// TestPostgresCapacity: Zu viele Workspaces für max_connections werden mit // einer Meldung abgelehnt, die die nötige Zahl nennt. -func TestPostgresKapazitaet(t *testing.T) { +func TestPostgresCapacity(t *testing.T) { sp := testPostgres(t) ctx := context.Background() - if err := sp.PruefeKapazitaet(ctx, 1); err != nil { + if err := sp.CheckCapacity(ctx, 1); err != nil { t.Errorf("ein Workspace sollte immer passen: %v", err) } - err := sp.PruefeKapazitaet(ctx, 100000) + err := sp.CheckCapacity(ctx, 100000) if err == nil || !strings.Contains(err.Error(), "max_connections auf mindestens") { t.Errorf("Fehler = %v, erwartet eine Meldung mit der nötigen Zahl", err) } @@ -265,72 +265,72 @@ func TestPostgresKapazitaet(t *testing.T) { // ── Schema auslesen und vergleichen ────────────────────────────────────────── -// schemaBild ist das ausgelesene Schema einer Datenbank, so aufbereitet, dass +// schemaSnapshot ist das ausgelesene Schema einer Datenbank, so aufbereitet, dass // SQLite und PostgreSQL vergleichbar werden. Jede Spalte wird zu einem Text // wie "text NOT NULL DEFAULT 0". -type schemaBild struct { - spalten map[string]string // "tabelle.spalte" → Beschreibung - indizes []string // eigene Indizes (idx_…), sortiert +type schemaSnapshot struct { + columns map[string]string // "tabelle.spalte" → Beschreibung + indexes []string // eigene Indizes (idx_…), sortiert } -// typKlasse fasst die Typnamen beider Datenbanken zusammen. INTEGER (SQLite) +// typeClass fasst die Typnamen beider Datenbanken zusammen. INTEGER (SQLite) // und integer/bigint (PostgreSQL) sind für Munin dasselbe: eine Ganzzahl. -func typKlasse(typ string) string { - switch strings.ToLower(typ) { +func typeClass(kind string) string { + switch strings.ToLower(kind) { case "integer", "bigint", "int", "int8", "int4": return "ganzzahl" case "text": return "text" } - return "?" + typ + return "?" + kind } -// standardwert gleicht die Schreibweise an: PostgreSQL hängt an Standardwerte +// defaultValue gleicht die Schreibweise an: PostgreSQL hängt an Standardwerte // den Typ an (leerer Text samt "::text"), SQLite nicht. -var textTypAngabe = regexp.MustCompile(`::[a-z ]+$`) +var textTypeSpec = regexp.MustCompile(`::[a-z ]+$`) -func standardwert(wert sql.NullString) string { - if !wert.Valid { +func defaultValue(value sql.NullString) string { + if !value.Valid { return "" } - return " DEFAULT " + textTypAngabe.ReplaceAllString(wert.String, "") + return " DEFAULT " + textTypeSpec.ReplaceAllString(value.String, "") } -func schemaSQLite(ctx context.Context, t *testing.T, p pool) schemaBild { +func schemaSQLite(ctx context.Context, t *testing.T, p pool) schemaSnapshot { t.Helper() - bild := schemaBild{spalten: map[string]string{}} + snapshot := schemaSnapshot{columns: map[string]string{}} - tabellen := abfrageListe(ctx, t, p, + tables := queryList(ctx, t, p, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`) - for _, tabelle := range tabellen { - rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, tabelle) + for _, table := range tables { + rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, table) if err != nil { t.Fatal(err) } for rows.Next() { - var name, typ string - var nichtNull, pk int + var name, kind string + var notNull, pk int var standard sql.NullString - if err := rows.Scan(&name, &typ, &nichtNull, &standard, &pk); err != nil { + if err := rows.Scan(&name, &kind, ¬Null, &standard, &pk); err != nil { t.Fatal(err) } // Ein Primärschlüssel ist in PostgreSQL immer NOT NULL, in SQLite // steht es nur dabei, wenn es ausdrücklich geschrieben wurde. - bild.spalten[tabelle+"."+name] = spaltenText(typ, nichtNull == 1 || pk > 0, standard) + snapshot.columns[table+"."+name] = columnsText(kind, notNull == 1 || pk > 0, standard) } rows.Close() } // Ausdrücklich angelegte Indizes haben einen SQL-Text, die automatischen // für PRIMARY KEY und UNIQUE nicht. - bild.indizes = abfrageListe(ctx, t, p, + snapshot.indexes = queryList(ctx, t, p, `SELECT name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name`) - return bild + return snapshot } -func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaBild { +func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaSnapshot { t.Helper() - bild := schemaBild{spalten: map[string]string{}} + snapshot := schemaSnapshot{columns: map[string]string{}} rows, err := p.QueryContext(ctx, `SELECT table_name, column_name, data_type, is_nullable, column_default @@ -340,68 +340,68 @@ func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaBild { t.Fatal(err) } for rows.Next() { - var tabelle, name, typ, nullbar string + var table, name, kind, nullable string var standard sql.NullString - if err := rows.Scan(&tabelle, &name, &typ, &nullbar, &standard); err != nil { + if err := rows.Scan(&table, &name, &kind, &nullable, &standard); err != nil { t.Fatal(err) } - bild.spalten[tabelle+"."+name] = spaltenText(typ, nullbar == "NO", standard) + snapshot.columns[table+"."+name] = columnsText(kind, nullable == "NO", standard) } rows.Close() // Die Indizes hinter PRIMARY KEY und UNIQUE benennt PostgreSQL selbst // (…_pkey, …_key). Verglichen werden nur die eigenen, die mit idx_ beginnen // — so heißen sie in den Migrationen. - bild.indizes = abfrageListe(ctx, t, p, + snapshot.indexes = queryList(ctx, t, p, `SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND indexname LIKE 'idx\_%' ORDER BY indexname`) - return bild + return snapshot } -func spaltenText(typ string, nichtNull bool, standard sql.NullString) string { - text := typKlasse(typ) - if nichtNull { +func columnsText(kind string, notNull bool, standard sql.NullString) string { + text := typeClass(kind) + if notNull { text += " NOT NULL" } - return text + standardwert(standard) + return text + defaultValue(standard) } -func abfrageListe(ctx context.Context, t *testing.T, p pool, abfrage string) []string { +func queryList(ctx context.Context, t *testing.T, p pool, query string) []string { t.Helper() - rows, err := p.QueryContext(ctx, abfrage) + rows, err := p.QueryContext(ctx, query) if err != nil { t.Fatal(err) } defer rows.Close() - var liste []string + var list []string for rows.Next() { var s string if err := rows.Scan(&s); err != nil { t.Fatal(err) } - liste = append(liste, s) + list = append(list, s) } - return liste + return list } -func vergleicheSchema(t *testing.T, sqlite, postgres schemaBild) { +func compareSchema(t *testing.T, sqlite, postgres schemaSnapshot) { t.Helper() - alle := map[string]bool{} - for k := range sqlite.spalten { - alle[k] = true + all := map[string]bool{} + for k := range sqlite.columns { + all[k] = true } - for k := range postgres.spalten { - alle[k] = true + for k := range postgres.columns { + all[k] = true } - var namen []string - for k := range alle { - namen = append(namen, k) + var names []string + for k := range all { + names = append(names, k) } - sort.Strings(namen) + sort.Strings(names) - for _, name := range namen { - s, inSQLite := sqlite.spalten[name] - p, inPostgres := postgres.spalten[name] + for _, name := range names { + s, inSQLite := sqlite.columns[name] + p, inPostgres := postgres.columns[name] switch { case !inSQLite: t.Errorf("%s gibt es nur in PostgreSQL (%s)", name, p) @@ -412,7 +412,7 @@ func vergleicheSchema(t *testing.T, sqlite, postgres schemaBild) { } } - if fmt.Sprint(sqlite.indizes) != fmt.Sprint(postgres.indizes) { - t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indizes, postgres.indizes) + if fmt.Sprint(sqlite.indexes) != fmt.Sprint(postgres.indexes) { + t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indexes, postgres.indexes) } } diff --git a/internal/store/projects.go b/internal/store/projects.go index 13db1c5..9ec625b 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -26,30 +26,30 @@ func ValidSlug(slug string) bool { // Wird als Vorschlag verwendet, wenn ein Mensch nur einen Titel eingibt. func Slugify(text string) string { var b strings.Builder - letzterStrich := false + lastDash := false for _, r := range strings.ToLower(strings.TrimSpace(text)) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) - letzterStrich = false + lastDash = false case r == 'ä': b.WriteString("ae") - letzterStrich = false + lastDash = false case r == 'ö': b.WriteString("oe") - letzterStrich = false + lastDash = false case r == 'ü': b.WriteString("ue") - letzterStrich = false + lastDash = false case r == 'ß': b.WriteString("ss") - letzterStrich = false + lastDash = false default: // Alles andere wird zu einem Bindestrich, aber nie zwei am Stück. - if !letzterStrich && b.Len() > 0 { + if !lastDash && b.Len() > 0 { b.WriteRune('-') - letzterStrich = true + lastDash = true } } } @@ -83,12 +83,12 @@ func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, desc Name: name, Description: description, } - jetzt := NowString() + now := NowString() - _, err := s.schreiben.ExecContext(ctx, + _, err := s.write.ExecContext(ctx, `INSERT INTO projects (id, workspace_id, slug, name, description, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - p.ID, p.WorkspaceID, p.Slug, p.Name, p.Description, jetzt, + p.ID, p.WorkspaceID, p.Slug, p.Name, p.Description, now, ) if isUniqueViolation(err) { return Project{}, fmt.Errorf("Projekt %q: %w", slug, ErrDuplicate) @@ -97,7 +97,7 @@ func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, desc return Project{}, fmt.Errorf("Projekt konnte nicht angelegt werden: %w", err) } - p.CreatedAt = parseTime(jetzt) + p.CreatedAt = parseTime(now) return p, nil } @@ -122,7 +122,7 @@ func scanProject(scan func(...any) error) (Project, error) { // ProjectByID liest ein Projekt anhand seiner ID. func (s *Store) ProjectByID(ctx context.Context, id string) (Project, error) { - row := s.lesen.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id) + row := s.read.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id) p, err := scanProject(row.Scan) if errors.Is(err, sql.ErrNoRows) { @@ -136,7 +136,7 @@ func (s *Store) ProjectByID(ctx context.Context, id string) (Project, error) { // ProjectBySlug liest ein Projekt anhand seines Bezeichners innerhalb eines Workspace. func (s *Store) ProjectBySlug(ctx context.Context, workspaceID, slug string) (Project, error) { - row := s.lesen.QueryRowContext(ctx, + row := s.read.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? AND slug = ?`, workspaceID, slug, ) @@ -153,7 +153,7 @@ func (s *Store) ProjectBySlug(ctx context.Context, workspaceID, slug string) (Pr // ListProjects gibt alle Projekte eines Workspace zurück, alphabetisch sortiert. func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project, error) { - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? ORDER BY name`, workspaceID, ) @@ -162,15 +162,15 @@ func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project } defer rows.Close() - var projekte []Project + var projects []Project for rows.Next() { p, err := scanProject(rows.Scan) if err != nil { return nil, err } - projekte = append(projekte, p) + projects = append(projects, p) } - return projekte, rows.Err() + return projects, rows.Err() } // ProjectUpdate sind die änderbaren Felder eines Projekts. @@ -207,7 +207,7 @@ func (s *Store) UpdateProject(ctx context.Context, id string, u ProjectUpdate) e return fmt.Errorf("Obergrenze darf nicht negativ sein (%d)", u.WriteLimitHourly) } - res, err := s.schreiben.ExecContext(ctx, + res, err := s.write.ExecContext(ctx, `UPDATE projects SET name = ?, description = ?, write_limit_hourly = ? WHERE id = ?`, u.Name, u.Description, u.WriteLimitHourly, id, ) @@ -222,7 +222,7 @@ func (s *Store) UpdateProject(ctx context.Context, id string, u ProjectUpdate) e // Das Aufräumen übernehmen die ON DELETE CASCADE-Regeln im Schema — deshalb ist // wichtig, dass foreign_keys eingeschaltet ist (siehe Open). func (s *Store) DeleteProject(ctx context.Context, id string) error { - res, err := s.schreiben.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id) + res, err := s.write.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id) if err != nil { return fmt.Errorf("Projekt konnte nicht gelöscht werden: %w", err) } diff --git a/internal/store/related.go b/internal/store/related.go index 559ec51..3fb8155 100644 --- a/internal/store/related.go +++ b/internal/store/related.go @@ -62,27 +62,27 @@ func (s *Store) RelatedEntries(ctx context.Context, projectID, slug string, maxD // Den Ausgangseintrag selbst prüfen. Sonst bekäme ein Tippfehler im // Bezeichner eine leere Liste zurück, und der Agent hielte das Thema für // unverknüpft statt für nicht vorhanden. - var vorhanden int - if err := s.lesen.QueryRowContext(ctx, - `SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, + var existing int + if err := s.read.QueryRowContext(ctx, + `SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+notInTrash, projectID, slug, - ).Scan(&vorhanden); err != nil { + ).Scan(&existing); err != nil { return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err) } - if vorhanden == 0 { + if existing == 0 { return nil, ErrNotFound } - // besucht verhindert, dass ein Eintrag zweimal auftaucht, etwa wenn A auf + // visited verhindert, dass ein Eintrag zweimal auftaucht, etwa wenn A auf // B verweist und B zurück auf A. Der Ausgangseintrag zählt von Anfang an // als besucht, er soll nicht als sein eigener Nachbar erscheinen. - besucht := map[string]bool{slug: true} - ebene := []string{slug} + visited := map[string]bool{slug: true} + level := []string{slug} - var ergebnis []RelatedEntry + var result []RelatedEntry - for abstand := 1; abstand <= maxDistance && len(ebene) > 0 && len(ergebnis) < limit; abstand++ { - kanten, err := s.nachbarn(ctx, projectID, ebene) + for distance := 1; distance <= maxDistance && len(level) > 0 && len(result) < limit; distance++ { + edges, err := s.neighbors(ctx, projectID, level) if err != nil { return nil, err } @@ -90,89 +90,89 @@ func (s *Store) RelatedEntries(ctx context.Context, projectID, slug string, maxD // Je neu entdecktem Bezeichner nur die erste Kante merken. Welcher // Vorgänger „gewinnt“, ist egal, aber es muss derselbe sein, egal in // welcher Reihenfolge die Datenbank die Zeilen liefert. Deshalb sind - // die Kanten vorher sortiert (siehe nachbarn). - neu := make(map[string]RelatedEntry) - var neueSlugs []string - for _, k := range kanten { - if besucht[k.nach] { + // die Kanten vorher sortiert (siehe neighbors). + newer := make(map[string]RelatedEntry) + var newSlugs []string + for _, k := range edges { + if visited[k.to] { continue } - if _, schonDa := neu[k.nach]; schonDa { + if _, already := newer[k.to]; already { continue } - neu[k.nach] = RelatedEntry{Slug: k.nach, Distance: abstand, Via: k.von, Relation: k.relation} - neueSlugs = append(neueSlugs, k.nach) + newer[k.to] = RelatedEntry{Slug: k.to, Distance: distance, Via: k.from, Relation: k.relation} + newSlugs = append(newSlugs, k.to) } // Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne // Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in // der Antwort. - details, err := s.eintragsKoepfe(ctx, projectID, neueSlugs) + details, err := s.entryHeads(ctx, projectID, newSlugs) if err != nil { return nil, err } - var dieseEbene []RelatedEntry - ebene = ebene[:0] - for _, nachbar := range neueSlugs { - besucht[nachbar] = true - kopf, ok := details[nachbar] + var thisLevel []RelatedEntry + level = level[:0] + for _, neighbor := range newSlugs { + visited[neighbor] = true + head, ok := details[neighbor] if !ok { continue } - r := neu[nachbar] - r.Title = kopf.Title - r.UpdatedAt = kopf.UpdatedAt - dieseEbene = append(dieseEbene, r) - ebene = append(ebene, nachbar) + r := newer[neighbor] + r.Title = head.Title + r.UpdatedAt = head.UpdatedAt + thisLevel = append(thisLevel, r) + level = append(level, neighbor) } - sort.Slice(dieseEbene, func(i, j int) bool { return dieseEbene[i].Title < dieseEbene[j].Title }) - ergebnis = append(ergebnis, dieseEbene...) + sort.Slice(thisLevel, func(i, j int) bool { return thisLevel[i].Title < thisLevel[j].Title }) + result = append(result, thisLevel...) } - if len(ergebnis) > limit { - ergebnis = ergebnis[:limit] + if len(result) > limit { + result = result[:limit] } - return ergebnis, nil + return result, nil } -// kante ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus. -type kante struct { - von, nach string - relation string +// edge ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus. +type edge struct { + from, to string + relation string } -// nachbarn liefert alle Verweise, die einen der Bezeichner in slugs berühren, +// neighbors liefert alle Verweise, die einen der Bezeichner in slugs berühren, // in beide Richtungen. // // Zwei Abfragen statt einer mit OR: So nutzt jede ihren eigenen Index. Die // ausgehenden laufen über den Primärschlüssel (from_entry_id, to_slug), die -// eingehenden über idx_entry_links_projekt (project_id, to_slug). -func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) ([]kante, error) { - platz, args := inListe(projectID, slugs) +// eingehenden über idx_entry_links_project (project_id, to_slug). +func (s *Store) neighbors(ctx context.Context, projectID string, slugs []string) ([]edge, error) { + placeholders, args := inList(projectID, slugs) - var kanten []kante + var edges []edge // Ausgehend: Einträge der Ebene verweisen auf etwas. - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT e.slug, l.to_slug FROM entries e JOIN entry_links l ON l.from_entry_id = e.id - WHERE e.project_id = ? AND e.slug IN (`+platz+`) AND e.deleted_at IS NULL`, + WHERE e.project_id = ? AND e.slug IN (`+placeholders+`) AND e.deleted_at IS NULL`, args..., ) if err != nil { return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err) } for rows.Next() { - var k kante - if err := rows.Scan(&k.von, &k.nach); err != nil { + var k edge + if err := rows.Scan(&k.from, &k.to); err != nil { rows.Close() return nil, err } k.relation = RelationLinksTo - kanten = append(kanten, k) + edges = append(edges, k) } if err := rows.Err(); err != nil { rows.Close() @@ -182,11 +182,11 @@ func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) // Eingehend: Etwas verweist auf Einträge der Ebene. Der verweisende // Eintrag ist hier der Nachbar, deshalb stehen die Spalten andersherum. - rows, err = s.lesen.QueryContext(ctx, + rows, err = s.read.QueryContext(ctx, `SELECT l.to_slug, e.slug FROM entry_links l JOIN entries e ON e.id = l.from_entry_id - WHERE l.project_id = ? AND l.to_slug IN (`+platz+`) AND e.deleted_at IS NULL`, + WHERE l.project_id = ? AND l.to_slug IN (`+placeholders+`) AND e.deleted_at IS NULL`, args..., ) if err != nil { @@ -194,12 +194,12 @@ func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) } defer rows.Close() for rows.Next() { - var k kante - if err := rows.Scan(&k.von, &k.nach); err != nil { + var k edge + if err := rows.Scan(&k.from, &k.to); err != nil { return nil, err } k.relation = RelationLinkedBy - kanten = append(kanten, k) + edges = append(edges, k) } if err := rows.Err(); err != nil { return nil, err @@ -207,36 +207,36 @@ func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) // Feste Reihenfolge, damit dasselbe Projekt immer dieselbe Antwort gibt // (siehe RelatedEntries). - sort.Slice(kanten, func(i, j int) bool { - if kanten[i].nach != kanten[j].nach { - return kanten[i].nach < kanten[j].nach + sort.Slice(edges, func(i, j int) bool { + if edges[i].to != edges[j].to { + return edges[i].to < edges[j].to } - if kanten[i].von != kanten[j].von { - return kanten[i].von < kanten[j].von + if edges[i].from != edges[j].from { + return edges[i].from < edges[j].from } - return kanten[i].relation < kanten[j].relation + return edges[i].relation < edges[j].relation }) - return kanten, nil + return edges, nil } -// eintragsKopf ist das Wenige, was RelatedEntries über einen Nachbarn braucht. -type eintragsKopf struct { +// entryHead ist das Wenige, was RelatedEntries über einen Nachbarn braucht. +type entryHead struct { Title string UpdatedAt time.Time } -// eintragsKoepfe lädt Titel und Änderungsdatum zu einer Liste von Bezeichnern. +// entryHeads lädt Titel und Änderungsdatum zu einer Liste von Bezeichnern. // Was nicht (mehr) existiert oder im Papierkorb liegt, fehlt in der Antwort. -func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []string) (map[string]eintragsKopf, error) { - koepfe := make(map[string]eintragsKopf, len(slugs)) +func (s *Store) entryHeads(ctx context.Context, projectID string, slugs []string) (map[string]entryHead, error) { + heads := make(map[string]entryHead, len(slugs)) if len(slugs) == 0 { - return koepfe, nil + return heads, nil } - platz, args := inListe(projectID, slugs) - rows, err := s.lesen.QueryContext(ctx, + placeholders, args := inList(projectID, slugs) + rows, err := s.read.QueryContext(ctx, `SELECT slug, title, updated_at FROM entries - WHERE project_id = ? AND slug IN (`+platz+`) AND `+nichtImPapierkorb, + WHERE project_id = ? AND slug IN (`+placeholders+`) AND `+notInTrash, args..., ) if err != nil { @@ -245,26 +245,26 @@ func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []st defer rows.Close() for rows.Next() { - var slug, title, geaendert string - if err := rows.Scan(&slug, &title, &geaendert); err != nil { + var slug, title, changed string + if err := rows.Scan(&slug, &title, &changed); err != nil { return nil, err } - koepfe[slug] = eintragsKopf{Title: title, UpdatedAt: parseTime(geaendert)} + heads[slug] = entryHead{Title: title, UpdatedAt: parseTime(changed)} } - return koepfe, rows.Err() + return heads, rows.Err() } -// inListe baut die Platzhalter für "IN (?, ?, …)" und die passenden +// inList baut die Platzhalter für "IN (?, ?, …)" und die passenden // Argumente, mit der Projekt-ID vorneweg. // // Die Werte werden nie in den SQL-Text geschrieben, sondern immer als // Parameter übergeben. Nur so ist ausgeschlossen, dass ein Bezeichner die // Abfrage verändert (SQL-Injection). -func inListe(projectID string, werte []string) (string, []any) { - args := make([]any, 0, len(werte)+1) +func inList(projectID string, values []string) (string, []any) { + args := make([]any, 0, len(values)+1) args = append(args, projectID) - for _, w := range werte { + for _, w := range values { args = append(args, w) } - return strings.TrimSuffix(strings.Repeat("?, ", len(werte)), ", "), args + return strings.TrimSuffix(strings.Repeat("?, ", len(values)), ", "), args } diff --git a/internal/store/related_test.go b/internal/store/related_test.go index 4d61333..09b613e 100644 --- a/internal/store/related_test.go +++ b/internal/store/related_test.go @@ -7,8 +7,8 @@ import ( "time" ) -// schreibe legt einen Eintrag an und bricht den Test bei einem Fehler ab. -func schreibe(t *testing.T, s *Store, projectID, slug, content string) { +// write legt einen Eintrag an und bricht den Test bei einem Fehler ab. +func write(t *testing.T, s *Store, projectID, slug, content string) { t.Helper() if _, err := s.PutEntry(context.Background(), EntryWrite{ ProjectID: projectID, Slug: slug, Title: "Titel " + slug, Content: content, @@ -29,24 +29,24 @@ func TestRelatedEntries(t *testing.T) { ctx := context.Background() p := testProject(t, s) - schreibe(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]") - schreibe(t, s, p.ID, "a", "weiter bei [[fern]]") - schreibe(t, s, p.ID, "b", "hängt an [[start]]") - schreibe(t, s, p.ID, "fern", "Ende") - schreibe(t, s, p.ID, "c", "allein") + write(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]") + write(t, s, p.ID, "a", "weiter bei [[fern]]") + write(t, s, p.ID, "b", "hängt an [[start]]") + write(t, s, p.ID, "fern", "Ende") + write(t, s, p.ID, "c", "allein") - verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20) + related, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20) if err != nil { t.Fatal(err) } - gefunden := make(map[string]RelatedEntry) - for _, v := range verwandte { - gefunden[v.Slug] = v + found := make(map[string]RelatedEntry) + for _, v := range related { + found[v.Slug] = v } - erwartet := map[string]struct { - abstand int + want := map[string]struct { + distance int via string relation string }{ @@ -54,15 +54,15 @@ func TestRelatedEntries(t *testing.T) { "b": {1, "start", RelationLinkedBy}, "fern": {2, "a", RelationLinksTo}, } - for slug, e := range erwartet { - v, ok := gefunden[slug] + for slug, e := range want { + v, ok := found[slug] if !ok { t.Errorf("%q fehlt", slug) continue } - if v.Distance != e.abstand || v.Via != e.via || v.Relation != e.relation { + if v.Distance != e.distance || v.Via != e.via || v.Relation != e.relation { t.Errorf("%q: Abstand %d über %q (%s), erwartet %d über %q (%s)", - slug, v.Distance, v.Via, v.Relation, e.abstand, e.via, e.relation) + slug, v.Distance, v.Via, v.Relation, e.distance, e.via, e.relation) } if v.Title != "Titel "+slug || v.UpdatedAt.IsZero() { t.Errorf("%q: Titel oder Änderungsdatum fehlen: %+v", slug, v) @@ -70,32 +70,32 @@ func TestRelatedEntries(t *testing.T) { } for _, slug := range []string{"start", "fehlt", "c"} { - if _, ok := gefunden[slug]; ok { + if _, ok := found[slug]; ok { t.Errorf("%q darf nicht unter den verwandten Einträgen stehen", slug) } } // Näheres vor Fernerem. - if verwandte[len(verwandte)-1].Slug != "fern" { - t.Errorf("die zweite Reihe steht nicht am Ende: %+v", verwandte) + if related[len(related)-1].Slug != "fern" { + t.Errorf("die zweite Reihe steht nicht am Ende: %+v", related) } // Mit Abstand 1 nur die direkten Nachbarn. - direkt, err := s.RelatedEntries(ctx, p.ID, "start", 1, 20) + direct, err := s.RelatedEntries(ctx, p.ID, "start", 1, 20) if err != nil { t.Fatal(err) } - if len(direkt) != 2 { - t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direkt), direkt) + if len(direct) != 2 { + t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direct), direct) } // Die Obergrenze gilt. - begrenzt, err := s.RelatedEntries(ctx, p.ID, "start", 2, 1) + limited, err := s.RelatedEntries(ctx, p.ID, "start", 2, 1) if err != nil { t.Fatal(err) } - if len(begrenzt) != 1 { - t.Errorf("Limit 1 lieferte %d Einträge", len(begrenzt)) + if len(limited) != 1 { + t.Errorf("Limit 1 lieferte %d Einträge", len(limited)) } } @@ -106,20 +106,20 @@ func TestRelatedEntriesSkipsTrash(t *testing.T) { ctx := context.Background() p := testProject(t, s) - schreibe(t, s, p.ID, "start", "siehe [[weg]]") - schreibe(t, s, p.ID, "weg", "siehe [[dahinter]]") - schreibe(t, s, p.ID, "dahinter", "Ende") + write(t, s, p.ID, "start", "siehe [[weg]]") + write(t, s, p.ID, "weg", "siehe [[dahinter]]") + write(t, s, p.ID, "dahinter", "Ende") if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "test"); err != nil { t.Fatal(err) } - verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20) + related, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20) if err != nil { t.Fatal(err) } - if len(verwandte) != 0 { - t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", verwandte) + if len(related) != 0 { + t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", related) } // Ein Ausgangseintrag im Papierkorb gilt als nicht vorhanden. @@ -139,22 +139,22 @@ func TestRelatedEntriesUnknownSlug(t *testing.T) { // TestEntryStale prüft die Grenze für veraltetes Wissen (AUSBAUSTUFEN.md 3.5). func TestEntryStale(t *testing.T) { - jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) - faelle := []struct { - name string - geaendert time.Time - erwartet bool + cases := []struct { + name string + changed time.Time + want bool }{ - {"gestern", jetzt.AddDate(0, 0, -1), false}, - {"vor elf Monaten", jetzt.AddDate(0, -11, 0), false}, - {"vor zwei Jahren", jetzt.AddDate(-2, 0, 0), true}, + {"gestern", now.AddDate(0, 0, -1), false}, + {"vor elf Monaten", now.AddDate(0, -11, 0), false}, + {"vor zwei Jahren", now.AddDate(-2, 0, 0), true}, {"ohne Datum", time.Time{}, false}, } - for _, f := range faelle { + for _, f := range cases { t.Run(f.name, func(t *testing.T) { - if got := (Entry{UpdatedAt: f.geaendert}).Stale(jetzt); got != f.erwartet { - t.Errorf("Stale = %v, erwartet %v", got, f.erwartet) + if got := (Entry{UpdatedAt: f.changed}).Stale(now); got != f.want { + t.Errorf("Stale = %v, erwartet %v", got, f.want) } }) } @@ -167,31 +167,31 @@ func TestPutEntryKeepsImportedTimestamps(t *testing.T) { ctx := context.Background() p := testProject(t, s) - alt := time.Date(2023, 3, 11, 9, 0, 0, 0, time.UTC) + old := time.Date(2023, 3, 11, 9, 0, 0, 0, time.UTC) e, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "x", - Source: SourceHuman, CreatedAt: alt, UpdatedAt: alt, + Source: SourceHuman, CreatedAt: old, UpdatedAt: old, }) if err != nil { t.Fatal(err) } - gelesen, _ := s.EntryBySlug(ctx, p.ID, "alt") - if !gelesen.CreatedAt.Equal(alt) || !gelesen.UpdatedAt.Equal(alt) || !e.UpdatedAt.Equal(alt) { - t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", gelesen.CreatedAt, gelesen.UpdatedAt) + read, _ := s.EntryBySlug(ctx, p.ID, "alt") + if !read.CreatedAt.Equal(old) || !read.UpdatedAt.Equal(old) || !e.UpdatedAt.Equal(old) { + t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", read.CreatedAt, read.UpdatedAt) } // Eine Änderung passiert jetzt, egal was mitgegeben wird. if _, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "neu", - Source: SourceHuman, UpdatedAt: alt, + Source: SourceHuman, UpdatedAt: old, }); err != nil { t.Fatal(err) } - gelesen, _ = s.EntryBySlug(ctx, p.ID, "alt") - if gelesen.UpdatedAt.Equal(alt) { + read, _ = s.EntryBySlug(ctx, p.ID, "alt") + if read.UpdatedAt.Equal(old) { t.Error("beim Ändern wurde der vorgegebene Zeitstempel übernommen") } - if !gelesen.CreatedAt.Equal(alt) { + if !read.CreatedAt.Equal(old) { t.Error("beim Ändern ging das Anlegedatum verloren") } } diff --git a/internal/store/suchprotokoll.go b/internal/store/searchlog.go similarity index 84% rename from internal/store/suchprotokoll.go rename to internal/store/searchlog.go index 5f117ea..569bcb7 100644 --- a/internal/store/suchprotokoll.go +++ b/internal/store/searchlog.go @@ -1,7 +1,7 @@ package store // Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden -// (migrations/006_suchprotokoll.sql). +// (migrations/006_search_log.sql). import ( "context" @@ -32,7 +32,7 @@ type SearchLogEntry struct { // Serverlog. Der Agent soll seine Treffer bekommen, auch wenn die // Protokollzeile nicht geschrieben werden konnte. func (s *Store) LogSearch(ctx context.Context, e SearchLogEntry) { - _, err := s.schreiben.ExecContext(ctx, + _, err := s.write.ExecContext(ctx, `INSERT INTO search_log (ts, project_id, api_key_id, query, synonyms, results) VALUES (?, ?, ?, ?, ?, ?)`, NowString(), e.ProjectID, e.APIKeyID, e.Query, strings.Join(e.Synonyms, ", "), e.Results, @@ -44,8 +44,8 @@ func (s *Store) LogSearch(ctx context.Context, e SearchLogEntry) { // PruneSearchLog löscht Suchen, die älter als die angegebene Grenze sind. // Die Grenze ist ein Zeitstempel im Datenbankformat, wie bei PruneAudit. -func (s *Store) PruneSearchLog(ctx context.Context, aelterAls string) (int64, error) { - res, err := s.schreiben.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, aelterAls) +func (s *Store) PruneSearchLog(ctx context.Context, olderThan string) (int64, error) { + res, err := s.write.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, olderThan) if err != nil { return 0, fmt.Errorf("Suchprotokoll konnte nicht aufgeräumt werden: %w", err) } @@ -61,29 +61,29 @@ type SearchStats struct { // EmptyPercent ist der Anteil der Suchen ohne Treffer in Prozent (0–100). func (s SearchStats) EmptyPercent() int { - return prozent(s.Empty, s.Total) + return percent(s.Empty, s.Total) } // SynonymPercent ist der Anteil der Suchen mit Synonymen in Prozent (0–100). // Zeigt, ob die Agenten die Bitte aus den instructions befolgen. func (s SearchStats) SynonymPercent() int { - return prozent(s.WithSynonyms, s.Total) + return percent(s.WithSynonyms, s.Total) } // SearchStatsFor zählt die Suchen eines Projekts im Zeitraum UsageWindow. -func (s *Store) SearchStatsFor(ctx context.Context, projectID string, jetzt time.Time) (SearchStats, error) { - seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) +func (s *Store) SearchStatsFor(ctx context.Context, projectID string, now time.Time) (SearchStats, error) { + since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) // Wie in UsageSummaries: CASE … THEN 1 ELSE 0 macht aus jeder Zeile eine // 1 oder 0, SUM zählt. COALESCE, weil SUM über null Zeilen NULL liefert. var st SearchStats - err := s.lesen.QueryRowContext(ctx, + err := s.read.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(CASE WHEN results = 0 THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN synonyms <> '' THEN 1 ELSE 0 END), 0) FROM search_log WHERE project_id = ? AND ts >= ?`, - projectID, seit, + projectID, since, ).Scan(&st.Total, &st.Empty, &st.WithSynonyms) if err != nil { return SearchStats{}, fmt.Errorf("Suchprotokoll konnte nicht gelesen werden: %w", err) @@ -106,8 +106,8 @@ type EmptySearch struct { // Gleiche Anfragen werden ohne Rücksicht auf Groß- und Kleinschreibung // zusammengefasst. lower() kennt in SQLite nur ASCII — „Über" und „über" // bleiben also zwei Zeilen. Für eine Übersicht reicht das. -func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time.Time, limit int) ([]EmptySearch, error) { - seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) +func (s *Store) EmptySearches(ctx context.Context, projectID string, now time.Time, limit int) ([]EmptySearch, error) { + since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) // Je Anfrage (ohne Rücksicht auf Groß-/Kleinschreibung und Leerzeichen am // Rand) eine Zeile: die jüngste, mit ihren Synonymen. COUNT(*) OVER zählt @@ -117,7 +117,7 @@ func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time. // Früher stand hier MAX(ts) mit Spalten ohne Aggregat — eine Eigenheit von // SQLite, die PostgreSQL ablehnt. Fensterfunktionen können beide (siehe // CuratedOverrides und AUSBAUSTUFEN.md 4.10). - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT query, synonyms, anzahl, ts FROM ( SELECT query, synonyms, ts, COUNT(*) OVER (PARTITION BY lower(trim(query))) AS anzahl, @@ -128,22 +128,22 @@ func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time. WHERE nr = 1 ORDER BY anzahl DESC, ts DESC LIMIT ?`, - projectID, seit, limit, + projectID, since, limit, ) if err != nil { return nil, fmt.Errorf("Suchen ohne Treffer konnten nicht gelesen werden: %w", err) } defer rows.Close() - var liste []EmptySearch + var list []EmptySearch for rows.Next() { var e EmptySearch - var zuletzt string - if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &zuletzt); err != nil { + var latest string + if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &latest); err != nil { return nil, err } - e.Last = parseTime(zuletzt) - liste = append(liste, e) + e.Last = parseTime(latest) + list = append(list, e) } - return liste, rows.Err() + return list, rows.Err() } diff --git a/internal/store/suchprotokoll_test.go b/internal/store/searchlog_test.go similarity index 67% rename from internal/store/suchprotokoll_test.go rename to internal/store/searchlog_test.go index 8ecea7c..4124991 100644 --- a/internal/store/suchprotokoll_test.go +++ b/internal/store/searchlog_test.go @@ -6,9 +6,9 @@ import ( "time" ) -// TestSearchStatsZaehlt prüft die Kennzahlen: alle Suchen, ohne Treffer, mit +// TestSearchStatsCounts prüft die Kennzahlen: alle Suchen, ohne Treffer, mit // Synonymen — und nur innerhalb des Zeitfensters und des Projekts. -func TestSearchStatsZaehlt(t *testing.T) { +func TestSearchStatsCounts(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) @@ -18,9 +18,9 @@ func TestSearchStatsZaehlt(t *testing.T) { s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "vpn", Results: 0}) // Außerhalb des Fensters: darf nicht mitzählen. - alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano) - if _, err := s.schreiben.ExecContext(ctx, - `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil { + old := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano) + if _, err := s.write.ExecContext(ctx, + `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, old, p.ID); err != nil { t.Fatal(err) } @@ -36,8 +36,8 @@ func TestSearchStatsZaehlt(t *testing.T) { } } -// TestSearchStatsLeer prüft ein Projekt ohne Suchen: alles 0, kein Fehler. -func TestSearchStatsLeer(t *testing.T) { +// TestSearchStatsEmpty prüft ein Projekt ohne Suchen: alles 0, kein Fehler. +func TestSearchStatsEmpty(t *testing.T) { s := testStore(t) p := testProject(t, s) @@ -50,10 +50,10 @@ func TestSearchStatsLeer(t *testing.T) { } } -// TestEmptySearchesFasstZusammen prüft, dass gleiche Anfragen ohne Treffer +// TestEmptySearchesAggregates prüft, dass gleiche Anfragen ohne Treffer // zusammengefasst werden, unabhängig von Groß- und Kleinschreibung, und die // häufigste vorne steht. -func TestEmptySearchesFasstZusammen(t *testing.T) { +func TestEmptySearchesAggregates(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) @@ -64,21 +64,21 @@ func TestEmptySearchesFasstZusammen(t *testing.T) { s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "drucker", Results: 0}) s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "deployment", Results: 2}) // hat Treffer - liste, err := s.EmptySearches(ctx, p.ID, time.Now(), 10) + list, err := s.EmptySearches(ctx, p.ID, time.Now(), 10) if err != nil { t.Fatal(err) } - if len(liste) != 2 { - t.Fatalf("%d Anfragen, erwartet 2: %+v", len(liste), liste) + if len(list) != 2 { + t.Fatalf("%d Anfragen, erwartet 2: %+v", len(list), list) } - if liste[0].Count != 3 { - t.Errorf("häufigste Anfrage = %+v, erwartet 3× vpn zugang", liste[0]) + if list[0].Count != 3 { + t.Errorf("häufigste Anfrage = %+v, erwartet 3× vpn zugang", list[0]) } - if liste[0].Synonyms != "remote" { - t.Errorf("Synonyme = %q, erwartet die der jüngsten Suche (remote)", liste[0].Synonyms) + if list[0].Synonyms != "remote" { + t.Errorf("Synonyme = %q, erwartet die der jüngsten Suche (remote)", list[0].Synonyms) } - if liste[1].Query != "drucker" { - t.Errorf("zweite Anfrage = %q, erwartet drucker", liste[1].Query) + if list[1].Query != "drucker" { + t.Errorf("zweite Anfrage = %q, erwartet drucker", list[1].Query) } } @@ -88,15 +88,15 @@ func TestPruneSearchLog(t *testing.T) { ctx := context.Background() p := testProject(t, s) - alt := time.Now().UTC().Add(-SearchLogRetention - time.Hour).Format(time.RFC3339Nano) - if _, err := s.schreiben.ExecContext(ctx, - `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil { + old := time.Now().UTC().Add(-SearchLogRetention - time.Hour).Format(time.RFC3339Nano) + if _, err := s.write.ExecContext(ctx, + `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, old, p.ID); err != nil { t.Fatal(err) } s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "neu", Results: 0}) - grenze := time.Now().UTC().Add(-SearchLogRetention).Format(time.RFC3339Nano) - n, err := s.PruneSearchLog(ctx, grenze) + limit := time.Now().UTC().Add(-SearchLogRetention).Format(time.RFC3339Nano) + n, err := s.PruneSearchLog(ctx, limit) if err != nil { t.Fatal(err) } @@ -105,9 +105,9 @@ func TestPruneSearchLog(t *testing.T) { } } -// TestSuchprotokollVerschwindetMitProjekt prüft den Fremdschlüssel: Anfragen +// TestSearchLogVanishesWithProject prüft den Fremdschlüssel: Anfragen // sind Inhalt des Projekts und gehen mit ihm. -func TestSuchprotokollVerschwindetMitProjekt(t *testing.T) { +func TestSearchLogVanishesWithProject(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) @@ -117,11 +117,11 @@ func TestSuchprotokollVerschwindetMitProjekt(t *testing.T) { t.Fatal(err) } - var anzahl int - if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM search_log`).Scan(&anzahl); err != nil { + var count int + if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM search_log`).Scan(&count); err != nil { t.Fatal(err) } - if anzahl != 0 { - t.Errorf("%d Anfragen des gelöschten Projekts sind noch da", anzahl) + if count != 0 { + t.Errorf("%d Anfragen des gelöschten Projekts sind noch da", count) } } diff --git a/internal/store/speicher_sqlite.go b/internal/store/speicher_sqlite.go deleted file mode 100644 index 41f93bc..0000000 --- a/internal/store/speicher_sqlite.go +++ /dev/null @@ -1,81 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" -) - -// SQLiteSpeicher legt die Kontroll-Datenbank und jeden Workspace als eigene -// SQLite-Datei ab. Das ist der Standard und der bisherige Aufbau -// (AUSBAUSTUFEN.md 4.4). -type SQLiteSpeicher struct { - controlPfad string // z.B. "<DATA_DIR>/control.db" - workspaceOrdner string // z.B. "<DATA_DIR>/workspaces", muss existieren -} - -// NewSQLite legt einen SQLite-Speicher an. Der Workspace-Ordner muss bereits -// existieren — das übernimmt main.go beim Start. -// -// Wer nur Workspaces braucht (etwa ein Test des workspace.Manager), kann -// controlPfad leer lassen; OpenControl meldet dann einen Fehler. -func NewSQLite(controlPfad, workspaceOrdner string) *SQLiteSpeicher { - return &SQLiteSpeicher{controlPfad: controlPfad, workspaceOrdner: workspaceOrdner} -} - -func (s *SQLiteSpeicher) OpenControl() (*ControlStore, error) { - if s.controlPfad == "" { - return nil, errors.New("SQLite-Speicher ohne Pfad zur Kontroll-Datenbank") - } - return OpenControl(s.controlPfad) -} - -func (s *SQLiteSpeicher) OpenWorkspace(workspaceID string) (*Store, error) { - return Open(s.workspaceDatei(workspaceID)) -} - -// DeleteWorkspace entfernt die Workspace-Datei unwiederbringlich von der -// Platte — samt WAL/SHM-Nebendateien, die SQLite im laufenden Betrieb -// danebenlegt. -func (s *SQLiteSpeicher) DeleteWorkspace(workspaceID string) error { - pfad := s.workspaceDatei(workspaceID) - for _, endung := range []string{"", "-wal", "-shm"} { - if err := os.Remove(pfad + endung); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("Workspace-Datei konnte nicht gelöscht werden: %w", err) - } - } - return nil -} - -// PruefeKapazitaet: Jede SQLite-Datei hat ihre eigenen Verbindungen, eine -// gemeinsame Obergrenze gibt es nicht. -func (s *SQLiteSpeicher) PruefeKapazitaet(context.Context, int) error { return nil } - -// Kapazitaet: SQLite kennt keine gemeinsame Grenze. -func (s *SQLiteSpeicher) Kapazitaet(context.Context) (Kapazitaet, error) { - return Kapazitaet{}, nil -} - -// Ort nennt die Datei relativ zum Datenverzeichnis, z.B. -// "workspaces/mue9bh3x-1c0e1120.db" — der volle Pfad stünde schon im -// Startprotokoll und wäre in der Tabelle nur lang. -func (s *SQLiteSpeicher) Ort(workspaceID string) string { - return filepath.Join(filepath.Base(s.workspaceOrdner), workspaceID+".db") -} - -func (s *SQLiteSpeicher) Beschreibung() string { - return fmt.Sprintf("SQLite — Workspaces: %s, Kontrolle: %s", s.workspaceOrdner, s.controlPfad) -} - -// Hinweise: Beim SQLite-Speicher gibt es beim Start nichts zu prüfen. Eine -// zu große WAL-Datei zeigt die Systemseite je Workspace (Store.Belegung). -func (s *SQLiteSpeicher) Hinweise() []string { return nil } - -// Close: Der SQLite-Speicher hält selbst nichts offen. -func (s *SQLiteSpeicher) Close() error { return nil } - -func (s *SQLiteSpeicher) workspaceDatei(workspaceID string) string { - return filepath.Join(s.workspaceOrdner, workspaceID+".db") -} diff --git a/internal/store/store.go b/internal/store/store.go index 4a61a98..454933f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,9 +13,9 @@ // kann. Die Regeln stehen als Kommentar in migrations/sqlite/001_init.sql. // // Seit AUSBAUSTUFEN.md 4.10 läuft derselbe Store wahlweise auf SQLite oder -// PostgreSQL. Was sich unterscheidet, steht im dialekt (dialekt.go); welche -// Datenbank benutzt wird, entscheidet der Speicher (speicher.go). Alle -// Abfragen laufen über pool (verbindung.go), damit keine am Dialekt +// PostgreSQL. Was sich unterscheidet, steht im dialect (dialect.go); welche +// Datenbank benutzt wird, entscheidet das Backend (backend.go). Alle +// Abfragen laufen über pool (pool.go), damit keine am Dialekt // vorbeikommt. // // # Warum modernc.org/sqlite? @@ -77,10 +77,10 @@ var migrationsFS embed.FS // die Leser sind davon nicht betroffen. // // Unter PostgreSQL gilt dieselbe Aufteilung, nur mit anderen Größen (siehe -// speicher_postgres.go). +// backend_postgres.go). type Store struct { - lesen pool // mehrere Verbindungen — nur lesende Abfragen - schreiben pool // genau eine Verbindung — alles, was verändert + read pool // mehrere Verbindungen — nur lesende Abfragen + write pool // genau eine Verbindung — alles, was verändert } // Open öffnet die Workspace-Datenbankdatei, setzt die nötigen Einstellungen und @@ -90,14 +90,14 @@ type Store struct { // sich um die einzige Abteilung handelt oder eine von vierzig. // // Open ist der Weg für SQLite. Welche Datenbank der Server benutzt, entscheidet -// der Speicher (speicher.go); Tests und Werkzeuge, die einfach eine Datei +// der Speicher (backend.go); Tests und Werkzeuge, die einfach eine Datei // brauchen, rufen Open direkt auf. func Open(path string) (*Store, error) { - lesen, schreiben, err := openSQLite(path, migrationsFS, "migrations") + read, write, err := openSQLite(path, migrationsFS, "migrations") if err != nil { return nil, err } - return &Store{lesen: lesen, schreiben: schreiben}, nil + return &Store{read: read, write: write}, nil } // openSQLite öffnet eine SQLite-Datei mit getrenntem Lese- und Schreibpool und @@ -105,9 +105,9 @@ func Open(path string) (*Store, error) { // (Workspace-Dateien) und OpenControl (Kontroll-Datenbank) — beide brauchen // exakt dieselben Einstellungen, nur unterschiedliche Dateien und Migrationen. // -// Das Gegenstück für PostgreSQL ist PostgresSpeicher.oeffnePools. -func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (lesen, schreiben pool, err error) { - d := sqliteDialekt{} +// Das Gegenstück für PostgreSQL ist PostgresBackend.oeffnePools. +func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (read, write pool, err error) { + d := sqliteDialect{} // Die Parameter in der URL setzen drei Dinge, ohne die SQLite unter // gleichzeitigen Zugriffen unangenehm wird: @@ -136,60 +136,60 @@ func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (lesen // weil SQLite den Wartenden nicht zurücksetzen kann, ohne die Transaktion // abzubrechen. Genau daran scheiterten 296 von 320 gleichzeitigen // Schreibvorgängen in einer früheren Messung (siehe AUSBAUSTUFEN.md). - schreibDB, err := sql.Open("sqlite", dsn+"&_txlock=immediate") + writeDB, err := sql.Open("sqlite", dsn+"&_txlock=immediate") if err != nil { return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err) } - schreibDB.SetMaxOpenConns(1) - schreiben = pool{db: schreibDB, d: d} + writeDB.SetMaxOpenConns(1) + write = pool{db: writeDB, d: d} - if err := schreibDB.Ping(); err != nil { - schreibDB.Close() + if err := writeDB.Ping(); err != nil { + writeDB.Close() return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err) } - if err := runMigrations(schreiben, migrationsFS, migrationsDir); err != nil { - schreibDB.Close() + if err := runMigrations(write, migrationsFS, migrationsDir); err != nil { + writeDB.Close() return pool{}, pool{}, err } // Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits. - leseDB, err := sql.Open("sqlite", dsn) + readDB, err := sql.Open("sqlite", dsn) if err != nil { - schreibDB.Close() + writeDB.Close() return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err) } - leseDB.SetMaxOpenConns(maxLeseverbindungen) - lesen = pool{db: leseDB, d: d} + readDB.SetMaxOpenConns(maxReadConns) + read = pool{db: readDB, d: d} - if err := leseDB.Ping(); err != nil { - schreibDB.Close() - leseDB.Close() + if err := readDB.Ping(); err != nil { + writeDB.Close() + readDB.Close() return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht lesbar: %w", err) } - return lesen, schreiben, nil + return read, write, nil } -// maxLeseverbindungen begrenzt die gleichzeitigen Lesezugriffe. +// maxReadConns begrenzt die gleichzeitigen Lesezugriffe. // // Mehr als eine Handvoll bringt nichts: Ab etwa acht Verbindungen ist der // Durchsatz erreicht, weil dann die Rechenkerne und nicht mehr das Warten die // Grenze setzen. Jede Verbindung kostet dafür Arbeitsspeicher. -const maxLeseverbindungen = 8 +const maxReadConns = 8 -// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen). -func (s *Store) Verbindungen() Verbindungen { - return Verbindungen{Lesen: s.lesen.db.Stats(), Schreiben: s.schreiben.db.Stats()} +// PoolStats liefert die Zahlen beider Pools (siehe Verbindungen). +func (s *Store) PoolStats() PoolStats { + return PoolStats{Read: s.read.db.Stats(), Write: s.write.db.Stats()} } // Close schließt beide Verbindungspools. func (s *Store) Close() error { - fehlerLesen := s.lesen.Close() - if err := s.schreiben.Close(); err != nil { + readErr := s.read.Close() + if err := s.write.Close(); err != nil { return err } - return fehlerLesen + return readErr } // runMigrations wendet alle noch nicht ausgeführten Migrationen aus @@ -214,11 +214,11 @@ func (s *Store) Close() error { // In schema_migrations steht nur der Dateiname ("001_init.sql"), nicht der // Ordner. Deshalb hat das Aufteilen in sqlite/ und postgres/ bestehende // Datenbanken nicht berührt. -func runMigrations(schreiben pool, fsys embed.FS, dir string) error { +func runMigrations(write pool, fsys embed.FS, dir string) error { ctx := context.Background() - dir = dir + "/" + schreiben.d.migrationsUnterordner() + dir = dir + "/" + write.d.migrationsSubdir() - _, err := schreiben.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + _, err := write.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL )`) @@ -226,27 +226,27 @@ func runMigrations(schreiben pool, fsys embed.FS, dir string) error { return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err) } - angewandt, err := appliedMigrations(schreiben) + applied, err := appliedMigrations(write) if err != nil { return err } - dateien, err := migrationFiles(fsys, dir) + files, err := migrationFiles(fsys, dir) if err != nil { return err } - for _, name := range dateien { - if angewandt[name] { + for _, name := range files { + if applied[name] { continue } - inhalt, err := fsys.ReadFile(dir + "/" + name) + content, err := fsys.ReadFile(dir + "/" + name) if err != nil { return fmt.Errorf("Migration %s konnte nicht gelesen werden: %w", name, err) } - if err := applyMigration(schreiben, name, string(inhalt)); err != nil { + if err := applyMigration(write, name, string(content)); err != nil { return err } } @@ -255,42 +255,42 @@ func runMigrations(schreiben pool, fsys embed.FS, dir string) error { } // appliedMigrations liest die Namen der bereits ausgeführten Migrationen. -func appliedMigrations(schreiben pool) (map[string]bool, error) { - rows, err := schreiben.QueryContext(context.Background(), `SELECT name FROM schema_migrations`) +func appliedMigrations(write pool) (map[string]bool, error) { + rows, err := write.QueryContext(context.Background(), `SELECT name FROM schema_migrations`) if err != nil { return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err) } defer rows.Close() - angewandt := make(map[string]bool) + applied := make(map[string]bool) for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } - angewandt[name] = true + applied[name] = true } - return angewandt, rows.Err() + return applied, rows.Err() } // migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge. func migrationFiles(fsys embed.FS, dir string) ([]string, error) { - eintraege, err := fsys.ReadDir(dir) + entries, err := fsys.ReadDir(dir) if err != nil { return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err) } - var namen []string - for _, e := range eintraege { + var names []string + for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { - namen = append(namen, e.Name()) + names = append(names, e.Name()) } } // Die Nummerierung am Anfang macht die alphabetische Sortierung zur // richtigen Reihenfolge. - sort.Strings(namen) - return namen, nil + sort.Strings(names) + return names, nil } // applyMigration führt eine einzelne Migration in einer Transaktion aus. @@ -300,8 +300,8 @@ func migrationFiles(fsys embed.FS, dir string) ([]string, error) { // Fragezeichen, und die gehören dort hin. Ohne Parameter schickt auch der // PostgreSQL-Treiber den Text unverändert und erlaubt dabei mehrere // Anweisungen hintereinander. -func applyMigration(schreiben pool, name, sqlText string) error { - tx, err := schreiben.db.Begin() +func applyMigration(write pool, name, sqlText string) error { + tx, err := write.db.Begin() if err != nil { return fmt.Errorf("Transaktion für %s konnte nicht gestartet werden: %w", name, err) } @@ -314,7 +314,7 @@ func applyMigration(schreiben pool, name, sqlText string) error { } if _, err := tx.Exec( - schreiben.d.rebind(`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`), + write.d.rebind(`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`), name, NowString(), ); err != nil { return fmt.Errorf("Migration %s konnte nicht vermerkt werden: %w", name, err) @@ -327,8 +327,8 @@ func applyMigration(schreiben pool, name, sqlText string) error { // // Damit steht die Fehlerbehandlung einmal hier statt in jeder schreibenden // Methode — und es kann nicht passieren, dass irgendwo ein Rollback vergessen wird. -func (s *Store) tx(ctx context.Context, fn func(*transaktion) error) error { - tx, err := s.schreiben.beginTx(ctx) +func (s *Store) tx(ctx context.Context, fn func(*transaction) error) error { + tx, err := s.write.beginTx(ctx) if err != nil { return fmt.Errorf("Transaktion konnte nicht gestartet werden: %w", err) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index eaca692..d15b34b 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -22,7 +22,7 @@ import ( // eigenen Schema (siehe postgres_test.go). func testStore(t *testing.T) *Store { t.Helper() - if pgTestAktiv() { + if pgTestEnabled() { return testPostgresStore(t) } @@ -61,16 +61,16 @@ func testProject(t *testing.T, s *Store) Project { // TestMigrationsAreIdempotent prüft, dass ein zweiter Start dieselbe Datenbank // nicht kaputt macht — der Normalfall bei jedem Neustart des Servers. func TestMigrationsAreIdempotent(t *testing.T) { - pfad := filepath.Join(t.TempDir(), "test.db") + filePath := filepath.Join(t.TempDir(), "test.db") - s1, err := Open(pfad) + s1, err := Open(filePath) if err != nil { t.Fatalf("erster Start: %v", err) } p := testProject(t, s1) s1.Close() - s2, err := Open(pfad) + s2, err := Open(filePath) if err != nil { t.Fatalf("zweiter Start: %v", err) } @@ -82,12 +82,12 @@ func TestMigrationsAreIdempotent(t *testing.T) { } } -// TestMigrationBestandsdaten prüft, dass eine mit Fassung 001 angelegte +// TestMigrationExistingData prüft, dass eine mit Fassung 001 angelegte // Datenbank sauber auf 002 gehoben wird — der Fall bei jedem Update. -func TestMigrationBestandsdaten(t *testing.T) { - pfad := filepath.Join(t.TempDir(), "alt.db") +func TestMigrationExistingData(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "alt.db") - s, err := Open(pfad) + s, err := Open(filePath) if err != nil { t.Fatal(err) } @@ -101,18 +101,18 @@ func TestMigrationBestandsdaten(t *testing.T) { s.Close() // Erneut öffnen — hier läuft die Migration bei Bestandsdaten. - s2, err := Open(pfad) + s2, err := Open(filePath) if err != nil { t.Fatalf("Migration fehlgeschlagen: %v", err) } defer s2.Close() // project_id muss in beiden Nebentabellen nachgetragen worden sein. - var leereTags, leereLinks int - s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&leereTags) - s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&leereLinks) - if leereTags != 0 || leereLinks != 0 { - t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", leereTags, leereLinks) + var emptyTags, emptyLinks int + s2.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&emptyTags) + s2.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&emptyLinks) + if emptyTags != 0 || emptyLinks != 0 { + t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", emptyTags, emptyLinks) } // Und die Abfragen liefern weiterhin das Richtige. @@ -126,29 +126,29 @@ func TestMigrationBestandsdaten(t *testing.T) { } } -// TestGleichzeitigeSchreibvorgaenge ist der Regressionstest zu den zwei +// TestConcurrentWrites ist der Regressionstest zu den zwei // Verbindungspools (siehe Store in store.go). // // Mit einem gemeinsamen Pool für Lesen und Schreiben scheiterten hier 296 von // 320 Schreibvorgängen mit "database is locked" — mehrere Verbindungen begannen // gleichzeitig eine Schreibtransaktion und liefen mitten drin auf die Sperre, // wo busy_timeout nicht mehr greift. -func TestGleichzeitigeSchreibvorgaenge(t *testing.T) { +func TestConcurrentWrites(t *testing.T) { s := testStore(t) p := testProject(t, s) ctx := context.Background() - const agenten, jeAgent = 8, 40 + const agents, perAgent = 8, 40 var wg sync.WaitGroup var mu sync.Mutex - var fehler []error + var errs []error - for a := 0; a < agenten; a++ { + for a := 0; a < agents; a++ { wg.Add(1) go func(a int) { defer wg.Done() - for i := 0; i < jeAgent; i++ { + for i := 0; i < perAgent; i++ { _, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: fmt.Sprintf("agent%d-%03d", a, i), @@ -159,7 +159,7 @@ func TestGleichzeitigeSchreibvorgaenge(t *testing.T) { }) if err != nil { mu.Lock() - fehler = append(fehler, err) + errs = append(errs, err) mu.Unlock() } } @@ -167,23 +167,23 @@ func TestGleichzeitigeSchreibvorgaenge(t *testing.T) { } wg.Wait() - if len(fehler) > 0 { + if len(errs) > 0 { t.Errorf("%d von %d Schreibvorgängen fehlgeschlagen, erster: %v", - len(fehler), agenten*jeAgent, fehler[0]) + len(errs), agents*perAgent, errs[0]) } n, err := s.CountEntries(ctx, p.ID) if err != nil { t.Fatal(err) } - if n != agenten*jeAgent { - t.Errorf("%d Einträge angelegt, erwartet %d", n, agenten*jeAgent) + if n != agents*perAgent { + t.Errorf("%d Einträge angelegt, erwartet %d", n, agents*perAgent) } } -// TestGleichzeitigesLesenUndSchreiben prüft, dass Lesezugriffe nicht hinter +// TestConcurrentReadAndWrite prüft, dass Lesezugriffe nicht hinter // Schreibvorgängen warten müssen — der Grund für den getrennten Lesepool. -func TestGleichzeitigesLesenUndSchreiben(t *testing.T) { +func TestConcurrentReadAndWrite(t *testing.T) { s := testStore(t) p := testProject(t, s) ctx := context.Background() @@ -194,7 +194,7 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) { } var wg sync.WaitGroup - var leseFehler, schreibFehler int64 + var readErr, writeErr int64 // Schreiber und Leser gleichzeitig laufen lassen. for i := 0; i < 4; i++ { @@ -205,7 +205,7 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) { if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: fmt.Sprintf("neu-%d-%03d", i, j), Title: "T", Content: "Inhalt", Source: SourceAgent}); err != nil { - atomic.AddInt64(&schreibFehler, 1) + atomic.AddInt64(&writeErr, 1) } } }(i) @@ -213,15 +213,15 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) { defer wg.Done() for j := 0; j < 100; j++ { if _, err := s.ListEntries(ctx, p.ID, EntryFilter{Limit: 20}); err != nil { - atomic.AddInt64(&leseFehler, 1) + atomic.AddInt64(&readErr, 1) } } }() } wg.Wait() - if leseFehler > 0 || schreibFehler > 0 { - t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", leseFehler, schreibFehler) + if readErr > 0 || writeErr > 0 { + t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", readErr, writeErr) } } @@ -279,29 +279,29 @@ func TestUpdateProject(t *testing.T) { t.Fatalf("Projekt ändern: %v", err) } - gelesen, err := s.ProjectByID(ctx, p.ID) + read, err := s.ProjectByID(ctx, p.ID) if err != nil { t.Fatal(err) } - if gelesen.Name != "Neuer Name" || gelesen.Description != "Neue Beschreibung" { - t.Errorf("Name/Beschreibung = %q/%q", gelesen.Name, gelesen.Description) + if read.Name != "Neuer Name" || read.Description != "Neue Beschreibung" { + t.Errorf("Name/Beschreibung = %q/%q", read.Name, read.Description) } - if gelesen.WriteLimitHourly != 250 { - t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly) + if read.WriteLimitHourly != 250 { + t.Errorf("WriteLimitHourly = %d, erwartet 250", read.WriteLimitHourly) } // Der Bezeichner bleibt fest — er steckt in URLs und MCP-Konfigurationen. - if gelesen.Slug != p.Slug { - t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", gelesen.Slug, p.Slug) + if read.Slug != p.Slug { + t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", read.Slug, p.Slug) } // 0 nimmt die eigene Obergrenze zurück. if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{Name: "Neuer Name"}); err != nil { t.Fatal(err) } - gelesen, _ = s.ProjectByID(ctx, p.ID) - if gelesen.WriteLimitHourly != 0 { - t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", gelesen.WriteLimitHourly) + read, _ = s.ProjectByID(ctx, p.ID) + if read.WriteLimitHourly != 0 { + t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", read.WriteLimitHourly) } } @@ -383,16 +383,16 @@ func TestResolveKeyHash(t *testing.T) { t.Fatal(err) } - auf, err := s.ResolveKeyHash(ctx, "hash-abc") + resolved, err := s.ResolveKeyHash(ctx, "hash-abc") if err != nil { t.Fatalf("gültiger Key wurde nicht aufgelöst: %v", err) } - if auf.Key.ID != k.ID { + if resolved.Key.ID != k.ID { t.Error("falscher Key aufgelöst") } // Das Projekt kommt in derselben Abfrage mit — sonst bräuchte jeder Request // einen zweiten Datenbankzugriff. - if auf.Project.ID != p.ID { + if resolved.Project.ID != p.ID { t.Error("das Projekt wurde nicht mitgeliefert") } } @@ -426,26 +426,26 @@ func TestRevokedKeyIsGone(t *testing.T) { // TestAPIKeyUnused prüft, wann ein Key als „ungenutzt“ gilt (AUSBAUSTUFEN.md 4.7). func TestAPIKeyUnused(t *testing.T) { - jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) - tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) } + now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + days := func(n int) time.Time { return now.Add(-time.Duration(n) * 24 * time.Hour) } - faelle := []struct { - name string - key APIKey - erwartet bool + cases := []struct { + name string + key APIKey + want bool }{ - {"gestern benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(1)}, false}, - {"vor 91 Tagen benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(91)}, true}, - {"genau 90 Tage", APIKey{CreatedAt: tage(400), LastUsedAt: tage(90)}, false}, - {"nie benutzt, frisch angelegt", APIKey{CreatedAt: tage(3)}, false}, - {"nie benutzt, lange angelegt", APIKey{CreatedAt: tage(120)}, true}, - {"zurückgezogen", APIKey{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false}, + {"gestern benutzt", APIKey{CreatedAt: days(400), LastUsedAt: days(1)}, false}, + {"vor 91 Tagen benutzt", APIKey{CreatedAt: days(400), LastUsedAt: days(91)}, true}, + {"genau 90 Tage", APIKey{CreatedAt: days(400), LastUsedAt: days(90)}, false}, + {"nie benutzt, frisch angelegt", APIKey{CreatedAt: days(3)}, false}, + {"nie benutzt, lange angelegt", APIKey{CreatedAt: days(120)}, true}, + {"zurückgezogen", APIKey{CreatedAt: days(400), LastUsedAt: days(200), RevokedAt: days(100)}, false}, } - for _, f := range faelle { + for _, f := range cases { t.Run(f.name, func(t *testing.T) { - if got := f.key.Unused(jetzt); got != f.erwartet { - t.Errorf("Unused = %v, erwartet %v", got, f.erwartet) + if got := f.key.Unused(now); got != f.want { + t.Errorf("Unused = %v, erwartet %v", got, f.want) } }) } @@ -453,25 +453,25 @@ func TestAPIKeyUnused(t *testing.T) { // TestWorkspaceAdminUnused: Workspace-Admins folgen derselben Regel wie Keys. func TestWorkspaceAdminUnused(t *testing.T) { - jetzt := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) - tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) } + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + days := func(n int) time.Time { return now.Add(-time.Duration(n) * 24 * time.Hour) } - faelle := []struct { - name string - admin WorkspaceAdmin - erwartet bool + cases := []struct { + name string + admin WorkspaceAdmin + want bool }{ - {"gestern benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(1)}, false}, - {"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(91)}, true}, - {"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: tage(3)}, false}, - {"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: tage(120)}, true}, - {"zurückgezogen", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false}, + {"gestern benutzt", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(1)}, false}, + {"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(91)}, true}, + {"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: days(3)}, false}, + {"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: days(120)}, true}, + {"zurückgezogen", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(200), RevokedAt: days(100)}, false}, } - for _, f := range faelle { + for _, f := range cases { t.Run(f.name, func(t *testing.T) { - if got := f.admin.Unused(jetzt); got != f.erwartet { - t.Errorf("Unused = %v, erwartet %v", got, f.erwartet) + if got := f.admin.Unused(now); got != f.want { + t.Errorf("Unused = %v, erwartet %v", got, f.want) } }) } @@ -493,7 +493,7 @@ func TestPutEntryCreatesAndUpdates(t *testing.T) { ctx := context.Background() p := testProject(t, s) - erst, err := s.PutEntry(ctx, EntryWrite{ + first, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: "deployment", Title: "Deployment", Content: "Erste Fassung", Tags: []string{"Ops", "ops", " Deploy "}, Source: SourceAgent, Author: "agent-1", @@ -501,27 +501,27 @@ func TestPutEntryCreatesAndUpdates(t *testing.T) { if err != nil { t.Fatal(err) } - if erst.Version != 1 { - t.Errorf("Version = %d, erwartet 1", erst.Version) + if first.Version != 1 { + t.Errorf("Version = %d, erwartet 1", first.Version) } // Schlagworte werden vereinheitlicht: klein, ohne Dubletten, sortiert. - if len(erst.Tags) != 2 || erst.Tags[0] != "deploy" || erst.Tags[1] != "ops" { - t.Errorf("Tags = %v, erwartet [deploy ops]", erst.Tags) + if len(first.Tags) != 2 || first.Tags[0] != "deploy" || first.Tags[1] != "ops" { + t.Errorf("Tags = %v, erwartet [deploy ops]", first.Tags) } // Derselbe Slug aktualisiert statt anzulegen — das ist der Kern der // Dublettenvermeidung. - zweit, err := s.PutEntry(ctx, EntryWrite{ + second, err := s.PutEntry(ctx, EntryWrite{ ProjectID: p.ID, Slug: "deployment", Title: "Deployment", Content: "Zweite Fassung", Source: SourceAgent, Author: "agent-2", }) if err != nil { t.Fatal(err) } - if zweit.Version != 2 { - t.Errorf("Version = %d, erwartet 2", zweit.Version) + if second.Version != 2 { + t.Errorf("Version = %d, erwartet 2", second.Version) } - if zweit.ID != erst.ID { + if second.ID != first.ID { t.Error("es wurde ein zweiter Eintrag angelegt statt zu aktualisieren") } if n, _ := s.CountEntries(ctx, p.ID); n != 1 { @@ -529,8 +529,8 @@ func TestPutEntryCreatesAndUpdates(t *testing.T) { } // Die Schlagworte des ersten Schreibvorgangs sind weg, weil der zweite keine // mitgebracht hat — der Eintrag ist die Wahrheit, nicht die Summe der Änderungen. - if len(zweit.Tags) != 0 { - t.Errorf("Tags = %v, erwartet leer", zweit.Tags) + if len(second.Tags) != 0 { + t.Errorf("Tags = %v, erwartet leer", second.Tags) } } @@ -540,15 +540,15 @@ func TestPutEntryOptimisticLocking(t *testing.T) { ctx := context.Background() p := testProject(t, s) - basis := EntryWrite{ProjectID: p.ID, Slug: "geteilt", Title: "T", Source: SourceAgent} + base := EntryWrite{ProjectID: p.ID, Slug: "geteilt", Title: "T", Source: SourceAgent} - e, err := s.PutEntry(ctx, basis) + e, err := s.PutEntry(ctx, base) if err != nil { t.Fatal(err) } // Agent A schreibt auf Basis von Version 1 — geht durch. - a := basis + a := base a.Content = "von A" a.ExpectedVersion = e.Version if _, err := s.PutEntry(ctx, a); err != nil { @@ -556,7 +556,7 @@ func TestPutEntryOptimisticLocking(t *testing.T) { } // Agent B hatte ebenfalls Version 1 gelesen — muss jetzt einen Konflikt bekommen. - b := basis + b := base b.Content = "von B" b.ExpectedVersion = e.Version if _, err := s.PutEntry(ctx, b); !errors.Is(err, ErrConflict) { @@ -640,18 +640,18 @@ func TestListEntriesFilterByTag(t *testing.T) { s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"docs"}, Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Tags: []string{"ops", "docs"}, Source: SourceHuman}) - gefiltert, err := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "ops"}) + filtered, err := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "ops"}) if err != nil { t.Fatal(err) } - if len(gefiltert) != 2 { - t.Errorf("%d Treffer, erwartet 2", len(gefiltert)) + if len(filtered) != 2 { + t.Errorf("%d Treffer, erwartet 2", len(filtered)) } // Groß-/Kleinschreibung darf keine Rolle spielen. - grossGeschrieben, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"}) - if len(grossGeschrieben) != 2 { - t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(grossGeschrieben)) + capitalized, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"}) + if len(capitalized) != 2 { + t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(capitalized)) } } @@ -707,7 +707,7 @@ func TestExtractLinks(t *testing.T) { "[[a-b_c1]] ist erlaubt": {"a-b_c1"}, "einfache [klammern] sind kein Verweis": nil, - // In Code zählt nichts (siehe verweiseAusserhalbVonCode). + // In Code zählt nichts (siehe refsOutsideCode). "Verweis: `[[beispiel]]`, echt: [[echt]]": {"echt"}, "doppelte Backticks: ``a `[[x]]` b`` und [[y]]": {"y"}, "```\n[[im-block]]\n```\n[[danach]]": {"danach"}, @@ -719,29 +719,29 @@ func TestExtractLinks(t *testing.T) { " [[eingerueckt-vier]] zählt (wie Listenzeilen)": {"eingerueckt-vier"}, } - for input, erwartet := range cases { + for input, want := range cases { got := ExtractLinks(input) - if len(got) != len(erwartet) { - t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet) + if len(got) != len(want) { + t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, want) continue } for i := range got { - if got[i] != erwartet[i] { - t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet) + if got[i] != want[i] { + t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, want) break } } } } -// TestReplaceLinksLaesstCodeStehen prüft das Gegenstück beim Anzeigen: Ein +// TestReplaceLinksLeavesCodeAlone prüft das Gegenstück beim Anzeigen: Ein // Verweis in Code bleibt Text, ein echter wird ersetzt. -func TestReplaceLinksLaesstCodeStehen(t *testing.T) { - ein := "So verweist man: `[[bezeichner]]`. Siehe [[deployment]].\n```\n[[auch-nicht]]\n```\n" - soll := "So verweist man: `[[bezeichner]]`. Siehe <deployment>.\n```\n[[auch-nicht]]\n```\n" - got := ReplaceLinks(ein, func(slug string) string { return "<" + slug + ">" }) - if got != soll { - t.Errorf("\nist: %q\nsoll: %q", got, soll) +func TestReplaceLinksLeavesCodeAlone(t *testing.T) { + in := "So verweist man: `[[bezeichner]]`. Siehe [[deployment]].\n```\n[[auch-nicht]]\n```\n" + expected := "So verweist man: `[[bezeichner]]`. Siehe <deployment>.\n```\n[[auch-nicht]]\n```\n" + got := ReplaceLinks(in, func(slug string) string { return "<" + slug + ">" }) + if got != expected { + t.Errorf("\nist: %q\nsoll: %q", got, expected) } } @@ -789,18 +789,18 @@ func TestAuditRoundTrip(t *testing.T) { s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryDelete, EntrySlug: "b", Source: SourceMCP}) - ereignisse, err := s.ListAudit(ctx, AuditFilter{ProjectID: p.ID}) + events, err := s.ListAudit(ctx, AuditFilter{ProjectID: p.ID}) if err != nil { t.Fatal(err) } - if len(ereignisse) != 2 { - t.Fatalf("%d Ereignisse, erwartet 2", len(ereignisse)) + if len(events) != 2 { + t.Fatalf("%d Ereignisse, erwartet 2", len(events)) } // Neueste zuerst. - if ereignisse[0].Action != ActionEntryDelete { - t.Errorf("erstes Ereignis = %q, erwartet %q", ereignisse[0].Action, ActionEntryDelete) + if events[0].Action != ActionEntryDelete { + t.Errorf("erstes Ereignis = %q, erwartet %q", events[0].Action, ActionEntryDelete) } - if ereignisse[0].Timestamp.IsZero() { + if events[0].Timestamp.IsZero() { t.Error("Zeitstempel wurde nicht gesetzt") } } @@ -815,8 +815,8 @@ func TestPruneAudit(t *testing.T) { s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: "test", Source: SourceSystem}) // Grenze in der Zukunft — alles muss weg. - zukunft := time.Now().UTC().Add(time.Hour).Format(timeFormat) - n, err := s.PruneAudit(ctx, zukunft) + future := time.Now().UTC().Add(time.Hour).Format(timeFormat) + n, err := s.PruneAudit(ctx, future) if err != nil { t.Fatal(err) } @@ -842,33 +842,33 @@ func TestSlugify(t *testing.T) { "straße": "strasse", } - for input, erwartet := range cases { - if got := Slugify(input); got != erwartet { - t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, erwartet) + for input, want := range cases { + if got := Slugify(input); got != want { + t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, want) } } } func TestNewIDIsSortableAndUnique(t *testing.T) { - gesehen := make(map[string]bool) - var vorher string + seen := make(map[string]bool) + var before string for i := 0; i < 200; i++ { id := NewID() - if gesehen[id] { + if seen[id] { t.Fatalf("doppelte ID: %s", id) } - gesehen[id] = true + seen[id] = true // Der Zeitanteil steht vorne, IDs sind also grob chronologisch sortierbar. - if vorher != "" && id < vorher { - zeitVorher, _, _ := strings.Cut(vorher, "-") - zeitJetzt, _, _ := strings.Cut(id, "-") - if zeitJetzt < zeitVorher { - t.Errorf("IDs sind nicht chronologisch: %s vor %s", vorher, id) + if before != "" && id < before { + earlierTime, _, _ := strings.Cut(before, "-") + nowTime, _, _ := strings.Cut(id, "-") + if nowTime < earlierTime { + t.Errorf("IDs sind nicht chronologisch: %s vor %s", before, id) } } - vorher = id + before = id } } @@ -893,32 +893,32 @@ func TestBulkUpdateTags(t *testing.T) { c, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Tags: []string{"alt"}, Source: SourceHuman}) - geaendert, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"}) + changed, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"}) if err != nil { t.Fatal(err) } - if len(geaendert) != 2 { - t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(geaendert)) + if len(changed) != 2 { + t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(changed)) } - neuA, _ := s.EntryBySlug(ctx, p.ID, "a") - if !stringSlicesEqual(neuA.Tags, []string{"neu"}) { - t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags) + freshA, _ := s.EntryBySlug(ctx, p.ID, "a") + if !stringSlicesEqual(freshA.Tags, []string{"neu"}) { + t.Errorf("Tags von A = %v, erwartet [neu]", freshA.Tags) } - if neuA.Version != 2 { - t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", neuA.Version) + if freshA.Version != 2 { + t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", freshA.Version) } - neuB, _ := s.EntryBySlug(ctx, p.ID, "b") - if !stringSlicesEqual(neuB.Tags, []string{"bleibt", "neu"}) { - t.Errorf("Tags von B = %v, erwartet [bleibt neu]", neuB.Tags) + freshB, _ := s.EntryBySlug(ctx, p.ID, "b") + if !stringSlicesEqual(freshB.Tags, []string{"bleibt", "neu"}) { + t.Errorf("Tags von B = %v, erwartet [bleibt neu]", freshB.Tags) } - unveraendertC, _ := s.EntryBySlug(ctx, p.ID, "c") - if !stringSlicesEqual(unveraendertC.Tags, []string{"alt"}) { - t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unveraendertC.Tags) + unchangedC, _ := s.EntryBySlug(ctx, p.ID, "c") + if !stringSlicesEqual(unchangedC.Tags, []string{"alt"}) { + t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unchangedC.Tags) } - if unveraendertC.Version != c.Version { + if unchangedC.Version != c.Version { t.Error("Version von C wurde erhöht, obwohl C nicht ausgewählt war") } } @@ -931,23 +931,23 @@ func TestBulkUpdateTagsIgnoresForeignProjectIDs(t *testing.T) { a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "") b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "") - fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "fremd", Title: "Fremd", + foreign, _ := s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "fremd", Title: "Fremd", Tags: []string{"x"}, Source: SourceHuman}) // Aufruf mit dem Projekt A, aber einer ID aus Projekt B — darf nichts // bewirken, sonst könnte ein untergeschobenes Formularfeld projektübergreifend // Einträge verändern. - geaendert, err := s.BulkUpdateTags(ctx, a.ID, []string{fremd.ID}, []string{"neu"}, nil) + changed, err := s.BulkUpdateTags(ctx, a.ID, []string{foreign.ID}, []string{"neu"}, nil) if err != nil { t.Fatal(err) } - if len(geaendert) != 0 { - t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(geaendert)) + if len(changed) != 0 { + t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(changed)) } - unveraendert, _ := s.EntryBySlug(ctx, b.ID, "fremd") - if !stringSlicesEqual(unveraendert.Tags, []string{"x"}) { - t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unveraendert.Tags) + unchanged, _ := s.EntryBySlug(ctx, b.ID, "fremd") + if !stringSlicesEqual(unchanged.Tags, []string{"x"}) { + t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unchanged.Tags) } } @@ -958,19 +958,19 @@ func TestPutEntryRecordsHistory(t *testing.T) { ctx := context.Background() p := testProject(t, s) - erste, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Erste Fassung", + first, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Erste Fassung", Content: "Inhalt 1", Tags: []string{"a"}, Source: SourceHuman, Author: "tom"}) if err != nil { t.Fatal(err) } // Beim erstmaligen Anlegen gibt es noch nichts zu sichern. - historie, err := s.EntryVersions(ctx, erste.ID) + history, err := s.EntryVersions(ctx, first.ID) if err != nil { t.Fatal(err) } - if len(historie) != 0 { - t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(historie)) + if len(history) != 0 { + t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(history)) } if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Zweite Fassung", @@ -982,33 +982,33 @@ func TestPutEntryRecordsHistory(t *testing.T) { t.Fatal(err) } - historie, err = s.EntryVersions(ctx, erste.ID) + history, err = s.EntryVersions(ctx, first.ID) if err != nil { t.Fatal(err) } - if len(historie) != 2 { - t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(historie)) + if len(history) != 2 { + t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(history)) } // Neueste zuerst. - if historie[0].Version != 2 || historie[0].Title != "Zweite Fassung" { - t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", historie[0].Version, historie[0].Title) + if history[0].Version != 2 || history[0].Title != "Zweite Fassung" { + t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", history[0].Version, history[0].Title) } - if historie[1].Version != 1 || historie[1].Title != "Erste Fassung" { - t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", historie[1].Version, historie[1].Title) + if history[1].Version != 1 || history[1].Title != "Erste Fassung" { + t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", history[1].Version, history[1].Title) } - if historie[1].Content != "Inhalt 1" || !stringSlicesEqual(historie[1].Tags, []string{"a"}) { - t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", historie[1].Content, historie[1].Tags) + if history[1].Content != "Inhalt 1" || !stringSlicesEqual(history[1].Tags, []string{"a"}) { + t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", history[1].Content, history[1].Tags) } - fassung1, err := s.EntryVersionByNumber(ctx, erste.ID, 1) + version1, err := s.EntryVersionByNumber(ctx, first.ID, 1) if err != nil { t.Fatal(err) } - if fassung1.Content != "Inhalt 1" { - t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", fassung1.Content) + if version1.Content != "Inhalt 1" { + t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", version1.Content) } - if _, err := s.EntryVersionByNumber(ctx, erste.ID, 99); !errors.Is(err, ErrNotFound) { + if _, err := s.EntryVersionByNumber(ctx, first.ID, 99); !errors.Is(err, ErrNotFound) { t.Errorf("unbekannte Fassung: err = %v, erwartet ErrNotFound", err) } } @@ -1025,15 +1025,15 @@ func TestBulkUpdateTagsRecordsHistory(t *testing.T) { t.Fatal(err) } - historie, err := s.EntryVersions(ctx, e.ID) + history, err := s.EntryVersions(ctx, e.ID) if err != nil { t.Fatal(err) } - if len(historie) != 1 { - t.Fatalf("%d Fassungen, erwartet 1", len(historie)) + if len(history) != 1 { + t.Fatalf("%d Fassungen, erwartet 1", len(history)) } - if !stringSlicesEqual(historie[0].Tags, []string{"alt"}) { - t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", historie[0].Tags) + if !stringSlicesEqual(history[0].Tags, []string{"alt"}) { + t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", history[0].Tags) } } @@ -1050,20 +1050,20 @@ func TestTrashKeepsHistoryPurgeRemovesIt(t *testing.T) { if err := s.MoveEntryToTrash(ctx, p.ID, "e", "tom"); err != nil { t.Fatal(err) } - if historie, _ := s.EntryVersions(ctx, e.ID); len(historie) != 1 { - t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(historie)) + if history, _ := s.EntryVersions(ctx, e.ID); len(history) != 1 { + t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(history)) } if n, err := s.PurgeFromTrash(ctx, p.ID, []string{"e"}); err != nil || n != 1 { t.Fatalf("PurgeFromTrash: %d, %v", n, err) } - historie, err := s.EntryVersions(ctx, e.ID) + history, err := s.EntryVersions(ctx, e.ID) if err != nil { t.Fatal(err) } - if len(historie) != 0 { - t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(historie)) + if len(history) != 0 { + t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(history)) } } @@ -1082,33 +1082,33 @@ func TestLinkGraph(t *testing.T) { s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Content: "allein", Source: SourceHuman}) - knoten, kanten, err := s.LinkGraph(ctx, p.ID) + nodes, edges, err := s.LinkGraph(ctx, p.ID) if err != nil { t.Fatal(err) } - if len(knoten) != 4 { - t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(knoten)) + if len(nodes) != 4 { + t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(nodes)) } - nachSlug := make(map[string]GraphNode, len(knoten)) - for _, k := range knoten { - nachSlug[k.Slug] = k + toSlug := make(map[string]GraphNode, len(nodes)) + for _, k := range nodes { + toSlug[k.Slug] = k } - if !nachSlug["a"].Exists || !nachSlug["b"].Exists || !nachSlug["c"].Exists { + if !toSlug["a"].Exists || !toSlug["b"].Exists || !toSlug["c"].Exists { t.Error("vorhandene Einträge sollten Exists = true tragen") } - fehlt, ok := nachSlug["fehlt"] + missing, ok := toSlug["fehlt"] if !ok { t.Fatal("Verweis auf einen nicht existierenden Bezeichner erzeugte keinen Knoten") } - if fehlt.Exists { + if missing.Exists { t.Error("Exists = true für einen Bezeichner ohne Eintrag") } - if len(kanten) != 3 { - t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(kanten)) + if len(edges) != 3 { + t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(edges)) } } @@ -1125,28 +1125,28 @@ func TestLinkGraphIsProjectScoped(t *testing.T) { s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "anderes", Title: "Anderes", Content: "nur in B", Source: SourceHuman}) - knoten, _, err := s.LinkGraph(ctx, a.ID) + nodes, _, err := s.LinkGraph(ctx, a.ID) if err != nil { t.Fatal(err) } - if len(knoten) != 1 || knoten[0].Slug != "geheim" { - t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", knoten) + if len(nodes) != 1 || nodes[0].Slug != "geheim" { + t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", nodes) } } -// TestVeralteteEintraegeFilternUndZaehlen: ListEntries mit ChangedBefore und +// TestStaleEntriesFilterAndCount: ListEntries mit ChangedBefore und // CountStaleEntries finden genau die Einträge, die seit über einem Jahr // unverändert sind — und keine im Papierkorb. -func TestVeralteteEintraegeFilternUndZaehlen(t *testing.T) { +func TestStaleEntriesFilterAndCount(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) - jetzt := time.Now() + now := time.Now() - alt := jetzt.Add(-400 * 24 * time.Hour) + old := now.Add(-400 * 24 * time.Hour) for _, w := range []EntryWrite{ - {Slug: "alt", Title: "Alt", CreatedAt: alt, UpdatedAt: alt}, - {Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: alt, UpdatedAt: alt}, + {Slug: "alt", Title: "Alt", CreatedAt: old, UpdatedAt: old}, + {Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: old, UpdatedAt: old}, {Slug: "frisch", Title: "Frisch"}, } { w.ProjectID, w.Content, w.Source = p.ID, "x", SourceHuman @@ -1158,17 +1158,17 @@ func TestVeralteteEintraegeFilternUndZaehlen(t *testing.T) { t.Fatal(err) } - liste, err := s.ListEntries(ctx, p.ID, EntryFilter{ChangedBefore: StaleCutoff(jetzt)}) + list, err := s.ListEntries(ctx, p.ID, EntryFilter{ChangedBefore: StaleCutoff(now)}) if err != nil { t.Fatal(err) } - if len(liste) != 1 || liste[0].Slug != "alt" { - t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(liste)) + if len(list) != 1 || list[0].Slug != "alt" { + t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(list)) } - anzahl, err := s.CountStaleEntries(ctx, p.ID, jetzt) - if err != nil || anzahl != 1 { - t.Errorf("CountStaleEntries = %d, %v; erwartet 1", anzahl, err) + count, err := s.CountStaleEntries(ctx, p.ID, now) + if err != nil || count != 1 { + t.Errorf("CountStaleEntries = %d, %v; erwartet 1", count, err) } } @@ -1179,86 +1179,86 @@ func TestAuditFilter(t *testing.T) { ctx := context.Background() p := testProject(t, s) - jetzt := time.Now().UTC() + now := time.Now().UTC() for _, z := range []struct { - alter time.Duration - wer, vorgang string + age time.Duration + who, event string }{ {48 * time.Hour, "anna", ActionEntryWrite}, {24 * time.Hour, "bernd", ActionEntryDelete}, {1 * time.Hour, "anna", ActionEntryDelete}, } { - if _, err := s.schreiben.ExecContext(ctx, + if _, err := s.write.ExecContext(ctx, `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) VALUES (?, ?, ?, '', ?, ?, '', 'web', '', '')`, - NewID(), jetzt.Add(-z.alter).Format(time.RFC3339Nano), p.ID, z.wer, z.vorgang); err != nil { + NewID(), now.Add(-z.age).Format(time.RFC3339Nano), p.ID, z.who, z.event); err != nil { t.Fatal(err) } } - zaehle := func(f AuditFilter) int { + count := func(f AuditFilter) int { t.Helper() f.ProjectID = p.ID - liste, err := s.ListAudit(ctx, f) + list, err := s.ListAudit(ctx, f) if err != nil { t.Fatal(err) } - return len(liste) + return len(list) } - if n := zaehle(AuditFilter{Action: ActionEntryDelete}); n != 2 { + if n := count(AuditFilter{Action: ActionEntryDelete}); n != 2 { t.Errorf("Vorgang: %d, erwartet 2", n) } - if n := zaehle(AuditFilter{Actor: "anna"}); n != 2 { + if n := count(AuditFilter{Actor: "anna"}); n != 2 { t.Errorf("Zugang: %d, erwartet 2", n) } - if n := zaehle(AuditFilter{Actor: "anna", Action: ActionEntryDelete}); n != 1 { + if n := count(AuditFilter{Actor: "anna", Action: ActionEntryDelete}); n != 1 { t.Errorf("Zugang und Vorgang: %d, erwartet 1", n) } - if n := zaehle(AuditFilter{Since: jetzt.Add(-30 * time.Hour), Until: jetzt.Add(-2 * time.Hour)}); n != 1 { + if n := count(AuditFilter{Since: now.Add(-30 * time.Hour), Until: now.Add(-2 * time.Hour)}); n != 1 { t.Errorf("Zeitraum: %d, erwartet 1 (bernd)", n) } - zugaenge, err := s.AuditActors(ctx, p.ID) - if err != nil || strings.Join(zugaenge, ",") != "anna,bernd" { - t.Errorf("AuditActors = %v, %v; erwartet anna,bernd", zugaenge, err) + keys, err := s.AuditActors(ctx, p.ID) + if err != nil || strings.Join(keys, ",") != "anna,bernd" { + t.Errorf("AuditActors = %v, %v; erwartet anna,bernd", keys, err) } } -// TestKeyAblauf: Ein abgelaufener Key gilt wie ein zurückgezogener — er lässt +// TestKeyExpiry: Ein abgelaufener Key gilt wie ein zurückgezogener — er lässt // sich nicht mehr auflösen. Ein Ablauf in der Vergangenheit wird gar nicht // erst angenommen. -func TestKeyAblauf(t *testing.T) { +func TestKeyExpiry(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) - jetzt := time.Now() + now := time.Now() - if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "zu-spaet", "h0", "write", jetzt.Add(-time.Hour)); err == nil { + if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "zu-spaet", "h0", "write", now.Add(-time.Hour)); err == nil { t.Error("Key mit Ablauf in der Vergangenheit wurde angelegt") } - k, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "auf-zeit", "h1", "write", jetzt.Add(time.Hour)) + k, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "auf-zeit", "h1", "write", now.Add(time.Hour)) if err != nil { t.Fatal(err) } if _, err := s.ResolveKeyHash(ctx, "h1"); err != nil { t.Errorf("gültiger Key lässt sich nicht auflösen: %v", err) } - gelesen, _ := s.APIKeyByID(ctx, k.ID) - if gelesen.ExpiresAt.IsZero() || !gelesen.ExpiresSoon(jetzt) || gelesen.Expired(jetzt) { - t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", gelesen) + read, _ := s.APIKeyByID(ctx, k.ID) + if read.ExpiresAt.IsZero() || !read.ExpiresSoon(now) || read.Expired(now) { + t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", read) } // Ablauf in die Vergangenheit setzen — wie ein Key, dessen Tag vorbei ist. - if _, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET expires_at = ? WHERE id = ?`, - jetzt.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil { + if _, err := s.write.ExecContext(ctx, `UPDATE api_keys SET expires_at = ? WHERE id = ?`, + now.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil { t.Fatal(err) } if _, err := s.ResolveKeyHash(ctx, "h1"); !errors.Is(err, ErrNotFound) { t.Errorf("abgelaufener Key: err = %v, erwartet ErrNotFound", err) } - gelesen, _ = s.APIKeyByID(ctx, k.ID) - if gelesen.Valid(jetzt) || gelesen.Unused(jetzt.Add(200*24*time.Hour)) { + read, _ = s.APIKeyByID(ctx, k.ID) + if read.Valid(now) || read.Unused(now.Add(200*24*time.Hour)) { t.Error("abgelaufener Key gilt als gültig oder als ungenutzt") } diff --git a/internal/store/systemstand.go b/internal/store/sysinfo.go similarity index 52% rename from internal/store/systemstand.go rename to internal/store/sysinfo.go index 449ce1a..c907626 100644 --- a/internal/store/systemstand.go +++ b/internal/store/sysinfo.go @@ -4,10 +4,10 @@ package store // (AUSBAUSTUFEN.md 5.16): Bestand, Platz auf der Platte, Aktivität je Tag und // Stand des Schemas. // -// Anders als die Nutzung (nutzung.go) fragt hier niemand nach einem einzelnen +// Anders als die Nutzung (usage.go) fragt hier niemand nach einem einzelnen // Projekt. Die Zahlen gelten für die ganze Workspace-Datei bzw. das ganze // Schema; zusammengezählt über alle Workspaces werden sie in -// internal/statistik. +// internal/stats. import ( "context" @@ -17,57 +17,57 @@ import ( "time" ) -// Bestand zählt, was in einem Workspace liegt. -type Bestand struct { - Projekte int - Eintraege int // ohne Papierkorb - Fassungen int // ältere Fassungen in entry_versions - Papierkorb int +// Inventory zählt, was in einem Workspace liegt. +type Inventory struct { + Projects int + Entries int // ohne Papierkorb + Versions int // ältere Fassungen in entry_versions + Trash int // Keys: nur gültige (weder zurückgezogen noch abgelaufen). Ungenutzt und // LaufenAb sind Teilmengen davon, nach denselben Regeln wie auf der Seite // "Zugänge" (APIKey.Unused, APIKey.ExpiresSoon). - Keys int - KeysUngenutzt int - KeysLaufenAb int + Keys int + KeysUnused int + KeysExpiring int } // Plus zählt zwei Bestände zusammen — für die Summenzeile über alle // Workspaces. -func (b Bestand) Plus(o Bestand) Bestand { - return Bestand{ - Projekte: b.Projekte + o.Projekte, - Eintraege: b.Eintraege + o.Eintraege, - Fassungen: b.Fassungen + o.Fassungen, - Papierkorb: b.Papierkorb + o.Papierkorb, - Keys: b.Keys + o.Keys, - KeysUngenutzt: b.KeysUngenutzt + o.KeysUngenutzt, - KeysLaufenAb: b.KeysLaufenAb + o.KeysLaufenAb, +func (b Inventory) Plus(o Inventory) Inventory { + return Inventory{ + Projects: b.Projects + o.Projects, + Entries: b.Entries + o.Entries, + Versions: b.Versions + o.Versions, + Trash: b.Trash + o.Trash, + Keys: b.Keys + o.Keys, + KeysUnused: b.KeysUnused + o.KeysUnused, + KeysExpiring: b.KeysExpiring + o.KeysExpiring, } } -// Bestand zählt Projekte, Einträge, Fassungen, Papierkorb und Keys des -// Workspace. jetzt entscheidet, welche Keys als ungenutzt oder bald +// Inventory zählt Projekte, Einträge, Fassungen, Papierkorb und Keys des +// Workspace. now entscheidet, welche Keys als ungenutzt oder bald // ablaufend gelten. -func (s *Store) Bestand(ctx context.Context, jetzt time.Time) (Bestand, error) { - var b Bestand +func (s *Store) Inventory(ctx context.Context, now time.Time) (Inventory, error) { + var b Inventory - if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects`).Scan(&b.Projekte); err != nil { + if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects`).Scan(&b.Projects); err != nil { return b, fmt.Errorf("Projekte konnten nicht gezählt werden: %w", err) } // Eine Abfrage für beide Zahlen. COALESCE, weil SUM über eine leere // Tabelle NULL ergibt, nicht 0. - err := s.lesen.QueryRowContext(ctx, + err := s.read.QueryRowContext(ctx, `SELECT COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END), 0) FROM entries`, - ).Scan(&b.Eintraege, &b.Papierkorb) + ).Scan(&b.Entries, &b.Trash) if err != nil { return b, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err) } - if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_versions`).Scan(&b.Fassungen); err != nil { + if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_versions`).Scan(&b.Versions); err != nil { return b, fmt.Errorf("Fassungen konnten nicht gezählt werden: %w", err) } @@ -75,7 +75,7 @@ func (s *Store) Bestand(ctx context.Context, jetzt time.Time) (Bestand, error) { // bald ab" heißt, steht in models.go — einmal, für die Seite "Zugänge" und // für diese hier. Eine zweite Fassung der Regel in SQL könnte unbemerkt // davon abweichen. Zurückgezogene Keys fallen schon in der Abfrage weg. - rows, err := s.lesen.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE revoked_at = ''`) + rows, err := s.read.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE revoked_at = ''`) if err != nil { return b, fmt.Errorf("Keys konnten nicht gelesen werden: %w", err) } @@ -85,25 +85,25 @@ func (s *Store) Bestand(ctx context.Context, jetzt time.Time) (Bestand, error) { if err != nil { return b, err } - if !k.Valid(jetzt) { + if !k.Valid(now) { continue } b.Keys++ - if k.Unused(jetzt) { - b.KeysUngenutzt++ + if k.Unused(now) { + b.KeysUnused++ } - if k.ExpiresSoon(jetzt) { - b.KeysLaufenAb++ + if k.ExpiresSoon(now) { + b.KeysExpiring++ } } return b, rows.Err() } -// Belegung ist der Platz, den ein Workspace in der Datenbank belegt, in Bytes. -type Belegung struct { - // Daten: bei SQLite die Datei selbst, bei PostgreSQL alle Tabellen des +// DiskUsage ist der Platz, den ein Workspace in der Datenbank belegt, in Bytes. +type DiskUsage struct { + // Data: bei SQLite die Datei selbst, bei PostgreSQL alle Tabellen des // Schemas samt Indizes. - Daten int64 + Data int64 // WAL ist nur bei SQLite gesetzt: die Datei <id>.db-wal. In ihr sammelt // SQLite Änderungen, bevor es sie in die Datenbank zurückschreibt @@ -113,80 +113,80 @@ type Belegung struct { WAL int64 } -// Gesamt ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte. -func (b Belegung) Gesamt() int64 { return b.Daten + b.WAL } +// Total ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte. +func (b DiskUsage) Total() int64 { return b.Data + b.WAL } -// Belegung misst den Platz des Workspace. Wie, hängt von der Datenbank ab -// (siehe dialekt.belegung). -func (s *Store) Belegung(ctx context.Context) (Belegung, error) { - return s.lesen.d.belegung(ctx, s.lesen) +// DiskUsage misst den Platz des Workspace. Wie, hängt von der Datenbank ab +// (siehe dialect.diskUsage). +func (s *Store) DiskUsage(ctx context.Context) (DiskUsage, error) { + return s.read.d.diskUsage(ctx, s.read) } -// belegung (SQLite): Seitenzahl mal Seitengröße ist die Größe der Datei, +// diskUsage (SQLite): Seitenzahl mal Seitengröße ist die Größe der Datei, // ohne sie im Dateisystem suchen zu müssen. Die WAL-Datei kennt SQLite selbst // nicht als Zahl; ihr Pfad ergibt sich aber aus dem der Datenbank, und den // nennt PRAGMA database_list. -func (sqliteDialekt) belegung(ctx context.Context, p pool) (Belegung, error) { - var b Belegung - var seiten, seitengroesse int64 - if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&seiten); err != nil { +func (sqliteDialect) diskUsage(ctx context.Context, p pool) (DiskUsage, error) { + var b DiskUsage + var pages, pageSize int64 + if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pages); err != nil { return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err) } - if err := p.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&seitengroesse); err != nil { + if err := p.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err != nil { return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err) } - b.Daten = seiten * seitengroesse + b.Data = pages * pageSize // database_list liefert je angehängter Datenbank eine Zeile: Nummer, Name // ("main" ist die eigene) und Pfad. - var nummer int - var name, pfad string - if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&nummer, &name, &pfad); err != nil { + var number int + var name, filePath string + if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&number, &name, &filePath); err != nil { return b, fmt.Errorf("Pfad der Datenbank unbekannt: %w", err) } // Ohne WAL-Datei (gerade alles zurückgeschrieben) bleibt WAL 0 — das ist // kein Fehler. - if info, err := os.Stat(pfad + "-wal"); err == nil { + if info, err := os.Stat(filePath + "-wal"); err == nil { b.WAL = info.Size() } return b, nil } -// belegung (PostgreSQL): die Tabellen des eigenen Schemas zusammengezählt. +// diskUsage (PostgreSQL): die Tabellen des eigenen Schemas zusammengezählt. // pg_total_relation_size rechnet Indizes und ausgelagerte große Werte (TOAST) // mit ein; deshalb nur über die Tabellen (relkind 'r') summieren, sonst // zählten die Indizes doppelt. // // current_schema() ist das Schema des Workspace, weil jeder Pool seinen -// search_path darauf setzt (speicher_postgres.go). Die Summe ist numeric und +// search_path darauf setzt (backend_postgres.go). Die Summe ist numeric und // wird nach bigint gewandelt, damit sie sich als int64 lesen lässt. -func (postgresDialekt) belegung(ctx context.Context, p pool) (Belegung, error) { - var b Belegung +func (postgresDialect) diskUsage(ctx context.Context, p pool) (DiskUsage, error) { + var b DiskUsage err := p.QueryRowContext(ctx, `SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)::bigint FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = current_schema() AND c.relkind = 'r'`, - ).Scan(&b.Daten) + ).Scan(&b.Data) if err != nil { return b, fmt.Errorf("Größe des Schemas unbekannt: %w", err) } return b, nil } -// Tagesaktivitaet zählt, was an einem Tag in einem Projekt geschah. -type Tagesaktivitaet struct { - ProjektID string +// DailyActivity zählt, was an einem Tag in einem Projekt geschah. +type DailyActivity struct { + ProjectID string Tag string // "2026-10-16", in UTC — so steht es im Zeitstempel - SchreibenAgent int // über MCP angelegt, geändert oder gelöscht - SchreibenMensch int // dasselbe über die Weboberfläche - Suchen int // recall-Aufrufe aus dem Suchprotokoll + AgentWrites int // über MCP angelegt, geändert oder gelöscht + HumanWrites int // dasselbe über die Weboberfläche + Searches int // recall-Aufrufe aus dem Suchprotokoll } -// Aktivitaet liefert je Projekt und Tag die Schreibvorgänge und Suchen seit -// seit. Tage und Projekte ohne jede Aktivität fehlen. +// Activity liefert je Projekt und Tag die Schreibvorgänge und Suchen seit +// since. Tage und Projekte ohne jede Aktivität fehlen. // -// Geschrieben heißt hier dasselbe wie in der Nutzung (nutzung.go): angelegt, +// Geschrieben heißt hier dasselbe wie in der Nutzung (usage.go): angelegt, // geändert, in den Papierkorb gelegt. Gelesen wird nur gezählt, was im // Suchprotokoll steht, also recall. Ein get taucht nirgends auf — es gibt // dafür kein Protokoll, und eines nur für diese Zahl wäre zu viel. @@ -194,43 +194,43 @@ type Tagesaktivitaet struct { // Der Tag sind die ersten zehn Zeichen des Zeitstempels (RFC3339 in UTC). // SUBSTR gibt es in beiden Datenbanken, und gruppiert wird nach genau // diesem Ausdruck, nicht nach einer Spaltennummer. -func (s *Store) Aktivitaet(ctx context.Context, seit time.Time) ([]Tagesaktivitaet, error) { - grenze := seit.UTC().Format(time.RFC3339Nano) +func (s *Store) Activity(ctx context.Context, since time.Time) ([]DailyActivity, error) { + limit := since.UTC().Format(time.RFC3339Nano) // Projekt und Tag zusammen sind der Schlüssel; beide Abfragen tragen in // dieselbe map ein. - type schluessel struct{ projekt, tag string } - je := map[schluessel]*Tagesaktivitaet{} - eintrag := func(projekt, tag string) *Tagesaktivitaet { - k := schluessel{projekt, tag} - if je[k] == nil { - je[k] = &Tagesaktivitaet{ProjektID: projekt, Tag: tag} + type activityKey struct{ project, tag string } + byKey := map[activityKey]*DailyActivity{} + entry := func(project, tag string) *DailyActivity { + k := activityKey{project, tag} + if byKey[k] == nil { + byKey[k] = &DailyActivity{ProjectID: project, Tag: tag} } - return je[k] + return byKey[k] } - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT project_id, SUBSTR(ts, 1, 10), source, COUNT(*) FROM audit_events WHERE ts >= ? AND action IN (?, ?) GROUP BY project_id, SUBSTR(ts, 1, 10), source`, - grenze, ActionEntryWrite, ActionEntryDelete, + limit, ActionEntryWrite, ActionEntryDelete, ) if err != nil { return nil, fmt.Errorf("Schreibvorgänge konnten nicht gezählt werden: %w", err) } for rows.Next() { - var projekt, tag, quelle string - var anzahl int - if err := rows.Scan(&projekt, &tag, &quelle, &anzahl); err != nil { + var project, tag, source string + var count int + if err := rows.Scan(&project, &tag, &source, &count); err != nil { rows.Close() return nil, err } - switch quelle { + switch source { case SourceMCP: - eintrag(projekt, tag).SchreibenAgent += anzahl + entry(project, tag).AgentWrites += count case SourceWeb: - eintrag(projekt, tag).SchreibenMensch += anzahl + entry(project, tag).HumanWrites += count } } if err := rows.Err(); err != nil { @@ -239,55 +239,55 @@ func (s *Store) Aktivitaet(ctx context.Context, seit time.Time) ([]Tagesaktivita } rows.Close() - rows, err = s.lesen.QueryContext(ctx, + rows, err = s.read.QueryContext(ctx, `SELECT project_id, SUBSTR(ts, 1, 10), COUNT(*) FROM search_log WHERE ts >= ? GROUP BY project_id, SUBSTR(ts, 1, 10)`, - grenze, + limit, ) if err != nil { return nil, fmt.Errorf("Suchen konnten nicht gezählt werden: %w", err) } defer rows.Close() for rows.Next() { - var projekt, tag string - var anzahl int - if err := rows.Scan(&projekt, &tag, &anzahl); err != nil { + var project, tag string + var count int + if err := rows.Scan(&project, &tag, &count); err != nil { return nil, err } - eintrag(projekt, tag).Suchen += anzahl + entry(project, tag).Searches += count } if err := rows.Err(); err != nil { return nil, err } - liste := make([]Tagesaktivitaet, 0, len(je)) - for _, a := range je { - liste = append(liste, *a) + list := make([]DailyActivity, 0, len(byKey)) + for _, a := range byKey { + list = append(list, *a) } - return liste, nil + return list, nil } -// Schemastand nennt die zuletzt angewandte Migration des Workspace (ist) und -// die neueste, die dieses Programm mitbringt (soll), z.B. "007_key_ablauf.sql". +// SchemaVersion nennt die zuletzt angewandte Migration des Workspace (actual) und +// die neueste, die dieses Programm mitbringt (expected), z.B. "007_key_expiry.sql". // // Beim Öffnen eines Workspace laufen fehlende Migrationen ohnehin durch // (runMigrations). Weichen beide trotzdem voneinander ab, hat eine neuere // Fassung von Munin die Datenbank schon weitergebracht als dieses Programm — // etwa nach dem Zurückrollen auf eine ältere Fassung. -func (s *Store) Schemastand(ctx context.Context) (ist, soll string, err error) { +func (s *Store) SchemaVersion(ctx context.Context) (actual, expected string, err error) { var name sql.NullString - if err := s.lesen.QueryRowContext(ctx, `SELECT MAX(name) FROM schema_migrations`).Scan(&name); err != nil { + if err := s.read.QueryRowContext(ctx, `SELECT MAX(name) FROM schema_migrations`).Scan(&name); err != nil { return "", "", fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err) } - dateien, err := migrationFiles(migrationsFS, "migrations/"+s.lesen.d.migrationsUnterordner()) + files, err := migrationFiles(migrationsFS, "migrations/"+s.read.d.migrationsSubdir()) if err != nil { return "", "", err } - if len(dateien) > 0 { - soll = dateien[len(dateien)-1] + if len(files) > 0 { + expected = files[len(files)-1] } - return name.String, soll, nil + return name.String, expected, nil } diff --git a/internal/store/systemstand_test.go b/internal/store/sysinfo_test.go similarity index 56% rename from internal/store/systemstand_test.go rename to internal/store/sysinfo_test.go index ab2aa0e..b855f27 100644 --- a/internal/store/systemstand_test.go +++ b/internal/store/sysinfo_test.go @@ -6,22 +6,22 @@ import ( "time" ) -// Tests der Kennzahlen für die Systemseite (systemstand.go). Laufen mit +// Tests der Kennzahlen für die Systemseite (sysinfo.go). Laufen mit // MUNIN_TEST_PG auch gegen PostgreSQL (siehe testStore). -// TestBestand zählt Projekte, Einträge, Papierkorb, Fassungen und Keys — +// TestInventory zählt Projekte, Einträge, Papierkorb, Fassungen und Keys — // Keys nach denselben Regeln wie die Seite "Zugänge". -func TestBestand(t *testing.T) { +func TestInventory(t *testing.T) { s := testStore(t) ctx := context.Background() - jetzt := time.Now() + now := time.Now() - leer, err := s.Bestand(ctx, jetzt) + empty, err := s.Inventory(ctx, now) if err != nil { t.Fatal(err) } - if leer != (Bestand{}) { - t.Errorf("leerer Workspace: %+v, erwartet nur Nullen", leer) + if empty != (Inventory{}) { + t.Errorf("leerer Workspace: %+v, erwartet nur Nullen", empty) } p := testProject(t, s) @@ -43,99 +43,99 @@ func TestBestand(t *testing.T) { if _, err := s.CreateAPIKey(ctx, p.ID, "frisch", "h1", "write"); err != nil { t.Fatal(err) } - if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "bald", "h2", "read", jetzt.Add(3*24*time.Hour)); err != nil { + if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "bald", "h2", "read", now.Add(3*24*time.Hour)); err != nil { t.Fatal(err) } - weg, err := s.CreateAPIKey(ctx, p.ID, "weg", "h3", "read") + gone, err := s.CreateAPIKey(ctx, p.ID, "weg", "h3", "read") if err != nil { t.Fatal(err) } - if err := s.RevokeAPIKey(ctx, weg.ID); err != nil { + if err := s.RevokeAPIKey(ctx, gone.ID); err != nil { t.Fatal(err) } - if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "abgelaufen", "h4", "read", jetzt.Add(time.Hour)); err != nil { + if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "abgelaufen", "h4", "read", now.Add(time.Hour)); err != nil { t.Fatal(err) } // Zwei Stunden später: "abgelaufen" ist es dann. - b, err := s.Bestand(ctx, jetzt.Add(2*time.Hour)) + b, err := s.Inventory(ctx, now.Add(2*time.Hour)) if err != nil { t.Fatal(err) } - erwartet := Bestand{Projekte: 1, Eintraege: 2, Fassungen: 1, Papierkorb: 1, Keys: 2, KeysLaufenAb: 1} - if b != erwartet { - t.Errorf("Bestand = %+v\nerwartet %+v", b, erwartet) + want := Inventory{Projects: 1, Entries: 2, Versions: 1, Trash: 1, Keys: 2, KeysExpiring: 1} + if b != want { + t.Errorf("Bestand = %+v\nerwartet %+v", b, want) } // 100 Tage später ist "frisch" nie benutzt worden und damit ungenutzt; // "bald" ist bis dahin abgelaufen und zählt gar nicht mehr. - b, err = s.Bestand(ctx, jetzt.Add(100*24*time.Hour)) + b, err = s.Inventory(ctx, now.Add(100*24*time.Hour)) if err != nil { t.Fatal(err) } - if b.Keys != 1 || b.KeysUngenutzt != 1 || b.KeysLaufenAb != 0 { - t.Errorf("nach 100 Tagen: Keys %d, ungenutzt %d, laufen ab %d — erwartet 1, 1, 0", b.Keys, b.KeysUngenutzt, b.KeysLaufenAb) + if b.Keys != 1 || b.KeysUnused != 1 || b.KeysExpiring != 0 { + t.Errorf("nach 100 Tagen: Keys %d, ungenutzt %d, laufen ab %d — erwartet 1, 1, 0", b.Keys, b.KeysUnused, b.KeysExpiring) } } -// TestBelegung: Ein Workspace mit Inhalt belegt Platz. Wie viel genau, hängt +// TestDiskUsage: Ein Workspace mit Inhalt belegt Platz. Wie viel genau, hängt // von der Datenbank ab — geprüft wird nur, dass die Zahl plausibel ist und // mit dem Inhalt wächst. -func TestBelegung(t *testing.T) { +func TestDiskUsage(t *testing.T) { s := testStore(t) ctx := context.Background() - vorher, err := s.Belegung(ctx) + before, err := s.DiskUsage(ctx) if err != nil { t.Fatal(err) } - if vorher.Daten <= 0 { - t.Fatalf("leerer Workspace belegt %d Bytes — die Tabellen allein brauchen Platz", vorher.Daten) + if before.Data <= 0 { + t.Fatalf("leerer Workspace belegt %d Bytes — die Tabellen allein brauchen Platz", before.Data) } p := testProject(t, s) - inhalt := make([]byte, 64<<10) - for i := range inhalt { - inhalt[i] = 'a' + byte(i%26) + content := make([]byte, 64<<10) + for i := range content { + content[i] = 'a' + byte(i%26) } for _, slug := range []string{"gross-1", "gross-2", "gross-3", "gross-4"} { - if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Content: string(inhalt), Source: SourceHuman}); err != nil { + if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Content: string(content), Source: SourceHuman}); err != nil { t.Fatal(err) } } - nachher, err := s.Belegung(ctx) + after, err := s.DiskUsage(ctx) if err != nil { t.Fatal(err) } // Bei SQLite landet das Geschriebene zunächst in der WAL-Datei, bei // PostgreSQL in den Tabellen. Zusammen muss es mehr geworden sein. - if nachher.Gesamt() <= vorher.Gesamt() { - t.Errorf("Belegung vorher %d, nachher %d — sollte gewachsen sein", vorher.Gesamt(), nachher.Gesamt()) + if after.Total() <= before.Total() { + t.Errorf("Belegung vorher %d, nachher %d — sollte gewachsen sein", before.Total(), after.Total()) } } -// TestAktivitaet zählt Schreibvorgänge je Projekt, Tag und Weg sowie Suchen, +// TestActivity zählt Schreibvorgänge je Projekt, Tag und Weg sowie Suchen, // und lässt alles vor der Grenze weg. -func TestAktivitaet(t *testing.T) { +func TestActivity(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) // Mit festen Zeitstempeln direkt eintragen — LogAudit und LogSearch // nehmen immer "jetzt". - audit := func(ts, aktion, quelle string) { + audit := func(ts, action, source string) { t.Helper() - _, err := s.schreiben.ExecContext(ctx, + _, err := s.write.ExecContext(ctx, `INSERT INTO audit_events (id, ts, project_id, actor, action, source) VALUES (?, ?, ?, 'test', ?, ?)`, - NewID(), ts, p.ID, aktion, quelle) + NewID(), ts, p.ID, action, source) if err != nil { t.Fatal(err) } } - suche := func(ts string) { + search := func(ts string) { t.Helper() - _, err := s.schreiben.ExecContext(ctx, + _, err := s.write.ExecContext(ctx, `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'frage', 1)`, ts, p.ID) if err != nil { t.Fatal(err) @@ -148,42 +148,42 @@ func TestAktivitaet(t *testing.T) { audit("2026-10-01T11:00:00Z", ActionLoginSuccess, SourceWeb) // kein Schreibvorgang audit("2026-10-02T08:00:00Z", ActionEntryWrite, SourceWeb) audit("2026-09-20T08:00:00Z", ActionEntryWrite, SourceMCP) // vor der Grenze - suche("2026-10-02T12:00:00Z") - suche("2026-10-02T13:00:00Z") - suche("2026-09-20T13:00:00Z") // vor der Grenze + search("2026-10-02T12:00:00Z") + search("2026-10-02T13:00:00Z") + search("2026-09-20T13:00:00Z") // vor der Grenze - liste, err := s.Aktivitaet(ctx, time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)) + list, err := s.Activity(ctx, time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatal(err) } - je := map[string]Tagesaktivitaet{} - for _, a := range liste { - if a.ProjektID != p.ID { - t.Errorf("fremdes Projekt %q", a.ProjektID) + per := map[string]DailyActivity{} + for _, a := range list { + if a.ProjectID != p.ID { + t.Errorf("fremdes Projekt %q", a.ProjectID) } - je[a.Tag] = a + per[a.Tag] = a } - if len(je) != 2 { - t.Fatalf("%d Tage, erwartet 2: %+v", len(je), liste) + if len(per) != 2 { + t.Fatalf("%d Tage, erwartet 2: %+v", len(per), list) } - if a := je["2026-10-01"]; a.SchreibenAgent != 2 || a.SchreibenMensch != 1 || a.Suchen != 0 { + if a := per["2026-10-01"]; a.AgentWrites != 2 || a.HumanWrites != 1 || a.Searches != 0 { t.Errorf("1.10.: %+v, erwartet 2 Agent, 1 Mensch, 0 Suchen", a) } - if a := je["2026-10-02"]; a.SchreibenAgent != 0 || a.SchreibenMensch != 1 || a.Suchen != 2 { + if a := per["2026-10-02"]; a.AgentWrites != 0 || a.HumanWrites != 1 || a.Searches != 2 { t.Errorf("2.10.: %+v, erwartet 0 Agent, 1 Mensch, 2 Suchen", a) } } -// TestSchemastand: Nach dem Öffnen steht der Workspace auf der neuesten +// TestSchemaVersion: Nach dem Öffnen steht der Workspace auf der neuesten // Migration, die das Programm mitbringt. -func TestSchemastand(t *testing.T) { +func TestSchemaVersion(t *testing.T) { s := testStore(t) - ist, soll, err := s.Schemastand(context.Background()) + actual, expected, err := s.SchemaVersion(context.Background()) if err != nil { t.Fatal(err) } - if soll == "" || ist != soll { - t.Errorf("Schemastand ist %q, soll %q", ist, soll) + if expected == "" || actual != expected { + t.Errorf("Schemastand ist %q, soll %q", actual, expected) } } diff --git a/internal/store/papierkorb.go b/internal/store/trash.go similarity index 78% rename from internal/store/papierkorb.go rename to internal/store/trash.go index f6e1fa9..b9ff0f0 100644 --- a/internal/store/papierkorb.go +++ b/internal/store/trash.go @@ -1,6 +1,6 @@ package store -// Papierkorb (AUSBAUSTUFEN.md 3.9, migrations/005_papierkorb.sql). +// Papierkorb (AUSBAUSTUFEN.md 3.9, migrations/005_trash.sql). // // Der Lebenslauf eines gelöschten Eintrags: // @@ -41,16 +41,16 @@ const TrashRetentionDays = int(TrashRetention / (24 * time.Hour)) // // Inhalt, Schlagworte, Verweise und Verlauf bleiben unverändert stehen, auch // die Fassungsnummer: Löschen ist keine neue Fassung, sondern eine Markierung -// daneben. geloeschtVon ist der Name des Zugangs, der gelöscht hat — die +// daneben. deletedBy ist der Name des Zugangs, der gelöscht hat — die // Papierkorb-Seite zeigt ihn, damit man sieht, ob ein Agent aufgeräumt hat. // // Liegt der Eintrag schon im Papierkorb (oder gibt es ihn nicht), kommt // ErrNotFound — aus Sicht des Aufrufers gibt es ihn ja nicht mehr. -func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloeschtVon string) error { - res, err := s.schreiben.ExecContext(ctx, +func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, deletedBy string) error { + res, err := s.write.ExecContext(ctx, `UPDATE entries SET deleted_at = ?, deleted_by = ? - WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, - NowString(), geloeschtVon, projectID, slug, + WHERE project_id = ? AND slug = ? AND `+notInTrash, + NowString(), deletedBy, projectID, slug, ) if err != nil { return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err) @@ -71,20 +71,20 @@ func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloescht // Alles läuft in einer Transaktion: Entweder liegen danach alle gültigen // Einträge im Papierkorb oder keiner. Zurück kommen die Bezeichner der // tatsächlich gelöschten Einträge — für Suchindex und Protokoll. -func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryIDs []string, geloeschtVon string) ([]string, error) { - var geloescht []string - jetzt := NowString() +func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryIDs []string, deletedBy string) ([]string, error) { + var deleted []string + now := NowString() - err := s.tx(ctx, func(tx *transaktion) error { + err := s.tx(ctx, func(tx *transaction) error { for _, id := range entryIDs { // RETURNING liefert die Spalte der geänderten Zeile gleich mit — // so braucht es keine zweite Abfrage, um den Bezeichner zu holen. var slug string err := tx.QueryRowContext(ctx, `UPDATE entries SET deleted_at = ?, deleted_by = ? - WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb+` + WHERE id = ? AND project_id = ? AND `+notInTrash+` RETURNING slug`, - jetzt, geloeschtVon, id, projectID, + now, deletedBy, id, projectID, ).Scan(&slug) if errors.Is(err, sql.ErrNoRows) { continue // fremdes Projekt, schon im Papierkorb oder gibt es nicht @@ -92,49 +92,49 @@ func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryI if err != nil { return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err) } - geloescht = append(geloescht, slug) + deleted = append(deleted, slug) } return nil }) if err != nil { return nil, err } - return geloescht, nil + return deleted, nil } // ListTrash gibt den Papierkorb eines Projekts zurück, zuletzt gelöschte zuerst. // -// geloeschtVon schränkt auf einen Zugang ein (leer = alle). So lässt sich +// deletedBy schränkt auf einen Zugang ein (leer = alle). So lässt sich // alles, was ein bestimmter Agent gelöscht hat, auf einen Blick sehen und in // einem Zug zurückholen. -func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) ([]TrashedEntry, error) { - abfrage := `SELECT id, slug, title, version, deleted_at, deleted_by +func (s *Store) ListTrash(ctx context.Context, projectID, deletedBy string) ([]TrashedEntry, error) { + query := `SELECT id, slug, title, version, deleted_at, deleted_by FROM entries WHERE project_id = ? AND deleted_at IS NOT NULL` - werte := []any{projectID} - if geloeschtVon != "" { - abfrage += ` AND deleted_by = ?` - werte = append(werte, geloeschtVon) + values := []any{projectID} + if deletedBy != "" { + query += ` AND deleted_by = ?` + values = append(values, deletedBy) } - abfrage += ` ORDER BY deleted_at DESC` + query += ` ORDER BY deleted_at DESC` - rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) + rows, err := s.read.QueryContext(ctx, query, values...) if err != nil { return nil, fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err) } defer rows.Close() - var liste []TrashedEntry + var list []TrashedEntry for rows.Next() { var t TrashedEntry - var geloeschtAm string - if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &geloeschtAm, &t.DeletedBy); err != nil { + var deletedAt string + if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &deletedAt, &t.DeletedBy); err != nil { return nil, err } - t.DeletedAt = parseTime(geloeschtAm) - liste = append(liste, t) + t.DeletedAt = parseTime(deletedAt) + list = append(list, t) } - return liste, rows.Err() + return list, rows.Err() } // RestoreFromTrash holt Einträge aus dem Papierkorb zurück. @@ -146,9 +146,9 @@ func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) ( // Zurückgegeben werden die wiederhergestellten Einträge samt Schlagworten, // damit der Aufrufer sie wieder in den Suchindex aufnehmen kann. func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []string) ([]Entry, error) { - var zurueck []Entry + var back []Entry - err := s.tx(ctx, func(tx *transaktion) error { + err := s.tx(ctx, func(tx *transaction) error { for _, slug := range slugs { res, err := tx.ExecContext(ctx, `UPDATE entries SET deleted_at = NULL, deleted_by = '' @@ -168,7 +168,7 @@ func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs [] if err != nil { return fmt.Errorf("Eintrag %q konnte nicht neu gelesen werden: %w", slug, err) } - zurueck = append(zurueck, e) + back = append(back, e) } return nil }) @@ -178,7 +178,7 @@ func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs [] // Die Schlagworte erst nach der Transaktion nachladen: attachTags liest // über den Lese-Pool, und der sieht die Änderung erst nach dem Commit. - return zurueck, s.attachTagsToSlice(ctx, zurueck) + return back, s.attachTagsToSlice(ctx, back) } // PurgeFromTrash löscht Einträge aus dem Papierkorb endgültig, samt Verlauf, @@ -189,8 +189,8 @@ func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs [] // Eintrag, der nicht im Papierkorb liegt, wird nie angefasst — auch nicht, // wenn jemand seinen Bezeichner ins Formular schmuggelt. func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []string) (int, error) { - geloescht := 0 - err := s.tx(ctx, func(tx *transaktion) error { + deleted := 0 + err := s.tx(ctx, func(tx *transaction) error { for _, slug := range slugs { res, err := tx.ExecContext(ctx, `DELETE FROM entries WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`, @@ -200,39 +200,39 @@ func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []st return fmt.Errorf("Eintrag %q konnte nicht endgültig gelöscht werden: %w", slug, err) } n, _ := res.RowsAffected() - geloescht += int(n) + deleted += int(n) } return nil }) if err != nil { return 0, err } - return geloescht, nil + return deleted, nil } -// PurgeTrash räumt alle Einträge endgültig ab, die länger als bis aelterAls im +// PurgeTrash räumt alle Einträge endgültig ab, die länger als bis olderThan im // Papierkorb liegen — über alle Projekte dieser Workspace-Datei. Gedacht für // die tägliche Aufgabe in main.go, die auch das Protokoll aufräumt. // // Gibt die Bezeichner der entfernten Einträge je Projekt zurück, damit der // Aufrufer jeden Fall im Protokoll vermerken kann. -func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]string, error) { - entfernt := map[string][]string{} +func (s *Store) PurgeTrash(ctx context.Context, olderThan string) (map[string][]string, error) { + removed := map[string][]string{} - err := s.tx(ctx, func(tx *transaktion) error { + err := s.tx(ctx, func(tx *transaction) error { // Erst lesen, was gleich verschwindet — nach dem DELETE wäre es weg. rows, err := tx.QueryContext(ctx, - `SELECT project_id, slug FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls) + `SELECT project_id, slug FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, olderThan) if err != nil { return fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err) } for rows.Next() { - var projektID, slug string - if err := rows.Scan(&projektID, &slug); err != nil { + var projectID, slug string + if err := rows.Scan(&projectID, &slug); err != nil { rows.Close() return err } - entfernt[projektID] = append(entfernt[projektID], slug) + removed[projectID] = append(removed[projectID], slug) } if err := rows.Err(); err != nil { rows.Close() @@ -241,7 +241,7 @@ func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][] rows.Close() if _, err := tx.ExecContext(ctx, - `DELETE FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls, + `DELETE FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, olderThan, ); err != nil { return fmt.Errorf("Papierkorb konnte nicht aufgeräumt werden: %w", err) } @@ -250,5 +250,5 @@ func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][] if err != nil { return nil, err } - return entfernt, nil + return removed, nil } diff --git a/internal/store/papierkorb_test.go b/internal/store/trash_test.go similarity index 67% rename from internal/store/papierkorb_test.go rename to internal/store/trash_test.go index 8c3dd8f..e0cf3fd 100644 --- a/internal/store/papierkorb_test.go +++ b/internal/store/trash_test.go @@ -7,22 +7,22 @@ import ( "time" ) -// TestPapierkorbUnsichtbar ist der wichtigste Test des Papierkorbs: Jeder +// TestTrashInvisible ist der wichtigste Test des Papierkorbs: Jeder // lesende Weg muss einen gelöschten Eintrag ausblenden. Kommt später eine // neue Abfrage auf entries dazu, gehört sie hier mit hinein (siehe -// nichtImPapierkorb in entries.go). +// notInTrash in entries.go). // // Aufbau: "lebt" verweist auf "weg", "weg" verweist auf "lebt". Beide tragen // das Schlagwort "gemeinsam", "weg" zusätzlich "nur-weg". Dann wird "weg" // gelöscht. -func TestPapierkorbUnsichtbar(t *testing.T) { +func TestTrashInvisible(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) - lebt, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "Lebt", + alive, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "Lebt", Content: "siehe [[weg]]", Tags: []string{"gemeinsam"}, Source: SourceHuman}) - weg, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", + gone, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Content: "siehe [[lebt]]", Tags: []string{"gemeinsam", "nur-weg"}, Source: SourceHuman, Curated: true}) if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "agent-1"); err != nil { @@ -43,11 +43,11 @@ func TestPapierkorbUnsichtbar(t *testing.T) { "mit Schlagwort": {Tag: "nur-weg"}, "nur kuratierte": {Curated: true}, } { - liste, err := s.ListEntries(ctx, p.ID, filter) + list, err := s.ListEntries(ctx, p.ID, filter) if err != nil { t.Fatal(err) } - for _, e := range liste { + for _, e := range list { if e.Slug == "weg" { t.Errorf("%s: gelöschter Eintrag in der Liste", name) } @@ -56,9 +56,9 @@ func TestPapierkorbUnsichtbar(t *testing.T) { }) t.Run("AllEntries (Suchindex, Export)", func(t *testing.T) { - alle, _ := s.AllEntries(ctx, p.ID) - if len(alle) != 1 || alle[0].Slug != "lebt" { - t.Errorf("AllEntries = %v, erwartet nur „lebt“", alle) + all, _ := s.AllEntries(ctx, p.ID) + if len(all) != 1 || all[0].Slug != "lebt" { + t.Errorf("AllEntries = %v, erwartet nur „lebt“", all) } }) @@ -89,15 +89,15 @@ func TestPapierkorbUnsichtbar(t *testing.T) { }) t.Run("LinkGraph", func(t *testing.T) { - knoten, kanten, _ := s.LinkGraph(ctx, p.ID) - for _, k := range knoten { + nodes, edges, _ := s.LinkGraph(ctx, p.ID) + for _, k := range nodes { // "lebt" verweist weiter auf "weg" — der Knoten darf bleiben, aber // nur als gestrichelter "gibt es nicht". if k.Slug == "weg" && k.Exists { t.Error("gelöschter Eintrag erscheint im Graph als vorhanden") } } - for _, e := range kanten { + for _, e := range edges { if e.From == "weg" { t.Error("Verweis eines gelöschten Eintrags erscheint im Graph") } @@ -105,9 +105,9 @@ func TestPapierkorbUnsichtbar(t *testing.T) { }) t.Run("BulkUpdateTags", func(t *testing.T) { - geaendert, _ := s.BulkUpdateTags(ctx, p.ID, []string{lebt.ID, weg.ID}, []string{"neu"}, nil) - if len(geaendert) != 1 || geaendert[0].Slug != "lebt" { - t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", geaendert) + changed, _ := s.BulkUpdateTags(ctx, p.ID, []string{alive.ID, gone.ID}, []string{"neu"}, nil) + if len(changed) != 1 || changed[0].Slug != "lebt" { + t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", changed) } }) } @@ -123,14 +123,14 @@ func TestListTrash(t *testing.T) { s.MoveEntryToTrash(ctx, p.ID, "a", "agent-1") s.MoveEntryToTrash(ctx, p.ID, "b", "tom") - alle, _ := s.ListTrash(ctx, p.ID, "") - if len(alle) != 2 { - t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(alle)) + all, _ := s.ListTrash(ctx, p.ID, "") + if len(all) != 2 { + t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(all)) } - vomAgenten, _ := s.ListTrash(ctx, p.ID, "agent-1") - if len(vomAgenten) != 1 || vomAgenten[0].Slug != "a" || vomAgenten[0].DeletedBy != "agent-1" { - t.Errorf("Filter nach Zugang: %+v", vomAgenten) + fromAgent, _ := s.ListTrash(ctx, p.ID, "agent-1") + if len(fromAgent) != 1 || fromAgent[0].Slug != "a" || fromAgent[0].DeletedBy != "agent-1" { + t.Errorf("Filter nach Zugang: %+v", fromAgent) } } @@ -138,31 +138,31 @@ func TestRestoreFromTrash(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) - fremd, _ := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "") + foreign, _ := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "") s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "Inhalt", Tags: []string{"x"}, Source: SourceHuman}) - s.PutEntry(ctx, EntryWrite{ProjectID: fremd.ID, Slug: "f", Title: "F", Source: SourceHuman}) + s.PutEntry(ctx, EntryWrite{ProjectID: foreign.ID, Slug: "f", Title: "F", Source: SourceHuman}) s.MoveEntryToTrash(ctx, p.ID, "e", "tom") - s.MoveEntryToTrash(ctx, fremd.ID, "f", "tom") + s.MoveEntryToTrash(ctx, foreign.ID, "f", "tom") // "f" liegt im Papierkorb eines ANDEREN Projekts — darf nicht mit. - zurueck, err := s.RestoreFromTrash(ctx, p.ID, []string{"e", "f", "gibt-es-nicht"}) + back, err := s.RestoreFromTrash(ctx, p.ID, []string{"e", "f", "gibt-es-nicht"}) if err != nil { t.Fatal(err) } - if len(zurueck) != 1 || zurueck[0].Slug != "e" { - t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", zurueck) + if len(back) != 1 || back[0].Slug != "e" { + t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", back) } - if len(zurueck[0].Tags) != 1 || zurueck[0].Tags[0] != "x" { - t.Errorf("Schlagworte nach Wiederherstellen = %v", zurueck[0].Tags) + if len(back[0].Tags) != 1 || back[0].Tags[0] != "x" { + t.Errorf("Schlagworte nach Wiederherstellen = %v", back[0].Tags) } e, err := s.EntryBySlug(ctx, p.ID, "e") if err != nil || e.Content != "Inhalt" || e.Version != 1 { t.Errorf("nach Wiederherstellen: %+v, %v — Inhalt und Fassung müssen unverändert sein", e, err) } - if liste, _ := s.ListTrash(ctx, fremd.ID, ""); len(liste) != 1 { + if list, _ := s.ListTrash(ctx, foreign.ID, ""); len(list) != 1 { t.Error("der Eintrag des fremden Projekts wurde angefasst") } } @@ -178,31 +178,31 @@ func TestPutEntryRevivesFromTrash(t *testing.T) { s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Alt", Content: "gelöscht", Source: SourceHuman}) s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1") - neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "frisch", Source: SourceAgent}) + newer, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "frisch", Source: SourceAgent}) if err != nil { t.Fatal(err) } - if !neu.RestoredFromTrash { + if !newer.RestoredFromTrash { t.Error("RestoredFromTrash nicht gesetzt") } - if neu.Version != 2 { - t.Errorf("Fassung %d, erwartet 2", neu.Version) + if newer.Version != 2 { + t.Errorf("Fassung %d, erwartet 2", newer.Version) } if _, err := s.EntryBySlug(ctx, p.ID, "e"); err != nil { t.Errorf("Eintrag nach dem Überschreiben nicht sichtbar: %v", err) } - if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 0 { + if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 0 { t.Error("Eintrag liegt nach dem Überschreiben noch im Papierkorb") } - alt, err := s.EntryVersionByNumber(ctx, neu.ID, 1) - if err != nil || alt.Content != "gelöscht" { - t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, alt.Content) + old, err := s.EntryVersionByNumber(ctx, newer.ID, 1) + if err != nil || old.Content != "gelöscht" { + t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, old.Content) } // Ein normaler Schreibvorgang danach meldet nichts mehr vom Papierkorb. - weiter, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent}) - if weiter.RestoredFromTrash { + more, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent}) + if more.RestoredFromTrash { t.Error("RestoredFromTrash bei einem gewöhnlichen Update gesetzt") } } @@ -223,7 +223,7 @@ func TestPutEntryConflictWhenDeletedMeanwhile(t *testing.T) { if !errors.Is(err, ErrConflict) { t.Errorf("Fehler = %v, erwartet ErrConflict", err) } - if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 { + if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 1 { t.Error("der Eintrag wurde trotz Konflikt aus dem Papierkorb geholt") } } @@ -251,9 +251,9 @@ func TestPurgeFromTrashOnlyTrashed(t *testing.T) { } // Der Bezeichner ist danach wieder frei: Neu anlegen beginnt bei Fassung 1. - neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman}) - if err != nil || neu.Version != 1 || neu.RestoredFromTrash { - t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", neu, err) + newer, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman}) + if err != nil || newer.Version != 1 || newer.RestoredFromTrash { + t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", newer, err) } } @@ -272,23 +272,23 @@ func TestPurgeTrashAfterRetention(t *testing.T) { // "alt" künstlich vor 31 Tage zurückdatieren — die Tests sollen nicht // einen Monat warten. - vorEinemMonat := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano) - if _, err := s.schreiben.ExecContext(ctx, - `UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, vorEinemMonat); err != nil { + oneMonthAgo := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano) + if _, err := s.write.ExecContext(ctx, + `UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, oneMonthAgo); err != nil { t.Fatal(err) } - grenze := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano) - entfernt, err := s.PurgeTrash(ctx, grenze) + limit := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano) + removed, err := s.PurgeTrash(ctx, limit) if err != nil { t.Fatal(err) } - if len(entfernt[p.ID]) != 1 || entfernt[p.ID][0] != "alt" { - t.Errorf("entfernt: %v, erwartet nur „alt“", entfernt) + if len(removed[p.ID]) != 1 || removed[p.ID][0] != "alt" { + t.Errorf("entfernt: %v, erwartet nur „alt“", removed) } - if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 || liste[0].Slug != "frisch" { - t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", liste) + if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 1 || list[0].Slug != "frisch" { + t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", list) } if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil { t.Error("ein normaler Eintrag wurde beim Aufräumen entfernt") @@ -302,7 +302,7 @@ func TestMoveEntriesToTrash(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) - fremdesProjekt, err := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "") + foreignProject, err := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "") if err != nil { t.Fatal(err) } @@ -310,34 +310,34 @@ func TestMoveEntriesToTrash(t *testing.T) { a, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Content: "x", Source: SourceHuman}) b, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Content: "x", Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "bleibt", Title: "Bleibt", Content: "x", Source: SourceHuman}) - fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: fremdesProjekt.ID, Slug: "f", Title: "F", Content: "x", Source: SourceHuman}) + foreign, _ := s.PutEntry(ctx, EntryWrite{ProjectID: foreignProject.ID, Slug: "f", Title: "F", Content: "x", Source: SourceHuman}) - geloescht, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID, fremd.ID, "gibtesnicht"}, "mensch") + deleted, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID, foreign.ID, "gibtesnicht"}, "mensch") if err != nil { t.Fatal(err) } - if len(geloescht) != 2 || geloescht[0] != "a" || geloescht[1] != "b" { - t.Errorf("gelöscht = %v, erwartet [a b]", geloescht) + if len(deleted) != 2 || deleted[0] != "a" || deleted[1] != "b" { + t.Errorf("gelöscht = %v, erwartet [a b]", deleted) } - if _, err := s.EntryBySlug(ctx, fremdesProjekt.ID, "f"); err != nil { + if _, err := s.EntryBySlug(ctx, foreignProject.ID, "f"); err != nil { t.Errorf("der Eintrag eines fremden Projekts wurde angetastet: %v", err) } if _, err := s.EntryBySlug(ctx, p.ID, "bleibt"); err != nil { t.Errorf("ein nicht ausgewählter Eintrag ist weg: %v", err) } - papierkorb, err := s.ListTrash(ctx, p.ID, "") + trash, err := s.ListTrash(ctx, p.ID, "") if err != nil { t.Fatal(err) } - if len(papierkorb) != 2 || papierkorb[0].DeletedBy != "mensch" { - t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", papierkorb) + if len(trash) != 2 || trash[0].DeletedBy != "mensch" { + t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", trash) } // Doppelt abgeschickt: nichts mehr zu tun, kein Fehler. - nochmal, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch") - if err != nil || len(nochmal) != 0 { - t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", nochmal, err) + again, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch") + if err != nil || len(again) != 0 { + t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", again, err) } } diff --git a/internal/store/nutzung.go b/internal/store/usage.go similarity index 85% rename from internal/store/nutzung.go rename to internal/store/usage.go index 4f394bf..3b56e92 100644 --- a/internal/store/nutzung.go +++ b/internal/store/usage.go @@ -77,7 +77,7 @@ func (u UsageSummary) Total() WriteCounts { // CuratedPercent ist der Anteil kuratierter Einträge in Prozent (0–100). func (u UsageSummary) CuratedPercent() int { - return prozent(u.Curated, u.Entries) + return percent(u.Curated, u.Entries) } // UpdatePercent ist der Anteil der Aktualisierungen an allen Schreibvorgängen @@ -88,19 +88,19 @@ func (u UsageSummary) UpdatePercent() int { if t.Created+t.Updated == 0 { return -1 } - return prozent(t.Updated, t.Created+t.Updated) + return percent(t.Updated, t.Created+t.Updated) } -// prozent rechnet teil/ganz in ganze Prozent um, kaufmännisch gerundet. +// percent rechnet part/whole in ganze Prozent um, kaufmännisch gerundet. // Ganzzahlig statt mit Kommazahlen: "37 %" reicht für diese Übersicht, und so // gibt es keine Rundungsfragen in der Anzeige. -func prozent(teil, ganz int) int { - if ganz == 0 { +func percent(part, whole int) int { + if whole == 0 { return 0 } // Die Hälfte des Nenners dazuzählen, bevor ganzzahlig geteilt wird — // das ist Runden statt Abschneiden. - return (teil*100 + ganz/2) / ganz + return (part*100 + whole/2) / whole } // UsageSummaries liefert die Kennzahlen je Projekt, als map von Projekt-ID @@ -111,9 +111,9 @@ func prozent(teil, ganz int) int { // Projekte ganz ohne Einträge und ohne Schreibvorgänge fehlen in der map. Der // Aufrufer bekommt für sie beim Nachschlagen den Nullwert — also überall 0, // und genau das stimmt ja auch. -func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time.Time) (map[string]UsageSummary, error) { - ergebnis := map[string]UsageSummary{} - seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) +func (s *Store) UsageSummaries(ctx context.Context, projectID string, now time.Time) (map[string]UsageSummary, error) { + result := map[string]UsageSummary{} + since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) // ── 1. Schreibvorgänge aus dem Protokoll ──────────────────────────────── // @@ -122,48 +122,48 @@ func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time // angelegt oder aktualisiert wurde (siehe DetailEntryCreated in audit.go). // Alles, was nicht "angelegt" und nicht leer ist, ist eine Aktualisierung: // "aktualisiert", "wiederhergestellt: …", "Sammelbearbeitung: …". - abfrage := `SELECT project_id, source, + query := `SELECT project_id, source, SUM(CASE WHEN action = ? AND detail = ? THEN 1 ELSE 0 END), SUM(CASE WHEN action = ? AND detail <> ? AND detail <> '' THEN 1 ELSE 0 END), SUM(CASE WHEN action = ? AND detail = '' THEN 1 ELSE 0 END), SUM(CASE WHEN action = ? THEN 1 ELSE 0 END) FROM audit_events WHERE ts >= ? AND action IN (?, ?)` - werte := []any{ + values := []any{ ActionEntryWrite, DetailEntryCreated, ActionEntryWrite, DetailEntryCreated, ActionEntryWrite, ActionEntryDelete, - seit, ActionEntryWrite, ActionEntryDelete, + since, ActionEntryWrite, ActionEntryDelete, } if projectID != "" { - abfrage += ` AND project_id = ?` - werte = append(werte, projectID) + query += ` AND project_id = ?` + values = append(values, projectID) } - abfrage += ` GROUP BY project_id, source` + query += ` GROUP BY project_id, source` - rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) + rows, err := s.read.QueryContext(ctx, query, values...) if err != nil { return nil, fmt.Errorf("Nutzung konnte nicht gelesen werden: %w", err) } for rows.Next() { - var projekt, quelle string + var project, source string var z WriteCounts - if err := rows.Scan(&projekt, &quelle, &z.Created, &z.Updated, &z.Unknown, &z.Deleted); err != nil { + if err := rows.Scan(&project, &source, &z.Created, &z.Updated, &z.Unknown, &z.Deleted); err != nil { rows.Close() return nil, err } // Werte aus einer map sind in Go Kopien: erst herausholen, ändern, // dann wieder hineinschreiben. - u := ergebnis[projekt] - switch quelle { + u := result[project] + switch source { case SourceWeb: u.Human = u.Human.add(z) case SourceMCP: u.Agent = u.Agent.add(z) } - ergebnis[projekt] = u + result[project] = u } if err := rows.Err(); err != nil { rows.Close() @@ -176,38 +176,38 @@ func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time // NOT EXISTS fragt je Eintrag, ob es in entry_tags gar keine Zeile für ihn // gibt. Das läuft über den Primärschlüssel (entry_id, tag) und ist damit // ein kurzer Blick in den Index je Eintrag. - abfrage = `SELECT project_id, COUNT(*), COALESCE(SUM(curated), 0), + query = `SELECT project_id, COUNT(*), COALESCE(SUM(curated), 0), SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) THEN 1 ELSE 0 END), MAX(updated_at) FROM entries e WHERE e.deleted_at IS NULL` - werte = nil + values = nil if projectID != "" { - abfrage += ` AND project_id = ?` - werte = append(werte, projectID) + query += ` AND project_id = ?` + values = append(values, projectID) } - abfrage += ` GROUP BY project_id` + query += ` GROUP BY project_id` - rows, err = s.lesen.QueryContext(ctx, abfrage, werte...) + rows, err = s.read.QueryContext(ctx, query, values...) if err != nil { return nil, fmt.Errorf("Bestand konnte nicht gelesen werden: %w", err) } defer rows.Close() for rows.Next() { - var projekt string - var zuletzt sql.NullString - var anzahl, kuratiert, ohneSchlagwort int - if err := rows.Scan(&projekt, &anzahl, &kuratiert, &ohneSchlagwort, &zuletzt); err != nil { + var project string + var latest sql.NullString + var count, curated, untagged int + if err := rows.Scan(&project, &count, &curated, &untagged, &latest); err != nil { return nil, err } - u := ergebnis[projekt] - u.Entries, u.Curated, u.Untagged = anzahl, kuratiert, ohneSchlagwort - if zuletzt.Valid { - u.LastUpdatedAt = parseTime(zuletzt.String) + u := result[project] + u.Entries, u.Curated, u.Untagged = count, curated, untagged + if latest.Valid { + u.LastUpdatedAt = parseTime(latest.String) } - ergebnis[projekt] = u + result[project] = u } - return ergebnis, rows.Err() + return result, rows.Err() } // EntryRef ist ein knapper Verweis auf einen Eintrag: Bezeichner und Titel. @@ -219,7 +219,7 @@ type EntryRef struct { // UntaggedEntries gibt Einträge ohne Schlagwort zurück, zuletzt geänderte // zuerst, höchstens limit Stück. func (s *Store) UntaggedEntries(ctx context.Context, projectID string, limit int) ([]EntryRef, error) { - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT slug, title FROM entries e WHERE project_id = ? AND e.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) @@ -232,15 +232,15 @@ func (s *Store) UntaggedEntries(ctx context.Context, projectID string, limit int } defer rows.Close() - var liste []EntryRef + var list []EntryRef for rows.Next() { var e EntryRef if err := rows.Scan(&e.Slug, &e.Title); err != nil { return nil, err } - liste = append(liste, e) + list = append(list, e) } - return liste, rows.Err() + return list, rows.Err() } // MissingLink ist ein Bezeichner, auf den verwiesen wird, zu dem es aber @@ -259,7 +259,7 @@ type MissingLink struct { // Verweise AUS gelöschten Einträgen zählen nicht; Verweise AUF gelöschte // Einträge dagegen schon — für alle Leser ist das Ziel ja weg. func (s *Store) MissingLinks(ctx context.Context, projectID string) ([]MissingLink, error) { - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT l.to_slug, COUNT(*) AS anzahl FROM entry_links l JOIN entries e ON e.id = l.from_entry_id @@ -276,22 +276,22 @@ func (s *Store) MissingLinks(ctx context.Context, projectID string) ([]MissingLi } defer rows.Close() - var liste []MissingLink + var list []MissingLink for rows.Next() { var m MissingLink if err := rows.Scan(&m.Slug, &m.Count); err != nil { return nil, err } - liste = append(liste, m) + list = append(list, m) } - return liste, rows.Err() + return list, rows.Err() } // RecentlyUsedKeys gibt die zuletzt benutzten, nicht zurückgezogenen Keys // eines Projekts zurück, höchstens limit Stück. Nie benutzte Keys fehlen — // die gehören in eine andere Frage (AUSBAUSTUFEN.md 4.7). func (s *Store) RecentlyUsedKeys(ctx context.Context, projectID string, limit int) ([]APIKey, error) { - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE project_id = ? AND revoked_at = '' AND last_used_at <> '' ORDER BY last_used_at DESC @@ -345,7 +345,7 @@ func (s *Store) CuratedOverrides(ctx context.Context, projectID string, limit in // SQLite (es nimmt die übrigen Spalten dann aus der Zeile mit dem // Höchstwert); PostgreSQL lehnt es ab. Fensterfunktionen können beide // (SQLite ab 3.25), siehe AUSBAUSTUFEN.md 4.10. - rows, err := s.lesen.QueryContext(ctx, + rows, err := s.read.QueryContext(ctx, `SELECT entry_slug, title, actor, detail, ts FROM ( SELECT a.entry_slug, e.title, a.actor, a.detail, a.ts, ROW_NUMBER() OVER (PARTITION BY a.entry_slug ORDER BY a.ts DESC, a.id DESC) AS nr @@ -364,17 +364,17 @@ func (s *Store) CuratedOverrides(ctx context.Context, projectID string, limit in } defer rows.Close() - var liste []CuratedOverride + var list []CuratedOverride for rows.Next() { var c CuratedOverride - var detail, zeitpunkt string - if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &zeitpunkt); err != nil { + var detail, at string + if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &at); err != nil { return nil, err } c.Reason = strings.TrimSpace(strings.TrimPrefix( strings.TrimPrefix(detail, DetailCuratedOverridden), ":")) - c.At = parseTime(zeitpunkt) - liste = append(liste, c) + c.At = parseTime(at) + list = append(list, c) } - return liste, rows.Err() + return list, rows.Err() } diff --git a/internal/store/nutzung_test.go b/internal/store/usage_test.go similarity index 63% rename from internal/store/nutzung_test.go rename to internal/store/usage_test.go index 83a9503..85aa302 100644 --- a/internal/store/nutzung_test.go +++ b/internal/store/usage_test.go @@ -8,41 +8,41 @@ import ( "unicode/utf8" ) -// TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und +// TestUsageSummariesCountsWrites: Neuanlagen, Aktualisierungen und // Löschungen werden getrennt nach Mensch (Web) und Agent (MCP) gezählt — und // nur innerhalb des Zeitfensters. -func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) { +func TestUsageSummariesCountsWrites(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) - protokoll := func(quelle, aktion, detail string) { - s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: aktion, Source: quelle, Detail: detail}) + log := func(source, action, detail string) { + s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: action, Source: source, Detail: detail}) } - protokoll(SourceMCP, ActionEntryWrite, DetailEntryCreated) - protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated) - protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated) - protokoll(SourceMCP, ActionEntryDelete, "in den Papierkorb") - protokoll(SourceWeb, ActionEntryWrite, DetailEntryCreated) - protokoll(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung - protokoll(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso - protokoll(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung - protokoll(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht + log(SourceMCP, ActionEntryWrite, DetailEntryCreated) + log(SourceMCP, ActionEntryWrite, DetailEntryUpdated) + log(SourceMCP, ActionEntryWrite, DetailEntryUpdated) + log(SourceMCP, ActionEntryDelete, "in den Papierkorb") + log(SourceWeb, ActionEntryWrite, DetailEntryCreated) + log(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung + log(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso + log(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung + log(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht // Ein Schreibvorgang außerhalb des Fensters darf nicht mitzählen. - alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano) - if _, err := s.schreiben.ExecContext(ctx, + old := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano) + if _, err := s.write.ExecContext(ctx, `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) VALUES (?, ?, ?, '', 'x', ?, '', ?, '', ?)`, - NewID(), alt, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil { + NewID(), old, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil { t.Fatal(err) } - alle, err := s.UsageSummaries(ctx, "", time.Now()) + all, err := s.UsageSummaries(ctx, "", time.Now()) if err != nil { t.Fatal(err) } - u := alle[p.ID] + u := all[p.ID] if (u.Agent != WriteCounts{Created: 1, Updated: 2, Deleted: 1}) { t.Errorf("Agent = %+v", u.Agent) @@ -56,15 +56,15 @@ func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) { } // Eingeschränkt auf das Projekt kommt dasselbe heraus. - einzeln, _ := s.UsageSummaries(ctx, p.ID, time.Now()) - if einzeln[p.ID] != u { - t.Errorf("mit Projektfilter: %+v, ohne: %+v", einzeln[p.ID], u) + single, _ := s.UsageSummaries(ctx, p.ID, time.Now()) + if single[p.ID] != u { + t.Errorf("mit Projektfilter: %+v, ohne: %+v", single[p.ID], u) } } -// TestUsageSummariesBestand: Einträge, kuratierte und solche ohne Schlagwort — +// TestUsageSummariesInventory: Einträge, kuratierte und solche ohne Schlagwort — // ohne Papierkorb. -func TestUsageSummariesBestand(t *testing.T) { +func TestUsageSummariesInventory(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) @@ -75,8 +75,8 @@ func TestUsageSummariesBestand(t *testing.T) { s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Curated: true, Source: SourceAgent}) s.MoveEntryToTrash(ctx, p.ID, "weg", "tom") - alle, _ := s.UsageSummaries(ctx, "", time.Now()) - u := alle[p.ID] + all, _ := s.UsageSummaries(ctx, "", time.Now()) + u := all[p.ID] if u.Entries != 3 || u.Curated != 1 || u.Untagged != 2 { t.Errorf("Bestand = %d Einträge, %d kuratiert, %d ohne Schlagwort; erwartet 3, 1, 2", @@ -93,9 +93,9 @@ func TestUsageSummariesBestand(t *testing.T) { t.Errorf("UpdatePercent ohne Schreibvorgänge = %d, erwartet -1", u.UpdatePercent()) } - ohne, _ := s.UntaggedEntries(ctx, p.ID, 10) - if len(ohne) != 2 { - t.Errorf("UntaggedEntries = %v, erwartet b und c", ohne) + without, _ := s.UntaggedEntries(ctx, p.ID, 10) + if len(without) != 2 { + t.Errorf("UntaggedEntries = %v, erwartet b und c", without) } } @@ -114,17 +114,17 @@ func TestMissingLinks(t *testing.T) { s.MoveEntryToTrash(ctx, p.ID, "geloescht", "tom") s.MoveEntryToTrash(ctx, p.ID, "im-papierkorb", "tom") - liste, err := s.MissingLinks(ctx, p.ID) + list, err := s.MissingLinks(ctx, p.ID) if err != nil { t.Fatal(err) } - erwartet := []MissingLink{{"fehlt", 2}, {"geloescht", 1}} - if len(liste) != len(erwartet) { - t.Fatalf("MissingLinks = %v, erwartet %v", liste, erwartet) + want := []MissingLink{{"fehlt", 2}, {"geloescht", 1}} + if len(list) != len(want) { + t.Fatalf("MissingLinks = %v, erwartet %v", list, want) } - for i := range erwartet { - if liste[i] != erwartet[i] { - t.Errorf("[%d] = %v, erwartet %v", i, liste[i], erwartet[i]) + for i := range want { + if list[i] != want[i] { + t.Errorf("[%d] = %v, erwartet %v", i, list[i], want[i]) } } } @@ -134,13 +134,13 @@ func TestRecentlyUsedKeys(t *testing.T) { ctx := context.Background() p := testProject(t, s) - benutzt, _ := s.CreateAPIKey(ctx, p.ID, "benutzt", "hash-1", "write") + used, _ := s.CreateAPIKey(ctx, p.ID, "benutzt", "hash-1", "write") s.CreateAPIKey(ctx, p.ID, "nie", "hash-2", "write") - weg, _ := s.CreateAPIKey(ctx, p.ID, "zurueckgezogen", "hash-3", "write") + gone, _ := s.CreateAPIKey(ctx, p.ID, "zurueckgezogen", "hash-3", "write") - jetzt := NowString() - s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, jetzt, benutzt.ID, weg.ID) - s.RevokeAPIKey(ctx, weg.ID) + now := NowString() + s.write.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, now, used.ID, gone.ID) + s.RevokeAPIKey(ctx, gone.ID) keys, err := s.RecentlyUsedKeys(ctx, p.ID, 5) if err != nil { @@ -151,12 +151,12 @@ func TestRecentlyUsedKeys(t *testing.T) { } } -// TestCuratedOverrideDetailKuerzt prüft, dass eine ausufernde Begründung +// TestCuratedOverrideDetailTruncates prüft, dass eine ausufernde Begründung // gekürzt wird — in Zeichen, nicht in Bytes, damit kein Umlaut zerbricht. -func TestCuratedOverrideDetailKuerzt(t *testing.T) { - lang := strings.Repeat("ü", maxBegruendung+50) +func TestCuratedOverrideDetailTruncates(t *testing.T) { + long := strings.Repeat("ü", maxJustification+50) - got := CuratedOverrideDetail(lang) + got := CuratedOverrideDetail(long) if !strings.HasPrefix(got, DetailCuratedOverridden+": ") { t.Errorf("Anfang fehlt: %q", got[:40]) @@ -164,19 +164,19 @@ func TestCuratedOverrideDetailKuerzt(t *testing.T) { if !utf8.ValidString(got) { t.Error("die Kürzung hat ein Zeichen zerbrochen") } - if n := utf8.RuneCountInString(got); n > len(DetailCuratedOverridden)+2+maxBegruendung+1 { + if n := utf8.RuneCountInString(got); n > len(DetailCuratedOverridden)+2+maxJustification+1 { t.Errorf("Detail ist %d Zeichen lang, nicht gekürzt", n) } } -// TestCuratedOverridesJuengsterVorgang prüft, dass bei mehreren Änderungen an +// TestCuratedOverridesLatestEvent prüft, dass bei mehreren Änderungen an // einem Eintrag Zugang und Begründung der JÜNGSTEN Änderung erscheinen — und // der Eintrag nur einmal. // // Früher hing das an einer Eigenheit von SQLite (MAX() mit Spalten ohne // Aggregat), die PostgreSQL nicht kennt. Die Zeitstempel sind hier fest // gesetzt, damit die Reihenfolge eindeutig ist. -func TestCuratedOverridesJuengsterVorgang(t *testing.T) { +func TestCuratedOverridesLatestEvent(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) @@ -185,39 +185,39 @@ func TestCuratedOverridesJuengsterVorgang(t *testing.T) { t.Fatal(err) } - jetzt := time.Now().UTC() + now := time.Now().UTC() for _, v := range []struct { - alter time.Duration - zugang, grund string + age time.Duration + key, reason string }{ {2 * time.Hour, "agent-alt", "alter Grund"}, {1 * time.Minute, "agent-neu", "neuer Grund"}, {1 * time.Hour, "agent-mitte", "mittlerer Grund"}, } { - if _, err := s.schreiben.ExecContext(ctx, + if _, err := s.write.ExecContext(ctx, `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) VALUES (?, ?, ?, '', ?, ?, 'vpn', ?, '', ?)`, - NewID(), jetzt.Add(-v.alter).Format(time.RFC3339Nano), p.ID, v.zugang, - ActionEntryWrite, SourceMCP, CuratedOverrideDetail(v.grund)); err != nil { + NewID(), now.Add(-v.age).Format(time.RFC3339Nano), p.ID, v.key, + ActionEntryWrite, SourceMCP, CuratedOverrideDetail(v.reason)); err != nil { t.Fatal(err) } } - liste, err := s.CuratedOverrides(ctx, p.ID, 10) + list, err := s.CuratedOverrides(ctx, p.ID, 10) if err != nil { t.Fatal(err) } - if len(liste) != 1 { - t.Fatalf("%d Zeilen, erwartet 1 (je Eintrag nur der jüngste Vorgang): %+v", len(liste), liste) + if len(list) != 1 { + t.Fatalf("%d Zeilen, erwartet 1 (je Eintrag nur der jüngste Vorgang): %+v", len(list), list) } - if liste[0].Actor != "agent-neu" || liste[0].Reason != "neuer Grund" { - t.Errorf("Zeile = %+v, erwartet agent-neu mit „neuer Grund“", liste[0]) + if list[0].Actor != "agent-neu" || list[0].Reason != "neuer Grund" { + t.Errorf("Zeile = %+v, erwartet agent-neu mit „neuer Grund“", list[0]) } } -// TestCuratedOverridesOhneGeloeschte prüft, dass Einträge im Papierkorb nicht +// TestCuratedOverridesWithoutDeleted prüft, dass Einträge im Papierkorb nicht // zur Prüfung erscheinen — es gibt dort nichts mehr zu prüfen. -func TestCuratedOverridesOhneGeloeschte(t *testing.T) { +func TestCuratedOverridesWithoutDeleted(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) @@ -228,13 +228,13 @@ func TestCuratedOverridesOhneGeloeschte(t *testing.T) { s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryWrite, EntrySlug: "weg", Source: SourceMCP, Detail: CuratedOverrideDetail("Grund")}) - if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 1 { - t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(liste)) + if list, _ := s.CuratedOverrides(ctx, p.ID, 10); len(list) != 1 { + t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(list)) } if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "mensch"); err != nil { t.Fatal(err) } - if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 0 { - t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", liste) + if list, _ := s.CuratedOverrides(ctx, p.ID, 10); len(list) != 0 { + t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", list) } } diff --git a/internal/store/verbindung.go b/internal/store/verbindung.go deleted file mode 100644 index 6bf7eac..0000000 --- a/internal/store/verbindung.go +++ /dev/null @@ -1,90 +0,0 @@ -package store - -import ( - "context" - "database/sql" -) - -// pool ist ein Verbindungspool samt dem Dialekt seiner Datenbank. -// -// # Warum nicht direkt *sql.DB? -// -// Jede Abfrage muss durch dialekt.rebind, bevor sie an die Datenbank geht — -// sonst bekommt PostgreSQL ein "?", mit dem es nichts anfangen kann. Würde der -// Store *sql.DB direkt benutzen, wäre das bei jeder der rund 100 Abfragen eine -// Stelle, an der man es vergessen kann. -// -// pool bietet deshalb nur die Methoden an, die der Store braucht, und jede -// davon wandelt um. An *sql.DB selbst kommt außerhalb dieser Datei niemand -// heran — eine Abfrage am Dialekt vorbei lässt sich gar nicht erst schreiben. -type pool struct { - db *sql.DB - d dialekt -} - -func (p pool) ExecContext(ctx context.Context, abfrage string, args ...any) (sql.Result, error) { - return p.db.ExecContext(ctx, p.d.rebind(abfrage), args...) -} - -func (p pool) QueryContext(ctx context.Context, abfrage string, args ...any) (*sql.Rows, error) { - return p.db.QueryContext(ctx, p.d.rebind(abfrage), args...) -} - -func (p pool) QueryRowContext(ctx context.Context, abfrage string, args ...any) *sql.Row { - return p.db.QueryRowContext(ctx, p.d.rebind(abfrage), args...) -} - -// beginTx startet eine Transaktion, deren Abfragen ebenfalls umgewandelt -// werden. -func (p pool) beginTx(ctx context.Context) (*transaktion, error) { - tx, err := p.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - return &transaktion{tx: tx, d: p.d}, nil -} - -func (p pool) Close() error { return p.db.Close() } - -// Verbindungen sind die Zahlen eines Lese- und eines Schreibpools, wie -// database/sql sie führt — für die Systemseite (AUSBAUSTUFEN.md 5.12). -type Verbindungen struct { - Lesen, Schreiben sql.DBStats -} - -// Offen zählt die offenen Verbindungen beider Pools zusammen. Genau so viele -// belegt Munin gerade beim Datenbankserver. -func (v Verbindungen) Offen() int { return v.Lesen.OpenConnections + v.Schreiben.OpenConnections } - -// InBenutzung zählt die Verbindungen, die gerade eine Abfrage ausführen. -func (v Verbindungen) InBenutzung() int { return v.Lesen.InUse + v.Schreiben.InUse } - -// Gewartet zählt, wie oft eine Anfrage auf eine freie Verbindung warten -// musste, seit der Pool geöffnet wurde. Wächst die Zahl stetig, sind die -// Pools zu klein für die Last. -func (v Verbindungen) Gewartet() int64 { return v.Lesen.WaitCount + v.Schreiben.WaitCount } - -// transaktion ist das Gegenstück zu pool für *sql.Tx: dieselben Methoden, -// dieselbe Umwandlung. -type transaktion struct { - tx *sql.Tx - d dialekt -} - -func (t *transaktion) ExecContext(ctx context.Context, abfrage string, args ...any) (sql.Result, error) { - return t.tx.ExecContext(ctx, t.d.rebind(abfrage), args...) -} - -func (t *transaktion) QueryContext(ctx context.Context, abfrage string, args ...any) (*sql.Rows, error) { - return t.tx.QueryContext(ctx, t.d.rebind(abfrage), args...) -} - -func (t *transaktion) QueryRowContext(ctx context.Context, abfrage string, args ...any) *sql.Row { - return t.tx.QueryRowContext(ctx, t.d.rebind(abfrage), args...) -} - -func (t *transaktion) Commit() error { return t.tx.Commit() } - -// Rollback nach einem erfolgreichen Commit ist wirkungslos — deshalb kann es -// bedenkenlos per defer stehen. -func (t *transaktion) Rollback() error { return t.tx.Rollback() } diff --git a/internal/store/workspace_admins.go b/internal/store/workspace_admins.go index 0cf084a..350371c 100644 --- a/internal/store/workspace_admins.go +++ b/internal/store/workspace_admins.go @@ -34,8 +34,8 @@ func (a WorkspaceAdmin) Revoked() bool { // // Bei einem Workspace-Admin wiegt das schwerer als bei einem Projekt-Key: Ein // vergessenes Token öffnet nicht ein Projekt, sondern den ganzen Workspace. -func (a WorkspaceAdmin) Unused(jetzt time.Time) bool { - return ungenutzt(a.Revoked(), a.LastUsedAt, a.CreatedAt, jetzt) +func (a WorkspaceAdmin) Unused(now time.Time) bool { + return unused(a.Revoked(), a.LastUsedAt, a.CreatedAt, now) } // workspaceAdminColumns steht in jeder Abfrage auf workspace_admins. @@ -71,17 +71,17 @@ func (c *ControlStore) CreateWorkspaceAdmin(ctx context.Context, workspaceID, na Name: name, Hash: hash, } - jetzt := NowString() + now := NowString() - if _, err := c.schreiben.ExecContext(ctx, + if _, err := c.write.ExecContext(ctx, `INSERT INTO workspace_admins (id, workspace_id, name, hash, created_at) VALUES (?, ?, ?, ?, ?)`, - a.ID, a.WorkspaceID, a.Name, a.Hash, jetzt, + a.ID, a.WorkspaceID, a.Name, a.Hash, now, ); err != nil { return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht angelegt werden: %w", err) } - a.CreatedAt = parseTime(jetzt) + a.CreatedAt = parseTime(now) return a, nil } @@ -90,7 +90,7 @@ func (c *ControlStore) CreateWorkspaceAdmin(ctx context.Context, workspaceID, na // // Zurückgezogene Zugänge gelten als nicht vorhanden, wie bei ResolveKeyHash. func (c *ControlStore) ResolveWorkspaceAdminHash(ctx context.Context, hash string) (WorkspaceAdmin, error) { - row := c.lesen.QueryRowContext(ctx, + row := c.read.QueryRowContext(ctx, `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE hash = ? AND revoked_at = ''`, hash) a, err := scanWorkspaceAdmin(row.Scan) @@ -106,7 +106,7 @@ func (c *ControlStore) ResolveWorkspaceAdminHash(ctx context.Context, hash strin // WorkspaceAdminByID liest einen Zugang anhand seiner ID — auch einen // zurückgezogenen. Ob er noch gilt, entscheidet der Aufrufer über Revoked(). func (c *ControlStore) WorkspaceAdminByID(ctx context.Context, id string) (WorkspaceAdmin, error) { - row := c.lesen.QueryRowContext(ctx, + row := c.read.QueryRowContext(ctx, `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE id = ?`, id) a, err := scanWorkspaceAdmin(row.Scan) @@ -122,7 +122,7 @@ func (c *ControlStore) WorkspaceAdminByID(ctx context.Context, id string) (Works // ListWorkspaceAdmins gibt alle Zugänge eines Workspace zurück, neueste zuerst. // Zurückgezogene sind dabei, damit die Verwaltung sie noch anzeigen kann. func (c *ControlStore) ListWorkspaceAdmins(ctx context.Context, workspaceID string) ([]WorkspaceAdmin, error) { - rows, err := c.lesen.QueryContext(ctx, + rows, err := c.read.QueryContext(ctx, `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID) if err != nil { @@ -145,7 +145,7 @@ func (c *ControlStore) ListWorkspaceAdmins(ctx context.Context, workspaceID stri // für die Übersicht des Plattform-Admins, in einer Abfrage statt einer je // Workspace. func (c *ControlStore) ListActiveWorkspaceAdmins(ctx context.Context) ([]WorkspaceAdmin, error) { - rows, err := c.lesen.QueryContext(ctx, + rows, err := c.read.QueryContext(ctx, `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE revoked_at = ''`) if err != nil { return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err) @@ -168,7 +168,7 @@ func (c *ControlStore) ListActiveWorkspaceAdmins(ctx context.Context) ([]Workspa // Die Workspace-ID steht mit in der Bedingung: Eine untergeschobene ID aus // einem anderen Workspace trifft dann keine Zeile und endet als ErrNotFound. func (c *ControlStore) RevokeWorkspaceAdmin(ctx context.Context, workspaceID, id string) error { - res, err := c.schreiben.ExecContext(ctx, + res, err := c.write.ExecContext(ctx, `UPDATE workspace_admins SET revoked_at = ? WHERE id = ? AND workspace_id = ? AND revoked_at = ''`, NowString(), id, workspaceID, @@ -185,7 +185,7 @@ func (c *ControlStore) TouchWorkspaceAdmin(ctx context.Context, id string, lastU if time.Since(lastUsed) < time.Hour { return nil } - _, err := c.schreiben.ExecContext(ctx, + _, err := c.write.ExecContext(ctx, `UPDATE workspace_admins SET last_used_at = ? WHERE id = ?`, NowString(), id) return err } diff --git a/internal/web/chart.go b/internal/web/chart.go new file mode 100644 index 0000000..6ba511c --- /dev/null +++ b/internal/web/chart.go @@ -0,0 +1,191 @@ +package web + +import ( + "fmt" + + "munin/internal/stats" +) + +// Balkendiagramme für die Systemseite (AUSBAUSTUFEN.md 5.16). +// +// # Warum selbst gezeichnet? +// +// Eine Diagramm-Bibliothek wäre JavaScript, das erst geladen werden und unter +// der Content-Security-Policy laufen muss — für 30 Balken. Stattdessen rechnet +// der Server die Rechtecke aus, und das Template schreibt sie als SVG direkt +// in die Seite. Der Browser zeichnet SVG wie HTML, ohne ein Skript. +// +// Die Farben kommen aus style.css über Klassen (keine style-Attribute — die +// verbietet die CSP), damit das dunkle Farbschema von selbst passt. Fährt man +// mit der Maus über einen Tag, zeigt der Browser dessen <title> an. +// +// Alle Maße sind ganze Zahlen im Koordinatensystem der viewBox. Das SVG +// skaliert mit der Breite der Seite; die Zahlen sind also keine Pixel. + +// Maße des Diagramms in viewBox-Einheiten. +const ( + chartLeft = 36 // Platz für die Zahlen der y-Achse + chartTop = 8 // damit die oberste Zahl nicht abgeschnitten wird + chartArea = 120 // Höhe der Balkenfläche + chartBottom = 20 // Platz für die Daten der x-Achse + chartColumn = 20 // Breite je Tag + chartBarWidth = 14 // Breite des Balkens darin, der Rest ist Abstand +) + +// chart ist ein fertig ausgerechnetes Balkendiagramm. +type chart struct { + Title string // für Screenreader (aria-label) + Width, Height int // viewBox + Links, Right int // x der Balkenfläche: Anfang und Ende + Baseline int // y der Nulllinie + Empty bool // im ganzen Zeitraum nichts — die Seite sagt das statt leerer Achsen + Grid []chartLine + Bars []chartBarDay + Axis []chartLabel +} + +// chartLine ist eine waagerechte Hilfslinie mit ihrem Wert. +type chartLine struct { + Y int + Text string +} + +// chartBarDay ist ein Tag: eine unsichtbare Trefferfläche über die +// ganze Höhe (damit der Tooltip auch bei kleinen Balken erscheint) und die +// gestapelten Teile. +type chartBarDay struct { + X, Width int // Trefferfläche + Title string + Parts []chartPart +} + +// chartPart ist ein Rechteck eines gestapelten Balkens. +type chartPart struct { + X, Y, Width, Height int + Class string +} + +// chartLabel ist ein Datum unter der x-Achse. +type chartLabel struct { + X int + Text string +} + +// chartSeries ist eine Teilmenge eines Balkens: welcher Wert, in welcher +// Farbe, wie heißt er im Tooltip. +type chartSeries struct { + value func(stats.Tag) int + class string + name string +} + +// weekdays für den Tooltip, mit Sonntag = 0 wie time.Weekday. +var weekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"} + +// chartUnit ist das Wort hinter der Summe im Tooltip, in Einzahl und +// Mehrzahl: "1 Schreibvorgang", "12 Schreibvorgänge". +type chartUnit struct{ one, more string } + +// buildChart rechnet ein gestapeltes Balkendiagramm aus: je Tag ein Balken, +// darin je Reihe ein Teil, von unten nach oben in der Reihenfolge der Reihen. +func buildChart(title string, unit chartUnit, days []stats.Tag, series ...chartSeries) chart { + d := chart{ + Title: title, + Links: chartLeft, + Right: chartLeft + len(days)*chartColumn, + Baseline: chartTop + chartArea, + } + d.Width = d.Right + 4 + d.Height = d.Baseline + chartBottom + + total := func(t stats.Tag) int { + n := 0 + for _, r := range series { + n += r.value(t) + } + return n + } + + highest := 0 + for _, t := range days { + highest = max(highest, total(t)) + } + d.Empty = highest == 0 + scale := round125(highest) + + // Hilfslinien bei der Hälfte und ganz oben. Die Hälfte nur, wenn sie + // eine ganze Zahl ist — "2,5 Suchen" wäre seltsam. + d.Grid = append(d.Grid, chartLine{Y: d.Baseline, Text: "0"}) + if scale%2 == 0 { + d.Grid = append(d.Grid, chartLine{Y: d.Baseline - chartArea/2, Text: fmt.Sprint(scale / 2)}) + } + d.Grid = append(d.Grid, chartLine{Y: chartTop, Text: fmt.Sprint(scale)}) + + for i, t := range days { + column := chartLeft + i*chartColumn + tag := chartBarDay{X: column, Width: chartColumn} + + // Tooltip: "Mo 13.10.: 12 Schreibvorgänge (Agenten 9, Menschen 3)". + // Der Name vor der Zahl erspart die Frage nach Einzahl oder Mehrzahl. + tag.Title = fmt.Sprintf("%s %s: %s", weekdays[t.Date.Weekday()], t.Date.Format("02.01."), + plural(total(t), unit.one, unit.more)) + if len(series) > 1 && total(t) > 0 { + tag.Title += " (" + for j, r := range series { + if j > 0 { + tag.Title += ", " + } + tag.Title += fmt.Sprintf("%s %d", r.name, r.value(t)) + } + tag.Title += ")" + } + + // Die Teile stapeln: y wächst in SVG nach unten, also von der + // Nulllinie aus nach oben abziehen. + top := d.Baseline + for _, r := range series { + h := heightOf(r.value(t), scale) + if h == 0 { + continue + } + top -= h + tag.Parts = append(tag.Parts, chartPart{ + X: column + (chartColumn-chartBarWidth)/2, Y: top, + Width: chartBarWidth, Height: h, Class: r.class, + }) + } + d.Bars = append(d.Bars, tag) + + // Ein Datum je Woche, vom heutigen Tag rückwärts gezählt — so steht + // heute immer unten rechts. + if (len(days)-1-i)%7 == 0 { + d.Axis = append(d.Axis, chartLabel{X: column + chartColumn/2, Text: t.Date.Format("02.01.")}) + } + } + return d +} + +// heightOf rechnet einen Wert in die Höhe seines Balkens um. Ein Wert über +// null bekommt mindestens eine Einheit, sonst verschwände ein einzelner +// Schreibvorgang neben einem Tag mit 500. +func heightOf(value, scale int) int { + if value <= 0 { + return 0 + } + return max(1, value*chartArea/scale) +} + +// round125 rundet auf die nächste "glatte" Zahl auf: 1, 2, 5, 10, 20, 50, … +// So endet die y-Achse bei einem Wert, den man gut lesen kann, statt bei 37. +func round125(n int) int { + if n <= 1 { + return 1 + } + for level := 1; ; level *= 10 { + for _, f := range []int{1, 2, 5} { + if f*level >= n { + return f * level + } + } + } +} diff --git a/internal/web/chart_test.go b/internal/web/chart_test.go new file mode 100644 index 0000000..4c4a9ad --- /dev/null +++ b/internal/web/chart_test.go @@ -0,0 +1,82 @@ +package web + +import ( + "testing" + "time" + + "munin/internal/stats" +) + +func TestRound125(t *testing.T) { + for _, tc := range []struct{ in, out int }{ + {0, 1}, {1, 1}, {2, 2}, {3, 5}, {5, 5}, {6, 10}, {11, 20}, {37, 50}, {51, 100}, {999, 1000}, {1001, 2000}, + } { + if got := round125(tc.in); got != tc.out { + t.Errorf("runde125(%d) = %d, erwartet %d", tc.in, got, tc.out) + } + } +} + +// TestChart: Die Balken stapeln sich von der Nulllinie nach oben, der +// höchste reicht bis zur Skala, ein kleiner Wert bleibt sichtbar, und unter +// dem letzten Tag steht ein Datum. +func TestChart(t *testing.T) { + tag0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + days := make([]stats.Tag, 8) + for i := range days { + days[i].Date = tag0.AddDate(0, 0, i) + } + days[3].AgentWrites, days[3].HumanWrites = 6, 4 // Summe 10 = Skala + days[5].AgentWrites = 1 + + d := buildChart("Test", chartUnit{"Schreibvorgang", "Schreibvorgänge"}, days, + chartSeries{value: func(t stats.Tag) int { return t.AgentWrites }, class: "a", name: "Agenten"}, + chartSeries{value: func(t stats.Tag) int { return t.HumanWrites }, class: "m", name: "Menschen"}, + ) + + if d.Empty || len(d.Bars) != len(days) { + t.Fatalf("Leer=%v, %d Balken", d.Empty, len(d.Bars)) + } + full := d.Bars[3].Parts + if len(full) != 2 { + t.Fatalf("Tag 3: %d Teile, erwartet 2", len(full)) + } + if full[0].Y+full[0].Height != d.Baseline { + t.Error("unterster Teil steht nicht auf der Nulllinie") + } + if full[1].Y+full[1].Height != full[0].Y { + t.Error("zweiter Teil liegt nicht auf dem ersten") + } + if full[1].Y != chartTop { + t.Errorf("höchster Balken endet bei y=%d, erwartet %d (die Skala)", full[1].Y, chartTop) + } + if small := d.Bars[5].Parts; len(small) != 1 || small[0].Height < 1 { + t.Errorf("ein einzelner Schreibvorgang ist unsichtbar: %+v", small) + } + if len(d.Bars[0].Parts) != 0 { + t.Error("ein Tag ohne Aktivität hat Balken") + } + if got := d.Bars[3].Title; got != "So 04.10.: 10 Schreibvorgänge (Agenten 6, Menschen 4)" { + t.Errorf("Tooltip %q", got) + } + if last := d.Axis[len(d.Axis)-1]; last.Text != "08.10." { + t.Errorf("letzte Beschriftung %q, erwartet den letzten Tag", last.Text) + } + + if empty := buildChart("Leer", chartUnit{"Suche", "Suchen"}, days[:2]); !empty.Empty { + t.Error("Diagramm ohne Werte ist nicht als leer markiert") + } +} + +func TestByteSize(t *testing.T) { + for _, tc := range []struct { + in int64 + out string + }{ + {0, "0 B"}, {1023, "1023 B"}, {1536, "1,5 KB"}, {812 << 10, "812 KB"}, {3 << 30, "3,0 GB"}, + } { + if got := byteSize(tc.in); got != tc.out { + t.Errorf("groesse(%d) = %q, erwartet %q", tc.in, got, tc.out) + } + } +} diff --git a/internal/web/context.go b/internal/web/context.go index 2283351..89536b8 100644 --- a/internal/web/context.go +++ b/internal/web/context.go @@ -5,6 +5,7 @@ package web import ( "context" "net/http" + "time" "munin/internal/auth" "munin/internal/store" @@ -35,7 +36,8 @@ import ( // künftigen Plattform-Funktion, an ihn zu denken, ist sie für ihn gesperrt, // nicht offen. type Session struct { - Subject string // für die CSRF-Token-Berechnung + Subject string // für die CSRF-Token-Berechnung + Expires time.Time // Ablauf der Sitzung; ein Workspace-Wechsel übernimmt ihn (siehe setSessionCookie) IsPlatformAdmin bool IsWorkspaceAdmin bool WorkspaceID string @@ -132,11 +134,11 @@ type contextKey int const ( sessionContextKey contextKey = iota projectContextKey - aktorContextKey + actorContextKey workspaceContextKey ) -// aktorHalter ist ein veränderbarer Platz im Context, in den die +// actorHolder ist ein veränderbarer Platz im Context, in den die // Anmelde-Middleware den Namen des Zugangs schreibt. // // # Warum dieser Umweg? @@ -151,18 +153,18 @@ const ( // // Ohne Absicherung gegen gleichzeitigen Zugriff: Geschrieben und gelesen wird in // derselben Goroutine, das Lesen erst nachdem der Handler zurückgekehrt ist. -type aktorHalter struct { +type actorHolder struct { name string } -// withAktorHalter legt einen leeren Halter im Context ab. -func withAktorHalter(ctx context.Context, h *aktorHalter) context.Context { - return context.WithValue(ctx, aktorContextKey, h) +// withActorHolder legt einen leeren Halter im Context ab. +func withActorHolder(ctx context.Context, h *actorHolder) context.Context { + return context.WithValue(ctx, actorContextKey, h) } -// merkeAktor trägt den Namen des Zugangs in den Halter ein, falls einer da ist. -func merkeAktor(ctx context.Context, name string) { - if h, ok := ctx.Value(aktorContextKey).(*aktorHalter); ok { +// recordActor trägt den Namen des Zugangs in den Halter ein, falls einer da ist. +func recordActor(ctx context.Context, name string) { + if h, ok := ctx.Value(actorContextKey).(*actorHolder); ok { h.name = name } } @@ -170,9 +172,9 @@ func merkeAktor(ctx context.Context, name string) { // withSession legt die Sitzung im Request-Context ab. // // Nebenbei wird der Name des Zugangs für das Zugriffsprotokoll vermerkt — das -// liegt weiter außen und käme sonst nicht an ihn heran (siehe aktorHalter). +// liegt weiter außen und käme sonst nicht an ihn heran (siehe actorHolder). func withSession(ctx context.Context, s Session) context.Context { - merkeAktor(ctx, s.ActorName()) + recordActor(ctx, s.ActorName()) return context.WithValue(ctx, sessionContextKey, s) } diff --git a/internal/web/diagramm.go b/internal/web/diagramm.go deleted file mode 100644 index fd3aaee..0000000 --- a/internal/web/diagramm.go +++ /dev/null @@ -1,191 +0,0 @@ -package web - -import ( - "fmt" - - "munin/internal/statistik" -) - -// Balkendiagramme für die Systemseite (AUSBAUSTUFEN.md 5.16). -// -// # Warum selbst gezeichnet? -// -// Eine Diagramm-Bibliothek wäre JavaScript, das erst geladen werden und unter -// der Content-Security-Policy laufen muss — für 30 Balken. Stattdessen rechnet -// der Server die Rechtecke aus, und das Template schreibt sie als SVG direkt -// in die Seite. Der Browser zeichnet SVG wie HTML, ohne ein Skript. -// -// Die Farben kommen aus style.css über Klassen (keine style-Attribute — die -// verbietet die CSP), damit das dunkle Farbschema von selbst passt. Fährt man -// mit der Maus über einen Tag, zeigt der Browser dessen <title> an. -// -// Alle Maße sind ganze Zahlen im Koordinatensystem der viewBox. Das SVG -// skaliert mit der Breite der Seite; die Zahlen sind also keine Pixel. - -// Maße des Diagramms in viewBox-Einheiten. -const ( - diagrammLinks = 36 // Platz für die Zahlen der y-Achse - diagrammOben = 8 // damit die oberste Zahl nicht abgeschnitten wird - diagrammFlaeche = 120 // Höhe der Balkenfläche - diagrammUnten = 20 // Platz für die Daten der x-Achse - diagrammSpalte = 20 // Breite je Tag - diagrammBalken = 14 // Breite des Balkens darin, der Rest ist Abstand -) - -// diagramm ist ein fertig ausgerechnetes Balkendiagramm. -type diagramm struct { - Titel string // für Screenreader (aria-label) - Breite, Hoehe int // viewBox - Links, Rechts int // x der Balkenfläche: Anfang und Ende - Grund int // y der Nulllinie - Leer bool // im ganzen Zeitraum nichts — die Seite sagt das statt leerer Achsen - Gitter []diagrammLinie - Balken []diagrammBalkenTag - Achse []diagrammBeschriftung -} - -// diagrammLinie ist eine waagerechte Hilfslinie mit ihrem Wert. -type diagrammLinie struct { - Y int - Text string -} - -// diagrammBalkenTag ist ein Tag: eine unsichtbare Trefferfläche über die -// ganze Höhe (damit der Tooltip auch bei kleinen Balken erscheint) und die -// gestapelten Teile. -type diagrammBalkenTag struct { - X, Breite int // Trefferfläche - Titel string - Teile []diagrammTeil -} - -// diagrammTeil ist ein Rechteck eines gestapelten Balkens. -type diagrammTeil struct { - X, Y, Breite, Hoehe int - Klasse string -} - -// diagrammBeschriftung ist ein Datum unter der x-Achse. -type diagrammBeschriftung struct { - X int - Text string -} - -// diagrammReihe ist eine Teilmenge eines Balkens: welcher Wert, in welcher -// Farbe, wie heißt er im Tooltip. -type diagrammReihe struct { - wert func(statistik.Tag) int - klasse string - name string -} - -// wochentage für den Tooltip, mit Sonntag = 0 wie time.Weekday. -var wochentage = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"} - -// diagrammEinheit ist das Wort hinter der Summe im Tooltip, in Einzahl und -// Mehrzahl: "1 Schreibvorgang", "12 Schreibvorgänge". -type diagrammEinheit struct{ eins, mehr string } - -// baueDiagramm rechnet ein gestapeltes Balkendiagramm aus: je Tag ein Balken, -// darin je Reihe ein Teil, von unten nach oben in der Reihenfolge der Reihen. -func baueDiagramm(titel string, einheit diagrammEinheit, tage []statistik.Tag, reihen ...diagrammReihe) diagramm { - d := diagramm{ - Titel: titel, - Links: diagrammLinks, - Rechts: diagrammLinks + len(tage)*diagrammSpalte, - Grund: diagrammOben + diagrammFlaeche, - } - d.Breite = d.Rechts + 4 - d.Hoehe = d.Grund + diagrammUnten - - summe := func(t statistik.Tag) int { - n := 0 - for _, r := range reihen { - n += r.wert(t) - } - return n - } - - hoechster := 0 - for _, t := range tage { - hoechster = max(hoechster, summe(t)) - } - d.Leer = hoechster == 0 - skala := runde125(hoechster) - - // Hilfslinien bei der Hälfte und ganz oben. Die Hälfte nur, wenn sie - // eine ganze Zahl ist — "2,5 Suchen" wäre seltsam. - d.Gitter = append(d.Gitter, diagrammLinie{Y: d.Grund, Text: "0"}) - if skala%2 == 0 { - d.Gitter = append(d.Gitter, diagrammLinie{Y: d.Grund - diagrammFlaeche/2, Text: fmt.Sprint(skala / 2)}) - } - d.Gitter = append(d.Gitter, diagrammLinie{Y: diagrammOben, Text: fmt.Sprint(skala)}) - - for i, t := range tage { - spalte := diagrammLinks + i*diagrammSpalte - tag := diagrammBalkenTag{X: spalte, Breite: diagrammSpalte} - - // Tooltip: "Mo 13.10.: 12 Schreibvorgänge (Agenten 9, Menschen 3)". - // Der Name vor der Zahl erspart die Frage nach Einzahl oder Mehrzahl. - tag.Titel = fmt.Sprintf("%s %s: %s", wochentage[t.Datum.Weekday()], t.Datum.Format("02.01."), - plural(summe(t), einheit.eins, einheit.mehr)) - if len(reihen) > 1 && summe(t) > 0 { - tag.Titel += " (" - for j, r := range reihen { - if j > 0 { - tag.Titel += ", " - } - tag.Titel += fmt.Sprintf("%s %d", r.name, r.wert(t)) - } - tag.Titel += ")" - } - - // Die Teile stapeln: y wächst in SVG nach unten, also von der - // Nulllinie aus nach oben abziehen. - oben := d.Grund - for _, r := range reihen { - h := hoeheVon(r.wert(t), skala) - if h == 0 { - continue - } - oben -= h - tag.Teile = append(tag.Teile, diagrammTeil{ - X: spalte + (diagrammSpalte-diagrammBalken)/2, Y: oben, - Breite: diagrammBalken, Hoehe: h, Klasse: r.klasse, - }) - } - d.Balken = append(d.Balken, tag) - - // Ein Datum je Woche, vom heutigen Tag rückwärts gezählt — so steht - // heute immer unten rechts. - if (len(tage)-1-i)%7 == 0 { - d.Achse = append(d.Achse, diagrammBeschriftung{X: spalte + diagrammSpalte/2, Text: t.Datum.Format("02.01.")}) - } - } - return d -} - -// hoeheVon rechnet einen Wert in die Höhe seines Balkens um. Ein Wert über -// null bekommt mindestens eine Einheit, sonst verschwände ein einzelner -// Schreibvorgang neben einem Tag mit 500. -func hoeheVon(wert, skala int) int { - if wert <= 0 { - return 0 - } - return max(1, wert*diagrammFlaeche/skala) -} - -// runde125 rundet auf die nächste "glatte" Zahl auf: 1, 2, 5, 10, 20, 50, … -// So endet die y-Achse bei einem Wert, den man gut lesen kann, statt bei 37. -func runde125(n int) int { - if n <= 1 { - return 1 - } - for stufe := 1; ; stufe *= 10 { - for _, f := range []int{1, 2, 5} { - if f*stufe >= n { - return f * stufe - } - } - } -} diff --git a/internal/web/diagramm_test.go b/internal/web/diagramm_test.go deleted file mode 100644 index cb5224f..0000000 --- a/internal/web/diagramm_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package web - -import ( - "testing" - "time" - - "munin/internal/statistik" -) - -func TestRunde125(t *testing.T) { - for _, fall := range []struct{ ein, aus int }{ - {0, 1}, {1, 1}, {2, 2}, {3, 5}, {5, 5}, {6, 10}, {11, 20}, {37, 50}, {51, 100}, {999, 1000}, {1001, 2000}, - } { - if got := runde125(fall.ein); got != fall.aus { - t.Errorf("runde125(%d) = %d, erwartet %d", fall.ein, got, fall.aus) - } - } -} - -// TestDiagramm: Die Balken stapeln sich von der Nulllinie nach oben, der -// höchste reicht bis zur Skala, ein kleiner Wert bleibt sichtbar, und unter -// dem letzten Tag steht ein Datum. -func TestDiagramm(t *testing.T) { - tag0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) - tage := make([]statistik.Tag, 8) - for i := range tage { - tage[i].Datum = tag0.AddDate(0, 0, i) - } - tage[3].SchreibenAgent, tage[3].SchreibenMensch = 6, 4 // Summe 10 = Skala - tage[5].SchreibenAgent = 1 - - d := baueDiagramm("Test", diagrammEinheit{"Schreibvorgang", "Schreibvorgänge"}, tage, - diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenAgent }, klasse: "a", name: "Agenten"}, - diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenMensch }, klasse: "m", name: "Menschen"}, - ) - - if d.Leer || len(d.Balken) != len(tage) { - t.Fatalf("Leer=%v, %d Balken", d.Leer, len(d.Balken)) - } - voll := d.Balken[3].Teile - if len(voll) != 2 { - t.Fatalf("Tag 3: %d Teile, erwartet 2", len(voll)) - } - if voll[0].Y+voll[0].Hoehe != d.Grund { - t.Error("unterster Teil steht nicht auf der Nulllinie") - } - if voll[1].Y+voll[1].Hoehe != voll[0].Y { - t.Error("zweiter Teil liegt nicht auf dem ersten") - } - if voll[1].Y != diagrammOben { - t.Errorf("höchster Balken endet bei y=%d, erwartet %d (die Skala)", voll[1].Y, diagrammOben) - } - if klein := d.Balken[5].Teile; len(klein) != 1 || klein[0].Hoehe < 1 { - t.Errorf("ein einzelner Schreibvorgang ist unsichtbar: %+v", klein) - } - if len(d.Balken[0].Teile) != 0 { - t.Error("ein Tag ohne Aktivität hat Balken") - } - if got := d.Balken[3].Titel; got != "So 04.10.: 10 Schreibvorgänge (Agenten 6, Menschen 4)" { - t.Errorf("Tooltip %q", got) - } - if letzte := d.Achse[len(d.Achse)-1]; letzte.Text != "08.10." { - t.Errorf("letzte Beschriftung %q, erwartet den letzten Tag", letzte.Text) - } - - if leer := baueDiagramm("Leer", diagrammEinheit{"Suche", "Suchen"}, tage[:2]); !leer.Leer { - t.Error("Diagramm ohne Werte ist nicht als leer markiert") - } -} - -func TestGroesse(t *testing.T) { - for _, fall := range []struct { - ein int64 - aus string - }{ - {0, "0 B"}, {1023, "1023 B"}, {1536, "1,5 KB"}, {812 << 10, "812 KB"}, {3 << 30, "3,0 GB"}, - } { - if got := groesse(fall.ein); got != fall.aus { - t.Errorf("groesse(%d) = %q, erwartet %q", fall.ein, got, fall.aus) - } - } -} diff --git a/internal/web/download_test.go b/internal/web/download_test.go index 881106a..a2d178d 100644 --- a/internal/web/download_test.go +++ b/internal/web/download_test.go @@ -16,50 +16,50 @@ import ( // Tests für den Download als ZIP (handlers_download.go). -// ladeHerunter ruft einen Download ab und gibt die Antwort zurück. -func ladeHerunter(h http.Handler, pfad string, cookie *http.Cookie) *httptest.ResponseRecorder { - r := httptest.NewRequest(http.MethodGet, pfad, nil) +// download ruft einen Download ab und gibt die Antwort zurück. +func download(h http.Handler, filePath string, cookie *http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, filePath, nil) r.AddCookie(cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } -// zipInhalt öffnet ein heruntergeladenes ZIP und gibt Pfad → Inhalt zurück. -func zipInhalt(t *testing.T, daten []byte) map[string]string { +// zipContents öffnet ein heruntergeladenes ZIP und gibt Pfad → Inhalt zurück. +func zipContents(t *testing.T, data []byte) map[string]string { t.Helper() - archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten))) + archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatalf("kein gültiges ZIP: %v", err) } - inhalt := make(map[string]string) - for _, f := range archiv.File { + content := make(map[string]string) + for _, f := range archive.File { rc, err := f.Open() if err != nil { t.Fatal(err) } b, _ := io.ReadAll(rc) rc.Close() - inhalt[f.Name] = string(b) + content[f.Name] = string(b) } - return inhalt + return content } -func TestProjektDownloadAlsAdmin(t *testing.T) { - srv, db, projekt := testServer(t) +func TestProjectDownloadAsAdmin(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() if _, err := db.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "deployment", Title: "Deployment", + ProjectID: project.ID, Slug: "deployment", Title: "Deployment", Content: "Wir deployen freitags nie.", Source: store.SourceHuman, }); err != nil { t.Fatal(err) } - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)) - w := ladeHerunter(h, "/p/testprojekt/export.zip", cookie) + cookie := login(t, h, createKey(t, srv, db, project.ID, "verwalterin", auth.RoleAdmin)) + w := download(h, "/p/testprojekt/export.zip", cookie) if w.Code != http.StatusOK { t.Fatalf("Status %d, erwartet 200: %s", w.Code, w.Body.String()) @@ -71,67 +71,67 @@ func TestProjektDownloadAlsAdmin(t *testing.T) { t.Errorf("Content-Disposition %q", cd) } - inhalt := zipInhalt(t, w.Body.Bytes()) - if !strings.Contains(inhalt["testprojekt/deployment.md"], "freitags nie") { - t.Errorf("Eintrag fehlt im ZIP, enthalten: %v", schluessel(inhalt)) + content := zipContents(t, w.Body.Bytes()) + if !strings.Contains(content["testprojekt/deployment.md"], "freitags nie") { + t.Errorf("Eintrag fehlt im ZIP, enthalten: %v", keysOf(content)) } - if _, ok := inhalt["testprojekt/_index.md"]; !ok { + if _, ok := content["testprojekt/_index.md"]; !ok { t.Error("_index.md fehlt im ZIP") } // Der Download steht im Protokoll des Projekts. - ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) - gefunden := false - for _, e := range ereignisse { + events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID}) + found := false + for _, e := range events { if e.Action == store.ActionExportDownload && e.Actor == "verwalterin" { - gefunden = true + found = true } } - if !gefunden { + if !found { t.Error("Download fehlt im Protokoll") } } -// TestProjektDownloadNurFuerAdmins: Lesen und Schreiben reichen (vorerst) +// TestProjectDownloadOnlyForAdmins: Lesen und Schreiben reichen (vorerst) // nicht, siehe Session.CanDownloadProject. -func TestProjektDownloadNurFuerAdmins(t *testing.T) { - srv, db, projekt := testServer(t) +func TestProjectDownloadOnlyForAdmins(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - for _, rolle := range []auth.Role{auth.RoleRead, auth.RoleWrite} { - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "key-"+string(rolle), rolle)) - if w := ladeHerunter(h, "/p/testprojekt/export.zip", cookie); w.Code != http.StatusForbidden { - t.Errorf("Rolle %s: Status %d, erwartet 403", rolle, w.Code) + for _, role := range []auth.Role{auth.RoleRead, auth.RoleWrite} { + cookie := login(t, h, createKey(t, srv, db, project.ID, "key-"+string(role), role)) + if w := download(h, "/p/testprojekt/export.zip", cookie); w.Code != http.StatusForbidden { + t.Errorf("Rolle %s: Status %d, erwartet 403", role, w.Code) } } } -// TestProjektDownloadFremdesProjekt: Der Admin von Projekt A kommt nicht an +// TestProjectDownloadForeignProject: Der Admin von Projekt A kommt nicht an // das ZIP von Projekt B. -func TestProjektDownloadFremdesProjekt(t *testing.T) { - srv, db, projekt := testServer(t) +func TestProjectDownloadForeignProject(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - if _, err := db.CreateProject(context.Background(), projekt.WorkspaceID, "anderes", "Anderes", ""); err != nil { + if _, err := db.CreateProject(context.Background(), project.WorkspaceID, "anderes", "Anderes", ""); err != nil { t.Fatal(err) } - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "verwalterin", auth.RoleAdmin)) - if w := ladeHerunter(h, "/p/anderes/export.zip", cookie); w.Code == http.StatusOK { + if w := download(h, "/p/anderes/export.zip", cookie); w.Code == http.StatusOK { t.Error("ZIP eines fremden Projekts ausgeliefert") } } func TestWorkspaceDownload(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - anderes, err := db.CreateProject(ctx, projekt.WorkspaceID, "anderes", "Anderes", "") + other, err := db.CreateProject(ctx, project.WorkspaceID, "anderes", "Anderes", "") if err != nil { t.Fatal(err) } - for _, p := range []store.Project{projekt, anderes} { + for _, p := range []store.Project{project, other} { if _, err := db.PutEntry(ctx, store.EntryWrite{ ProjectID: p.ID, Slug: "notiz", Title: "Notiz", Content: "aus " + p.Slug, Source: store.SourceHuman, }); err != nil { @@ -139,48 +139,48 @@ func TestWorkspaceDownload(t *testing.T) { } } - wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) - plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + wsAdmin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) + platform := loginAsPlatformAdmin(t, h, project.WorkspaceID) - for name, cookie := range map[string]*http.Cookie{"Workspace-Admin": wsAdmin.cookie, "Plattform-Admin": plattform} { - w := ladeHerunter(h, "/admin/export.zip", cookie) + for name, cookie := range map[string]*http.Cookie{"Workspace-Admin": wsAdmin.cookie, "Plattform-Admin": platform} { + w := download(h, "/admin/export.zip", cookie) if w.Code != http.StatusOK { t.Fatalf("%s: Status %d, erwartet 200", name, w.Code) } - inhalt := zipInhalt(t, w.Body.Bytes()) - for _, pfad := range []string{"_workspace.md", "testprojekt/notiz.md", "anderes/notiz.md"} { - if _, ok := inhalt[pfad]; !ok { - t.Errorf("%s: %s fehlt im ZIP, enthalten: %v", name, pfad, schluessel(inhalt)) + content := zipContents(t, w.Body.Bytes()) + for _, filePath := range []string{"_workspace.md", "testprojekt/notiz.md", "anderes/notiz.md"} { + if _, ok := content[filePath]; !ok { + t.Errorf("%s: %s fehlt im ZIP, enthalten: %v", name, filePath, keysOf(content)) } } } // Ein Projekt-Admin verwaltet keine Projekte, also auch kein Workspace-ZIP. - key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)) - if w := ladeHerunter(h, "/admin/export.zip", key); w.Code != http.StatusForbidden { + key := login(t, h, createKey(t, srv, db, project.ID, "verwalterin", auth.RoleAdmin)) + if w := download(h, "/admin/export.zip", key); w.Code != http.StatusForbidden { t.Errorf("Projekt-Admin: Status %d, erwartet 403", w.Code) } } -func TestDateinamenTeil(t *testing.T) { - faelle := map[string]string{ +func TestFilenamePart(t *testing.T) { + cases := map[string]string{ "Plattform": "plattform", "Büro Süd & Nord": "buero-sued-nord", `Böse "Name"` + "\r\n": "boese-name", "!!!": "workspace", "": "workspace", } - for eingabe, erwartet := range faelle { - if got := dateinamenTeil(eingabe, "workspace"); got != erwartet { - t.Errorf("dateinamenTeil(%q) = %q, erwartet %q", eingabe, got, erwartet) + for input, want := range cases { + if got := filenamePart(input, "workspace"); got != want { + t.Errorf("dateinamenTeil(%q) = %q, erwartet %q", input, got, want) } } } -func schluessel(m map[string]string) []string { - var liste []string +func keysOf(m map[string]string) []string { + var list []string for k := range m { - liste = append(liste, k) + list = append(list, k) } - return liste + return list } diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index 6354cdf..ad0ec6d 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "net/http" - "net/url" "strconv" "strings" "time" @@ -36,12 +35,12 @@ type projectListItem struct { // Schreibbudget die Auflösung kennt: eigene Angabe des Projekts, sonst der // Standard aus der Umgebung. Limit int - Frei int + Free int - // EigenesLimit ist die im Projekt hinterlegte Zahl — 0 bedeutet "keine + // OwnLimit ist die im Projekt hinterlegte Zahl — 0 bedeutet "keine // eigene Angabe". Steht im Formularfeld, damit dort der gesetzte Wert // erscheint und nicht die aufgelöste Grenze. - EigenesLimit int + OwnLimit int } // handleProjectList zeigt alle Projekte des Workspace — für Plattform- und @@ -49,13 +48,13 @@ type projectListItem struct { func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) - projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) + projects, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Projekte konnten nicht geladen werden", err) return } - items, err := s.projectListItems(r, projekte) + items, err := s.projectListItems(r, projects) if err != nil { s.serverError(w, "Einträge konnten nicht gezählt werden", err) return @@ -63,8 +62,8 @@ func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { s.render(w, r, "projects.html", http.StatusOK, pageData{ Title: "Projekte", - Bereich: "projekte", - Flash: r.URL.Query().Get("hinweis"), + Section: "projekte", + Flash: s.hintFrom(r), Data: projectListData{Projects: items}, }) } @@ -75,26 +74,26 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.FormValue("name")) slug := strings.TrimSpace(r.FormValue("slug")) - beschreibung := strings.TrimSpace(r.FormValue("description")) + description := strings.TrimSpace(r.FormValue("description")) if slug == "" { slug = store.Slugify(name) } - projekt, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, beschreibung) + project, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, description) if err != nil { s.renderProjectListWithError(w, r, err.Error()) return } ws.Store.LogAudit(r.Context(), store.AuditEvent{ - ProjectID: projekt.ID, Actor: sess.ActorName(), + ProjectID: project.ID, Actor: sess.ActorName(), Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), }) // Direkt zur Key-Verwaltung: Ein Projekt ohne Key ist nutzlos, und der erste // Handgriff nach dem Anlegen ist immer, einen Zugang zu erzeugen. - http.Redirect(w, r, "/p/"+projekt.Slug+"/keys", http.StatusSeeOther) + http.Redirect(w, r, "/p/"+project.Slug+"/keys", http.StatusSeeOther) } // handleProjectDelete löscht ein Projekt samt Einträgen und Keys. @@ -102,7 +101,7 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) id := r.FormValue("id") - projekt, err := ws.Store.ProjectByID(r.Context(), id) + project, err := ws.Store.ProjectByID(r.Context(), id) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return @@ -115,9 +114,9 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { // Sicherheitsabfrage im Formular: Der eingegebene Bezeichner muss stimmen. // Ein Klick auf "Löschen" reicht nicht — dahinter verschwinden alle Einträge // des Projekts unwiederbringlich. - if r.FormValue("confirm") != projekt.Slug { - s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", - "Zum Löschen muss der Projekt-Bezeichner \""+projekt.Slug+"\" zur Bestätigung eingegeben werden.") + if r.FormValue("confirm") != project.Slug { + s.renderProjectEdit(w, r, http.StatusBadRequest, project, "", + "Zum Löschen muss der Projekt-Bezeichner \""+project.Slug+"\" zur Bestätigung eingegeben werden.") return } @@ -130,17 +129,17 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: id, Actor: sess.ActorName(), Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r), - Detail: projekt.Slug, + Detail: project.Slug, }) - http.Redirect(w, r, "/admin/projects?hinweis=Projekt+gel%C3%B6scht", http.StatusSeeOther) + http.Redirect(w, r, s.withHint("/admin/projects", "Projekt gelöscht"), http.StatusSeeOther) } // renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung. -func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, meldung string) { +func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, message string) { ws, sess := s.workspaceAndSession(r) - projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) + projects, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Projekte konnten nicht geladen werden", err) return @@ -148,12 +147,12 @@ func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Reque // Fehler beim Zählen sind hier gleichgültig: Auf dem Weg hierher ist bereits // etwas schiefgegangen, und die Fehlermeldung ist wichtiger als die Zahlen. - items, _ := s.projectListItems(r, projekte) + items, _ := s.projectListItems(r, projects) s.render(w, r, "projects.html", http.StatusBadRequest, pageData{ Title: "Projekte", - Bereich: "projekte", - Error: meldung, + Section: "projekte", + Error: message, Data: projectListData{Projects: items}, }) } @@ -164,28 +163,28 @@ func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Reque // Eine eigene Funktion, weil die Übersicht an zwei Stellen gerendert wird — mit // und ohne Fehlermeldung. Ohne sie liefen beide Fassungen früher oder später // auseinander. -func (s *Server) projectListItems(r *http.Request, projekte []store.Project) ([]projectListItem, error) { +func (s *Server) projectListItems(r *http.Request, projects []store.Project) ([]projectListItem, error) { ws, _ := s.workspaceAndSession(r) - items := make([]projectListItem, 0, len(projekte)) + items := make([]projectListItem, 0, len(projects)) // Die Kennzahlen aller Projekte in einem Rutsch (zwei Abfragen), statt // je Projekt einzeln nachzufragen — bei 40 Projekten wären das sonst 80. // Die Eintragszahl steckt mit darin. - nutzung, err := ws.Store.UsageSummaries(r.Context(), "", time.Now()) + usage, err := ws.Store.UsageSummaries(r.Context(), "", time.Now()) if err != nil { return items, err } - for _, p := range projekte { - u := nutzung[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch + for _, p := range projects { + u := usage[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch item := projectListItem{ - Project: p, - Entries: u.Entries, - Usage: u, - EigenesLimit: p.WriteLimitHourly, + Project: p, + Entries: u.Entries, + Usage: u, + OwnLimit: p.WriteLimitHourly, } if s.writeBudget != nil { - item.Frei, item.Limit = s.writeBudget.Remaining(p.ID, p.WriteLimitHourly) + item.Free, item.Limit = s.writeBudget.Remaining(p.ID, p.WriteLimitHourly) } items = append(items, item) @@ -207,11 +206,11 @@ type projectEditData struct { // hat aber eine Falle: Wer speichert, ohne ein Feld anzufassen, überschreibt den // bestehenden Wert mit der Vorgabe des Formulars. func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) { - projekt, ok := s.projectFromSlug(w, r) + project, ok := s.projectFromSlug(w, r) if !ok { return } - s.renderProjectEdit(w, r, http.StatusOK, projekt, "", "") + s.renderProjectEdit(w, r, http.StatusOK, project, "", "") } // handleProjectEditSave speichert Name, Beschreibung und Obergrenze. @@ -222,40 +221,40 @@ func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) - projekt, ok := s.projectFromSlug(w, r) + project, ok := s.projectFromSlug(w, r) if !ok { return } name := strings.TrimSpace(r.FormValue("name")) - beschreibung := strings.TrimSpace(r.FormValue("description")) + description := strings.TrimSpace(r.FormValue("description")) if name == "" { - s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", "Der Name darf nicht leer sein.") + s.renderProjectEdit(w, r, http.StatusBadRequest, project, "", "Der Name darf nicht leer sein.") return } - limit := projekt.WriteLimitHourly + limit := project.WriteLimitHourly if sess.CanEditWriteBudget() { - roh := strings.TrimSpace(r.FormValue("write_limit_hourly")) + raw := strings.TrimSpace(r.FormValue("write_limit_hourly")) var err error - limit, err = strconv.Atoi(roh) + limit, err = strconv.Atoi(raw) if err != nil || limit < 0 { - s.renderProjectEdit(w, r, http.StatusBadRequest, projekt, "", + s.renderProjectEdit(w, r, http.StatusBadRequest, project, "", "Die Obergrenze muss eine Zahl ab 0 sein (0 = Standardwert des Servers verwenden).") return } } - aenderungen := projektAenderungen(projekt, name, beschreibung, limit) - if len(aenderungen) == 0 { - s.renderProjectEdit(w, r, http.StatusOK, projekt, "Nichts geändert.", "") + changes := projectChanges(project, name, description, limit) + if len(changes) == 0 { + s.renderProjectEdit(w, r, http.StatusOK, project, "Nichts geändert.", "") return } - if err := ws.Store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{ + if err := ws.Store.UpdateProject(r.Context(), project.ID, store.ProjectUpdate{ Name: name, - Description: beschreibung, + Description: description, WriteLimitHourly: limit, }); err != nil { s.serverError(w, "Projekt konnte nicht geändert werden", err) @@ -267,32 +266,32 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { // interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine // Betriebsentscheidung. ws.Store.LogAudit(r.Context(), store.AuditEvent{ - ProjectID: projekt.ID, Actor: sess.ActorName(), + ProjectID: project.ID, Actor: sess.ActorName(), Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r), - Detail: strings.Join(aenderungen, "; "), + Detail: strings.Join(changes, "; "), }) - http.Redirect(w, r, "/admin/projects?hinweis="+url.QueryEscape(projekt.Slug+" gespeichert"), + http.Redirect(w, r, s.withHint("/admin/projects", project.Slug+" gespeichert"), http.StatusSeeOther) } -// projektAenderungen beschreibt, was sich gegenüber dem gespeicherten Stand +// projectChanges beschreibt, was sich gegenüber dem gespeicherten Stand // ändert — fürs Protokoll, und um ein Speichern ohne Änderung zu erkennen. -func projektAenderungen(alt store.Project, name, beschreibung string, limit int) []string { - var aenderungen []string +func projectChanges(old store.Project, name, description string, limit int) []string { + var changes []string - if name != alt.Name { - aenderungen = append(aenderungen, fmt.Sprintf("Name: %q → %q", alt.Name, name)) + if name != old.Name { + changes = append(changes, fmt.Sprintf("Name: %q → %q", old.Name, name)) } - if beschreibung != alt.Description { - aenderungen = append(aenderungen, fmt.Sprintf("Beschreibung: %q → %q", alt.Description, beschreibung)) + if description != old.Description { + changes = append(changes, fmt.Sprintf("Beschreibung: %q → %q", old.Description, description)) } - if limit != alt.WriteLimitHourly { - aenderungen = append(aenderungen, - fmt.Sprintf("write_limit_hourly: %d → %d", alt.WriteLimitHourly, limit)) + if limit != old.WriteLimitHourly { + changes = append(changes, + fmt.Sprintf("write_limit_hourly: %d → %d", old.WriteLimitHourly, limit)) } - return aenderungen + return changes } // projectFromSlug löst den Bezeichner aus der URL zu einem Projekt auf. @@ -305,7 +304,7 @@ func projektAenderungen(alt store.Project, name, beschreibung string, limit int) func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) { ws, sess := s.workspaceAndSession(r) - projekt, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, r.PathValue("slug")) + project, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, r.PathValue("slug")) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return store.Project{}, false @@ -314,24 +313,24 @@ func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store. s.serverError(w, "Projekt konnte nicht geladen werden", err) return store.Project{}, false } - return projekt, true + return project, true } // renderProjectEdit zeigt die Bearbeitungsseite eines Projekts. func (s *Server) renderProjectEdit(w http.ResponseWriter, r *http.Request, status int, - projekt store.Project, flash, fehler string) { + project store.Project, flash, errs string) { - items, err := s.projectListItems(r, []store.Project{projekt}) + items, err := s.projectListItems(r, []store.Project{project}) if err != nil { s.serverError(w, "Einträge konnten nicht gezählt werden", err) return } s.render(w, r, "project-edit.html", status, pageData{ - Title: projekt.Name, - Bereich: "projekte", + Title: project.Name, + Section: "projekte", Flash: flash, - Error: fehler, + Error: errs, Data: projectEditData{Item: items[0]}, }) } @@ -350,26 +349,26 @@ type keyListData struct { // ServerURL ist die Adresse dieses Servers für die fertigen // Konfigurationsblöcke, etwa "https://munin.example.org". Dieselbe Funktion - // wie in der Hilfe (serverURL in handlers_hilfe.go) — gerade hier zählt es, + // wie in der Hilfe (serverURL in handlers_help.go) — gerade hier zählt es, // weil der Key nur dieses eine Mal zu sehen ist und der Block ohne // Nacharbeit kopierbar sein soll. ServerURL string - // BaldAbgelaufen zählt gültige Keys, die innerhalb von + // ExpiringSoon zählt gültige Keys, die innerhalb von // store.ExpiresSoonWithin ablaufen — für den Hinweis über der Tabelle. - BaldAbgelaufen int + ExpiringSoon int - // Heute ist das frühestmögliche Ablaufdatum im Formular (morgen wäre + // Today ist das frühestmögliche Ablaufdatum im Formular (morgen wäre // strenger, aber "heute" heißt: bis heute Abend). - Heute string + Today string - // Jetzt ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro + // Now ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro // Seitenaufruf festgehalten, damit alle Zeilen mit demselben Wert rechnen. - Jetzt time.Time + Now time.Time - // Ungenutzt zählt die aktiven Keys, die seit über 90 Tagen niemand benutzt + // Unused zählt die aktiven Keys, die seit über 90 Tagen niemand benutzt // hat. Ist die Zahl größer als null, steht über der Tabelle ein Hinweis. - Ungenutzt int + Unused int } // handleKeyList zeigt die Keys eines Projekts. @@ -383,32 +382,32 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { project, _ := ProjectFrom(r) name := strings.TrimSpace(r.FormValue("name")) - rolle := auth.Role(r.FormValue("role")) + role := auth.Role(r.FormValue("role")) - if !rolle.Valid() { + if !role.Valid() { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Unbekannte Rolle.") return } // Ablaufdatum (optional, AUSBAUSTUFEN.md 4.7 Stufe 2): Der Key gilt bis // einschließlich des gewählten Tages, also bis zum Beginn des nächsten. - var ablauf time.Time + var expiry time.Time if text := strings.TrimSpace(r.FormValue("expires")); text != "" { - tag, err := time.ParseInLocation(auditDatum, text, time.Local) + tag, err := time.ParseInLocation(auditDate, text, time.Local) if err != nil { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Das Ablaufdatum ist kein gültiges Datum.") return } - ablauf = tag.AddDate(0, 0, 1) + expiry = tag.AddDate(0, 0, 1) } - klartext, err := auth.GenerateKey() + plaintext, err := auth.GenerateKey() if err != nil { s.serverError(w, "Key konnte nicht erzeugt werden", err) return } - erzeugt, err := ws.Store.CreateAPIKeyWithExpiry(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle), ablauf) + created, err := ws.Store.CreateAPIKeyWithExpiry(r.Context(), project.ID, name, auth.HashKey(plaintext), string(role), expiry) if err != nil { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", err.Error()) return @@ -420,18 +419,18 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { // Workspace-Datei trotzdem gültig; nur das schnelle Routing über mehrere // Workspaces hinweg wäre für ihn kaputt. Deshalb kein Abbruch, nur ein // Log-Eintrag zum Nachziehen. - if err := s.control.IndexKey(r.Context(), erzeugt.Hash, sess.WorkspaceID); err != nil { - log.Printf("Key %s konnte nicht im Routing-Index vermerkt werden: %v", erzeugt.ID, err) + if err := s.control.IndexKey(r.Context(), created.Hash, sess.WorkspaceID); err != nil { + log.Printf("Key %s konnte nicht im Routing-Index vermerkt werden: %v", created.ID, err) } ws.Store.LogAudit(r.Context(), store.AuditEvent{ - ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(), + ProjectID: project.ID, APIKeyID: created.ID, Actor: sess.ActorName(), Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), - Detail: keyDetail(erzeugt), + Detail: keyDetail(created), }) s.renderKeyList(w, r, http.StatusOK, - keyListData{NewKey: klartext, NewKeyName: erzeugt.Name}, + keyListData{NewKey: plaintext, NewKeyName: created.Name}, "Key angelegt. Er wird nur jetzt angezeigt — bitte gleich übernehmen.", "") } @@ -479,7 +478,7 @@ func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { } // renderKeyList zeigt die Key-Verwaltung mit optionaler Meldung. -func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status int, daten keyListData, hinweis, fehler string) { +func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status int, data keyListData, hint, errs string) { ws, _ := s.workspaceAndSession(r) project, _ := ProjectFrom(r) @@ -488,25 +487,25 @@ func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status in s.serverError(w, "Keys konnten nicht geladen werden", err) return } - daten.Keys = keys - daten.ServerURL = serverURL(r) - daten.Jetzt = time.Now() - daten.Heute = daten.Jetzt.Format(auditDatum) + data.Keys = keys + data.ServerURL = serverURL(r) + data.Now = time.Now() + data.Today = data.Now.Format(auditDate) for _, k := range keys { - if k.Unused(daten.Jetzt) { - daten.Ungenutzt++ + if k.Unused(data.Now) { + data.Unused++ } - if k.ExpiresSoon(daten.Jetzt) { - daten.BaldAbgelaufen++ + if k.ExpiresSoon(data.Now) { + data.ExpiringSoon++ } } s.render(w, r, "keys.html", status, pageData{ Title: "Zugänge – " + project.Name, - Bereich: "zugaenge", - Flash: hinweis, - Error: fehler, - Data: daten, + Section: "zugaenge", + Flash: hint, + Error: errs, + Data: data, }) } @@ -521,69 +520,69 @@ type auditData struct { // Filter (AUSBAUSTUFEN.md 5.11): die gewählten Werte, so wie sie wieder // ins Formular gehören, und die Auswahl für die Listen. - Filter auditFormular - Aktionen []auditAktion - Zugaenge []string - Gefiltert bool // mindestens ein Filter ist gesetzt + Filter auditForm + Actions []auditAction + Keys []string + Filtered bool // mindestens ein Filter ist gesetzt } -// auditFormular sind die Filterwerte der Protokollseite, als Text wie im +// auditForm sind die Filterwerte der Protokollseite, als Text wie im // Formular — Datumsangaben im Format JJJJ-MM-TT. -type auditFormular struct { - Vorgang string - Wer string - Projekt string // nur im Protokoll über alle Projekte - Von string - Bis string +type auditForm struct { + Event string + Who string + Project string // nur im Protokoll über alle Projekte + From string + Until string } -// auditAktion ist ein Vorgang samt verständlichem Namen für die Auswahlliste. -type auditAktion struct{ Action, Label string } +// auditAction ist ein Vorgang samt verständlichem Namen für die Auswahlliste. +type auditAction struct{ Action, Label string } -// auditDatum ist das Format der Datumsfelder im Filter (input type=date). -const auditDatum = "2006-01-02" +// auditDate ist das Format der Datumsfelder im Filter (input type=date). +const auditDate = "2006-01-02" -// leseAuditFilter übernimmt die Filter aus der Adresse in filter. +// readAuditFilter übernimmt die Filter aus der Adresse in filter. // // Unbekannte oder kaputte Werte werden ignoriert statt gemeldet: Wer an der // Adresse herumbastelt, bekommt das ungefilterte Protokoll, keine // Fehlerseite. "bis" schließt den ganzen Tag ein — wer „bis 16.10." wählt, // meint den 16. mit. -func leseAuditFilter(r *http.Request, filter *store.AuditFilter, global bool) auditFormular { +func readAuditFilter(r *http.Request, filter *store.AuditFilter, global bool) auditForm { q := r.URL.Query() - f := auditFormular{ - Vorgang: strings.TrimSpace(q.Get("vorgang")), - Wer: strings.TrimSpace(q.Get("wer")), - Von: strings.TrimSpace(q.Get("von")), - Bis: strings.TrimSpace(q.Get("bis")), + f := auditForm{ + Event: strings.TrimSpace(q.Get("vorgang")), + Who: strings.TrimSpace(q.Get("wer")), + From: strings.TrimSpace(q.Get("von")), + Until: strings.TrimSpace(q.Get("bis")), } for _, a := range store.AuditActions { - if a.Action == f.Vorgang { - filter.Action = f.Vorgang + if a.Action == f.Event { + filter.Action = f.Event } } if filter.Action == "" { - f.Vorgang = "" + f.Event = "" } - filter.Actor = f.Wer + filter.Actor = f.Who if global { - f.Projekt = strings.TrimSpace(q.Get("projekt")) - filter.ProjectID = f.Projekt + f.Project = strings.TrimSpace(q.Get("projekt")) + filter.ProjectID = f.Project } // Die Tage gelten in der Zeitzone des Servers — derselben, in der die // Tabelle die Zeitpunkte anzeigt. - if t, err := time.ParseInLocation(auditDatum, f.Von, time.Local); err == nil { + if t, err := time.ParseInLocation(auditDate, f.From, time.Local); err == nil { filter.Since = t } else { - f.Von = "" + f.From = "" } - if t, err := time.ParseInLocation(auditDatum, f.Bis, time.Local); err == nil { + if t, err := time.ParseInLocation(auditDate, f.Until, time.Local); err == nil { filter.Until = t.AddDate(0, 0, 1) } else { - f.Bis = "" + f.Until = "" } return f } @@ -601,22 +600,22 @@ func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) { } // renderAudit lädt Ereignisse samt Klarnamen und rendert die Tabelle. -func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter store.AuditFilter, global bool, titel string) { +func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter store.AuditFilter, global bool, title string) { ws, sess := s.workspaceAndSession(r) ctx := r.Context() // Die Zugänge für die Auswahl: im Projektprotokoll die dieses Projekts, // sonst alle — vor dem Übernehmen der Filter, damit die Liste nicht vom // gewählten Projekt abhängt. - zugaenge, err := ws.Store.AuditActors(ctx, filter.ProjectID) + keys, err := ws.Store.AuditActors(ctx, filter.ProjectID) if err != nil { s.serverError(w, "Protokoll konnte nicht geladen werden", err) return } - formular := leseAuditFilter(r, &filter, global) + form := readAuditFilter(r, &filter, global) - ereignisse, err := ws.Store.ListAudit(ctx, filter) + events, err := ws.Store.ListAudit(ctx, filter) if err != nil { s.serverError(w, "Protokoll konnte nicht geladen werden", err) return @@ -624,47 +623,47 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor // IDs in lesbare Namen übersetzen — in der Tabelle sollen keine rohen // Kennungen stehen. - projektNamen, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID) + projectNames, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID) if err != nil { s.serverError(w, "Projektnamen konnten nicht geladen werden", err) return } - projektIDs := make([]string, 0, len(projektNamen)) - for id := range projektNamen { - projektIDs = append(projektIDs, id) + projectIDs := make([]string, 0, len(projectNames)) + for id := range projectNames { + projectIDs = append(projectIDs, id) } - keyNamen, err := ws.Store.KeyNames(ctx, projektIDs) + keyNames, err := ws.Store.KeyNames(ctx, projectIDs) if err != nil { s.serverError(w, "Key-Namen konnten nicht geladen werden", err) return } s.render(w, r, "audit.html", http.StatusOK, pageData{ - Title: titel, - Bereich: "protokoll", + Title: title, + Section: "protokoll", Data: auditData{ - Events: ereignisse, - ProjectNames: projektNamen, - KeyNames: keyNamen, + Events: events, + ProjectNames: projectNames, + KeyNames: keyNames, Global: global, - Filter: formular, - Aktionen: auditAktionen(), - Zugaenge: zugaenge, - Gefiltert: formular != auditFormular{}, + Filter: form, + Actions: auditActions(), + Keys: keys, + Filtered: form != auditForm{}, }, }) } -// auditAktionen übersetzt store.AuditActions in den Typ der Seite. -func auditAktionen() []auditAktion { - liste := make([]auditAktion, 0, len(store.AuditActions)) +// auditActions übersetzt store.AuditActions in den Typ der Seite. +func auditActions() []auditAction { + list := make([]auditAction, 0, len(store.AuditActions)) for _, a := range store.AuditActions { - liste = append(liste, auditAktion{Action: a.Action, Label: a.Label}) + list = append(list, auditAction{Action: a.Action, Label: a.Label}) } - return liste + return list } // ── Export ─────────────────────────────────────────────────────────────────── @@ -689,22 +688,22 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { // Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung: Er // landet in _workspace.md, und nach einem Umbenennen soll dort der neue // stehen, nicht der beim Anmelden gemerkte. - eintrag, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) + entry, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Workspace konnte nicht gelesen werden", err) return } - stats, err := s.exporter.ExportWorkspace(r.Context(), ws.Store, eintrag) + stats, err := s.exporter.ExportWorkspace(r.Context(), ws.Store, entry) if err != nil { s.serverError(w, "Export fehlgeschlagen", err) return } - meldung := fmt.Sprintf("Export abgeschlossen: %d Dateien geschrieben", stats.Written) + message := fmt.Sprintf("Export abgeschlossen: %d Dateien geschrieben", stats.Written) if stats.Removed > 0 { - meldung += fmt.Sprintf(", %d verwaiste entfernt", stats.Removed) + message += fmt.Sprintf(", %d verwaiste entfernt", stats.Removed) } - http.Redirect(w, r, "/admin/projects?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther) + http.Redirect(w, r, s.withHint("/admin/projects", message), http.StatusSeeOther) } diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 3d58d1b..9442bc4 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -40,18 +40,22 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { ip := auth.ClientIP(r) key := r.FormValue("key") + // Die Sperre zuerst prüfen, erst danach den Key. Andersherum bekäme eine + // gesperrte IP beim richtigen Key trotzdem eine Sitzung — die Sperre + // änderte dann nur den Statuscode, bremste das Raten aber nicht. Das zählt + // vor allem für ADMIN_TOKEN, das (anders als die zufälligen Keys) von Hand + // gewählt sein kann. Der Preis: Wer sich fünfmal vertippt, wartet eine + // Minute, auch mit dem richtigen Key. + if allowed, retryAfter := s.limiter.Allow(ip); !allowed { + seconds := int(retryAfter.Seconds()) + 1 + w.Header().Set("Retry-After", strconv.Itoa(seconds)) + http.Error(w, fmt.Sprintf("Zu viele Anmeldeversuche. Bitte in %d Sekunden erneut versuchen.", seconds), + http.StatusTooManyRequests) + return + } + subject, ok := s.resolveLoginKey(r, key) if !ok { - // Erst nach einem Fehlversuch die Sperre prüfen und hochzählen — wer den - // richtigen Key hat, kommt immer durch (siehe Kommentar an APIAuth). - if allowed, retryAfter := s.limiter.Allow(ip); !allowed { - sekunden := int(retryAfter.Seconds()) + 1 - w.Header().Set("Retry-After", strconv.Itoa(sekunden)) - http.Error(w, fmt.Sprintf("Zu viele Anmeldeversuche. Bitte in %d Sekunden erneut versuchen.", sekunden), - http.StatusTooManyRequests) - return - } - s.limiter.RecordFailure(ip) // Ein ungültiger Key oder Klartext lässt sich keinem Workspace zuordnen // — siehe der entsprechende Kommentar in APIAuth (middleware.go). @@ -64,8 +68,11 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { return } - s.limiter.Reset(ip) - s.setSessionCookie(w, r, subject) + // Bewusst kein limiter.Reset: Sonst könnte jemand mit einem eigenen, + // gültigen Key den Zähler nach jedem vierten Fehlversuch leeren und + // beliebig lange weiterraten. Die Fehlversuche verfallen nach einer Minute + // von selbst. + s.setSessionCookie(w, r, subject, time.Now().Add(auth.SessionTTLFor(subject))) http.Redirect(w, r, "/", http.StatusSeeOther) } @@ -85,7 +92,7 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { // verglichen wird und die Antwortzeit nichts verrät. if auth.HashKey(key) == s.adminTokenHash { log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r)) - return s.plattformSubjekt(""), true + return s.platformSubject(""), true } if auth.LooksLikeWorkspaceAdminToken(key) { @@ -160,18 +167,25 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) } -// plattformSubjekt baut das Session-Subject des Plattform-Admins mit dem +// platformSubject baut das Session-Subject des Plattform-Admins mit dem // Fingerabdruck des aktuellen ADMIN_TOKEN. workspaceID darf leer sein. -func (s *Server) plattformSubjekt(workspaceID string) string { +func (s *Server) platformSubject(workspaceID string) string { return auth.PlatformSubject(s.adminFingerprint, workspaceID) } -// setSessionCookie signiert das Subject und setzt es als Cookie. -func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string) { +// setSessionCookie signiert das Subject und setzt es als Cookie, gültig bis +// expiry. +// +// Bei einer Anmeldung ist expiry jetzt plus auth.SessionTTLFor. Wechselt der +// Plattform-Admin nur den Workspace, wird der Ablauf der laufenden Sitzung +// übernommen (Session.Expires). Sonst verlängerte jeder Wechsel die Sitzung — +// und wer einen Cookie gestohlen hat, könnte ihn durch Umschalten ewig am +// Leben halten. +func (s *Server) setSessionCookie(w http.ResponseWriter, r *http.Request, subject string, expiry time.Time) { cookie := &http.Cookie{ Name: auth.SessionCookieName, - Value: s.signer.SignSession(subject), - Expires: time.Now().Add(auth.SessionTTL), + Value: s.signer.SignSession(subject, expiry), + Expires: expiry, } s.cookies.Apply(cookie, r) http.SetCookie(w, cookie) diff --git a/internal/web/handlers_download.go b/internal/web/handlers_download.go index 907064b..3a78114 100644 --- a/internal/web/handlers_download.go +++ b/internal/web/handlers_download.go @@ -29,7 +29,7 @@ func (s *Server) handleProjectDownload(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) project, _ := ProjectFrom(r) - dateien, err := export.ProjektDateien(r.Context(), ws.Store, project) + files, err := export.ProjectFiles(r.Context(), ws.Store, project) if err != nil { s.serverError(w, "Export fehlgeschlagen", err) return @@ -38,10 +38,10 @@ func (s *Server) handleProjectDownload(w http.ResponseWriter, r *http.Request) { ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionExportDownload, Source: store.SourceWeb, IP: auth.ClientIP(r), - Detail: fmt.Sprintf("ZIP mit %d Einträgen", len(dateien)-1), // ohne _index.md + Detail: fmt.Sprintf("ZIP mit %d Einträgen", len(files)-1), // ohne _index.md }) - sendeZIP(w, "munin-"+project.Slug, dateien) + sendZIP(w, "munin-"+project.Slug, files) } // handleWorkspaceDownload liefert alle Projekte des gewählten Workspace als @@ -51,13 +51,13 @@ func (s *Server) handleWorkspaceDownload(w http.ResponseWriter, r *http.Request) // Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung — wie // beim Export auf die Platte (handleExport). - eintrag, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) + entry, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Workspace konnte nicht gelesen werden", err) return } - dateien, err := export.WorkspaceDateien(r.Context(), ws.Store, eintrag) + files, err := export.WorkspaceFiles(r.Context(), ws.Store, entry) if err != nil { s.serverError(w, "Export fehlgeschlagen", err) return @@ -65,61 +65,61 @@ func (s *Server) handleWorkspaceDownload(w http.ResponseWriter, r *http.Request) ws.Store.LogAudit(r.Context(), store.AuditEvent{ Actor: sess.ActorName(), Action: store.ActionExportDownload, Source: store.SourceWeb, - IP: auth.ClientIP(r), Detail: fmt.Sprintf("ZIP des ganzen Workspace, %d Dateien", len(dateien)), + IP: auth.ClientIP(r), Detail: fmt.Sprintf("ZIP des ganzen Workspace, %d Dateien", len(files)), }) - sendeZIP(w, "munin-"+dateinamenTeil(eintrag.Name, "workspace"), dateien) + sendZIP(w, "munin-"+filenamePart(entry.Name, "workspace"), files) } -// sendeZIP schickt die Dateien als ZIP-Download. name ist der Dateiname ohne +// sendZIP schickt die Dateien als ZIP-Download. name ist der Dateiname ohne // Datum und Endung. // // Bis hierher ist alles aus der Datenbank gelesen. Was jetzt noch scheitern // kann, ist nur die Verbindung — dann bekommt der Browser ein abgebrochenes // Archiv, und eine Fehlerseite wäre ohnehin nicht mehr möglich, weil die // Kopfzeilen schon unterwegs sind. -func sendeZIP(w http.ResponseWriter, name string, dateien []export.Datei) { - dateiname := name + "-" + time.Now().Format("2006-01-02") + ".zip" +func sendZIP(w http.ResponseWriter, name string, files []export.File) { + filename := name + "-" + time.Now().Format("2006-01-02") + ".zip" w.Header().Set("Content-Type", "application/zip") // attachment: Der Browser speichert die Datei, statt sie anzeigen zu wollen. - w.Header().Set("Content-Disposition", `attachment; filename="`+dateiname+`"`) + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) // Der Inhalt ist der gesamte Bestand. Er gehört in keinen Zwischenspeicher, // weder im Browser noch in einem Proxy dazwischen. w.Header().Set("Cache-Control", "no-store") - if err := export.SchreibeZIP(w, dateien); err != nil { + if err := export.WriteZIP(w, files); err != nil { // Nur ins Log: Antworten lässt sich nicht mehr. log.Printf("ZIP-Download abgebrochen: %v", err) } } -// dateinamenTeil macht aus einem frei gewählten Namen ein sicheres Stück +// filenamePart macht aus einem frei gewählten Namen ein sicheres Stück // Dateiname: Kleinbuchstaben, Ziffern und Bindestriche. Umlaute werden -// umschrieben. Bleibt nichts übrig, gilt ersatz. +// umschrieben. Bleibt nichts übrig, gilt replacement. // // Wichtig ist das wegen der Kopfzeile Content-Disposition: Ein Anführungszeichen // oder Zeilenumbruch im Workspace-Namen dürfte dort nicht hineingeraten. -func dateinamenTeil(name, ersatz string) string { +func filenamePart(name, replacement string) string { name = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", "Ä", "ae", "Ö", "oe", "Ü", "ue").Replace(strings.ToLower(name)) var b strings.Builder - bindestrich := false + hyphen := false for _, z := range name { switch { case z >= 'a' && z <= 'z', z >= '0' && z <= '9': b.WriteRune(z) - bindestrich = false - case !bindestrich && b.Len() > 0: + hyphen = false + case !hyphen && b.Len() > 0: b.WriteByte('-') - bindestrich = true + hyphen = true } } - ergebnis := strings.TrimSuffix(b.String(), "-") - if ergebnis == "" { - return ersatz + result := strings.TrimSuffix(b.String(), "-") + if result == "" { + return replacement } - return ergebnis + return result } diff --git a/internal/web/handlers_entries.go b/internal/web/handlers_entries.go index cc0b567..070abe5 100644 --- a/internal/web/handlers_entries.go +++ b/internal/web/handlers_entries.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "munin/internal/dubletten" + "munin/internal/duplicates" "munin/internal/entry" "munin/internal/store" ) @@ -26,11 +26,11 @@ type entryListData struct { Tag string Total int - // Veraltet: Die Liste zeigt nur Einträge, die seit über einem Jahr - // unverändert sind (?veraltet=1). VeraltetAnzahl nennt, wie viele das im + // Stale: Die Liste zeigt nur Einträge, die seit über einem Jahr + // unverändert sind (?veraltet=1). StaleCount nennt, wie viele das im // ganzen Projekt sind — für den Filterlink, auch wenn er nicht aktiv ist. - Veraltet bool - VeraltetAnzahl int + Stale bool + StaleCount int // RetentionDays: so lange liegen gelöschte Einträge im Papierkorb — für // die Rückfrage beim Sammellöschen, damit dort dieselbe Zahl steht, nach @@ -57,48 +57,48 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) tag := strings.TrimSpace(r.URL.Query().Get("tag")) - veraltet := r.URL.Query().Get("veraltet") != "" - jetzt := time.Now() + stale := r.URL.Query().Get("veraltet") != "" + now := time.Now() - daten := entryListData{Query: query, Tag: tag, Veraltet: veraltet, RetentionDays: store.TrashRetentionDays} + data := entryListData{Query: query, Tag: tag, Stale: stale, RetentionDays: store.TrashRetentionDays} var err error if query != "" { - daten.Entries, err = s.searchEntries(r, project, query) + data.Entries, err = s.searchEntries(r, project, query) } else { filter := store.EntryFilter{Tag: tag, Limit: 100} - if veraltet { - filter.ChangedBefore = store.StaleCutoff(jetzt) + if stale { + filter.ChangedBefore = store.StaleCutoff(now) } - daten.Entries, err = s.listEntries(r, project, filter) + data.Entries, err = s.listEntries(r, project, filter) } if err != nil { s.serverError(w, "Einträge konnten nicht geladen werden", err) return } - for i := range daten.Entries { - daten.Entries[i].Stale = daten.Entries[i].Entry.Stale(jetzt) + for i := range data.Entries { + data.Entries[i].Stale = data.Entries[i].Entry.Stale(now) } - if daten.VeraltetAnzahl, err = ws.Store.CountStaleEntries(ctx, project.ID, jetzt); err != nil { + if data.StaleCount, err = ws.Store.CountStaleEntries(ctx, project.ID, now); err != nil { s.serverError(w, "Einträge konnten nicht gezählt werden", err) return } - if daten.Tags, err = ws.Store.ListTags(ctx, project.ID); err != nil { + if data.Tags, err = ws.Store.ListTags(ctx, project.ID); err != nil { s.serverError(w, "Schlagworte konnten nicht geladen werden", err) return } - if daten.Total, err = ws.Store.CountEntries(ctx, project.ID); err != nil { + if data.Total, err = ws.Store.CountEntries(ctx, project.ID); err != nil { s.serverError(w, "Einträge konnten nicht gezählt werden", err) return } s.render(w, r, "entries.html", http.StatusOK, pageData{ - Title: project.Name, - Bereich: "eintraege", - AktivesTag: tag, - Flash: r.URL.Query().Get("hinweis"), - Data: daten, + Title: project.Name, + Section: "eintraege", + ActiveTag: tag, + Flash: s.hintFrom(r), + Data: data, }) } @@ -112,10 +112,10 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str return nil, err } - treffer := idx.SearchFuzzy(query, 30) + hits := idx.SearchFuzzy(query, 30) - items := make([]entryListItem, 0, len(treffer)) - for _, t := range treffer { + items := make([]entryListItem, 0, len(hits)) + for _, t := range hits { e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID) if errors.Is(err, store.ErrNotFound) { // Der Index kann einen Eintrag kennen, den es nicht mehr gibt — @@ -137,13 +137,13 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str // listEntries lädt die Einträge ohne Suche, zuletzt geänderte zuerst. func (s *Server) listEntries(r *http.Request, project store.Project, filter store.EntryFilter) ([]entryListItem, error) { ws, _ := WorkspaceFrom(r) - eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, filter) + entries, err := ws.Store.ListEntries(r.Context(), project.ID, filter) if err != nil { return nil, err } - items := make([]entryListItem, 0, len(eintraege)) - for _, e := range eintraege { + items := make([]entryListItem, 0, len(entries)) + for _, e := range entries { items = append(items, entryListItem{ Entry: e, Snippet: template.HTML(template.HTMLEscapeString(entry.Snippet(e.Content, ""))), @@ -158,11 +158,11 @@ type entryViewData struct { HTML template.HTML Backlinks []store.Backlink - // Verwandt sind Einträge über einen Zwischeneintrag (zweite Reihe im + // Related sind Einträge über einen Zwischeneintrag (zweite Reihe im // Verweisgraphen, store.RelatedEntries). Die erste Reihe steht schon im // Text (eigene Verweise) bzw. unter Backlinks — doppelt wäre Rauschen. // Dasselbe liefert MCP "related" den Agenten. - Verwandt []store.RelatedEntry + Related []store.RelatedEntry // Stale: seit über einem Jahr unverändert (siehe store.Entry.Stale). Die // Seite zeigt dann einen Vermerk, damit Menschen den Eintrag genauso @@ -187,7 +187,7 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) { } // Die [[slug]]-Verweise zeigen auf die Detailansicht desselben Projekts. - inhalt, err := entry.RenderHTML(e.Content, "/p/"+project.Slug+"/e/") + content, err := entry.RenderHTML(e.Content, "/p/"+project.Slug+"/e/") if err != nil { s.serverError(w, "Inhalt konnte nicht dargestellt werden", err) return @@ -199,34 +199,34 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) { return } - nachbarn, err := ws.Store.RelatedEntries(r.Context(), project.ID, slug, 2, verwandtLimit) + neighbors, err := ws.Store.RelatedEntries(r.Context(), project.ID, slug, 2, relatedLimit) if err != nil { s.serverError(w, "Verwandte Einträge konnten nicht geladen werden", err) return } - var verwandt []store.RelatedEntry - for _, n := range nachbarn { + var related []store.RelatedEntry + for _, n := range neighbors { if n.Distance == 2 { - verwandt = append(verwandt, n) + related = append(related, n) } } s.render(w, r, "entry.html", http.StatusOK, pageData{ Title: e.Title, - Bereich: "eintraege", - Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung + Section: "eintraege", + Flash: s.hintFrom(r), // z. B. nach einer Wiederherstellung Data: entryViewData{ - Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks, - Verwandt: verwandt, - Stale: e.Stale(time.Now()), + Entry: e, HTML: template.HTML(content), Backlinks: backlinks, + Related: related, + Stale: e.Stale(time.Now()), }, }) } -// verwandtLimit begrenzt die Nachbarn, die die Eintragsansicht lädt (beide +// relatedLimit begrenzt die Nachbarn, die die Eintragsansicht lädt (beide // Reihen zusammen). Mehr als eine Handvoll in der zweiten Reihe hilft beim // Lesen nicht, sondern lenkt ab; wer mehr will, hat den Graph. -const verwandtLimit = 30 +const relatedLimit = 30 // entryEditData sind die Daten des Bearbeitungsformulars. type entryEditData struct { @@ -241,15 +241,15 @@ type entryEditData struct { // einem Verweis ins Leere direkt zum passenden neuen Eintrag. Ein ungültiger // Wert wird ignoriert statt ins Formular übernommen. func (s *Server) handleEntryNew(w http.ResponseWriter, r *http.Request) { - var vorbelegt store.Entry + var prefilled store.Entry if slug := r.URL.Query().Get("slug"); store.ValidSlug(slug) { - vorbelegt.Slug = slug + prefilled.Slug = slug } s.render(w, r, "edit.html", http.StatusOK, pageData{ Title: "Neuer Eintrag", - Bereich: "eintraege", - Data: entryEditData{IsNew: true, Entry: vorbelegt}, + Section: "eintraege", + Data: entryEditData{IsNew: true, Entry: prefilled}, }) } @@ -270,7 +270,7 @@ func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) { s.render(w, r, "edit.html", http.StatusOK, pageData{ Title: "Bearbeiten: " + e.Title, - Bereich: "eintraege", + Section: "eintraege", Data: entryEditData{Entry: e, TagsIn: strings.Join(e.Tags, ", ")}, }) } @@ -300,16 +300,16 @@ func (s *Server) handleEntrySuggest(w http.ResponseWriter, r *http.Request) { // Nicht nil, damit die Antwort immer ein JSON-Array ist ([] statt null) — // das JavaScript ruft direkt .forEach darauf auf. - vorschlaege := []suggestItem{} + suggestions := []suggestItem{} if query == "" { - eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Limit: suggestLimit}) + entries, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Limit: suggestLimit}) if err != nil { s.serverError(w, "Vorschläge konnten nicht geladen werden", err) return } - for _, e := range eintraege { - vorschlaege = append(vorschlaege, suggestItem{Slug: e.Slug, Title: e.Title}) + for _, e := range entries { + suggestions = append(suggestions, suggestItem{Slug: e.Slug, Title: e.Title}) } } else { idx, err := ws.Search.For(r.Context(), project.ID) @@ -317,33 +317,33 @@ func (s *Server) handleEntrySuggest(w http.ResponseWriter, r *http.Request) { s.serverError(w, "Vorschläge konnten nicht geladen werden", err) return } - for _, treffer := range idx.SearchFuzzy(query, suggestLimit) { - e, err := ws.Store.EntryBySlug(r.Context(), project.ID, treffer.DocID) + for _, hits := range idx.SearchFuzzy(query, suggestLimit) { + e, err := ws.Store.EntryBySlug(r.Context(), project.ID, hits.DocID) if err != nil { continue // Index kann einen Eintrag kennen, den es nicht mehr gibt } - vorschlaege = append(vorschlaege, suggestItem{Slug: e.Slug, Title: e.Title}) + suggestions = append(suggestions, suggestItem{Slug: e.Slug, Title: e.Title}) } } w.Header().Set("Content-Type", "application/json; charset=utf-8") - if err := json.NewEncoder(w).Encode(vorschlaege); err != nil { + if err := json.NewEncoder(w).Encode(suggestions); err != nil { log.Printf("Vorschläge konnten nicht gesendet werden: %v", err) } } -// aehnlichItem ist ein vorhandener Eintrag, der dem gerade angelegten ähnelt -// — für den Hinweis „Gibt es schon?" im Formular (static/aehnlich.js). -type aehnlichItem struct { - Slug string `json:"slug"` - Title string `json:"title"` - Overlap int `json:"overlap"` - Gemeinsam []string `json:"gemeinsam"` +// similarItem ist ein vorhandener Eintrag, der dem gerade angelegten ähnelt +// — für den Hinweis „Gibt es schon?" im Formular (static/similar.js). +type similarItem struct { + Slug string `json:"slug"` + Title string `json:"title"` + Overlap int `json:"overlap"` + Shared []string `json:"shared"` } // handleEntrySimilar liefert vorhandene Einträge, die einem neuen Titel // ähneln — dieselbe Prüfung, mit der "remember" über MCP bei Agenten -// nachfragt (internal/dubletten). +// nachfragt (internal/duplicates). // // Anders als bei MCP ist das nur ein Hinweis: Das Formular zeigt die // Kandidaten unter dem Titelfeld an, speichern lässt sich trotzdem. Ein @@ -352,30 +352,30 @@ type aehnlichItem struct { func (s *Server) handleEntrySimilar(w http.ResponseWriter, r *http.Request) { ws, _ := WorkspaceFrom(r) project, _ := ProjectFrom(r) - titel := strings.TrimSpace(r.URL.Query().Get("title")) + title := strings.TrimSpace(r.URL.Query().Get("title")) slug := strings.TrimSpace(r.URL.Query().Get("slug")) // Nicht nil, damit die Antwort immer ein JSON-Array ist. - antwort := []aehnlichItem{} + answer := []similarItem{} - if titel != "" { + if title != "" { if slug == "" { - slug = store.Slugify(titel) + slug = store.Slugify(title) } - kandidaten, err := dubletten.Pruefe(r.Context(), ws, project, slug, titel, "") + candidates, err := duplicates.Check(r.Context(), ws, project, slug, title, "") if err != nil { s.serverError(w, "Ähnliche Einträge konnten nicht gesucht werden", err) return } - for _, k := range kandidaten { - antwort = append(antwort, aehnlichItem{ - Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, Gemeinsam: k.MatchedWords, + for _, k := range candidates { + answer = append(answer, similarItem{ + Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, Shared: k.MatchedWords, }) } } w.Header().Set("Content-Type", "application/json; charset=utf-8") - if err := json.NewEncoder(w).Encode(antwort); err != nil { + if err := json.NewEncoder(w).Encode(answer); err != nil { log.Printf("Ähnliche Einträge konnten nicht gesendet werden: %v", err) } } @@ -408,13 +408,13 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { project, _ := ProjectFrom(r) sess, _ := SessionFrom(r) - titel := strings.TrimSpace(r.FormValue("title")) - inhalt := r.FormValue("content") + title := strings.TrimSpace(r.FormValue("title")) + content := r.FormValue("content") slug := strings.TrimSpace(r.FormValue("slug")) // Kein Bezeichner angegeben (neuer Eintrag) → aus dem Titel ableiten. if slug == "" { - slug = store.Slugify(titel) + slug = store.Slugify(title) } // Die im Formular mitgeführte Version ermöglicht das optimistische Sperren: @@ -422,11 +422,11 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { // Hinweis statt eines stillen Überschreibens. version, _ := strconv.Atoi(r.FormValue("version")) - geschrieben, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{ + written, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{ ProjectID: project.ID, Slug: slug, - Title: titel, - Content: inhalt, + Title: title, + Content: content, Tags: splitTags(r.FormValue("tags")), Source: store.SourceHuman, Author: sess.ActorName(), @@ -440,11 +440,11 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { // Text darf dabei nicht verloren gehen. s.render(w, r, "edit.html", http.StatusBadRequest, pageData{ Title: "Bearbeiten", - Bereich: "eintraege", + Section: "eintraege", Error: err.Error(), Data: entryEditData{ Entry: store.Entry{ - Slug: slug, Title: titel, Content: inhalt, + Slug: slug, Title: title, Content: content, Version: version, Curated: r.FormValue("curated") != "", }, TagsIn: r.FormValue("tags"), @@ -454,15 +454,15 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { return } - ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug) + ws.Search.Refresh(r.Context(), project.ID, written.Slug) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb, - Detail: store.EntryWriteDetail(geschrieben), + Detail: store.EntryWriteDetail(written), }) - ziel := "/p/" + project.Slug + "/e/" + slug - if geschrieben.RestoredFromTrash { + target := "/p/" + project.Slug + "/e/" + slug + if written.RestoredFromTrash { // Der Bezeichner gehörte einem Eintrag im Papierkorb. PutEntry hat ihn // zurückgeholt und überschrieben — das soll im Protokoll stehen und // der Mensch soll es erfahren, sonst wundert er sich über "Fassung 4" @@ -472,11 +472,11 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceWeb, Detail: "durch Speichern unter demselben Bezeichner", }) - ziel += "?hinweis=" + url.QueryEscape( + target = s.withHint(target, "Unter diesem Bezeichner lag ein Eintrag im Papierkorb. Er wurde zurückgeholt und überschrieben; der gelöschte Stand steht im Verlauf.") } - http.Redirect(w, r, ziel, http.StatusSeeOther) + http.Redirect(w, r, target, http.StatusSeeOther) } // handleEntryDelete legt einen Eintrag in den Papierkorb (AUSBAUSTUFEN.md 3.9). @@ -506,9 +506,9 @@ func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) { Detail: "in den Papierkorb", }) - meldung := fmt.Sprintf("„%s“ liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.", + message := fmt.Sprintf("„%s“ liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.", slug, store.TrashRetentionDays) - http.Redirect(w, r, "/p/"+project.Slug+"/?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther) + http.Redirect(w, r, s.withHint("/p/"+project.Slug+"/", message), http.StatusSeeOther) } // handleEntryBulkTags fügt einer Auswahl von Einträgen Schlagworte hinzu @@ -527,37 +527,37 @@ func (s *Server) handleEntryBulkTags(w http.ResponseWriter, r *http.Request) { return } - ziel := sammelZiel(r, project) + target := bulkTarget(r, project) ids := r.Form["ids"] add := splitTags(r.FormValue("add_tags")) remove := splitTags(r.FormValue("remove_tags")) if len(ids) == 0 || (len(add) == 0 && len(remove) == 0) { - http.Redirect(w, r, ziel, http.StatusSeeOther) + http.Redirect(w, r, target, http.StatusSeeOther) return } - geaendert, err := ws.Store.BulkUpdateTags(r.Context(), project.ID, ids, add, remove) + changed, err := ws.Store.BulkUpdateTags(r.Context(), project.ID, ids, add, remove) if err != nil { s.serverError(w, "Schlagworte konnten nicht geändert werden", err) return } - for _, e := range geaendert { + for _, e := range changed { ws.Search.Refresh(r.Context(), project.ID, e.Slug) } - if len(geaendert) > 0 { + if len(changed) > 0 { ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryWrite, Source: store.SourceWeb, - Detail: fmt.Sprintf("Sammelbearbeitung: %d Einträge, +%v -%v", len(geaendert), add, remove), + Detail: fmt.Sprintf("Sammelbearbeitung: %d Einträge, +%v -%v", len(changed), add, remove), }) } - meldung := fmt.Sprintf("%d Eintrag/Einträge aktualisiert", len(geaendert)) - http.Redirect(w, r, mitHinweis(ziel, meldung), http.StatusSeeOther) + message := fmt.Sprintf("%d Eintrag/Einträge aktualisiert", len(changed)) + http.Redirect(w, r, s.withHint(target, message), http.StatusSeeOther) } // handleEntryBulkDelete legt die in der Eintragsliste ausgewählten Einträge @@ -578,15 +578,15 @@ func (s *Server) handleEntryBulkDelete(w http.ResponseWriter, r *http.Request) { return } - ziel := sammelZiel(r, project) + target := bulkTarget(r, project) ids := r.Form["ids"] if len(ids) == 0 { - http.Redirect(w, r, mitHinweis(ziel, "Nichts ausgewählt — es wurde nichts gelöscht."), http.StatusSeeOther) + http.Redirect(w, r, s.withHint(target, "Nichts ausgewählt — es wurde nichts gelöscht."), http.StatusSeeOther) return } - geloescht, err := ws.Store.MoveEntriesToTrash(r.Context(), project.ID, ids, sess.ActorName()) + deleted, err := ws.Store.MoveEntriesToTrash(r.Context(), project.ID, ids, sess.ActorName()) if err != nil { s.serverError(w, "Einträge konnten nicht gelöscht werden", err) return @@ -595,7 +595,7 @@ func (s *Server) handleEntryBulkDelete(w http.ResponseWriter, r *http.Request) { // Je Eintrag eine eigene Protokollzeile, genau wie beim einzelnen // Löschen. So zählt die Nutzungsübersicht jede Löschung, und im Protokoll // steht bei jedem Eintrag, wer ihn wann gelöscht hat. - for _, slug := range geloescht { + for _, slug := range deleted { ws.Search.Refresh(r.Context(), project.ID, slug) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), @@ -604,43 +604,67 @@ func (s *Server) handleEntryBulkDelete(w http.ResponseWriter, r *http.Request) { }) } - meldung := fmt.Sprintf("%d Einträge liegen jetzt im Papierkorb und lassen sich dort %d Tage lang wiederherstellen.", - len(geloescht), store.TrashRetentionDays) - if len(geloescht) == 1 { - meldung = fmt.Sprintf("1 Eintrag liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.", + message := fmt.Sprintf("%d Einträge liegen jetzt im Papierkorb und lassen sich dort %d Tage lang wiederherstellen.", + len(deleted), store.TrashRetentionDays) + if len(deleted) == 1 { + message = fmt.Sprintf("1 Eintrag liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.", store.TrashRetentionDays) } - http.Redirect(w, r, mitHinweis(ziel, meldung), http.StatusSeeOther) + http.Redirect(w, r, s.withHint(target, message), http.StatusSeeOther) } -// sammelZiel ist die Adresse, zu der die Sammelaktionen der Eintragsliste +// bulkTarget ist die Adresse, zu der die Sammelaktionen der Eintragsliste // zurückführen: dieselbe Liste, mit demselben Schlagwortfilter und derselben // Suche wie vorher. Sonst stünde man nach dem Löschen aus einer Trefferliste // plötzlich vor allen Einträgen. -func sammelZiel(r *http.Request, project store.Project) string { - werte := url.Values{} +func bulkTarget(r *http.Request, project store.Project) string { + values := url.Values{} if tag := r.FormValue("tag"); tag != "" { - werte.Set("tag", tag) + values.Set("tag", tag) } if q := r.FormValue("q"); q != "" { - werte.Set("q", q) + values.Set("q", q) } - ziel := "/p/" + project.Slug + "/" - if len(werte) > 0 { - ziel += "?" + werte.Encode() + target := "/p/" + project.Slug + "/" + if len(values) > 0 { + target += "?" + values.Encode() } - return ziel + return target } -// mitHinweis hängt eine Meldung als Parameter "hinweis" an eine Adresse an — -// mit "?" oder "&", je nachdem, ob schon Parameter dastehen. -func mitHinweis(ziel, meldung string) string { - trenner := "?" - if strings.Contains(ziel, "?") { - trenner = "&" +// hintParam trägt eine Erfolgsmeldung über einen Redirect, hintSigParam ihre +// Signatur (siehe auth.Signer.SignHint). +const ( + hintParam = "hinweis" + hintSigParam = "hs" +) + +// withHint hängt eine Meldung als Parameter "hinweis" an eine Adresse an — +// mit "?" oder "&", je nachdem, ob schon Parameter dastehen. Dazu kommt die +// Signatur: Angezeigt wird später nur, was der Server selbst geschrieben hat +// (siehe hintFrom). +func (s *Server) withHint(target, message string) string { + values := url.Values{} + values.Set(hintParam, message) + values.Set(hintSigParam, s.signer.SignHint(message)) + + sep := "?" + if strings.Contains(target, "?") { + sep = "&" } - return ziel + trenner + "hinweis=" + url.QueryEscape(meldung) + return target + sep + values.Encode() +} + +// hintFrom liest die Meldung aus der Adresse — aber nur, wenn die Signatur +// passt. Ein Link mit selbst ausgedachtem Text zeigt also nichts an. +func (s *Server) hintFrom(r *http.Request) string { + query := r.URL.Query() + message := query.Get(hintParam) + if message == "" || !s.signer.ValidHint(message, query.Get(hintSigParam)) { + return "" + } + return message } // ── Änderungshistorie ──────────────────────────────────────────────────────── @@ -675,10 +699,10 @@ func (s *Server) handleEntryVersions(w http.ResponseWriter, r *http.Request) { // renderEntryVersions zeigt die Verlaufsseite, auf Wunsch mit Fehlermeldung. // Eine eigene Funktion, weil auch die Wiederherstellung bei einem Konflikt // hierher zurückführt (siehe handleEntryRestore). -func (s *Server) renderEntryVersions(w http.ResponseWriter, r *http.Request, status int, e store.Entry, fehler string) { +func (s *Server) renderEntryVersions(w http.ResponseWriter, r *http.Request, status int, e store.Entry, errs string) { ws, _ := WorkspaceFrom(r) - versionen, err := ws.Store.EntryVersions(r.Context(), e.ID) + versions, err := ws.Store.EntryVersions(r.Context(), e.ID) if err != nil { s.serverError(w, "Änderungshistorie konnte nicht geladen werden", err) return @@ -686,9 +710,9 @@ func (s *Server) renderEntryVersions(w http.ResponseWriter, r *http.Request, sta s.render(w, r, "entry-versions.html", status, pageData{ Title: "Verlauf – " + e.Title, - Bereich: "eintraege", - Error: fehler, - Data: entryVersionListData{Entry: e, Versions: versionen}, + Section: "eintraege", + Error: errs, + Data: entryVersionListData{Entry: e, Versions: versions}, }) } @@ -741,7 +765,7 @@ func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) { // EntryVersionByNumber sucht über die Eintrags-ID. Eine Fassung eines // fremden Eintrags (oder fremden Projekts) ist darüber nicht erreichbar. - alt, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version) + old, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return @@ -755,28 +779,28 @@ func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) { // PutEntry ohne Konfliktprüfung schreiben. Das wollen wir hier nicht: Wer // eine alte Fassung zurückholt, soll dabei nie unbemerkt eine neuere // Änderung eines anderen überschreiben. Deshalb lieber ablehnen. - erwartet, err := strconv.Atoi(r.FormValue("current_version")) - if err != nil || erwartet < 1 { + want, err := strconv.Atoi(r.FormValue("current_version")) + if err != nil || want < 1 { http.Error(w, "Fassungsangabe fehlt", http.StatusBadRequest) return } - geschrieben, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{ + written, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{ ProjectID: project.ID, Slug: e.Slug, - Title: alt.Title, - Content: alt.Content, - Tags: alt.Tags, + Title: old.Title, + Content: old.Content, + Tags: old.Tags, Source: store.SourceHuman, Author: sess.ActorName(), Curated: e.Curated, - ExpectedVersion: erwartet, + ExpectedVersion: want, }) if errors.Is(err, store.ErrConflict) { // Den Eintrag frisch laden, damit die Verlaufsseite die Änderung zeigt, // die uns zuvorgekommen ist. Klappt das nicht, reicht auch der alte Stand. - if frisch, ladeFehler := ws.Store.EntryBySlug(r.Context(), project.ID, slug); ladeFehler == nil { - e = frisch + if fresh, loadErr := ws.Store.EntryBySlug(r.Context(), project.ID, slug); loadErr == nil { + e = fresh } s.renderEntryVersions(w, r, http.StatusConflict, e, "Der Eintrag wurde inzwischen geändert. Bitte den Verlauf prüfen und dann erneut wiederherstellen.") @@ -787,15 +811,15 @@ func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) { return } - ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug) + ws.Search.Refresh(r.Context(), project.ID, written.Slug) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb, - Detail: store.EntryRestoreDetail(alt.Version), + Detail: store.EntryRestoreDetail(old.Version), }) - meldung := fmt.Sprintf("Fassung %d wiederhergestellt — jetzt Fassung %d.", alt.Version, geschrieben.Version) - http.Redirect(w, r, "/p/"+project.Slug+"/e/"+e.Slug+"?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther) + message := fmt.Sprintf("Fassung %d wiederhergestellt — jetzt Fassung %d.", old.Version, written.Version) + http.Redirect(w, r, s.withHint("/p/"+project.Slug+"/e/"+e.Slug, message), http.StatusSeeOther) } // entryDiffData sind die Daten der Vergleichsseite zweier Fassungen. @@ -842,7 +866,7 @@ func (s *Server) handleEntryDiff(w http.ResponseWriter, r *http.Request) { return } - alt, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version) + old, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return @@ -852,23 +876,23 @@ func (s *Server) handleEntryDiff(w http.ResponseWriter, r *http.Request) { return } - daten := entryDiffData{ + data := entryDiffData{ Entry: e, - OldVersion: alt.Version, OldTitle: alt.Title, OldAuthor: alt.Author, OldTime: alt.CreatedAt, OldTags: alt.Tags, + OldVersion: old.Version, OldTitle: old.Title, OldAuthor: old.Author, OldTime: old.CreatedAt, OldTags: old.Tags, } - neu, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version+1) + newer, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version+1) switch { case err == nil: - daten.NewVersion, daten.NewTitle, daten.NewAuthor = neu.Version, neu.Title, neu.Author - daten.NewTime, daten.NewTags = neu.CreatedAt, neu.Tags - daten.Lines = entry.DiffLines(alt.Content, neu.Content) + data.NewVersion, data.NewTitle, data.NewAuthor = newer.Version, newer.Title, newer.Author + data.NewTime, data.NewTags = newer.CreatedAt, newer.Tags + data.Lines = entry.DiffLines(old.Content, newer.Content) case errors.Is(err, store.ErrNotFound): // Keine weitere abgelöste Fassung mehr — die Nachfolgerin ist die // aktuell gültige. - daten.NewVersion, daten.NewTitle, daten.NewAuthor = e.Version, e.Title, e.Author - daten.NewTime, daten.NewTags, daten.NewIsCurrent = e.UpdatedAt, e.Tags, true - daten.Lines = entry.DiffLines(alt.Content, e.Content) + data.NewVersion, data.NewTitle, data.NewAuthor = e.Version, e.Title, e.Author + data.NewTime, data.NewTags, data.NewIsCurrent = e.UpdatedAt, e.Tags, true + data.Lines = entry.DiffLines(old.Content, e.Content) default: s.serverError(w, "Fassung konnte nicht geladen werden", err) return @@ -876,8 +900,8 @@ func (s *Server) handleEntryDiff(w http.ResponseWriter, r *http.Request) { s.render(w, r, "entry-diff.html", http.StatusOK, pageData{ Title: "Vergleich – " + e.Title, - Bereich: "eintraege", - Data: daten, + Section: "eintraege", + Data: data, }) } @@ -898,7 +922,7 @@ type graphNode struct { Slug string `json:"slug"` Title string `json:"title"` Exists bool `json:"exists"` - Grad int `json:"degree"` + Degree int `json:"degree"` } type graphEdge struct { @@ -919,7 +943,7 @@ func (s *Server) handleEntryGraph(w http.ResponseWriter, r *http.Request) { project, _ := ProjectFrom(r) s.render(w, r, "graph.html", http.StatusOK, pageData{ Title: "Graph – " + project.Name, - Bereich: "graph", + Section: "graph", }) } @@ -928,34 +952,34 @@ func (s *Server) handleEntryGraphData(w http.ResponseWriter, r *http.Request) { ws, _ := WorkspaceFrom(r) project, _ := ProjectFrom(r) - knoten, kanten, err := ws.Store.LinkGraph(r.Context(), project.ID) + nodes, edges, err := ws.Store.LinkGraph(r.Context(), project.ID) if err != nil { s.serverError(w, "Graph konnte nicht geladen werden", err) return } - grad := make(map[string]int, len(knoten)) - for _, k := range kanten { - grad[k.From]++ - grad[k.To]++ + degree := make(map[string]int, len(nodes)) + for _, k := range edges { + degree[k.From]++ + degree[k.To]++ } - resp := graphResponse{Nodes: []graphNode{}, Edges: []graphEdge{}, Total: len(knoten)} + resp := graphResponse{Nodes: []graphNode{}, Edges: []graphEdge{}, Total: len(nodes)} - behalten := knoten - if len(knoten) > graphNodeLimit { + kept := nodes + if len(nodes) > graphNodeLimit { resp.Truncated = true - sort.Slice(knoten, func(i, j int) bool { return grad[knoten[i].Slug] > grad[knoten[j].Slug] }) - behalten = knoten[:graphNodeLimit] + sort.Slice(nodes, func(i, j int) bool { return degree[nodes[i].Slug] > degree[nodes[j].Slug] }) + kept = nodes[:graphNodeLimit] } - erlaubt := make(map[string]bool, len(behalten)) - for _, k := range behalten { - erlaubt[k.Slug] = true - resp.Nodes = append(resp.Nodes, graphNode{Slug: k.Slug, Title: k.Title, Exists: k.Exists, Grad: grad[k.Slug]}) + allowed := make(map[string]bool, len(kept)) + for _, k := range kept { + allowed[k.Slug] = true + resp.Nodes = append(resp.Nodes, graphNode{Slug: k.Slug, Title: k.Title, Exists: k.Exists, Degree: degree[k.Slug]}) } - for _, e := range kanten { - if erlaubt[e.From] && erlaubt[e.To] { + for _, e := range edges { + if allowed[e.From] && allowed[e.To] { resp.Edges = append(resp.Edges, graphEdge{From: e.From, To: e.To}) } } @@ -968,13 +992,13 @@ func (s *Server) handleEntryGraphData(w http.ResponseWriter, r *http.Request) { // splitTags zerlegt die Schlagwort-Eingabe an Kommas. // Das Vereinheitlichen (klein, ohne Dubletten) übernimmt danach der Store. -func splitTags(eingabe string) []string { - if strings.TrimSpace(eingabe) == "" { +func splitTags(input string) []string { + if strings.TrimSpace(input) == "" { return nil } - teile := strings.Split(eingabe, ",") - for i := range teile { - teile[i] = strings.TrimSpace(teile[i]) + parts := strings.Split(input, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) } - return teile + return parts } diff --git a/internal/web/handlers_hilfe.go b/internal/web/handlers_help.go similarity index 71% rename from internal/web/handlers_hilfe.go rename to internal/web/handlers_help.go index d26061c..a3ffc21 100644 --- a/internal/web/handlers_hilfe.go +++ b/internal/web/handlers_help.go @@ -7,17 +7,17 @@ import ( "strings" "munin/internal/auth" - "munin/internal/hilfe" + "munin/internal/help" ) -// hilfeData sind die Daten einer Hilfeseite. -type hilfeData struct { - Seite hilfe.Seite // die angezeigte Seite - Seiten []hilfe.Seite // alle Seiten, für die Navigation - HTML template.HTML // der gerenderte Inhalt +// helpData sind die Daten einer Hilfeseite. +type helpData struct { + Page help.Page // die angezeigte Seite + Pages []help.Page // alle Seiten, für die Navigation + HTML template.HTML // der gerenderte Inhalt } -// handleHilfe zeigt eine Seite der eingebauten Onboarding-Dokumentation. +// handleHelp zeigt eine Seite der eingebauten Onboarding-Dokumentation. // // Erreichbar für jede angemeldete Sitzung, unabhängig von Rolle und Projekt: // Die Hilfe enthält nichts, was nicht jeder Nutzer wissen darf. Auch ein @@ -25,7 +25,7 @@ type hilfeData struct { // // /hilfe ohne Seitenangabe zeigt die Startseite. Deshalb kommt der Slug aus // r.PathValue und fällt auf "start" zurück, wenn er leer ist. -func (s *Server) handleHilfe(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleHelp(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("seite") if slug == "" { slug = "start" @@ -35,14 +35,14 @@ func (s *Server) handleHilfe(w http.ResponseWriter, r *http.Request) { // Eine Admin-Seite antwortet für alle anderen mit 404, genau wie eine // Seite, die es nicht gibt — sie steht für sie ja auch nirgends verlinkt. - leser := hilfe.Leser{PlattformAdmin: sess.IsPlatformAdmin, WorkspaceAdmin: sess.IsWorkspaceAdmin} - seite, ok := hilfe.Finde(slug) - if !ok || !seite.DarfLesen(leser) { + reader := help.Reader{PlatformAdmin: sess.IsPlatformAdmin, WorkspaceAdmin: sess.IsWorkspaceAdmin} + page, ok := help.Find(slug) + if !ok || !page.CanRead(reader) { http.NotFound(w, r) return } - markdown, err := seite.Markdown() + markdown, err := page.Markdown() if err != nil { s.serverError(w, "Hilfeseite konnte nicht geladen werden", err) return @@ -52,28 +52,28 @@ func (s *Server) handleHilfe(w http.ResponseWriter, r *http.Request) { // Adresse dieses Servers und dem Bezeichner des eigenen Projekts statt // mit "DEIN-SERVER". Das passiert vor dem Rendern — goldmark sieht nur // noch fertigen Text und maskiert ihn wie jeden anderen. - projektSlug := "mein-projekt" + projectSlug := "mein-projekt" if sess.Project.Slug != "" { - projektSlug = sess.Project.Slug + projectSlug = sess.Project.Slug } markdown = strings.NewReplacer( "{{SERVER}}", serverURL(r), - "{{PROJEKT}}", projektSlug, + "{{PROJEKT}}", projectSlug, ).Replace(markdown) - inhalt, err := hilfe.Render(markdown) + content, err := help.Render(markdown) if err != nil { s.serverError(w, "Hilfeseite konnte nicht dargestellt werden", err) return } data := pageData{ - Title: seite.Titel, - Bereich: "hilfe", - Data: hilfeData{ - Seite: seite, - Seiten: hilfe.Sichtbar(leser), - HTML: template.HTML(inhalt), + Title: page.Title, + Section: "hilfe", + Data: helpData{ + Page: page, + Pages: help.Visible(reader), + HTML: template.HTML(content), }, } @@ -84,13 +84,13 @@ func (s *Server) handleHilfe(w http.ResponseWriter, r *http.Request) { data.Project = sess.Project } - s.render(w, r, "hilfe.html", http.StatusOK, data) + s.render(w, r, "help.html", http.StatusOK, data) } -// gueltigerHost erlaubt nur, was in einem Hostnamen mit Port vorkommen kann: +// validHost erlaubt nur, was in einem Hostnamen mit Port vorkommen kann: // Buchstaben, Ziffern, Punkt, Bindestrich, Doppelpunkt und die eckigen // Klammern einer IPv6-Adresse. -var gueltigerHost = regexp.MustCompile(`^[A-Za-z0-9.\-:\[\]]+$`) +var validHost = regexp.MustCompile(`^[A-Za-z0-9.\-:\[\]]+$`) // serverURL errät die Adresse, unter der dieser Server von außen erreichbar // ist — für die Beispiele in der Hilfe. @@ -108,7 +108,7 @@ var gueltigerHost = regexp.MustCompile(`^[A-Za-z0-9.\-:\[\]]+$`) // deshalb auch dieselbe Funktion (auth.IsLocalhost). func serverURL(r *http.Request) string { host := r.Host - if host == "" || !gueltigerHost.MatchString(host) { + if host == "" || !validHost.MatchString(host) { return "https://DEIN-SERVER" } diff --git a/internal/web/handlers_import.go b/internal/web/handlers_import.go index fc46a85..4e6c3de 100644 --- a/internal/web/handlers_import.go +++ b/internal/web/handlers_import.go @@ -35,22 +35,22 @@ import ( // importData sind die Daten der Import-Seite. type importData struct { - // Bericht ist nach einem Lauf gesetzt, sonst nil. - Bericht *importer.Bericht + // Report ist nach einem Lauf gesetzt, sonst nil. + Report *importer.Report - // Vorschau: Der Bericht zeigt nur, was passieren würde. - Vorschau bool + // Preview: Der Bericht zeigt nur, was passieren würde. + Preview bool - // Modus ist die zuletzt gewählte Einstellung, damit das Formular sie nach + // Mode ist die zuletzt gewählte Einstellung, damit das Formular sie nach // einer Vorschau beibehält. - Modus importer.Modus + Mode importer.Mode - MaxDateien int + MaxFiles int } // handleImportForm zeigt das Formular zum Hochladen. func (s *Server) handleImportForm(w http.ResponseWriter, r *http.Request) { - s.renderImport(w, r, http.StatusOK, importData{Modus: importer.ModusUeberspringen}, "", "") + s.renderImport(w, r, http.StatusOK, importData{Mode: importer.ModeSkip}, "", "") } // handleImport nimmt ein ZIP-Archiv entgegen und importiert es, oder zeigt @@ -59,54 +59,54 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) project, _ := ProjectFrom(r) - daten := importData{ - Modus: importer.Modus(r.FormValue("modus")), - Vorschau: r.FormValue("vorschau") == "1", + data := importData{ + Mode: importer.Mode(r.FormValue("modus")), + Preview: r.FormValue("vorschau") == "1", } - if daten.Modus != importer.ModusUeberschreiben { - daten.Modus = importer.ModusUeberspringen + if data.Mode != importer.ModeOverwrite { + data.Mode = importer.ModeSkip } // r.FormFile liest das hochgeladene Feld "archiv". Die Größe ist schon // begrenzt (LimitBodies in middleware.go), ReadAll kann also nicht mehr // als das Import-Limit in den Speicher holen. - datei, _, err := r.FormFile("archiv") + file, _, err := r.FormFile("archiv") if err != nil { - var zuGross *http.MaxBytesError - if errors.As(err, &zuGross) { - s.renderImport(w, r, http.StatusRequestEntityTooLarge, daten, "", + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + s.renderImport(w, r, http.StatusRequestEntityTooLarge, data, "", "Das Archiv ist zu groß. Bitte in mehrere kleinere aufteilen.") return } - s.renderImport(w, r, http.StatusBadRequest, daten, "", "Bitte ein ZIP-Archiv auswählen.") + s.renderImport(w, r, http.StatusBadRequest, data, "", "Bitte ein ZIP-Archiv auswählen.") return } - defer datei.Close() + defer file.Close() - inhalt, err := io.ReadAll(datei) + content, err := io.ReadAll(file) if err != nil { - s.renderImport(w, r, http.StatusBadRequest, daten, "", "Das Archiv konnte nicht gelesen werden.") + s.renderImport(w, r, http.StatusBadRequest, data, "", "Das Archiv konnte nicht gelesen werden.") return } - dateien, err := importer.LeseZip(inhalt) + files, err := importer.ReadZip(content) if err != nil { - s.renderImport(w, r, http.StatusBadRequest, daten, "", "Import nicht möglich: "+err.Error()+".") + s.renderImport(w, r, http.StatusBadRequest, data, "", "Import nicht möglich: "+err.Error()+".") return } - bericht, err := importer.Importiere(r.Context(), ws.Store, project.ID, dateien, importer.Optionen{ - Modus: daten.Modus, - Vorschau: daten.Vorschau, - Autor: sess.ActorName(), + report, err := importer.Import(r.Context(), ws.Store, project.ID, files, importer.Options{ + Mode: data.Mode, + Preview: data.Preview, + Author: sess.ActorName(), }) // Auch nach einem Abbruch sind die bis dahin geschriebenen Einträge da. // Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den // Fehler melden. - for _, e := range bericht.Geschrieben { + for _, e := range report.Written { ws.Search.Refresh(r.Context(), project.ID, e.Slug) } - if !daten.Vorschau && len(bericht.Geschrieben) > 0 { + if !data.Preview && len(report.Written) > 0 { // Ein Protokolleintrag für den ganzen Import statt einer je Datei. // Sonst verschwände alles andere im Protokoll unter tausend Zeilen. // Und die Nutzungsübersicht (4.6) soll zählen, was die Teams im @@ -114,33 +114,33 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionEntryImport, Source: store.SourceWeb, IP: auth.ClientIP(r), - Detail: bericht.Zusammenfassung(), + Detail: report.Summary(), }) } if err != nil { - s.serverError(w, "Import abgebrochen nach "+bericht.Zusammenfassung(), err) + s.serverError(w, "Import abgebrochen nach "+report.Summary(), err) return } - daten.Bericht = &bericht - hinweis := "Import abgeschlossen: " + bericht.Zusammenfassung() + "." - if daten.Vorschau { - hinweis = "Vorschau, noch nichts geschrieben: " + bericht.Zusammenfassung() + + data.Report = &report + hint := "Import abgeschlossen: " + report.Summary() + "." + if data.Preview { + hint = "Vorschau, noch nichts geschrieben: " + report.Summary() + ". Zum Übernehmen dasselbe Archiv ohne Häkchen bei „Nur Vorschau“ erneut hochladen." } - s.renderImport(w, r, http.StatusOK, daten, hinweis, "") + s.renderImport(w, r, http.StatusOK, data, hint, "") } // renderImport zeigt die Import-Seite mit optionaler Meldung. -func (s *Server) renderImport(w http.ResponseWriter, r *http.Request, status int, daten importData, hinweis, fehler string) { +func (s *Server) renderImport(w http.ResponseWriter, r *http.Request, status int, data importData, hint, errs string) { project, _ := ProjectFrom(r) - daten.MaxDateien = importer.MaxDateien + data.MaxFiles = importer.MaxFiles s.render(w, r, "import.html", status, pageData{ Title: "Import – " + project.Name, - Bereich: "import", - Flash: hinweis, - Error: fehler, - Data: daten, + Section: "import", + Flash: hint, + Error: errs, + Data: data, }) } diff --git a/internal/web/handlers_overview.go b/internal/web/handlers_overview.go new file mode 100644 index 0000000..5571a23 --- /dev/null +++ b/internal/web/handlers_overview.go @@ -0,0 +1,286 @@ +package web + +import ( + "context" + "fmt" + "net/http" + "sort" + "time" + + "munin/internal/auth" + "munin/internal/stats" + "munin/internal/store" +) + +// Überblick eines Workspace (AUSBAUSTUFEN.md 5.17). +// +// Das Gegenstück zur Systemseite, aber für den Workspace-Admin: nur sein +// Workspace und nur, was er selbst in die Hand nehmen kann — Zugänge, die +// Pflege der Inhalte, die Frage, ob die Teams Munin benutzen. Verbindungen, +// Platz, Schema und Export bleiben auf der Systemseite; daran kann er nichts +// ändern. + +// Schwellen für die Hinweise. Bewusst grob: Sie sollen auf ein Projekt +// aufmerksam machen, nicht jede Schwankung melden. +const ( + // Ab so vielen Suchen im Zeitraum ist ein Anteil ohne Treffer + // aussagekräftig — bei drei Suchen wäre eine leere schon 33 %. + emptySearchesFrom = 10 + + // Ab diesem Anteil ohne Treffer (in Prozent) lohnt ein Blick: Dann + // fehlt Wissen, oder Agenten suchen mit anderen Wörtern, als in den + // Einträgen stehen. + emptySearchesPercent = 30 + + // reviewMax: So viele Einträge „zur Prüfung“ liest der + // Überblick je Projekt, um sie zu zählen. CuratedOverrides braucht eine + // Obergrenze; mehr als das prüft ohnehin niemand am Stück. + reviewMax = 1000 +) + +// overviewData sind die Daten des Überblicks. +type overviewData struct { + Workspace string // Name + + // Groups: was zu tun ist, je Projekt zusammengefasst. Projekte mit + // einer Warnung zuerst, sonst alphabetisch. Projekte ohne Befund fehlen. + Groups []overviewGroup + + Projects []overviewProject // alphabetisch + Total store.Inventory + Writes int // im Zeitraum, alle Projekte + Searches int + ChartDays int + + WriteChart chart + SearchChart chart +} + +// overviewGroup sind die Befunde eines Projekts. Je Projekt eine Karte +// statt einer Zeile je Befund: Bei 20 Projekten mit je drei Befunden würde +// eine flache Liste unlesbar lang, und derselbe Projektname stünde dreimal +// untereinander. +type overviewGroup struct { + Project string // Name + Warning bool // mindestens ein Befund ist dringend + Items []overviewHint +} + +// overviewHint ist ein Befund, mit einem Link genau dorthin, wo er zu +// erledigen ist. +type overviewHint struct { + Warning bool + Text string + Link string + LinkText string +} + +// overviewProject ist eine Zeile der Projekttabelle. +type overviewProject struct { + stats.Project + Entries int + LastChanged time.Time // jüngste Änderung eines Eintrags; Nullzeitpunkt = keine + NoHitsPercent int // Anteil der Suchen ohne Treffer in Prozent, -1 = keine Suchen +} + +// handleOverview zeigt den Überblick des gewählten Workspace — für +// Workspace-Admins und den Plattform-Admin (der damit sieht, was der +// Workspace-Admin sieht). +func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { + ws, sess := s.workspaceAndSession(r) + ctx := r.Context() + now := time.Now() + + entry, err := s.control.WorkspaceByID(ctx, sess.WorkspaceID) + if err != nil { + s.serverError(w, "Workspace konnte nicht gelesen werden", err) + return + } + + // Aktivität je Tag und je Projekt: dieselbe Zählung wie auf der + // Systemseite, nur für diesen einen Workspace. + snapshot := s.stats.ForWorkspace(ctx, entry, now) + if len(snapshot.Errors) > 0 { + s.serverError(w, "Überblick konnte nicht erhoben werden", fmt.Errorf("%v", snapshot.Errors)) + return + } + + // Einträge und jüngste Änderung je Projekt, in einer Abfrage für alle. + usage, err := ws.Store.UsageSummaries(ctx, "", now) + if err != nil { + s.serverError(w, "Nutzung konnte nicht gelesen werden", err) + return + } + + data := overviewData{ + Workspace: entry.Name, + ChartDays: len(snapshot.Days), + } + if len(snapshot.Workspaces) == 1 { + data.Total = snapshot.Workspaces[0].Inventory + } + for _, t := range snapshot.Days { + data.Writes += t.Writes() + data.Searches += t.Searches + } + data.WriteChart = buildChart("Schreibvorgänge je Tag", chartUnit{"Schreibvorgang", "Schreibvorgänge"}, snapshot.Days, + chartSeries{value: func(t stats.Tag) int { return t.AgentWrites }, class: "bar-agent", name: "Agenten"}, + chartSeries{value: func(t stats.Tag) int { return t.HumanWrites }, class: "bar-human", name: "Menschen"}, + ) + data.SearchChart = buildChart("Suchen je Tag", chartUnit{"Suche", "Suchen"}, snapshot.Days, + chartSeries{value: func(t stats.Tag) int { return t.Searches }, class: "bar-search", name: "Suchen"}, + ) + + // Aktive und ruhige Projekte kommen getrennt aus der Erhebung; hier + // stehen alle in einer Tabelle, alphabetisch. + all := append(append([]stats.Project{}, snapshot.Projects...), snapshot.Idle...) + sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) + + for _, p := range all { + line := overviewProject{Project: p, NoHitsPercent: -1} + u := usage[p.ID] + line.Entries, line.LastChanged = u.Entries, u.LastUpdatedAt + + searches, err := ws.Store.SearchStatsFor(ctx, p.ID, now) + if err != nil { + s.serverError(w, "Suchprotokoll konnte nicht gelesen werden", err) + return + } + if searches.Total > 0 { + line.NoHitsPercent = searches.EmptyPercent() + } + data.Projects = append(data.Projects, line) + + warnings, hints, err := projectHints(ctx, ws.Store, p, searches, now) + if err != nil { + s.serverError(w, "Hinweise konnten nicht erhoben werden", err) + return + } + if len(warnings)+len(hints) > 0 { + data.Groups = append(data.Groups, overviewGroup{ + Project: p.Name, + Warning: len(warnings) > 0, + Items: append(warnings, hints...), // Dringendes zuerst + }) + } + } + // Projekte mit Warnung nach oben. SliceStable erhält innerhalb beider + // Gruppen die alphabetische Reihenfolge von oben. + sort.SliceStable(data.Groups, func(i, j int) bool { + return data.Groups[i].Warning && !data.Groups[j].Warning + }) + + s.render(w, r, "overview.html", http.StatusOK, pageData{ + Title: "Überblick", + Section: "ueberblick", + Data: data, + }) +} + +// projectHints prüft ein Projekt auf das, was ein Workspace-Admin +// erledigen kann. Warnungen sind dringend (ein Zugang endet bald, niemand +// kommt hinein), Hinweise eine Bitte, bei Gelegenheit hinzusehen. +// +// Jede Prüfung nimmt dieselbe Regel wie die Seite, auf die sie verlinkt — +// "ungenutzt" wie unter Zugänge, "veraltet" wie in der Eintragsliste, "zur +// Prüfung" wie auf der Nutzungsseite. Wer dem Link folgt, findet dort genau +// das, was hier gezählt wurde. +func projectHints(ctx context.Context, st *store.Store, p stats.Project, searches store.SearchStats, now time.Time) (warnings, hints []overviewHint, err error) { + keysLink := "/p/" + p.Slug + "/keys" + usage := "/p/" + p.Slug + "/usage" + + keys, err := st.ListAPIKeys(ctx, p.ID) + if err != nil { + return nil, nil, err + } + valid, admins, unused := 0, 0, 0 + for _, k := range keys { + if !k.Valid(now) { + continue + } + valid++ + if k.Role == string(auth.RoleAdmin) { + admins++ + } + if k.Unused(now) { + unused++ + } + if k.ExpiresSoon(now) { + // Wie bisTag: angezeigt wird der letzte gültige Tag. + warnings = append(warnings, overviewHint{ + Warning: true, + Text: fmt.Sprintf("Der Key „%s“ gilt nur noch bis %s. Noch gebraucht? Dann jetzt einen neuen anlegen und im Client austauschen.", + k.Name, k.ExpiresAt.Add(-time.Second).Local().Format("02.01.2006")), + Link: keysLink, LinkText: "Zugänge", + }) + } + } + + switch { + case valid == 0: + warnings = append(warnings, overviewHint{ + Warning: true, + Text: "Kein gültiger Key — weder das Team noch ein Agent kommt hinein.", + Link: keysLink, LinkText: "Key anlegen", + }) + case admins == 0: + hints = append(hints, overviewHint{ + Text: "Kein Key mit der Rolle admin — das Team kann seine Zugänge nicht selbst verwalten und muss jedes Mal dich fragen.", + Link: keysLink, LinkText: "Zugänge", + }) + } + if unused > 0 { + hints = append(hints, overviewHint{ + Text: fmt.Sprintf("%s seit %d Tagen nicht benutzt. Weiß niemand mehr, wofür, zurückziehen.", + plural(unused, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24)), + Link: keysLink, LinkText: "Zugänge", + }) + } + + // Ein Projekt ohne Key ist schon oben gewarnt; dass dort nichts + // geschieht, ist dann keine Nachricht. + if p.Total() == 0 && valid > 0 { + hints = append(hints, overviewHint{ + Text: fmt.Sprintf("In %d Tagen weder geschrieben noch gesucht. Wird das Projekt noch gebraucht, oder fehlt dem Team die Anbindung?", stats.Days), + Link: usage, LinkText: "Nutzung", + }) + } + + if searches.Total >= emptySearchesFrom && searches.EmptyPercent() >= emptySearchesPercent { + hints = append(hints, overviewHint{ + Text: fmt.Sprintf("%d %% der Suchen fanden nichts. Entweder fehlt Wissen, oder die Agenten suchen mit anderen Wörtern, als in den Einträgen stehen.", + searches.EmptyPercent()), + Link: usage, LinkText: "Suchen ohne Treffer", + }) + } + + check, err := st.CuratedOverrides(ctx, p.ID, reviewMax) + if err != nil { + return nil, nil, err + } + if n := len(check); n > 0 { + text := "Ein kuratierter Eintrag wurde von einem Agenten geändert und wartet auf Prüfung durch das Team." + if n > 1 { + text = fmt.Sprintf("%d kuratierte Einträge wurden von Agenten geändert und warten auf Prüfung durch das Team.", n) + } + hints = append(hints, overviewHint{ + Text: text, Link: usage, LinkText: "Zur Prüfung", + }) + } + + stale, err := st.CountStaleEntries(ctx, p.ID, now) + if err != nil { + return nil, nil, err + } + if stale > 0 { + text := "Ein Eintrag ist seit über einem Jahr unverändert. Das Team prüfen lassen, ob er noch stimmt." + if stale > 1 { + text = fmt.Sprintf("%d Einträge sind seit über einem Jahr unverändert. Das Team prüfen lassen, ob sie noch stimmen.", stale) + } + hints = append(hints, overviewHint{ + Text: text, Link: "/p/" + p.Slug + "/?veraltet=1", LinkText: "Veraltete Einträge", + }) + } + + return warnings, hints, nil +} diff --git a/internal/web/handlers_bereitschaft.go b/internal/web/handlers_ready.go similarity index 78% rename from internal/web/handlers_bereitschaft.go rename to internal/web/handlers_ready.go index 1c08078..0b21794 100644 --- a/internal/web/handlers_bereitschaft.go +++ b/internal/web/handlers_ready.go @@ -8,10 +8,10 @@ import ( "time" ) -// bereitZeitlimit begrenzt, wie lange /ready auf die Datenbank wartet. Ein +// readyTimeout begrenzt, wie lange /ready auf die Datenbank wartet. Ein // Load-Balancer fragt typischerweise alle paar Sekunden mit eigenem // Zeitlimit; eine Antwort nach 30 Sekunden hilft ihm nicht. -const bereitZeitlimit = 2 * time.Second +const readyTimeout = 2 * time.Second // handleLive antwortet, solange der Prozess läuft — ohne Datenbank, ohne // Anmeldung. Siehe Routes in server.go, warum das getrennt von /ready ist. @@ -32,11 +32,11 @@ func handleLive(w http.ResponseWriter, r *http.Request) { // Anmeldung erreichbar, und eine Fehlermeldung der Datenbank verrät Host, // Port oder Benutzer. func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), bereitZeitlimit) + ctx, cancel := context.WithTimeout(r.Context(), readyTimeout) defer cancel() err := s.control.Ping(ctx) - meldeBereitschaft(err) + reportReadiness(err) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") @@ -48,21 +48,21 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) } -// bereitschaft merkt sich das letzte Ergebnis von /ready: 0 = noch nie +// readiness merkt sich das letzte Ergebnis von /ready: 0 = noch nie // geprüft, 1 = bereit, 2 = nicht bereit. -var bereitschaft atomic.Int32 +var readiness atomic.Int32 -// meldeBereitschaft schreibt nur einen WECHSEL ins Log. /ready wird alle paar +// reportReadiness schreibt nur einen WECHSEL ins Log. /ready wird alle paar // Sekunden abgefragt; bei einem Ausfall stünde sonst dieselbe Zeile hundertfach // da, und die Meldung, wann es wieder ging, ginge darin unter. -func meldeBereitschaft(err error) { +func reportReadiness(err error) { if err != nil { - if bereitschaft.Swap(2) != 2 { + if readiness.Swap(2) != 2 { log.Printf("Bereitschaft: Datenbank antwortet nicht — /ready meldet 503: %v", err) } return } - if bereitschaft.Swap(1) == 2 { + if readiness.Swap(1) == 2 { log.Printf("Bereitschaft: Datenbank antwortet wieder — /ready meldet ok") } } diff --git a/internal/web/handlers_suche.go b/internal/web/handlers_search.go similarity index 63% rename from internal/web/handlers_suche.go rename to internal/web/handlers_search.go index 0c0d709..b3ba9dd 100644 --- a/internal/web/handlers_suche.go +++ b/internal/web/handlers_search.go @@ -13,22 +13,22 @@ import ( // Wie viele Treffer die Suche über alle Projekte je Projekt zeigt. Genug, um // das richtige Projekt zu erkennen; wer mehr will, sucht dort weiter. -const workspaceSucheJeProjekt = 5 +const workspaceSearchPerProject = 5 -// workspaceSucheData sind die Daten der Suche über alle Projekte. -type workspaceSucheData struct { +// workspaceSearchData sind die Daten der Suche über alle Projekte. +type workspaceSearchData struct { Query string - Gruppen []sucheGruppe - Treffer int // Summe über alle Gruppen - Projekte int // so viele Projekte wurden durchsucht + Groups []searchGroup + Hits int // Summe über alle Gruppen + Projects int // so viele Projekte wurden durchsucht } -// sucheGruppe sind die Treffer eines Projekts. -type sucheGruppe struct { +// searchGroup sind die Treffer eines Projekts. +type searchGroup struct { Project store.Project Items []entryListItem - Mehr bool // es gibt mehr als workspaceSucheJeProjekt Treffer - besten float64 + More bool // es gibt mehr als workspaceSearchPerProject Treffer + top float64 } // handleWorkspaceSearch sucht in allen Projekten des gewählten Workspace @@ -48,17 +48,17 @@ func (s *Server) handleWorkspaceSearch(w http.ResponseWriter, r *http.Request) { ctx := r.Context() query := strings.TrimSpace(r.URL.Query().Get("q")) - daten := workspaceSucheData{Query: query} + data := workspaceSearchData{Query: query} if query != "" { - projekte, err := ws.Store.ListProjects(ctx, sess.WorkspaceID) + projects, err := ws.Store.ListProjects(ctx, sess.WorkspaceID) if err != nil { s.serverError(w, "Projekte konnten nicht geladen werden", err) return } - daten.Projekte = len(projekte) + data.Projects = len(projects) - for _, p := range projekte { + for _, p := range projects { idx, err := ws.Search.For(ctx, p.ID) if err != nil { s.serverError(w, "Suchindex konnte nicht geladen werden", err) @@ -66,15 +66,15 @@ func (s *Server) handleWorkspaceSearch(w http.ResponseWriter, r *http.Request) { } // Einen mehr holen als gezeigt, um "weitere Treffer" anbieten zu // können. - treffer := idx.SearchFuzzy(query, workspaceSucheJeProjekt+1) - if len(treffer) == 0 { + hits := idx.SearchFuzzy(query, workspaceSearchPerProject+1) + if len(hits) == 0 { continue } - gruppe := sucheGruppe{Project: p, besten: treffer[0].Score} - for i, t := range treffer { - if i == workspaceSucheJeProjekt { - gruppe.Mehr = true + group := searchGroup{Project: p, top: hits[0].Score} + for i, t := range hits { + if i == workspaceSearchPerProject { + group.More = true break } e, err := ws.Store.EntryBySlug(ctx, p.ID, t.DocID) @@ -85,25 +85,25 @@ func (s *Server) handleWorkspaceSearch(w http.ResponseWriter, r *http.Request) { s.serverError(w, "Eintrag konnte nicht geladen werden", err) return } - gruppe.Items = append(gruppe.Items, entryListItem{ + group.Items = append(group.Items, entryListItem{ Entry: e, Snippet: template.HTML(entry.HighlightSnippet(e.Content, query)), }) } - if len(gruppe.Items) > 0 { - daten.Gruppen = append(daten.Gruppen, gruppe) - daten.Treffer += len(gruppe.Items) + if len(group.Items) > 0 { + data.Groups = append(data.Groups, group) + data.Hits += len(group.Items) } } - sort.SliceStable(daten.Gruppen, func(i, j int) bool { - return daten.Gruppen[i].besten > daten.Gruppen[j].besten + sort.SliceStable(data.Groups, func(i, j int) bool { + return data.Groups[i].top > data.Groups[j].top }) } - s.render(w, r, "suche.html", http.StatusOK, pageData{ + s.render(w, r, "search.html", http.StatusOK, pageData{ Title: "Suche in allen Projekten", - Bereich: "suche", - Data: daten, + Section: "suche", + Data: data, }) } diff --git a/internal/web/handlers_system.go b/internal/web/handlers_system.go index 23be865..646d866 100644 --- a/internal/web/handlers_system.go +++ b/internal/web/handlers_system.go @@ -11,79 +11,79 @@ import ( "time" "munin/internal/export" - "munin/internal/statistik" + "munin/internal/stats" "munin/internal/store" ) // systemData sind die Daten der Systemseite (AUSBAUSTUFEN.md 5.12, 5.16). type systemData struct { - Speicher string // store.Speicher.Beschreibung, ohne Passwort - Bereit bool // Kontroll-Datenbank antwortet - BereitText string // Grund, falls nicht — nur für den Plattform-Admin + Backend string // store.Backend.Description, ohne Passwort + Ready bool // Kontroll-Datenbank antwortet + ReadyText string // Grund, falls nicht — nur für den Plattform-Admin - // Hinweise: Was zu tun ist, die Warnungen zuerst. Leer heißt: alles in + // Hints: Was zu tun ist, die Warnungen zuerst. Leer heißt: alles in // Ordnung. - Hinweise []systemHinweis + Hints []systemHint - Kapazitaet store.Kapazitaet + Capacity store.Capacity // Verbindungen: Kontroll-Datenbank und jeder geöffnete Workspace. - Kontrolle store.Verbindungen + Control store.PoolStats Workspaces []systemWorkspace - Offen int // Summe der offenen Verbindungen aller Pools - Gewartet int64 + OpenConns int // Summe der offenen Verbindungen aller Pools + WaitCount int64 - AnzahlWorkspaces int // in der Kontroll-Datenbank, geöffnet oder nicht + WorkspaceCount int // in der Kontroll-Datenbank, geöffnet oder nicht - // Stand: Bestand, Belegung und Aktivität aller Workspaces, höchstens - // statistik.MindestAbstand alt. - Stand statistik.Stand - DiagrammSchreiben diagramm - DiagrammSuchen diagramm - TageImDiagramm int - TopProjekte []statistik.Projekt // die aktivsten, höchstens topProjekte - WeitereAktive int // aktive Projekte, die nicht mehr in die Liste passen - ExportEingerichtet bool - Export export.Lauf + // Snapshot: Bestand, Belegung und Aktivität aller Workspaces, höchstens + // stats.MinInterval alt. + Snapshot stats.Snapshot + WriteChart chart + SearchChart chart + ChartDays int + TopProjects []stats.Project // die aktivsten, höchstens topProjects + MoreActive int // aktive Projekte, die nicht mehr in die Liste passen + ExportConfigured bool + Export export.Run - Fassung string - GoFassung string - Gestartet time.Time - Laufzeit string + Version string + GoVersion string + StartedAt time.Time + Uptime string } // systemWorkspace ist ein geöffneter Workspace auf der Systemseite. type systemWorkspace struct { - ID string - Name string - Ort string - Verbindungen store.Verbindungen + ID string + Name string + Location string + PoolStats store.PoolStats } -// systemHinweis ist eine Zeile im Kasten "Zustand" oben auf der Seite. -type systemHinweis struct { - Warnung bool // true: sollte behoben werden; false: zur Kenntnis +// systemHint ist eine Zeile im Kasten "Zustand" oben auf der Seite. +type systemHint struct { + Warning bool // true: sollte behoben werden; false: zur Kenntnis Text string } const ( - // walGrenze: Ab dieser Größe der WAL-Datei warnt die Systemseite. + // walLimit: Ab dieser Größe der WAL-Datei warnt die Systemseite. // SQLite schreibt die WAL-Datei normalerweise ab etwa 4 MB in die // Datenbank zurück (1000 Seiten); ist sie das Sechzehnfache, kommt das // Zurückschreiben offenbar nicht durch. - walGrenze = 64 << 20 + walLimit = 64 << 20 - // kapazitaetWarnung: Ab diesem Anteil der höchstens möglichen Workspaces + // capacityWarning: Ab diesem Anteil der höchstens möglichen Workspaces // (in Prozent) warnt die Seite — bevor Munin neue ablehnt. - kapazitaetWarnung = 80 + capacityWarning = 80 - // exportSpielraum: So lange darf ein Export über sein Intervall hinaus + // exportHeadroom: So lange darf ein Export über sein Intervall hinaus // ausbleiben, bevor er als überfällig gilt. Ein großer Lauf braucht // selbst etwas Zeit. - exportSpielraum = 2 * time.Hour + exportHeadroom = 2 * time.Hour - // topProjekte: so viele Projekte zeigt die Liste "am aktivsten". - topProjekte = 10 + // topProjects: so viele Projekte zeigt die Liste "am aktivsten". + topProjects = 10 ) // handleSystem zeigt dem Plattform-Admin, worauf Munin läuft, ob etwas zu tun @@ -101,131 +101,131 @@ const ( // Betriebsinterna (das Passwort steht nirgends). func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - jetzt := time.Now() - speicher := s.workspaces.Speicher() + now := time.Now() + backend := s.workspaces.Backend() - daten := systemData{ - Speicher: speicher.Beschreibung(), - Kontrolle: s.control.Verbindungen(), - ExportEingerichtet: s.exporter != nil, - Fassung: munFassung(), - GoFassung: runtime.Version(), - Gestartet: s.gestartet, - Laufzeit: laufzeit(jetzt.Sub(s.gestartet)), + data := systemData{ + Backend: backend.Description(), + Control: s.control.PoolStats(), + ExportConfigured: s.exporter != nil, + Version: muninVersion(), + GoVersion: runtime.Version(), + StartedAt: s.startedAt, + Uptime: uptime(now.Sub(s.startedAt)), } - pingCtx, cancel := context.WithTimeout(ctx, bereitZeitlimit) + pingCtx, cancel := context.WithTimeout(ctx, readyTimeout) if err := s.control.Ping(pingCtx); err != nil { - daten.BereitText = err.Error() + data.ReadyText = err.Error() } else { - daten.Bereit = true + data.Ready = true } cancel() - if k, err := speicher.Kapazitaet(ctx); err == nil { - daten.Kapazitaet = k + if k, err := backend.Capacity(ctx); err == nil { + data.Capacity = k } - namen := map[string]string{} - if alle, err := s.control.ListWorkspaces(ctx); err == nil { - daten.AnzahlWorkspaces = len(alle) - for _, ws := range alle { - namen[ws.ID] = ws.Name + names := map[string]string{} + if all, err := s.control.ListWorkspaces(ctx); err == nil { + data.WorkspaceCount = len(all) + for _, ws := range all { + names[ws.ID] = ws.Name } } // Die Kennzahlen aller Workspaces — nur, wenn die Datenbank antwortet. // Sonst liefe jede Abfrage einzeln in ihr Zeitlimit. - if daten.Bereit { - daten.Stand = s.statistik.Aktuell(ctx, jetzt) + if data.Ready { + data.Snapshot = s.stats.Current(ctx, now) } - daten.TageImDiagramm = len(daten.Stand.Tage) - daten.DiagrammSchreiben = baueDiagramm("Schreibvorgänge je Tag", diagrammEinheit{"Schreibvorgang", "Schreibvorgänge"}, daten.Stand.Tage, - diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenAgent }, klasse: "balken-agent", name: "Agenten"}, - diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenMensch }, klasse: "balken-mensch", name: "Menschen"}, + data.ChartDays = len(data.Snapshot.Days) + data.WriteChart = buildChart("Schreibvorgänge je Tag", chartUnit{"Schreibvorgang", "Schreibvorgänge"}, data.Snapshot.Days, + chartSeries{value: func(t stats.Tag) int { return t.AgentWrites }, class: "bar-agent", name: "Agenten"}, + chartSeries{value: func(t stats.Tag) int { return t.HumanWrites }, class: "bar-human", name: "Menschen"}, ) - daten.DiagrammSuchen = baueDiagramm("Suchen je Tag", diagrammEinheit{"Suche", "Suchen"}, daten.Stand.Tage, - diagrammReihe{wert: func(t statistik.Tag) int { return t.Suchen }, klasse: "balken-suche", name: "Suchen"}, + data.SearchChart = buildChart("Suchen je Tag", chartUnit{"Suche", "Suchen"}, data.Snapshot.Days, + chartSeries{value: func(t stats.Tag) int { return t.Searches }, class: "bar-search", name: "Suchen"}, ) - daten.TopProjekte = daten.Stand.Projekte - if len(daten.TopProjekte) > topProjekte { - daten.WeitereAktive = len(daten.TopProjekte) - topProjekte - daten.TopProjekte = daten.TopProjekte[:topProjekte] + data.TopProjects = data.Snapshot.Projects + if len(data.TopProjects) > topProjects { + data.MoreActive = len(data.TopProjects) - topProjects + data.TopProjects = data.TopProjects[:topProjects] } - daten.Offen = daten.Kontrolle.Offen() - daten.Gewartet = daten.Kontrolle.Gewartet() - for _, offen := range s.workspaces.Offen() { - v := offen.Store.Verbindungen() - daten.Workspaces = append(daten.Workspaces, systemWorkspace{ - ID: offen.ID, Name: namen[offen.ID], Ort: s.workspaces.Ort(offen.ID), Verbindungen: v, + data.OpenConns = data.Control.OpenConns() + data.WaitCount = data.Control.WaitCount() + for _, opened := range s.workspaces.OpenWorkspaces() { + v := opened.Store.PoolStats() + data.Workspaces = append(data.Workspaces, systemWorkspace{ + ID: opened.ID, Name: names[opened.ID], Location: s.workspaces.Location(opened.ID), PoolStats: v, }) - daten.Offen += v.Offen() - daten.Gewartet += v.Gewartet() + data.OpenConns += v.OpenConns() + data.WaitCount += v.WaitCount() } if s.exporter != nil { - daten.Export = s.exporter.LetzterLauf() + data.Export = s.exporter.LastRun() } - daten.Hinweise = s.systemHinweise(ctx, jetzt, daten, speicher.Hinweise()) + data.Hints = s.systemHints(ctx, now, data, backend.Hints()) s.render(w, r, "system.html", http.StatusOK, pageData{ Title: "System", - Bereich: "system", - Data: daten, + Section: "system", + Data: data, }) } -// systemHinweise stellt zusammen, was der Plattform-Admin wissen sollte: erst +// systemHints stellt zusammen, was der Plattform-Admin wissen sollte: erst // die Warnungen (sollte behoben werden), dann die Hinweise (zur Kenntnis). // // Fast alles davon prüft Munin auch an anderer Stelle — beim Start, auf der // Seite "Zugänge", im Log des Exports. Hier steht es an einem Ort, damit ein // Blick genügt. -func (s *Server) systemHinweise(ctx context.Context, jetzt time.Time, d systemData, speicherHinweise []string) []systemHinweis { - var warnungen, hinweise []systemHinweis - warnung := func(format string, args ...any) { - warnungen = append(warnungen, systemHinweis{Warnung: true, Text: fmt.Sprintf(format, args...)}) +func (s *Server) systemHints(ctx context.Context, now time.Time, d systemData, backendHints []string) []systemHint { + var warnings, hints []systemHint + warning := func(format string, args ...any) { + warnings = append(warnings, systemHint{Warning: true, Text: fmt.Sprintf(format, args...)}) } - hinweis := func(format string, args ...any) { - hinweise = append(hinweise, systemHinweis{Text: fmt.Sprintf(format, args...)}) + hint := func(format string, args ...any) { + hints = append(hints, systemHint{Text: fmt.Sprintf(format, args...)}) } // ── Warnungen ──────────────────────────────────────────────────────────── - if k := d.Kapazitaet; k.Begrenzt && k.MaxWorkspaces() > 0 && d.AnzahlWorkspaces*100 >= k.MaxWorkspaces()*kapazitaetWarnung { - warnung("%d von höchstens %d Workspaces sind angelegt. Vor weiteren beim Datenbank-Admin max_connections erhöhen lassen (README, „Mit PostgreSQL“).", - d.AnzahlWorkspaces, k.MaxWorkspaces()) + if k := d.Capacity; k.Limited && k.MaxWorkspaces() > 0 && d.WorkspaceCount*100 >= k.MaxWorkspaces()*capacityWarning { + warning("%d von höchstens %d Workspaces sind angelegt. Vor weiteren beim Datenbank-Admin max_connections erhöhen lassen (README, „Mit PostgreSQL“).", + d.WorkspaceCount, k.MaxWorkspaces()) } - for _, h := range speicherHinweise { - warnung("%s", h) + for _, h := range backendHints { + warning("%s", h) } - for _, f := range d.Stand.Fehler { - warnung("Workspace ließ sich nicht auswerten — %s", f) + for _, f := range d.Snapshot.Errors { + warning("Workspace ließ sich nicht auswerten — %s", f) } - for _, ws := range d.Stand.Workspaces { - if ws.Belegung.WAL > walGrenze { - warnung("Die WAL-Datei von „%s“ ist %s groß. SQLite schreibt sie offenbar nicht mehr in die Datenbank zurück — meist hält eine Leseverbindung zu lange offen. Ein Neustart behebt es; tritt es wieder auf, ins Log schauen.", - ws.Name, groesse(ws.Belegung.WAL)) + for _, ws := range d.Snapshot.Workspaces { + if ws.DiskUsage.WAL > walLimit { + warning("Die WAL-Datei von „%s“ ist %s groß. SQLite schreibt sie offenbar nicht mehr in die Datenbank zurück — meist hält eine Leseverbindung zu lange offen. Ein Neustart behebt es; tritt es wieder auf, ins Log schauen.", + ws.Name, byteSize(ws.DiskUsage.WAL)) } - if ws.SchemaAbweichend(d.Stand.Schemastand) { - warnung("„%s“ steht auf Schema %s, dieses Programm kennt höchstens %s. Läuft hier eine ältere Fassung von Munin als zuletzt?", - ws.Name, ws.Schema, d.Stand.Schemastand) + if ws.SchemaMismatch(d.Snapshot.SchemaVersion) { + warning("„%s“ steht auf Schema %s, dieses Programm kennt höchstens %s. Läuft hier eine ältere Fassung von Munin als zuletzt?", + ws.Name, ws.Schema, d.Snapshot.SchemaVersion) } } - if d.ExportEingerichtet { + if d.ExportConfigured { l := d.Export switch { - case l.Fehler != "": - warnung("Der letzte Export (%s) war unvollständig: %s", l.Ende.Local().Format("02.01. 15:04"), l.Fehler) - case l.Ende.IsZero() && jetzt.Sub(s.gestartet) > time.Hour: - warnung("Seit dem Start vor %s ist kein Export fertig geworden. Läuft er noch, oder hängt er? Siehe Log.", laufzeit(jetzt.Sub(s.gestartet))) - case !l.Ende.IsZero() && jetzt.Sub(l.Ende) > export.Intervall+exportSpielraum: - warnung("Der letzte Export ist von %s — er läuft sonst täglich. Siehe Log.", l.Ende.Local().Format("02.01. 15:04")) + case l.Err != "": + warning("Der letzte Export (%s) war unvollständig: %s", l.End.Local().Format("02.01. 15:04"), l.Err) + case l.End.IsZero() && now.Sub(s.startedAt) > time.Hour: + warning("Seit dem Start vor %s ist kein Export fertig geworden. Läuft er noch, oder hängt er? Siehe Log.", uptime(now.Sub(s.startedAt))) + case !l.End.IsZero() && now.Sub(l.End) > export.Interval+exportHeadroom: + warning("Der letzte Export ist von %s — er läuft sonst täglich. Siehe Log.", l.End.Local().Format("02.01. 15:04")) } } @@ -233,70 +233,70 @@ func (s *Server) systemHinweise(ctx context.Context, jetzt time.Time, d systemDa // Die Differenz zur vorigen Erhebung; bei der ersten seit dem Start. Ein // gelöschter Workspace nimmt seine Zahlen mit, deshalb nie unter null. - if seit := max(0, s.statistik.Gewartet()-d.Stand.GewartetVorher); seit > 0 && !d.Stand.Erhoben.IsZero() { - ab := "dem Start" - if !d.Stand.VorherErhoben.IsZero() { - ab = d.Stand.VorherErhoben.Local().Format("15:04") + if since := max(0, s.stats.WaitCount()-d.Snapshot.PrevWaitCount); since > 0 && !d.Snapshot.CollectedAt.IsZero() { + from := "dem Start" + if !d.Snapshot.PrevCollectedAt.IsZero() { + from = d.Snapshot.PrevCollectedAt.Local().Format("15:04") } - hinweis("Seit %s mussten Anfragen %d× auf eine freie Datenbankverbindung warten. Ab und zu ist normal; kommt es bei jedem Blick hierher vor, sind die Pools zu klein.", - ab, seit) + hint("Seit %s mussten Anfragen %d× auf eine freie Datenbankverbindung warten. Ab und zu ist normal; kommt es bei jedem Blick hierher vor, sind die Pools zu klein.", + from, since) } - if n, wo := zaehleJeWorkspace(d.Stand.Workspaces, func(b store.Bestand) int { return b.KeysLaufenAb }); n > 0 { - hinweis("%s in den nächsten %d Tagen ab (%s). Noch gebraucht? Dann rechtzeitig einen neuen anlegen.", - plural(n, "Key läuft", "Keys laufen"), int(store.ExpiresSoonWithin.Hours()/24), wo) + if n, where := countPerWorkspace(d.Snapshot.Workspaces, func(b store.Inventory) int { return b.KeysExpiring }); n > 0 { + hint("%s in den nächsten %d Tagen ab (%s). Noch gebraucht? Dann rechtzeitig einen neuen anlegen.", + plural(n, "Key läuft", "Keys laufen"), int(store.ExpiresSoonWithin.Hours()/24), where) } - if n, wo := zaehleJeWorkspace(d.Stand.Workspaces, func(b store.Bestand) int { return b.KeysUngenutzt }); n > 0 { - hinweis("%s seit %d Tagen nicht benutzt (%s). Zu finden unter „Zugänge“ der Projekte, markiert mit „ungenutzt“.", - plural(n, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24), wo) + if n, where := countPerWorkspace(d.Snapshot.Workspaces, func(b store.Inventory) int { return b.KeysUnused }); n > 0 { + hint("%s seit %d Tagen nicht benutzt (%s). Zu finden unter „Zugänge“ der Projekte, markiert mit „ungenutzt“.", + plural(n, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24), where) } if admins, err := s.control.ListActiveWorkspaceAdmins(ctx); err == nil { - ungenutzt := 0 + unused := 0 for _, a := range admins { - if a.Unused(jetzt) { - ungenutzt++ + if a.Unused(now) { + unused++ } } - if ungenutzt > 0 { - hinweis("%s seit %d Tagen nicht benutzt — unter „Workspaces“ prüfen und gegebenenfalls zurückziehen.", - plural(ungenutzt, "Workspace-Admin-Zugang ist", "Workspace-Admin-Zugänge sind"), int(store.UnusedAfter.Hours()/24)) + if unused > 0 { + hint("%s seit %d Tagen nicht benutzt — unter „Workspaces“ prüfen und gegebenenfalls zurückziehen.", + plural(unused, "Workspace-Admin-Zugang ist", "Workspace-Admin-Zugänge sind"), int(store.UnusedAfter.Hours()/24)) } } - return append(warnungen, hinweise...) + return append(warnings, hints...) } -// zaehleJeWorkspace zählt einen Wert über alle Workspaces und nennt dazu, wo +// countPerWorkspace zählt einen Wert über alle Workspaces und nennt dazu, wo // er vorkommt: "Intern 2, Vertrieb 1". -func zaehleJeWorkspace(liste []statistik.Workspace, wert func(store.Bestand) int) (int, string) { - summe := 0 - var wo []string - for _, ws := range liste { - if n := wert(ws.Bestand); n > 0 { - summe += n - wo = append(wo, fmt.Sprintf("%s %d", ws.Name, n)) +func countPerWorkspace(list []stats.Workspace, value func(store.Inventory) int) (int, string) { + total := 0 + var where []string + for _, ws := range list { + if n := value(ws.Inventory); n > 0 { + total += n + where = append(where, fmt.Sprintf("%s %d", ws.Name, n)) } } - return summe, strings.Join(wo, ", ") + return total, strings.Join(where, ", ") } -// groesse zeigt eine Zahl von Bytes lesbar an, mit Komma wie im Deutschen: +// byteSize zeigt eine Zahl von Bytes lesbar an, mit Komma wie im Deutschen: // "812 KB", "1,4 GB". Gerechnet wird in Schritten von 1024 — so zählen auch // Dateisysteme und PostgreSQL (pg_size_pretty). -func groesse(bytes int64) string { +func byteSize(bytes int64) string { if bytes < 1024 { return fmt.Sprintf("%d B", bytes) } - wert := float64(bytes) - for _, einheit := range []string{"KB", "MB", "GB", "TB"} { - wert /= 1024 - if wert < 1024 || einheit == "TB" { + value := float64(bytes) + for _, unit := range []string{"KB", "MB", "GB", "TB"} { + value /= 1024 + if value < 1024 || unit == "TB" { // Eine Nachkommastelle nur bei kleinen Werten — "812,3 KB" ist // genauer, als jemand wissen will. - text := fmt.Sprintf("%.0f %s", wert, einheit) - if wert < 10 { - text = strings.Replace(fmt.Sprintf("%.1f %s", wert, einheit), ".", ",", 1) + text := fmt.Sprintf("%.0f %s", value, unit) + if value < 10 { + text = strings.Replace(fmt.Sprintf("%.1f %s", value, unit), ".", ",", 1) } return text } @@ -304,60 +304,60 @@ func groesse(bytes int64) string { return "" // nicht erreichbar: die Schleife endet spätestens bei TB } -// munFassung liest die Fassung aus den Angaben, die Go beim Bauen ins +// muninVersion liest die Fassung aus den Angaben, die Go beim Bauen ins // Programm schreibt: die Git-Fassung (gekürzt), ihr Datum und ob es // ungespeicherte Änderungen gab. Bei "go run" fehlt das meist. -func munFassung() string { +func muninVersion() string { info, ok := debug.ReadBuildInfo() if !ok { return "unbekannt" } - var fassung, zeit string - geaendert := false + var version, ts string + changed := false for _, e := range info.Settings { switch e.Key { case "vcs.revision": - fassung = e.Value - if len(fassung) > 12 { - fassung = fassung[:12] + version = e.Value + if len(version) > 12 { + version = version[:12] } case "vcs.time": - zeit = e.Value + ts = e.Value case "vcs.modified": - geaendert = e.Value == "true" + changed = e.Value == "true" } } - if fassung == "" { + if version == "" { return "Entwicklungsstand (ohne Git-Angabe gebaut)" } - if zeit != "" { - fassung += " vom " + zeit + if ts != "" { + version += " vom " + ts } - if geaendert { - fassung += ", mit nicht eingecheckten Änderungen" + if changed { + version += ", mit nicht eingecheckten Änderungen" } - return fassung + return version } -// laufzeit schreibt eine Dauer grob und lesbar: "3 Tage, 4 Std." statt +// uptime schreibt eine Dauer grob und lesbar: "3 Tage, 4 Std." statt // "76h12m3.4s". -func laufzeit(d time.Duration) string { - tage := int(d.Hours()) / 24 - stunden := int(d.Hours()) % 24 - minuten := int(d.Minutes()) % 60 +func uptime(d time.Duration) string { + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 switch { - case tage > 0: - return plural(tage, "Tag", "Tage") + ", " + plural(stunden, "Std.", "Std.") - case stunden > 0: - return plural(stunden, "Std.", "Std.") + ", " + plural(minuten, "Min.", "Min.") + case days > 0: + return plural(days, "Tag", "Tage") + ", " + plural(hours, "Std.", "Std.") + case hours > 0: + return plural(hours, "Std.", "Std.") + ", " + plural(minutes, "Min.", "Min.") default: - return plural(minuten, "Min.", "Min.") + return plural(minutes, "Min.", "Min.") } } -func plural(n int, eins, mehr string) string { +func plural(n int, one, more string) string { if n == 1 { - return "1 " + eins + return "1 " + one } - return strconv.Itoa(n) + " " + mehr + return strconv.Itoa(n) + " " + more } diff --git a/internal/web/handlers_tags.go b/internal/web/handlers_tags.go index e98c3e4..2589ebd 100644 --- a/internal/web/handlers_tags.go +++ b/internal/web/handlers_tags.go @@ -12,21 +12,21 @@ import ( // tagListData sind die Daten der Seite "Alle Schlagworte". type tagListData struct { - // Sortierung ist "anzahl" (häufigste zuerst, Standard) oder "name". - Sortierung string + // Sort ist "anzahl" (häufigste zuerst, Standard) oder "name". + Sort string // Tags ist die vollständige Liste — bei Sortierung "anzahl" genutzt. Tags []store.TagCount - // Gruppen fasst die Schlagworte nach Anfangsbuchstaben zusammen — bei + // Groups fasst die Schlagworte nach Anfangsbuchstaben zusammen — bei // Sortierung "name" genutzt. - Gruppen []tagGruppe + Groups []tagGroup } -// tagGruppe ist ein Buchstabenabschnitt der alphabetischen Ansicht. -type tagGruppe struct { - Buchstabe string - Tags []store.TagCount +// tagGroup ist ein Buchstabenabschnitt der alphabetischen Ansicht. +type tagGroup struct { + Letter string + Tags []store.TagCount } // handleTagList zeigt alle Schlagworte eines Projekts. Die Seitenleiste zeigt @@ -41,66 +41,66 @@ func (s *Server) handleTagList(w http.ResponseWriter, r *http.Request) { return } - daten := tagListData{Sortierung: "anzahl", Tags: tags} + data := tagListData{Sort: "anzahl", Tags: tags} if r.URL.Query().Get("sort") == "name" { - daten.Sortierung = "name" - daten.Gruppen = gruppiereTags(tags) + data.Sort = "name" + data.Groups = groupTags(tags) } s.render(w, r, "tags.html", http.StatusOK, pageData{ Title: "Schlagworte – " + project.Name, - Bereich: "schlagworte", - Data: daten, + Section: "schlagworte", + Data: data, }) } -// gruppiereTags sortiert die Schlagworte alphabetisch und teilt sie nach +// groupTags sortiert die Schlagworte alphabetisch und teilt sie nach // ihrem Anfangsbuchstaben auf. Schlagworte, die nicht mit einem Buchstaben // beginnen (etwa "2fa"), landen gemeinsam in der Gruppe "#". -func gruppiereTags(tags []store.TagCount) []tagGruppe { +func groupTags(tags []store.TagCount) []tagGroup { // Eine Kopie sortieren, damit die Reihenfolge des Aufrufers erhalten bleibt. - sortiert := make([]store.TagCount, len(tags)) - copy(sortiert, tags) - sort.Slice(sortiert, func(i, j int) bool { - a, b := sortierSchluessel(sortiert[i].Tag), sortierSchluessel(sortiert[j].Tag) + sorted := make([]store.TagCount, len(tags)) + copy(sorted, tags) + sort.Slice(sorted, func(i, j int) bool { + a, b := sortKey(sorted[i].Tag), sortKey(sorted[j].Tag) if a != b { return a < b } // Gleicher Schlüssel (etwa "ubersicht" und "übersicht"): die // ursprüngliche Schreibweise entscheidet, damit die Reihenfolge // bei jedem Aufruf dieselbe ist. - return sortiert[i].Tag < sortiert[j].Tag + return sorted[i].Tag < sorted[j].Tag }) - var gruppen []tagGruppe - for _, t := range sortiert { - buchstabe := anfangsbuchstabe(t.Tag) + var groups []tagGroup + for _, t := range sorted { + letter := firstLetter(t.Tag) // Die Liste ist sortiert, also reicht der Blick auf die letzte Gruppe. - if len(gruppen) == 0 || gruppen[len(gruppen)-1].Buchstabe != buchstabe { - gruppen = append(gruppen, tagGruppe{Buchstabe: buchstabe}) + if len(groups) == 0 || groups[len(groups)-1].Letter != letter { + groups = append(groups, tagGroup{Letter: letter}) } - letzte := &gruppen[len(gruppen)-1] - letzte.Tags = append(letzte.Tags, t) + last := &groups[len(groups)-1] + last.Tags = append(last.Tags, t) } - return gruppen + return groups } -// umlautErsatz bildet Umlaute auf ihren Grundbuchstaben ab — so sortiert +// umlautReplacements bildet Umlaute auf ihren Grundbuchstaben ab — so sortiert // "übersicht" bei "u" statt hinter "z" (ein einfacher Byte-Vergleich würde // alle Umlaute ans Ende stellen). -var umlautErsatz = strings.NewReplacer("ä", "a", "ö", "o", "ü", "u", "ß", "ss") +var umlautReplacements = strings.NewReplacer("ä", "a", "ö", "o", "ü", "u", "ß", "ss") -// sortierSchluessel liefert die Form eines Schlagworts, nach der sortiert wird. -func sortierSchluessel(tag string) string { - return umlautErsatz.Replace(strings.ToLower(tag)) +// sortKey liefert die Form eines Schlagworts, nach der sortiert wird. +func sortKey(tag string) string { + return umlautReplacements.Replace(strings.ToLower(tag)) } -// anfangsbuchstabe liefert die Gruppenüberschrift für ein Schlagwort: den +// firstLetter liefert die Gruppenüberschrift für ein Schlagwort: den // ersten Buchstaben groß und ohne Umlaut, oder "#" für alles andere. -func anfangsbuchstabe(tag string) string { - erstes, _ := utf8.DecodeRuneInString(sortierSchluessel(tag)) - if !unicode.IsLetter(erstes) { +func firstLetter(tag string) string { + first, _ := utf8.DecodeRuneInString(sortKey(tag)) + if !unicode.IsLetter(first) { return "#" } - return string(unicode.ToUpper(erstes)) + return string(unicode.ToUpper(first)) } diff --git a/internal/web/handlers_papierkorb.go b/internal/web/handlers_trash.go similarity index 69% rename from internal/web/handlers_papierkorb.go rename to internal/web/handlers_trash.go index 993fe46..8b89194 100644 --- a/internal/web/handlers_papierkorb.go +++ b/internal/web/handlers_trash.go @@ -23,9 +23,9 @@ import ( type trashData struct { Entries []store.TrashedEntry - // Von ist der Filter "nur, was dieser Zugang gelöscht hat" — leer, wenn + // From ist der Filter "nur, was dieser Zugang gelöscht hat" — leer, wenn // alles angezeigt wird. - Von string + From string // Frist in Tagen, für den Hinweistext. RetentionDays int @@ -35,28 +35,28 @@ type trashData struct { func (s *Server) handleTrashList(w http.ResponseWriter, r *http.Request) { ws, _ := WorkspaceFrom(r) project, _ := ProjectFrom(r) - von := r.URL.Query().Get("von") + from := r.URL.Query().Get("von") - eintraege, err := ws.Store.ListTrash(r.Context(), project.ID, von) + entries, err := ws.Store.ListTrash(r.Context(), project.ID, from) if err != nil { s.serverError(w, "Papierkorb konnte nicht geladen werden", err) return } - s.render(w, r, "papierkorb.html", http.StatusOK, pageData{ + s.render(w, r, "trash.html", http.StatusOK, pageData{ Title: "Papierkorb – " + project.Name, - Bereich: "papierkorb", - Flash: r.URL.Query().Get("hinweis"), - Data: trashData{Entries: eintraege, Von: von, RetentionDays: store.TrashRetentionDays}, + Section: "papierkorb", + Flash: s.hintFrom(r), + Data: trashData{Entries: entries, From: from, RetentionDays: store.TrashRetentionDays}, }) } -// papierkorbAuswahl liest die ausgewählten Bezeichner aus dem Formular. +// trashSelection liest die ausgewählten Bezeichner aus dem Formular. // // Das Formular schickt für jeden angehakten Eintrag ein eigenes Feld "slugs" // — mehrere Felder mit demselben Namen. r.Form["slugs"] liefert sie alle als // Liste; r.FormValue("slugs") gäbe nur das erste zurück. -func papierkorbAuswahl(r *http.Request) ([]string, error) { +func trashSelection(r *http.Request) ([]string, error) { if err := r.ParseForm(); err != nil { return nil, err } @@ -64,36 +64,37 @@ func papierkorbAuswahl(r *http.Request) ([]string, error) { // Doppelte Angaben (etwa ein Kästchen plus derselbe Eintrag über "alle // wiederherstellen") nur einmal behandeln — sonst stünde der Vorgang // zweimal im Protokoll. Die map merkt sich, was schon übernommen wurde. - gesehen := map[string]bool{} + seen := map[string]bool{} var slugs []string for _, slug := range r.Form["slugs"] { - if slug == "" || gesehen[slug] { + if slug == "" || seen[slug] { continue } - gesehen[slug] = true + seen[slug] = true slugs = append(slugs, slug) } return slugs, nil } -// zurueckZumPapierkorb leitet mit einer Meldung auf die Papierkorb-Seite +// backToTrash leitet mit einer Meldung auf die Papierkorb-Seite // zurück — und behält dabei einen gesetzten Filter bei. -func zurueckZumPapierkorb(w http.ResponseWriter, r *http.Request, projektSlug, meldung string) { +func (s *Server) backToTrash(w http.ResponseWriter, r *http.Request, projectSlug, message string) { // url.Values baut eine Query ("a=1&b=2") und maskiert dabei jeden Wert // korrekt — sicherer als Zeichenketten von Hand zusammenzusetzen. - werte := url.Values{} - if von := r.FormValue("von"); von != "" { - werte.Set("von", von) + values := url.Values{} + if from := r.FormValue("von"); from != "" { + values.Set("von", from) } - if meldung != "" { - werte.Set("hinweis", meldung) + if message != "" { + values.Set(hintParam, message) + values.Set(hintSigParam, s.signer.SignHint(message)) } - ziel := "/p/" + projektSlug + "/trash" - if len(werte) > 0 { - ziel += "?" + werte.Encode() + target := "/p/" + projectSlug + "/trash" + if len(values) > 0 { + target += "?" + values.Encode() } - http.Redirect(w, r, ziel, http.StatusSeeOther) + http.Redirect(w, r, target, http.StatusSeeOther) } // handleTrashRestore holt die ausgewählten Einträge zurück. @@ -102,17 +103,17 @@ func (s *Server) handleTrashRestore(w http.ResponseWriter, r *http.Request) { project, _ := ProjectFrom(r) sess, _ := SessionFrom(r) - slugs, err := papierkorbAuswahl(r) + slugs, err := trashSelection(r) if err != nil { s.serverError(w, "Formular konnte nicht gelesen werden", err) return } if len(slugs) == 0 { - zurueckZumPapierkorb(w, r, project.Slug, "Nichts ausgewählt.") + s.backToTrash(w, r, project.Slug, "Nichts ausgewählt.") return } - zurueck, err := ws.Store.RestoreFromTrash(r.Context(), project.ID, slugs) + back, err := ws.Store.RestoreFromTrash(r.Context(), project.ID, slugs) if err != nil { s.serverError(w, "Einträge konnten nicht wiederhergestellt werden", err) return @@ -121,7 +122,7 @@ func (s *Server) handleTrashRestore(w http.ResponseWriter, r *http.Request) { // Jeder zurückgeholte Eintrag kommt wieder in den Suchindex und bekommt // eine eigene Protokollzeile — so lässt sich im Protokoll nach einem // einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen. - for _, e := range zurueck { + for _, e := range back { ws.Search.Refresh(r.Context(), project.ID, e.Slug) ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), @@ -130,7 +131,7 @@ func (s *Server) handleTrashRestore(w http.ResponseWriter, r *http.Request) { }) } - zurueckZumPapierkorb(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge wiederhergestellt.", len(zurueck))) + s.backToTrash(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge wiederhergestellt.", len(back))) } // handleTrashPurge löscht die ausgewählten Einträge endgültig. @@ -139,37 +140,37 @@ func (s *Server) handleTrashPurge(w http.ResponseWriter, r *http.Request) { project, _ := ProjectFrom(r) sess, _ := SessionFrom(r) - slugs, err := papierkorbAuswahl(r) + slugs, err := trashSelection(r) if err != nil { s.serverError(w, "Formular konnte nicht gelesen werden", err) return } if len(slugs) == 0 { - zurueckZumPapierkorb(w, r, project.Slug, "Nichts ausgewählt.") + s.backToTrash(w, r, project.Slug, "Nichts ausgewählt.") return } // Vorher festhalten, welche Bezeichner wirklich im Papierkorb liegen — // nur die werden gelöscht und nur die gehören ins Protokoll. Eine // untergeschobene Angabe taucht so auch dort nicht auf. - imPapierkorb, err := ws.Store.ListTrash(r.Context(), project.ID, "") + inTrash, err := ws.Store.ListTrash(r.Context(), project.ID, "") if err != nil { s.serverError(w, "Papierkorb konnte nicht geladen werden", err) return } - vorhanden := make(map[string]bool, len(imPapierkorb)) - for _, t := range imPapierkorb { - vorhanden[t.Slug] = true + existing := make(map[string]bool, len(inTrash)) + for _, t := range inTrash { + existing[t.Slug] = true } - anzahl, err := ws.Store.PurgeFromTrash(r.Context(), project.ID, slugs) + count, err := ws.Store.PurgeFromTrash(r.Context(), project.ID, slugs) if err != nil { s.serverError(w, "Einträge konnten nicht endgültig gelöscht werden", err) return } for _, slug := range slugs { - if !vorhanden[slug] { + if !existing[slug] { continue } ws.Store.LogAudit(r.Context(), store.AuditEvent{ @@ -179,5 +180,5 @@ func (s *Server) handleTrashPurge(w http.ResponseWriter, r *http.Request) { }) } - zurueckZumPapierkorb(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge endgültig gelöscht.", anzahl)) + s.backToTrash(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge endgültig gelöscht.", count)) } diff --git a/internal/web/handlers_ueberblick.go b/internal/web/handlers_ueberblick.go deleted file mode 100644 index c26663d..0000000 --- a/internal/web/handlers_ueberblick.go +++ /dev/null @@ -1,286 +0,0 @@ -package web - -import ( - "context" - "fmt" - "net/http" - "sort" - "time" - - "munin/internal/auth" - "munin/internal/statistik" - "munin/internal/store" -) - -// Überblick eines Workspace (AUSBAUSTUFEN.md 5.17). -// -// Das Gegenstück zur Systemseite, aber für den Workspace-Admin: nur sein -// Workspace und nur, was er selbst in die Hand nehmen kann — Zugänge, die -// Pflege der Inhalte, die Frage, ob die Teams Munin benutzen. Verbindungen, -// Platz, Schema und Export bleiben auf der Systemseite; daran kann er nichts -// ändern. - -// Schwellen für die Hinweise. Bewusst grob: Sie sollen auf ein Projekt -// aufmerksam machen, nicht jede Schwankung melden. -const ( - // Ab so vielen Suchen im Zeitraum ist ein Anteil ohne Treffer - // aussagekräftig — bei drei Suchen wäre eine leere schon 33 %. - leereSuchenAb = 10 - - // Ab diesem Anteil ohne Treffer (in Prozent) lohnt ein Blick: Dann - // fehlt Wissen, oder Agenten suchen mit anderen Wörtern, als in den - // Einträgen stehen. - leereSuchenProzent = 30 - - // pruefenHoechstens: So viele Einträge „zur Prüfung“ liest der - // Überblick je Projekt, um sie zu zählen. CuratedOverrides braucht eine - // Obergrenze; mehr als das prüft ohnehin niemand am Stück. - pruefenHoechstens = 1000 -) - -// ueberblickData sind die Daten des Überblicks. -type ueberblickData struct { - Workspace string // Name - - // Gruppen: was zu tun ist, je Projekt zusammengefasst. Projekte mit - // einer Warnung zuerst, sonst alphabetisch. Projekte ohne Befund fehlen. - Gruppen []ueberblickGruppe - - Projekte []ueberblickProjekt // alphabetisch - Summe store.Bestand - Schreiben int // im Zeitraum, alle Projekte - Suchen int - TageImDiagramm int - - DiagrammSchreiben diagramm - DiagrammSuchen diagramm -} - -// ueberblickGruppe sind die Befunde eines Projekts. Je Projekt eine Karte -// statt einer Zeile je Befund: Bei 20 Projekten mit je drei Befunden würde -// eine flache Liste unlesbar lang, und derselbe Projektname stünde dreimal -// untereinander. -type ueberblickGruppe struct { - Projekt string // Name - Warnung bool // mindestens ein Befund ist dringend - Punkte []ueberblickHinweis -} - -// ueberblickHinweis ist ein Befund, mit einem Link genau dorthin, wo er zu -// erledigen ist. -type ueberblickHinweis struct { - Warnung bool - Text string - Link string - LinkText string -} - -// ueberblickProjekt ist eine Zeile der Projekttabelle. -type ueberblickProjekt struct { - statistik.Projekt - Eintraege int - Zuletzt time.Time // jüngste Änderung eines Eintrags; Nullzeitpunkt = keine - OhneTreffer int // Anteil der Suchen ohne Treffer in Prozent, -1 = keine Suchen -} - -// handleUeberblick zeigt den Überblick des gewählten Workspace — für -// Workspace-Admins und den Plattform-Admin (der damit sieht, was der -// Workspace-Admin sieht). -func (s *Server) handleUeberblick(w http.ResponseWriter, r *http.Request) { - ws, sess := s.workspaceAndSession(r) - ctx := r.Context() - jetzt := time.Now() - - eintrag, err := s.control.WorkspaceByID(ctx, sess.WorkspaceID) - if err != nil { - s.serverError(w, "Workspace konnte nicht gelesen werden", err) - return - } - - // Aktivität je Tag und je Projekt: dieselbe Zählung wie auf der - // Systemseite, nur für diesen einen Workspace. - stand := s.statistik.FuerWorkspace(ctx, eintrag, jetzt) - if len(stand.Fehler) > 0 { - s.serverError(w, "Überblick konnte nicht erhoben werden", fmt.Errorf("%v", stand.Fehler)) - return - } - - // Einträge und jüngste Änderung je Projekt, in einer Abfrage für alle. - nutzung, err := ws.Store.UsageSummaries(ctx, "", jetzt) - if err != nil { - s.serverError(w, "Nutzung konnte nicht gelesen werden", err) - return - } - - daten := ueberblickData{ - Workspace: eintrag.Name, - TageImDiagramm: len(stand.Tage), - } - if len(stand.Workspaces) == 1 { - daten.Summe = stand.Workspaces[0].Bestand - } - for _, t := range stand.Tage { - daten.Schreiben += t.Schreiben() - daten.Suchen += t.Suchen - } - daten.DiagrammSchreiben = baueDiagramm("Schreibvorgänge je Tag", diagrammEinheit{"Schreibvorgang", "Schreibvorgänge"}, stand.Tage, - diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenAgent }, klasse: "balken-agent", name: "Agenten"}, - diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenMensch }, klasse: "balken-mensch", name: "Menschen"}, - ) - daten.DiagrammSuchen = baueDiagramm("Suchen je Tag", diagrammEinheit{"Suche", "Suchen"}, stand.Tage, - diagrammReihe{wert: func(t statistik.Tag) int { return t.Suchen }, klasse: "balken-suche", name: "Suchen"}, - ) - - // Aktive und ruhige Projekte kommen getrennt aus der Erhebung; hier - // stehen alle in einer Tabelle, alphabetisch. - alle := append(append([]statistik.Projekt{}, stand.Projekte...), stand.Ruhig...) - sort.Slice(alle, func(i, j int) bool { return alle[i].Name < alle[j].Name }) - - for _, p := range alle { - zeile := ueberblickProjekt{Projekt: p, OhneTreffer: -1} - u := nutzung[p.ID] - zeile.Eintraege, zeile.Zuletzt = u.Entries, u.LastUpdatedAt - - suchen, err := ws.Store.SearchStatsFor(ctx, p.ID, jetzt) - if err != nil { - s.serverError(w, "Suchprotokoll konnte nicht gelesen werden", err) - return - } - if suchen.Total > 0 { - zeile.OhneTreffer = suchen.EmptyPercent() - } - daten.Projekte = append(daten.Projekte, zeile) - - warnungen, hinweise, err := projektHinweise(ctx, ws.Store, p, suchen, jetzt) - if err != nil { - s.serverError(w, "Hinweise konnten nicht erhoben werden", err) - return - } - if len(warnungen)+len(hinweise) > 0 { - daten.Gruppen = append(daten.Gruppen, ueberblickGruppe{ - Projekt: p.Name, - Warnung: len(warnungen) > 0, - Punkte: append(warnungen, hinweise...), // Dringendes zuerst - }) - } - } - // Projekte mit Warnung nach oben. SliceStable erhält innerhalb beider - // Gruppen die alphabetische Reihenfolge von oben. - sort.SliceStable(daten.Gruppen, func(i, j int) bool { - return daten.Gruppen[i].Warnung && !daten.Gruppen[j].Warnung - }) - - s.render(w, r, "ueberblick.html", http.StatusOK, pageData{ - Title: "Überblick", - Bereich: "ueberblick", - Data: daten, - }) -} - -// projektHinweise prüft ein Projekt auf das, was ein Workspace-Admin -// erledigen kann. Warnungen sind dringend (ein Zugang endet bald, niemand -// kommt hinein), Hinweise eine Bitte, bei Gelegenheit hinzusehen. -// -// Jede Prüfung nimmt dieselbe Regel wie die Seite, auf die sie verlinkt — -// "ungenutzt" wie unter Zugänge, "veraltet" wie in der Eintragsliste, "zur -// Prüfung" wie auf der Nutzungsseite. Wer dem Link folgt, findet dort genau -// das, was hier gezählt wurde. -func projektHinweise(ctx context.Context, st *store.Store, p statistik.Projekt, suchen store.SearchStats, jetzt time.Time) (warnungen, hinweise []ueberblickHinweis, err error) { - zugaenge := "/p/" + p.Slug + "/keys" - nutzung := "/p/" + p.Slug + "/usage" - - keys, err := st.ListAPIKeys(ctx, p.ID) - if err != nil { - return nil, nil, err - } - gueltig, admins, ungenutzt := 0, 0, 0 - for _, k := range keys { - if !k.Valid(jetzt) { - continue - } - gueltig++ - if k.Role == string(auth.RoleAdmin) { - admins++ - } - if k.Unused(jetzt) { - ungenutzt++ - } - if k.ExpiresSoon(jetzt) { - // Wie bisTag: angezeigt wird der letzte gültige Tag. - warnungen = append(warnungen, ueberblickHinweis{ - Warnung: true, - Text: fmt.Sprintf("Der Key „%s“ gilt nur noch bis %s. Noch gebraucht? Dann jetzt einen neuen anlegen und im Client austauschen.", - k.Name, k.ExpiresAt.Add(-time.Second).Local().Format("02.01.2006")), - Link: zugaenge, LinkText: "Zugänge", - }) - } - } - - switch { - case gueltig == 0: - warnungen = append(warnungen, ueberblickHinweis{ - Warnung: true, - Text: "Kein gültiger Key — weder das Team noch ein Agent kommt hinein.", - Link: zugaenge, LinkText: "Key anlegen", - }) - case admins == 0: - hinweise = append(hinweise, ueberblickHinweis{ - Text: "Kein Key mit der Rolle admin — das Team kann seine Zugänge nicht selbst verwalten und muss jedes Mal dich fragen.", - Link: zugaenge, LinkText: "Zugänge", - }) - } - if ungenutzt > 0 { - hinweise = append(hinweise, ueberblickHinweis{ - Text: fmt.Sprintf("%s seit %d Tagen nicht benutzt. Weiß niemand mehr, wofür, zurückziehen.", - plural(ungenutzt, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24)), - Link: zugaenge, LinkText: "Zugänge", - }) - } - - // Ein Projekt ohne Key ist schon oben gewarnt; dass dort nichts - // geschieht, ist dann keine Nachricht. - if p.Summe() == 0 && gueltig > 0 { - hinweise = append(hinweise, ueberblickHinweis{ - Text: fmt.Sprintf("In %d Tagen weder geschrieben noch gesucht. Wird das Projekt noch gebraucht, oder fehlt dem Team die Anbindung?", statistik.Tage), - Link: nutzung, LinkText: "Nutzung", - }) - } - - if suchen.Total >= leereSuchenAb && suchen.EmptyPercent() >= leereSuchenProzent { - hinweise = append(hinweise, ueberblickHinweis{ - Text: fmt.Sprintf("%d %% der Suchen fanden nichts. Entweder fehlt Wissen, oder die Agenten suchen mit anderen Wörtern, als in den Einträgen stehen.", - suchen.EmptyPercent()), - Link: nutzung, LinkText: "Suchen ohne Treffer", - }) - } - - pruefen, err := st.CuratedOverrides(ctx, p.ID, pruefenHoechstens) - if err != nil { - return nil, nil, err - } - if n := len(pruefen); n > 0 { - text := "Ein kuratierter Eintrag wurde von einem Agenten geändert und wartet auf Prüfung durch das Team." - if n > 1 { - text = fmt.Sprintf("%d kuratierte Einträge wurden von Agenten geändert und warten auf Prüfung durch das Team.", n) - } - hinweise = append(hinweise, ueberblickHinweis{ - Text: text, Link: nutzung, LinkText: "Zur Prüfung", - }) - } - - veraltet, err := st.CountStaleEntries(ctx, p.ID, jetzt) - if err != nil { - return nil, nil, err - } - if veraltet > 0 { - text := "Ein Eintrag ist seit über einem Jahr unverändert. Das Team prüfen lassen, ob er noch stimmt." - if veraltet > 1 { - text = fmt.Sprintf("%d Einträge sind seit über einem Jahr unverändert. Das Team prüfen lassen, ob sie noch stimmen.", veraltet) - } - hinweise = append(hinweise, ueberblickHinweis{ - Text: text, Link: "/p/" + p.Slug + "/?veraltet=1", LinkText: "Veraltete Einträge", - }) - } - - return warnungen, hinweise, nil -} diff --git a/internal/web/handlers_nutzung.go b/internal/web/handlers_usage.go similarity index 67% rename from internal/web/handlers_nutzung.go rename to internal/web/handlers_usage.go index e707870..254daec 100644 --- a/internal/web/handlers_nutzung.go +++ b/internal/web/handlers_usage.go @@ -17,8 +17,8 @@ import ( // soll einen Überblick geben, keine vollständige Aufstellung — wer mehr // braucht, findet sie über die Eintragsliste oder den Graph. const ( - nutzungListenLaenge = 15 - nutzungKeysLaenge = 5 + usageListLen = 15 + usageKeysLen = 5 ) // usageData sind die Daten der Nutzungsseite. @@ -34,7 +34,7 @@ type usageData struct { // Kuratierte Einträge, die ein Agent seitdem geändert hat — zur Prüfung. CuratedOverrides []store.CuratedOverride - // Suchen der Agenten über recall (Suchprotokoll, migrations/006). + // Searches der Agenten über recall (Suchprotokoll, migrations/006). Searches store.SearchStats EmptySearches []store.EmptySearch } @@ -45,80 +45,80 @@ func (s *Server) handleProjectUsage(w http.ResponseWriter, r *http.Request) { project, _ := ProjectFrom(r) ctx := r.Context() - alle, err := ws.Store.UsageSummaries(ctx, project.ID, time.Now()) + all, err := ws.Store.UsageSummaries(ctx, project.ID, time.Now()) if err != nil { s.serverError(w, "Nutzung konnte nicht geladen werden", err) return } - ohneSchlagwort, err := ws.Store.UntaggedEntries(ctx, project.ID, nutzungListenLaenge) + untagged, err := ws.Store.UntaggedEntries(ctx, project.ID, usageListLen) if err != nil { s.serverError(w, "Einträge ohne Schlagwort konnten nicht geladen werden", err) return } - insLeere, err := ws.Store.MissingLinks(ctx, project.ID) + dangling, err := ws.Store.MissingLinks(ctx, project.ID) if err != nil { s.serverError(w, "Verweise konnten nicht geladen werden", err) return } - keys, err := ws.Store.RecentlyUsedKeys(ctx, project.ID, nutzungKeysLaenge) + keys, err := ws.Store.RecentlyUsedKeys(ctx, project.ID, usageKeysLen) if err != nil { s.serverError(w, "Zugänge konnten nicht geladen werden", err) return } - zurPruefung, err := ws.Store.CuratedOverrides(ctx, project.ID, nutzungListenLaenge) + forReview, err := ws.Store.CuratedOverrides(ctx, project.ID, usageListLen) if err != nil { s.serverError(w, "Geänderte kuratierte Einträge konnten nicht geladen werden", err) return } - suchen, err := ws.Store.SearchStatsFor(ctx, project.ID, time.Now()) + searches, err := ws.Store.SearchStatsFor(ctx, project.ID, time.Now()) if err != nil { s.serverError(w, "Suchprotokoll konnte nicht geladen werden", err) return } - ohneTreffer, err := ws.Store.EmptySearches(ctx, project.ID, time.Now(), nutzungListenLaenge) + noHits, err := ws.Store.EmptySearches(ctx, project.ID, time.Now(), usageListLen) if err != nil { s.serverError(w, "Suchen ohne Treffer konnten nicht geladen werden", err) return } - veraltet, err := ws.Store.CountStaleEntries(ctx, project.ID, time.Now()) + stale, err := ws.Store.CountStaleEntries(ctx, project.ID, time.Now()) if err != nil { s.serverError(w, "veraltete Einträge konnten nicht gezählt werden", err) return } - daten := usageData{ - Stale: veraltet, + data := usageData{ + Stale: stale, // Ein Projekt ohne Einträge und Schreibvorgänge fehlt in der map — // dann liefert alle[project.ID] den Nullwert, also überall 0. - Summary: alle[project.ID], + Summary: all[project.ID], WindowDays: store.UsageWindowDays, - Untagged: ohneSchlagwort, - Missing: insLeere, + Untagged: untagged, + Missing: dangling, Keys: keys, - CuratedOverrides: zurPruefung, + CuratedOverrides: forReview, - Searches: suchen, - EmptySearches: ohneTreffer, + Searches: searches, + EmptySearches: noHits, } // Die Verweise ins Leere kommen vollständig (die Gesamtzahl ist selbst // eine Aussage), angezeigt werden nur die meistgenannten. - if len(daten.Missing) > nutzungListenLaenge { - daten.MissingMore = len(daten.Missing) - nutzungListenLaenge - daten.Missing = daten.Missing[:nutzungListenLaenge] + if len(data.Missing) > usageListLen { + data.MissingMore = len(data.Missing) - usageListLen + data.Missing = data.Missing[:usageListLen] } - s.render(w, r, "nutzung.html", http.StatusOK, pageData{ + s.render(w, r, "usage.html", http.StatusOK, pageData{ Title: "Nutzung – " + project.Name, - Bereich: "nutzung", - Data: daten, + Section: "nutzung", + Data: data, }) } diff --git a/internal/web/handlers_workspace_admins.go b/internal/web/handlers_workspace_admins.go index 4e05939..1251dd6 100644 --- a/internal/web/handlers_workspace_admins.go +++ b/internal/web/handlers_workspace_admins.go @@ -30,13 +30,13 @@ func (s *Server) handleWorkspaceAdminCreate(w http.ResponseWriter, r *http.Reque name := strings.TrimSpace(r.FormValue("name")) - klartext, err := auth.GenerateWorkspaceAdminToken() + plaintext, err := auth.GenerateWorkspaceAdminToken() if err != nil { s.serverError(w, "Token konnte nicht erzeugt werden", err) return } - admin, err := s.control.CreateWorkspaceAdmin(r.Context(), ws.ID, name, auth.HashKey(klartext)) + admin, err := s.control.CreateWorkspaceAdmin(r.Context(), ws.ID, name, auth.HashKey(plaintext)) if err != nil { s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, workspaceEditData{}, "", err.Error()) return @@ -46,7 +46,7 @@ func (s *Server) handleWorkspaceAdminCreate(w http.ResponseWriter, r *http.Reque s.logWorkspaceAdminEvent(r, ws.ID, sess, store.ActionWorkspaceAdminCreated, admin.Name) s.renderWorkspaceEdit(w, r, http.StatusOK, ws, - workspaceEditData{NewToken: klartext, NewTokenName: admin.Name}, + workspaceEditData{NewToken: plaintext, NewTokenName: admin.Name}, "Workspace-Admin angelegt. Das Token wird nur jetzt angezeigt — bitte gleich übernehmen.", "") } @@ -87,14 +87,14 @@ func (s *Server) handleWorkspaceAdminRevoke(w http.ResponseWriter, r *http.Reque // logWorkspaceAdminEvent schreibt ein Ereignis ins Protokoll des betroffenen // Workspace — dort, wo auch alles steht, was der Zugang später tut. Der // Workspace muss dafür nicht der gerade gewählte des Plattform-Admins sein. -func (s *Server) logWorkspaceAdminEvent(r *http.Request, workspaceID string, sess Session, aktion, name string) { +func (s *Server) logWorkspaceAdminEvent(r *http.Request, workspaceID string, sess Session, action, name string) { ws, err := s.workspaces.Get(workspaceID) if err != nil { log.Printf("Protokoll von Workspace %s nicht erreichbar: %v", workspaceID, err) return } ws.Store.LogAudit(r.Context(), store.AuditEvent{ - Actor: sess.ActorName(), Action: aktion, Source: store.SourceWeb, + Actor: sess.ActorName(), Action: action, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: name, }) } diff --git a/internal/web/handlers_workspaces.go b/internal/web/handlers_workspaces.go index 5f68bc3..6253ff6 100644 --- a/internal/web/handlers_workspaces.go +++ b/internal/web/handlers_workspaces.go @@ -4,7 +4,6 @@ import ( "errors" "log" "net/http" - "net/url" "strings" "time" @@ -26,14 +25,14 @@ type workspaceItem struct { CreatedAt string Projects int - // Ort ist das PostgreSQL-Schema bzw. die SQLite-Datei des Workspace — + // Location ist das PostgreSQL-Schema bzw. die SQLite-Datei des Workspace — // damit der Plattform-Admin ihn in der Datenbank wiederfindet. - Ort string + Location string - // Admins zählt die aktiven Workspace-Admins, AdminsUngenutzt die davon, + // Admins zählt die aktiven Workspace-Admins, AdminsUnused die davon, // die seit über 90 Tagen niemand benutzt hat (store.WorkspaceAdmin.Unused). - Admins int - AdminsUngenutzt int + Admins int + AdminsUnused int } // handleWorkspaceList zeigt alle Workspaces zur Auswahl. @@ -42,11 +41,11 @@ type workspaceItem struct { // Eine frische Plattform-Admin-Sitzung landet hier, bevor sie überhaupt eine // Projektseite aufrufen kann (siehe requireProject in middleware.go). func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) { - s.renderWorkspaceList(w, r, http.StatusOK, "", "") + s.renderWorkspaceList(w, r, http.StatusOK, s.hintFrom(r), "") } // renderWorkspaceList lädt alle Workspaces und rendert die Auswahlseite. -func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, status int, hinweis, fehler string) { +func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, status int, hint, errs string) { sess, _ := SessionFrom(r) workspaces, err := s.control.ListWorkspaces(r.Context()) @@ -62,13 +61,13 @@ func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, sta s.serverError(w, "Workspace-Admins konnten nicht geladen werden", err) return } - jetzt := time.Now() - aktiv := make(map[string]int) - ungenutzt := make(map[string]int) + now := time.Now() + active := make(map[string]int) + unused := make(map[string]int) for _, a := range admins { - aktiv[a.WorkspaceID]++ - if a.Unused(jetzt) { - ungenutzt[a.WorkspaceID]++ + active[a.WorkspaceID]++ + if a.Unused(now) { + unused[a.WorkspaceID]++ } } @@ -78,18 +77,18 @@ func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, sta for i, ws := range workspaces { items[len(workspaces)-1-i] = workspaceItem{ ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"), - Ort: s.workspaces.Ort(ws.ID), - Projects: s.projectCount(r, ws.ID), - Admins: aktiv[ws.ID], - AdminsUngenutzt: ungenutzt[ws.ID], + Location: s.workspaces.Location(ws.ID), + Projects: s.projectCount(r, ws.ID), + Admins: active[ws.ID], + AdminsUnused: unused[ws.ID], } } s.render(w, r, "workspaces.html", status, pageData{ Title: "Workspaces", - Bereich: "workspaces", - Flash: hinweis, - Error: fehler, + Section: "workspaces", + Flash: hint, + Error: errs, Data: workspaceListData{Workspaces: items, Current: sess.WorkspaceID}, }) } @@ -113,7 +112,7 @@ func (s *Server) handleWorkspaceCreate(w http.ResponseWriter, r *http.Request) { sess, _ := SessionFrom(r) log.Printf("Workspace %q (%s) angelegt von %s", ws.Name, ws.ID, sess.ActorName()) - s.setSessionCookie(w, r, s.plattformSubjekt(ws.ID)) + s.setSessionCookie(w, r, s.platformSubject(ws.ID), sess.Expires) http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) } @@ -127,11 +126,11 @@ type workspaceEditData struct { // Admins sind die Workspace-Admin-Zugänge, auch zurückgezogene. Admins []store.WorkspaceAdmin - // Jetzt ist der Bezugszeitpunkt für WorkspaceAdmin.Unused im Template, + // Now ist der Bezugszeitpunkt für WorkspaceAdmin.Unused im Template, // Ungenutzt die Zahl der aktiven Zugänge, die seit über 90 Tagen niemand // benutzt hat — wie in keyListData. - Jetzt time.Time - Ungenutzt int + Now time.Time + Unused int // NewToken wird nur unmittelbar nach dem Anlegen gesetzt und genau // einmal angezeigt — wie NewKey in keyListData. @@ -169,7 +168,7 @@ func (s *Server) handleWorkspaceEditSave(w http.ResponseWriter, r *http.Request) sess, _ := SessionFrom(r) log.Printf("Workspace %s umbenannt in %q von %s", ws.ID, name, sess.ActorName()) - http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape(name+" gespeichert"), http.StatusSeeOther) + http.Redirect(w, r, s.withHint("/admin/workspaces", name+" gespeichert"), http.StatusSeeOther) } // handleWorkspaceDelete löscht einen Workspace samt aller seiner Projekte, @@ -208,9 +207,9 @@ func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) { log.Printf("Workspace %q (%s) gelöscht von %s", ws.Name, ws.ID, sess.ActorName()) if sess.WorkspaceID == ws.ID { - s.setSessionCookie(w, r, s.plattformSubjekt("")) + s.setSessionCookie(w, r, s.platformSubject(""), sess.Expires) } - http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape("Workspace gelöscht"), http.StatusSeeOther) + http.Redirect(w, r, s.withHint("/admin/workspaces", "Workspace gelöscht"), http.StatusSeeOther) } // projectCount zählt die Projekte eines Workspace. @@ -226,19 +225,19 @@ func (s *Server) projectCount(r *http.Request, workspaceID string) int { log.Printf("Projekte von Workspace %s konnten nicht gezählt werden: %v", workspaceID, err) return 0 } - projekte, err := ws.Store.ListProjects(r.Context(), workspaceID) + projects, err := ws.Store.ListProjects(r.Context(), workspaceID) if err != nil { log.Printf("Projekte von Workspace %s konnten nicht gezählt werden: %v", workspaceID, err) return 0 } - return len(projekte) + return len(projects) } // renderWorkspaceEdit lädt Projektzahl und Workspace-Admins und rendert die -// Bearbeitungsseite. In daten sind nur NewToken/NewTokenName von Belang, den +// Bearbeitungsseite. In data sind nur NewToken/NewTokenName von Belang, den // Rest füllt diese Funktion. func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, status int, - ws workspaceItem, daten workspaceEditData, flash, fehler string) { + ws workspaceItem, data workspaceEditData, flash, errs string) { admins, err := s.control.ListWorkspaceAdmins(r.Context(), ws.ID) if err != nil { @@ -246,22 +245,22 @@ func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, sta return } - daten.Workspace = ws - daten.Projects = s.projectCount(r, ws.ID) - daten.Admins = admins - daten.Jetzt = time.Now() + data.Workspace = ws + data.Projects = s.projectCount(r, ws.ID) + data.Admins = admins + data.Now = time.Now() for _, a := range admins { - if a.Unused(daten.Jetzt) { - daten.Ungenutzt++ + if a.Unused(data.Now) { + data.Unused++ } } s.render(w, r, "workspace-edit.html", status, pageData{ Title: ws.Name, - Bereich: "workspaces", + Section: "workspaces", Flash: flash, - Error: fehler, - Data: daten, + Error: errs, + Data: data, }) } @@ -300,6 +299,7 @@ func (s *Server) handleWorkspaceSelect(w http.ResponseWriter, r *http.Request) { return } - s.setSessionCookie(w, r, s.plattformSubjekt(id)) + sess, _ := SessionFrom(r) + s.setSessionCookie(w, r, s.platformSubject(id), sess.Expires) http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) } diff --git a/internal/web/import_test.go b/internal/web/import_test.go index e925057..74355dc 100644 --- a/internal/web/import_test.go +++ b/internal/web/import_test.go @@ -15,26 +15,26 @@ import ( "munin/internal/store" ) -// importAnfrage baut einen Upload wie das Formular der Import-Seite. -func importAnfrage(t *testing.T, cookie *http.Cookie, csrf string, dateien map[string]string, felder map[string]string) *http.Request { +// importRequest baut einen Upload wie das Formular der Import-Seite. +func importRequest(t *testing.T, cookie *http.Cookie, csrf string, files map[string]string, fields map[string]string) *http.Request { t.Helper() - var archiv bytes.Buffer - zw := zip.NewWriter(&archiv) - for pfad, inhalt := range dateien { - f, _ := zw.Create(pfad) - f.Write([]byte(inhalt)) + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + for filePath, content := range files { + f, _ := zw.Create(filePath) + f.Write([]byte(content)) } zw.Close() var body bytes.Buffer mw := multipart.NewWriter(&body) mw.WriteField(auth.CSRFFieldName, csrf) - for name, wert := range felder { - mw.WriteField(name, wert) + for name, value := range fields { + mw.WriteField(name, value) } - teil, _ := mw.CreateFormFile("archiv", "import.zip") - teil.Write(archiv.Bytes()) + part, _ := mw.CreateFormFile("archiv", "import.zip") + part.Write(archive.Bytes()) mw.Close() r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body) @@ -43,55 +43,55 @@ func importAnfrage(t *testing.T, cookie *http.Cookie, csrf string, dateien map[s return r } -// adminMitToken meldet einen Admin-Key an und berechnet sein CSRF-Token so, +// adminWithToken meldet einen Admin-Key an und berechnet sein CSRF-Token so, // wie es das Template tut. -func adminMitToken(t *testing.T, srv *Server, db *store.Store, projekt store.Project, h http.Handler) (*http.Cookie, string) { +func adminWithToken(t *testing.T, srv *Server, db *store.Store, project store.Project, h http.Handler) (*http.Cookie, string) { t.Helper() - key := erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "verwalterin", auth.RoleAdmin) + cookie := login(t, h, key) - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - subject := auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID + keys, _ := db.ListAPIKeys(context.Background(), project.ID) + subject := auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID return cookie, auth.NewSigner([]byte(testSecret)).CSRFToken(subject) } func TestImportPreviewThenApply(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - cookie, csrf := adminMitToken(t, srv, db, projekt, h) + cookie, csrf := adminWithToken(t, srv, db, project, h) ctx := context.Background() // Suchindex vorher aufbauen. Der Import muss ihn nachziehen, sonst fände // die Suche die neuen Einträge erst nach einem Neustart. - suche := httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil) - suche.AddCookie(cookie) - h.ServeHTTP(httptest.NewRecorder(), suche) + search := httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil) + search.AddCookie(cookie) + h.ServeHTTP(httptest.NewRecorder(), search) - dateien := map[string]string{ + files := map[string]string{ "doku/verkehr.md": "# Verkehr\n\nAm Zebrastreifen anhalten.", "doku/_index.md": "Übersicht", } // Vorschau: nichts geschrieben. w := httptest.NewRecorder() - h.ServeHTTP(w, importAnfrage(t, cookie, csrf, dateien, map[string]string{"vorschau": "1", "modus": "skip"})) + h.ServeHTTP(w, importRequest(t, cookie, csrf, files, map[string]string{"vorschau": "1", "modus": "skip"})) if w.Code != http.StatusOK { t.Fatalf("Vorschau: Status %d: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "Vorschau") || !strings.Contains(w.Body.String(), "verkehr") { t.Error("die Vorschau zeigt die Datei nicht") } - if _, err := db.EntryBySlug(ctx, projekt.ID, "verkehr"); err == nil { + if _, err := db.EntryBySlug(ctx, project.ID, "verkehr"); err == nil { t.Fatal("die Vorschau hat geschrieben") } // Übernehmen. w = httptest.NewRecorder() - h.ServeHTTP(w, importAnfrage(t, cookie, csrf, dateien, map[string]string{"modus": "skip"})) + h.ServeHTTP(w, importRequest(t, cookie, csrf, files, map[string]string{"modus": "skip"})) if w.Code != http.StatusOK { t.Fatalf("Import: Status %d: %s", w.Code, w.Body.String()) } - e, err := db.EntryBySlug(ctx, projekt.ID, "verkehr") + e, err := db.EntryBySlug(ctx, project.ID, "verkehr") if err != nil { t.Fatalf("Eintrag fehlt nach dem Import: %v", err) } @@ -99,36 +99,36 @@ func TestImportPreviewThenApply(t *testing.T) { t.Errorf("Autor %q, Titel %q", e.Author, e.Title) } - suche = httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil) - suche.AddCookie(cookie) + search = httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil) + search.AddCookie(cookie) w = httptest.NewRecorder() - h.ServeHTTP(w, suche) + h.ServeHTTP(w, search) if !strings.Contains(w.Body.String(), "/e/verkehr") { t.Error("die Suche findet den importierten Eintrag nicht — Suchindex nicht nachgezogen") } // Genau ein Protokolleintrag für den ganzen Import. - ereignisse, err := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID, Limit: 50}) + events, err := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID, Limit: 50}) if err != nil { t.Fatal(err) } - anzahl := 0 - for _, ev := range ereignisse { + count := 0 + for _, ev := range events { if ev.Action == store.ActionEntryImport { - anzahl++ + count++ } } - if anzahl != 1 { - t.Errorf("%d Import-Einträge im Protokoll, erwartet 1", anzahl) + if count != 1 { + t.Errorf("%d Import-Einträge im Protokoll, erwartet 1", count) } } func TestImportNeedsAdmin(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "schreiber", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "schreiber", auth.RoleWrite) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/import", nil) r.AddCookie(cookie) @@ -141,15 +141,15 @@ func TestImportNeedsAdmin(t *testing.T) { } func TestImportRejectsNonZip(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - cookie, csrf := adminMitToken(t, srv, db, projekt, h) + cookie, csrf := adminWithToken(t, srv, db, project, h) var body bytes.Buffer mw := multipart.NewWriter(&body) mw.WriteField(auth.CSRFFieldName, csrf) - teil, _ := mw.CreateFormFile("archiv", "kaputt.zip") - teil.Write([]byte("das ist kein zip")) + part, _ := mw.CreateFormFile("archiv", "kaputt.zip") + part.Write([]byte("das ist kein zip")) mw.Close() r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body) @@ -167,15 +167,15 @@ func TestImportRejectsNonZip(t *testing.T) { // größere Grenze bekommen und alles andere die kleine. Früher lag ein // einziges Limit über allem und schnitt MCP auf 1 MB ab. func TestLimitBodiesPerRoute(t *testing.T) { - var gelesen int64 + var read int64 inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gelesen, _ = io.Copy(io.Discard, r.Body) + read, _ = io.Copy(io.Discard, r.Body) }) h := LimitBodies(inner, BodyLimits{Web: 100, API: 1000, Import: 5000}) - faelle := []struct { - methode, pfad string - erwartet int64 + cases := []struct { + method, filePath string + want int64 }{ {http.MethodPost, "/p/x/save", 100}, {http.MethodPost, "/mcp", 1000}, @@ -183,12 +183,12 @@ func TestLimitBodiesPerRoute(t *testing.T) { {http.MethodGet, "/p/x/import", 100}, {http.MethodPost, "/p/x/e/import", 100}, // ein Eintrag namens "import", nicht die Import-Seite } - for _, f := range faelle { - gelesen = 0 + for _, f := range cases { + read = 0 h.ServeHTTP(httptest.NewRecorder(), - httptest.NewRequest(f.methode, f.pfad, strings.NewReader(strings.Repeat("x", 10000)))) - if gelesen != f.erwartet { - t.Errorf("%s %s: %d Bytes gelesen, erwartet %d", f.methode, f.pfad, gelesen, f.erwartet) + httptest.NewRequest(f.method, f.filePath, strings.NewReader(strings.Repeat("x", 10000)))) + if read != f.want { + t.Errorf("%s %s: %d Bytes gelesen, erwartet %d", f.method, f.filePath, read, f.want) } } } diff --git a/internal/web/middleware.go b/internal/web/middleware.go index b058dcc..fa71b67 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -34,7 +34,7 @@ func (s *Server) requireSession(next http.Handler) http.Handler { return } - subject, ok := s.signer.VerifySession(cookie.Value) + subject, expiry, ok := s.signer.VerifySession(cookie.Value) if !ok { s.redirectToLogin(w, r) return @@ -45,6 +45,7 @@ func (s *Server) requireSession(next http.Handler) http.Handler { s.redirectToLogin(w, r) return } + sess.Expires = expiry if !s.checkCSRF(w, r, subject) { return @@ -89,11 +90,11 @@ func (s *Server) requireSession(next http.Handler) http.Handler { // Sitzungen aus der Zeit vor dem Fingerabdruck ("platform" ohne Zusatz) passen // zu keiner Form mehr und enden. Der Plattform-Admin meldet sich einmal neu an. func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) { - if fingerabdruck, workspaceID, ok := auth.ParsePlatformSubject(subject); ok { + if fingerprint, workspaceID, ok := auth.ParsePlatformSubject(subject); ok { // Mit einem anderen ADMIN_TOKEN entstanden: Das Token wurde seitdem // ersetzt, die Sitzung gilt nicht mehr. Vergleich in konstanter Zeit, // damit die Antwortzeit nichts über den richtigen Abdruck verrät. - if subtle.ConstantTimeCompare([]byte(fingerabdruck), []byte(s.adminFingerprint)) != 1 { + if subtle.ConstantTimeCompare([]byte(fingerprint), []byte(s.adminFingerprint)) != 1 { return Session{}, false } @@ -383,7 +384,12 @@ func SecurityHeaders(next http.Handler, enableHSTS bool) http.Handler { // diese Regel trotzdem die Ausführung. // // default-src 'self' alles nur von der eigenen Adresse - // img-src … data: https: Bilder dürfen auch extern eingebunden werden + // img-src 'self' data: Bilder nur von uns selbst oder eingebettet. + // Externe Bilder (https:) sind gesperrt: Ein + // Eintrag — etwa von einem Agenten geschrieben — + // könnte sonst ein Zählpixel enthalten, das + // jedem Leser beim Öffnen IP und Zeitpunkt an + // einen fremden Server meldet. // object-src 'none' keine Plugin-Einbettungen // base-uri 'none' verbietet <base href>, das sonst alle // relativen Links der Seite umbiegen könnte @@ -391,7 +397,7 @@ func SecurityHeaders(next http.Handler, enableHSTS bool) http.Handler { // frame-ancestors 'none' die Seite darf in keinem fremden Rahmen // stecken (Schutz vor Clickjacking) h.Set("Content-Security-Policy", - "default-src 'self'; img-src 'self' data: https:; style-src 'self'; "+ + "default-src 'self'; img-src 'self' data:; style-src 'self'; "+ "script-src 'self'; object-src 'none'; base-uri 'none'; "+ "form-action 'self'; frame-ancestors 'none'") @@ -462,26 +468,26 @@ type BodyLimits struct { // irgendetwas anderes den Body anfasst. func LimitBodies(next http.Handler, limits BodyLimits) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - grenze := limits.Web + limit := limits.Web switch { case r.URL.Path == "/mcp": - grenze = limits.API - case r.Method == http.MethodPost && istImportPfad(r.URL.Path): - grenze = limits.Import + limit = limits.API + case r.Method == http.MethodPost && isImportPath(r.URL.Path): + limit = limits.Import } if r.Body != nil { - r.Body = http.MaxBytesReader(w, r.Body, grenze) + r.Body = http.MaxBytesReader(w, r.Body, limit) } next.ServeHTTP(w, r) }) } -// istImportPfad erkennt "/p/{projekt}/import". Der Mux hat zu diesem +// isImportPath erkennt "/p/{projekt}/import". Der Mux hat zu diesem // Zeitpunkt noch nicht entschieden, welche Route passt, deshalb wird der Pfad // hier von Hand zerlegt: genau drei Teile, der erste "p", der letzte "import". -func istImportPfad(pfad string) bool { - teile := strings.Split(strings.Trim(pfad, "/"), "/") - return len(teile) == 3 && teile[0] == "p" && teile[2] == "import" +func isImportPath(filePath string) bool { + parts := strings.Split(strings.Trim(filePath, "/"), "/") + return len(parts) == 3 && parts[0] == "p" && parts[2] == "import" } // ── API-Zugang (MCP) ───────────────────────────────────────────────────────── @@ -518,7 +524,10 @@ func (s *Server) APIAuth(next http.Handler) http.Handler { var resolved store.ResolvedKey resolved, err = ws.Store.ResolveKeyHash(r.Context(), hash) if err == nil { - s.limiter.Reset(ip) + // Kein limiter.Reset: Ein gültiger Key soll die + // Fehlversuche seiner IP nicht löschen — sonst ließe sich + // damit die Sperre der Anmeldung aushebeln (siehe + // handleLoginSubmit). // Nebenbei vermerken, dass der Key benutzt wurde — damit man // in der Verwaltung sieht, welche Keys noch aktiv sind. Ein @@ -545,9 +554,9 @@ func (s *Server) APIAuth(next http.Handler) http.Handler { } if allowed, retryAfter := s.limiter.Allow(ip); !allowed { - sekunden := int(retryAfter.Seconds()) + 1 - w.Header().Set("Retry-After", strconv.Itoa(sekunden)) - http.Error(w, fmt.Sprintf("Zu viele fehlgeschlagene Versuche. Bitte in %d Sekunden erneut versuchen.", sekunden), + seconds := int(retryAfter.Seconds()) + 1 + w.Header().Set("Retry-After", strconv.Itoa(seconds)) + http.Error(w, fmt.Sprintf("Zu viele fehlgeschlagene Versuche. Bitte in %d Sekunden erneut versuchen.", seconds), http.StatusTooManyRequests) return } @@ -566,22 +575,22 @@ func (s *Server) APIAuth(next http.Handler) http.Handler { // ── Zugriffsprotokoll ──────────────────────────────────────────────────────── -// statusSchreiber merkt sich den Statuscode einer Antwort. +// statusWriter merkt sich den Statuscode einer Antwort. // // http.ResponseWriter verrät ihn nicht — man kann ihn setzen, aber nicht lesen. // Deshalb dieser dünne Mantel, der WriteHeader abfängt. -type statusSchreiber struct { +type statusWriter struct { http.ResponseWriter status int bytes int } -func (w *statusSchreiber) WriteHeader(code int) { +func (w *statusWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } -func (w *statusSchreiber) Write(b []byte) (int, error) { +func (w *statusWriter) Write(b []byte) (int, error) { // Wer direkt schreibt, ohne WriteHeader aufzurufen, erzeugt implizit ein 200. if w.status == 0 { w.status = http.StatusOK @@ -596,7 +605,7 @@ func (w *statusSchreiber) Write(b []byte) (int, error) { // http.ResponseController nutzt diese Methode, um an Fähigkeiten des // eingewickelten Schreibers heranzukommen. Der MCP-Transport geht diesen Weg für // seine gestreamten Antworten — ohne Unwrap bliebe dort alles im Puffer hängen. -func (w *statusSchreiber) Unwrap() http.ResponseWriter { +func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } @@ -611,7 +620,7 @@ func (w *statusSchreiber) Unwrap() http.ResponseWriter { // selbst anbietet — und zwar lautlos: Der Aufrufer nimmt an, dass Leeren eben // nicht unterstützt wird, und schreibt weiter in den Puffer. Solche Fehler // bemerkt man erst im Betrieb, deshalb hier ausdrücklich. -func (w *statusSchreiber) Flush() { +func (w *statusWriter) Flush() { if f, ok := w.ResponseWriter.(http.Flusher); ok { f.Flush() } @@ -632,41 +641,41 @@ func (w *statusSchreiber) Flush() { // kein Betriebsdatum. Der Pfad allein reicht, um zu sehen, was aufgerufen wurde. // - Statische Dateien und die Prüfungen /live, /ready (und /healthz). Das // erzeugt nur Rauschen; die Prüfungen laufen alle paar Sekunden. Ein -// Ausfall der Datenbank steht trotzdem im Log, siehe meldeBereitschaft. +// Ausfall der Datenbank steht trotzdem im Log, siehe reportReadiness. func LogRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/static/") || istPruefpfad(r.URL.Path) { + if strings.HasPrefix(r.URL.Path, "/static/") || isProbePath(r.URL.Path) { next.ServeHTTP(w, r) return } start := time.Now() - schreiber := &statusSchreiber{ResponseWriter: w} + writer := &statusWriter{ResponseWriter: w} // Halter für den Namen des Zugangs mitgeben. Die Anmelde-Middleware - // weiter innen trägt ihn ein — siehe aktorHalter in context.go. - halter := &aktorHalter{} - r = r.WithContext(withAktorHalter(r.Context(), halter)) + // weiter innen trägt ihn ein — siehe actorHolder in context.go. + holder := &actorHolder{} + r = r.WithContext(withActorHolder(r.Context(), holder)) - next.ServeHTTP(schreiber, r) + next.ServeHTTP(writer, r) - wer := halter.name - if wer == "" { - wer = "—" // nicht angemeldet oder abgewiesen + who := holder.name + if who == "" { + who = "—" // nicht angemeldet oder abgewiesen } - status := schreiber.status + status := writer.status if status == 0 { status = http.StatusOK // Handler hat nichts geschrieben } log.Printf("%d %-6s %-44s %8s %s", status, r.Method, r.URL.Path, - time.Since(start).Round(time.Microsecond), wer) + time.Since(start).Round(time.Microsecond), who) }) } -// istPruefpfad erkennt die Prüfungen für Container und Load-Balancer. -func istPruefpfad(pfad string) bool { - return pfad == "/live" || pfad == "/ready" || pfad == "/healthz" +// isProbePath erkennt die Prüfungen für Container und Load-Balancer. +func isProbePath(filePath string) bool { + return filePath == "/live" || filePath == "/ready" || filePath == "/healthz" } diff --git a/internal/web/oberflaeche_test.go b/internal/web/oberflaeche_test.go deleted file mode 100644 index e483257..0000000 --- a/internal/web/oberflaeche_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package web - -import ( - "context" - "encoding/json" - "net/http" - "net/url" - "strings" - "testing" - "time" - - "munin/internal/auth" - "munin/internal/store" -) - -// Tests zu den Ergänzungen der Weboberfläche vom 2026-10-16 (AUSBAUSTUFEN.md -// 4.7 Stufe 2 und 5.7–5.14): veraltete Einträge, „Auch verwandt", „Gibt es -// schon?", ungespeicherte Änderungen, Tastenkürzel, Protokollfilter, -// Systemseite, Ablaufdatum für Keys und Suche über alle Projekte. - -// legeAn schreibt einen Eintrag direkt in den Store. -func legeAn(t *testing.T, db *store.Store, projekt store.Project, w store.EntryWrite) { - t.Helper() - w.ProjectID = projekt.ID - if w.Source == "" { - w.Source = store.SourceHuman - } - if _, err := db.PutEntry(context.Background(), w); err != nil { - t.Fatal(err) - } -} - -// TestVeralteteEintraege: Die Liste markiert Einträge, die seit über einem -// Jahr unverändert sind, nennt ihre Zahl als Filterlink und filtert auf -// Wunsch. Die Nutzungsseite nennt die Zahl ebenfalls. -func TestVeralteteEintraege(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite)) - - vor2Jahren := time.Now().Add(-2 * 365 * 24 * time.Hour) - legeAn(t, db, projekt, store.EntryWrite{Slug: "alt", Title: "Uralter Eintrag", Content: "x", - CreatedAt: vor2Jahren, UpdatedAt: vor2Jahren}) - legeAn(t, db, projekt, store.EntryWrite{Slug: "neu", Title: "Frischer Eintrag", Content: "y"}) - - liste := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/").Body.String() - if !strings.Contains(liste, ">1 veraltet</a>") { - t.Error("Filterlink „1 veraltet“ fehlt in der Liste") - } - if strings.Count(liste, `class="marke warnung"`) != 1 { - t.Error("genau ein Eintrag sollte als veraltet markiert sein") - } - - gefiltert := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/?veraltet=1").Body.String() - if !strings.Contains(gefiltert, "Uralter Eintrag") || strings.Contains(gefiltert, "Frischer Eintrag") { - t.Error("Filter veraltet zeigt nicht genau den alten Eintrag") - } - - nutzung := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/usage").Body.String() - if !strings.Contains(nutzung, `href="/p/`+projekt.Slug+`/?veraltet=1">1</a>`) { - t.Error("Nutzungsseite nennt die Zahl der veralteten Einträge nicht") - } -} - -// TestAuchVerwandt: Die Eintragsansicht zeigt die zweite Reihe im -// Verweisgraphen samt Zwischeneintrag — a verweist auf b, b auf c, also ist -// c über b mit a verwandt. -func TestAuchVerwandt(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead)) - - legeAn(t, db, projekt, store.EntryWrite{Slug: "a", Title: "Eintrag A", Content: "siehe [[b]]"}) - legeAn(t, db, projekt, store.EntryWrite{Slug: "b", Title: "Eintrag B", Content: "siehe [[c]]"}) - legeAn(t, db, projekt, store.EntryWrite{Slug: "c", Title: "Eintrag C", Content: "Ende"}) - - seite := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/e/a").Body.String() - _, kasten, gefunden := strings.Cut(seite, "Auch verwandt") - if !gefunden { - t.Fatal("Kasten „Auch verwandt“ fehlt") - } - if !strings.Contains(kasten, "Eintrag C") || !strings.Contains(kasten, `über <a href="/p/`+projekt.Slug+`/e/b">b</a>`) { - t.Errorf("C über B fehlt im Kasten") - } - - // Ohne zweite Reihe kein Kasten: c verweist auf nichts, b auf c, a auf b - // — von b aus sind a und c direkte Nachbarn. - if strings.Contains(seiteHolen(h, cookie, "/p/"+projekt.Slug+"/e/b").Body.String(), "Auch verwandt") { - t.Error("Kasten erscheint, obwohl es keine zweite Reihe gibt") - } -} - -// TestAehnlicheEintraege: Das Formular fragt beim Tippen nach ähnlichen -// Einträgen — dieselbe Prüfung wie "remember" über MCP. -func TestAehnlicheEintraege(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - schreiben := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite)) - lesen := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead)) - - legeAn(t, db, projekt, store.EntryWrite{Slug: "deployment-prozess", Title: "Deployment-Prozess", Content: "Pipeline"}) - - frage := func(cookie *http.Cookie, titel, slug string) (int, []aehnlichItem) { - w := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/entries/similar?title="+titel+"&slug="+slug) - var antwort []aehnlichItem - if w.Code == http.StatusOK { - if err := json.Unmarshal(w.Body.Bytes(), &antwort); err != nil { - t.Fatalf("keine JSON-Liste: %v (%s)", err, w.Body.String()) - } - } - return w.Code, antwort - } - - code, antwort := frage(schreiben, "Deployment-Ablauf", "") - if code != http.StatusOK || len(antwort) != 1 || antwort[0].Slug != "deployment-prozess" { - t.Fatalf("Antwort = %d %+v, erwartet deployment-prozess", code, antwort) - } - if strings.Join(antwort[0].Gemeinsam, ",") != "deployment" { - t.Errorf("gemeinsame Wörter = %v, erwartet deployment", antwort[0].Gemeinsam) - } - - if _, antwort := frage(schreiben, "VPN-Zugang", ""); len(antwort) != 0 { - t.Errorf("unähnlicher Titel liefert Kandidaten: %+v", antwort) - } - // Der Bezeichner gibt es schon: Das wäre eine Aktualisierung, kein Hinweis. - if _, antwort := frage(schreiben, "Deployment-Ablauf", "deployment-prozess"); len(antwort) != 0 { - t.Errorf("bei vorhandenem Bezeichner kommen Kandidaten: %+v", antwort) - } - // Leserechte reichen nicht — das Formular gibt es für sie auch nicht. - if code, _ := frage(lesen, "Deployment-Ablauf", ""); code == http.StatusOK { - t.Error("Lesezugang bekommt ähnliche Einträge") - } -} - -// TestFormularUndKuerzel: Das Bearbeitungsformular trägt die Attribute für -// Rückfrage, Speichern per Tastatur und „Gibt es schon?"; die Kürzel n und e -// erscheinen nur, wo geschrieben werden darf. -func TestFormularUndKuerzel(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - schreiben := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite)) - lesen := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead)) - legeAn(t, db, projekt, store.EntryWrite{Slug: "x", Title: "X", Content: "x"}) - - neu := seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/new").Body.String() - for _, attr := range []string{"data-ungespeichert", "data-kuerzel-speichern", "data-aehnlich-url"} { - if !strings.Contains(neu, attr) { - t.Errorf("%s fehlt im Formular für neue Einträge", attr) - } - } - // Beim Bearbeiten gibt es nichts „schon": kein Hinweis auf ähnliche. - if strings.Contains(seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/e/x/edit").Body.String(), "data-aehnlich-url") { - t.Error("„Gibt es schon?“ erscheint beim Bearbeiten") - } - - if !strings.Contains(seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/").Body.String(), `data-kuerzel="n"`) { - t.Error("Kürzel n fehlt mit Schreibrecht") - } - if strings.Contains(seiteHolen(h, lesen, "/p/"+projekt.Slug+"/").Body.String(), `data-kuerzel="n"`) { - t.Error("Kürzel n erscheint ohne Schreibrecht") - } - if strings.Contains(seiteHolen(h, lesen, "/p/"+projekt.Slug+"/e/x").Body.String(), `data-kuerzel="e"`) { - t.Error("Kürzel e erscheint ohne Schreibrecht") - } - - for _, datei := range []string{"ungespeichert.js", "tastenkuerzel.js", "aehnlich.js"} { - if w := seiteHolen(h, lesen, "/static/"+datei); w.Code != http.StatusOK { - t.Errorf("/static/%s = %d", datei, w.Code) - } - } -} - -// TestProtokollFiltern: Die Protokollseite filtert nach Vorgang und Zugang -// und behält die Auswahl im Formular. -func TestProtokollFiltern(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite)) - ctx := context.Background() - - db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-a", Action: store.ActionEntryWrite, - EntrySlug: "geschrieben-von-a", Source: store.SourceMCP}) - db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-b", Action: store.ActionEntryDelete, - EntrySlug: "geloescht-von-b", Source: store.SourceMCP}) - - seite := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/audit?vorgang=entry_delete&wer=agent-b").Body.String() - if !strings.Contains(seite, "geloescht-von-b") || strings.Contains(seite, "geschrieben-von-a") { - t.Error("Filter zeigt nicht genau den Löschvorgang von agent-b") - } - if !strings.Contains(seite, `<option value="agent-b" selected>`) || - !strings.Contains(seite, `<option value="entry_delete" selected>`) { - t.Error("Auswahl bleibt im Formular nicht erhalten") - } - - // Unsinn in der Adresse: ungefiltert statt Fehlerseite. - w := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/audit?vorgang=gibts-nicht&von=gestern") - if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "geschrieben-von-a") { - t.Errorf("kaputter Filter: %d, erwartet das ungefilterte Protokoll", w.Code) - } -} - -// TestSystemseite: nur für den Plattform-Admin; zeigt Speicher, Bereitschaft -// und die geöffneten Workspaces mit ihrem Speicherort. -func TestSystemseite(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - - admin := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) - w := seiteHolen(h, admin, "/admin/system") - if w.Code != http.StatusOK { - t.Fatalf("/admin/system = %d", w.Code) - } - seite := w.Body.String() - for _, erwartet := range []string{"SQLite", "bereit", "keins — jede SQLite-Datei", projekt.WorkspaceID + ".db", "Kontroll-Datenbank"} { - if !strings.Contains(seite, erwartet) { - t.Errorf("%q fehlt auf der Systemseite", erwartet) - } - } - - // Ein Projektzugang kommt nicht dran. - key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin)) - if code := seiteHolen(h, key, "/admin/system").Code; code == http.StatusOK { - t.Error("Projekt-Admin sieht die Systemseite") - } -} - -// TestSystemseiteZustand: Zustand, Aktivität und Bestand (AUSBAUSTUFEN.md -// 5.16). Ein bald ablaufender Key erscheint als Hinweis, ein geschriebener -// Eintrag im Diagramm und in der Bestandstabelle. -func TestSystemseiteZustand(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - ctx := context.Background() - - if _, err := db.CreateAPIKeyWithExpiry(ctx, projekt.ID, "praktikum", "hash-praktikum", string(auth.RoleRead), - time.Now().Add(5*24*time.Hour)); err != nil { - t.Fatal(err) - } - if _, err := db.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceHuman, - }); err != nil { - t.Fatal(err) - } - db.LogAudit(ctx, store.AuditEvent{ - ProjectID: projekt.ID, Actor: "test", Action: store.ActionEntryWrite, Source: store.SourceWeb, - }) - - admin := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) - seite := seiteHolen(h, admin, "/admin/system").Body.String() - - for _, erwartet := range []string{ - "Zustand", - "1 Key läuft in den nächsten 14 Tagen ab", - "Aktivität der letzten 30 Tage", - `<svg class="diagramm"`, - "1 Schreibvorgang (Agenten 0, Menschen 1)", // Tooltip des heutigen Tages - "Bestand je Workspace", - "Schema", - } { - if !strings.Contains(seite, erwartet) { - t.Errorf("%q fehlt auf der Systemseite", erwartet) - } - } - // Ohne Suchen bleibt das zweite Diagramm leer und sagt das. - if !strings.Contains(seite, "Keine im ganzen Zeitraum") { - t.Error("leeres Suchdiagramm ohne Hinweis") - } -} - -// TestKeyMitAblaufdatum: Ein Key lässt sich mit "gültig bis" anlegen, die -// Liste zeigt das Datum und markiert ihn als bald ablaufend. Läuft er ab, -// endet auch eine bestehende Sitzung. -func TestKeyMitAblaufdatum(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - admin := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalter", auth.RoleAdmin)) - - // Ein Datum in der Vergangenheit wird abgelehnt. (Zuerst, weil - // formularSenden das CSRF-Token mit dem jüngsten Key des Projekts bildet - // — das muss noch der angemeldete sein.) - gestern := time.Now().AddDate(0, 0, -2).Format("2006-01-02") - w := formularSenden(t, h, db, projekt, admin, "/p/"+projekt.Slug+"/keys/create", url.Values{ - "name": {"zu-spaet"}, "role": {"read"}, "expires": {gestern}, - }) - if w.Code != http.StatusBadRequest { - t.Errorf("Ablauf in der Vergangenheit: %d, erwartet 400", w.Code) - } - - morgen := time.Now().AddDate(0, 0, 1) - w = formularSenden(t, h, db, projekt, admin, "/p/"+projekt.Slug+"/keys/create", url.Values{ - "name": {"praktikum"}, "role": {"read"}, "expires": {morgen.Format("2006-01-02")}, - }) - if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Key angelegt") { - t.Fatalf("Anlegen: %d", w.Code) - } - liste := seiteHolen(h, admin, "/p/"+projekt.Slug+"/keys").Body.String() - if !strings.Contains(liste, morgen.Format("02.01.2006")) || !strings.Contains(liste, "läuft bald ab") { - t.Error("Liste zeigt das Ablaufdatum oder „läuft bald ab“ nicht") - } - - // Sitzung eines Keys, der gleich abläuft: danach kein Zugriff mehr. - klartext, _ := auth.GenerateKey() - kurz, err := db.CreateAPIKeyWithExpiry(context.Background(), projekt.ID, "kurz", auth.HashKey(klartext), "read", - time.Now().Add(1500*time.Millisecond)) - if err != nil { - t.Fatal(err) - } - if err := srv.control.IndexKey(context.Background(), kurz.Hash, projekt.WorkspaceID); err != nil { - t.Fatal(err) - } - sitzung := anmelden(t, h, klartext) - if code := seiteHolen(h, sitzung, "/p/"+projekt.Slug+"/").Code; code != http.StatusOK { - t.Fatalf("vor dem Ablauf: %d", code) - } - time.Sleep(2 * time.Second) - if code := seiteHolen(h, sitzung, "/p/"+projekt.Slug+"/").Code; code == http.StatusOK { - t.Error("Sitzung gilt nach dem Ablauf des Keys weiter") - } -} - -// TestSucheInAllenProjekten: Workspace- und Plattform-Admin finden Einträge -// aus allen Projekten des Workspace, nach Projekt gruppiert. Ein -// Projektzugang kommt nicht dran — er sieht weiter nur sein Projekt. -func TestSucheInAllenProjekten(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - ctx := context.Background() - - zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "vertrieb", "Vertrieb", "") - if err != nil { - t.Fatal(err) - } - legeAn(t, db, projekt, store.EntryWrite{Slug: "vpn", Title: "VPN-Zugang Technik", Content: "Zugang über das Firmen-VPN."}) - legeAn(t, db, zweites, store.EntryWrite{Slug: "vpn-vertrieb", Title: "VPN unterwegs", Content: "VPN für Außendienst."}) - legeAn(t, db, zweites, store.EntryWrite{Slug: "preise", Title: "Preisliste", Content: "Rabatte."}) - - pruefe := func(name string, seite string) { - t.Helper() - for _, erwartet := range []string{"VPN-Zugang Technik", "VPN unterwegs", ">Testprojekt</a>", ">Vertrieb</a>"} { - if !strings.Contains(seite, erwartet) { - t.Errorf("%s: %q fehlt", name, erwartet) - } - } - if strings.Contains(seite, "Preisliste") { - t.Errorf("%s: unpassender Eintrag „Preisliste“ in den Treffern", name) - } - } - - plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) - pruefe("Plattform-Admin", seiteHolen(h, plattform, "/admin/search?q=vpn").Body.String()) - - wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) - pruefe("Workspace-Admin", wsAdmin.get(h, "/admin/search?q=vpn").Body.String()) - - key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin)) - if w := seiteHolen(h, key, "/admin/search?q=vpn"); w.Code == http.StatusOK && strings.Contains(w.Body.String(), "VPN unterwegs") { - t.Error("Projektzugang findet Einträge aus einem anderen Projekt") - } -} - -// TestHilfeJeRolle: Die Seite für Workspace-Admins sehen Workspace- und -// Plattform-Admin, die für Plattform-Admins nur dieser. Ein Projektzugang -// sieht keine von beiden — auch nicht über die Adresse. -func TestHilfeJeRolle(t *testing.T) { - srv, db, projekt := testServer(t) - h := testHandler(t, srv) - - key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin)) - wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) - plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) - - for _, f := range []struct { - name string - hole func(string) int - workspace, plattSei int - }{ - {"Projektzugang", func(p string) int { return seiteHolen(h, key, p).Code }, http.StatusNotFound, http.StatusNotFound}, - {"Workspace-Admin", func(p string) int { return wsAdmin.get(h, p).Code }, http.StatusOK, http.StatusNotFound}, - {"Plattform-Admin", func(p string) int { return seiteHolen(h, plattform, p).Code }, http.StatusOK, http.StatusOK}, - } { - if code := f.hole("/hilfe/workspace-admin"); code != f.workspace { - t.Errorf("%s: /hilfe/workspace-admin = %d, erwartet %d", f.name, code, f.workspace) - } - if code := f.hole("/hilfe/plattform-admin"); code != f.plattSei { - t.Errorf("%s: /hilfe/plattform-admin = %d, erwartet %d", f.name, code, f.plattSei) - } - } -} diff --git a/internal/web/server.go b/internal/web/server.go index 85bca2e..e7d932a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -10,7 +10,7 @@ import ( "munin/internal/auth" "munin/internal/export" - "munin/internal/statistik" + "munin/internal/stats" "munin/internal/store" "munin/internal/workspace" ) @@ -42,13 +42,13 @@ type Server struct { // herum und der Vergleich läuft über dieselbe Funktion wie bei den API-Keys. adminTokenHash string - // gestartet ist der Zeitpunkt, zu dem der Server gebaut wurde — für die + // startedAt ist der Zeitpunkt, zu dem der Server gebaut wurde — für die // Laufzeit auf der Systemseite. - gestartet time.Time + startedAt time.Time - // statistik zählt die Kennzahlen aller Workspaces für die Systemseite - // und merkt sie sich ein paar Minuten (siehe internal/statistik). - statistik *statistik.Sammler + // stats zählt die Kennzahlen aller Workspaces für die Systemseite + // und merkt sie sich ein paar Minuten (siehe internal/stats). + stats *stats.Collector // adminFingerprint steckt in jeder Sitzung des Plattform-Admins und wird // bei jedem Request verglichen. Ändert sich das ADMIN_TOKEN, passt er @@ -90,8 +90,8 @@ func NewServer(cfg Config) (*Server, error) { exporter: cfg.Exporter, writeBudget: cfg.WriteBudget, adminTokenHash: adminTokenHash, - gestartet: time.Now(), - statistik: statistik.NewSammler(cfg.Control, cfg.Workspaces), + startedAt: time.Now(), + stats: stats.NewCollector(cfg.Control, cfg.Workspaces), adminFingerprint: cfg.Signer.Fingerprint(adminTokenHash), }, nil @@ -127,43 +127,43 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { mux.HandleFunc("POST /login", s.handleLoginSubmit) mux.HandleFunc("POST /logout", s.handleLogout) - // geschuetzt verpackt einen Handler mit der Sitzungsprüfung. - geschuetzt := func(h http.HandlerFunc) http.Handler { + // protected verpackt einen Handler mit der Sitzungsprüfung. + protected := func(h http.HandlerFunc) http.Handler { return s.requireSession(h) } - // imProjekt ergänzt die Auflösung des Projekts aus der URL. - imProjekt := func(h http.HandlerFunc) http.Handler { + // inProject ergänzt die Auflösung des Projekts aus der URL. + inProject := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireProject(h)) } - // mitSchreibrecht ergänzt die Rollenprüfung fürs Schreiben. - mitSchreibrecht := func(h http.HandlerFunc) http.Handler { + // withWriteAccess ergänzt die Rollenprüfung fürs Schreiben. + withWriteAccess := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireProject(s.requireWrite(h))) } - mux.Handle("GET /{$}", geschuetzt(s.handleHome)) + mux.Handle("GET /{$}", protected(s.handleHome)) // Eingebaute Hilfe — für jede Sitzung, auch ohne gewählten Workspace. // "/hilfe" zeigt die Startseite, "/hilfe/{seite}" eine bestimmte Seite. - mux.Handle("GET /hilfe", geschuetzt(s.handleHilfe)) - mux.Handle("GET /hilfe/{seite}", geschuetzt(s.handleHilfe)) + mux.Handle("GET /hilfe", protected(s.handleHelp)) + mux.Handle("GET /hilfe/{seite}", protected(s.handleHelp)) // Workspace-Auswahl des Plattform-Admins — erreichbar auch ohne bereits // gewählten Workspace, siehe handleWorkspaceList. - mux.Handle("GET /admin/workspaces", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceList))) - mux.Handle("POST /admin/workspaces/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceCreate))) - mux.Handle("POST /admin/workspaces/select", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceSelect))) - mux.Handle("POST /admin/workspaces/delete", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceDelete))) + mux.Handle("GET /admin/workspaces", protected(s.requirePlatformAdminFunc(s.handleWorkspaceList))) + mux.Handle("POST /admin/workspaces/create", protected(s.requirePlatformAdminFunc(s.handleWorkspaceCreate))) + mux.Handle("POST /admin/workspaces/select", protected(s.requirePlatformAdminFunc(s.handleWorkspaceSelect))) + mux.Handle("POST /admin/workspaces/delete", protected(s.requirePlatformAdminFunc(s.handleWorkspaceDelete))) // Wie bei /admin/projects/{slug}/edit: eigener Pfad statt {id} direkt unter // /admin/workspaces, sonst wäre er mit "create"/"select"/"delete" mehrdeutig. - mux.Handle("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm))) - mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave))) + mux.Handle("GET /admin/workspaces/{id}/edit", protected(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm))) + mux.Handle("POST /admin/workspaces/{id}/edit", protected(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave))) // Systemseite: Speicher, Verbindungen, Fassung — nur der Plattform-Admin. - mux.Handle("GET /admin/system", geschuetzt(s.requirePlatformAdminFunc(s.handleSystem))) + mux.Handle("GET /admin/system", protected(s.requirePlatformAdminFunc(s.handleSystem))) // Workspace-Admins eines Workspace anlegen und zurückziehen — nur der // Plattform-Admin, siehe handlers_workspace_admins.go. - mux.Handle("POST /admin/workspaces/{id}/admins/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminCreate))) - mux.Handle("POST /admin/workspaces/{id}/admins/revoke", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminRevoke))) + mux.Handle("POST /admin/workspaces/{id}/admins/create", protected(s.requirePlatformAdminFunc(s.handleWorkspaceAdminCreate))) + mux.Handle("POST /admin/workspaces/{id}/admins/revoke", protected(s.requirePlatformAdminFunc(s.handleWorkspaceAdminRevoke))) // Verwaltung eines Workspace. requireWorkspace stellt sicher, dass der // Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte @@ -171,59 +171,59 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { // // Zwei Stufen: Projekte und das Protokoll aller Projekte darf auch der // Workspace-Admin (AUSBAUSTUFEN.md 4.8), alles Übrige bleibt beim - // Plattform-Admin. Neue Routen gehören im Zweifel nach imWorkspace. - imWorkspace := func(h http.HandlerFunc) http.Handler { + // Plattform-Admin. Neue Routen gehören im Zweifel nach inWorkspace. + inWorkspace := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h))) } - projektVerwaltung := func(h http.HandlerFunc) http.Handler { + projectManagement := func(h http.HandlerFunc) http.Handler { return s.requireSession(s.requireWorkspace(s.requireProjectManagement(h))) } - mux.Handle("GET /admin/projects", projektVerwaltung(s.handleProjectList)) - mux.Handle("POST /admin/projects/create", projektVerwaltung(s.handleProjectCreate)) - mux.Handle("POST /admin/projects/delete", projektVerwaltung(s.handleProjectDelete)) + mux.Handle("GET /admin/projects", projectManagement(s.handleProjectList)) + mux.Handle("POST /admin/projects/create", projectManagement(s.handleProjectCreate)) + mux.Handle("POST /admin/projects/delete", projectManagement(s.handleProjectDelete)) // Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst // wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen // Projekt-Bezeichner. - mux.Handle("GET /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditForm)) - mux.Handle("POST /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditSave)) - mux.Handle("GET /admin/audit", projektVerwaltung(s.handleGlobalAudit)) + mux.Handle("GET /admin/projects/{slug}/edit", projectManagement(s.handleProjectEditForm)) + mux.Handle("POST /admin/projects/{slug}/edit", projectManagement(s.handleProjectEditSave)) + mux.Handle("GET /admin/audit", projectManagement(s.handleGlobalAudit)) // Suche über alle Projekte des Workspace — wer alle Projekte verwalten // darf, darf auch in allen suchen (AUSBAUSTUFEN.md 5.13). - mux.Handle("GET /admin/search", projektVerwaltung(s.handleWorkspaceSearch)) + mux.Handle("GET /admin/search", projectManagement(s.handleWorkspaceSearch)) // Überblick des Workspace: was zu tun ist, Aktivität, Projekte // (AUSBAUSTUFEN.md 5.17). Das Gegenstück zur Systemseite für den // Workspace-Admin. - mux.Handle("GET /admin/overview", projektVerwaltung(s.handleUeberblick)) - mux.Handle("POST /admin/export", imWorkspace(s.handleExport)) + mux.Handle("GET /admin/overview", projectManagement(s.handleOverview)) + mux.Handle("POST /admin/export", inWorkspace(s.handleExport)) // Download als ZIP: unabhängig von EXPORT_DIR, für alle, die Projekte // verwalten — also auch den Workspace-Admin (siehe handlers_download.go). - mux.Handle("GET /admin/export.zip", projektVerwaltung(s.handleWorkspaceDownload)) + mux.Handle("GET /admin/export.zip", projectManagement(s.handleWorkspaceDownload)) // Projektseiten. - mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList)) - mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew)) - mux.Handle("POST /p/{project}/preview", mitSchreibrecht(s.handleEntryPreview)) - mux.Handle("GET /p/{project}/entries/suggest", mitSchreibrecht(s.handleEntrySuggest)) - mux.Handle("GET /p/{project}/entries/similar", mitSchreibrecht(s.handleEntrySimilar)) - mux.Handle("GET /p/{project}/e/{slug}", imProjekt(s.handleEntryView)) - mux.Handle("GET /p/{project}/e/{slug}/versions", imProjekt(s.handleEntryVersions)) - mux.Handle("GET /p/{project}/e/{slug}/versions/{version}", imProjekt(s.handleEntryDiff)) - mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", mitSchreibrecht(s.handleEntryRestore)) - mux.Handle("GET /p/{project}/graph", imProjekt(s.handleEntryGraph)) - mux.Handle("GET /p/{project}/graph/data", imProjekt(s.handleEntryGraphData)) - mux.Handle("GET /p/{project}/tags", imProjekt(s.handleTagList)) - mux.Handle("GET /p/{project}/e/{slug}/edit", mitSchreibrecht(s.handleEntryEdit)) - mux.Handle("POST /p/{project}/e/{slug}/save", mitSchreibrecht(s.handleEntrySave)) - mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete)) - mux.Handle("POST /p/{project}/save", mitSchreibrecht(s.handleEntrySave)) - mux.Handle("POST /p/{project}/entries/bulk-tags", mitSchreibrecht(s.handleEntryBulkTags)) - mux.Handle("POST /p/{project}/entries/bulk-delete", mitSchreibrecht(s.handleEntryBulkDelete)) - // Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_papierkorb.go. - mux.Handle("GET /p/{project}/trash", mitSchreibrecht(s.handleTrashList)) - mux.Handle("POST /p/{project}/trash/restore", mitSchreibrecht(s.handleTrashRestore)) - mux.Handle("POST /p/{project}/trash/purge", mitSchreibrecht(s.handleTrashPurge)) - mux.Handle("GET /p/{project}/audit", imProjekt(s.handleProjectAudit)) - mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage)) + mux.Handle("GET /p/{project}/{$}", inProject(s.handleEntryList)) + mux.Handle("GET /p/{project}/new", withWriteAccess(s.handleEntryNew)) + mux.Handle("POST /p/{project}/preview", withWriteAccess(s.handleEntryPreview)) + mux.Handle("GET /p/{project}/entries/suggest", withWriteAccess(s.handleEntrySuggest)) + mux.Handle("GET /p/{project}/entries/similar", withWriteAccess(s.handleEntrySimilar)) + mux.Handle("GET /p/{project}/e/{slug}", inProject(s.handleEntryView)) + mux.Handle("GET /p/{project}/e/{slug}/versions", inProject(s.handleEntryVersions)) + mux.Handle("GET /p/{project}/e/{slug}/versions/{version}", inProject(s.handleEntryDiff)) + mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", withWriteAccess(s.handleEntryRestore)) + mux.Handle("GET /p/{project}/graph", inProject(s.handleEntryGraph)) + mux.Handle("GET /p/{project}/graph/data", inProject(s.handleEntryGraphData)) + mux.Handle("GET /p/{project}/tags", inProject(s.handleTagList)) + mux.Handle("GET /p/{project}/e/{slug}/edit", withWriteAccess(s.handleEntryEdit)) + mux.Handle("POST /p/{project}/e/{slug}/save", withWriteAccess(s.handleEntrySave)) + mux.Handle("POST /p/{project}/e/{slug}/delete", withWriteAccess(s.handleEntryDelete)) + mux.Handle("POST /p/{project}/save", withWriteAccess(s.handleEntrySave)) + mux.Handle("POST /p/{project}/entries/bulk-tags", withWriteAccess(s.handleEntryBulkTags)) + mux.Handle("POST /p/{project}/entries/bulk-delete", withWriteAccess(s.handleEntryBulkDelete)) + // Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_trash.go. + mux.Handle("GET /p/{project}/trash", withWriteAccess(s.handleTrashList)) + mux.Handle("POST /p/{project}/trash/restore", withWriteAccess(s.handleTrashRestore)) + mux.Handle("POST /p/{project}/trash/purge", withWriteAccess(s.handleTrashPurge)) + mux.Handle("GET /p/{project}/audit", inProject(s.handleProjectAudit)) + mux.Handle("GET /p/{project}/usage", inProject(s.handleProjectUsage)) // Key-Verwaltung. mux.Handle("GET /p/{project}/export.zip", s.requireSession(s.requireProject(s.requireProjectDownload(http.HandlerFunc(s.handleProjectDownload))))) @@ -261,17 +261,17 @@ type pageData struct { // ExportEnabled steuert, ob die Oberfläche den Export-Knopf zeigt. ExportEnabled bool - // Bereich markiert den aktiven Punkt in der Seitenleiste: + // Section markiert den aktiven Punkt in der Seitenleiste: // etwa "eintraege", "graph", "schlagworte", "zugaenge", "protokoll", // "projekte" oder "hilfe". - Bereich string + Section string // Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit // nicht jeder Handler daran denken muss. Nav navData - // AktivesTag markiert das gewählte Schlagwort in der Seitenleiste. - AktivesTag string + // ActiveTag markiert das gewählte Schlagwort in der Seitenleiste. + ActiveTag string Data any } @@ -288,10 +288,10 @@ type navData struct { // aktive Filter in der Seitenleiste unsichtbar. Tags []store.TagCount - // TagsGesamt ist die Zahl aller Schlagworte des Projekts. Ist sie größer + // TagsTotal ist die Zahl aller Schlagworte des Projekts. Ist sie größer // als len(Tags), zeigt die Seitenleiste einen Link auf die vollständige // Liste (/p/{project}/tags). - TagsGesamt int + TagsTotal int } // navTagLimit begrenzt die Schlagworte in der Seitenleiste. Ohne Grenze wird @@ -314,10 +314,10 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta data.Project = project } data.ExportEnabled = s.exporter != nil - data.Nav = s.buildNav(r, data.Session, data.Project, data.AktivesTag) + data.Nav = s.buildNav(r, data.Session, data.Project, data.ActiveTag) - var puffer bytes.Buffer - if err := s.templates.ExecuteTemplate(&puffer, name, data); err != nil { + var buf bytes.Buffer + if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil { log.Printf("Template %q konnte nicht gerendert werden: %v", name, err) http.Error(w, "Seite konnte nicht dargestellt werden", http.StatusInternalServerError) return @@ -325,7 +325,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) - if _, err := w.Write(puffer.Bytes()); err != nil { + if _, err := w.Write(buf.Bytes()); err != nil { // Der Browser hat die Verbindung getrennt — nichts, was wir noch retten könnten. log.Printf("Antwort konnte nicht gesendet werden: %v", err) } @@ -339,7 +339,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta // // Fehler brechen die Seite nicht ab: Eine unvollständige Navigation ist // ärgerlich, aber kein Grund, den eigentlichen Inhalt nicht anzuzeigen. -func (s *Server) buildNav(r *http.Request, sess Session, project store.Project, aktivesTag string) navData { +func (s *Server) buildNav(r *http.Request, sess Session, project store.Project, activeTag string) navData { var nav navData // Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) gibt @@ -350,11 +350,11 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project, } if sess.CanManageProjects() { - projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) + projects, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID) if err != nil { log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err) } else { - nav.Projects = projekte + nav.Projects = projects } } @@ -363,39 +363,39 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project, if err != nil { log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err) } else { - nav.Tags = kuerzeTags(tags, navTagLimit, aktivesTag) - nav.TagsGesamt = len(tags) + nav.Tags = truncateTags(tags, navTagLimit, activeTag) + nav.TagsTotal = len(tags) } } return nav } -// kuerzeTags nimmt die ersten limit Schlagworte aus tags (die schon nach -// Häufigkeit sortiert sind). Gehört aktiv nicht dazu, wird es hinten +// truncateTags nimmt die ersten limit Schlagworte aus tags (die schon nach +// Häufigkeit sortiert sind). Gehört active nicht dazu, wird es hinten // angehängt, damit der gewählte Filter sichtbar bleibt. -func kuerzeTags(tags []store.TagCount, limit int, aktiv string) []store.TagCount { +func truncateTags(tags []store.TagCount, limit int, active string) []store.TagCount { if len(tags) <= limit { return tags } // Eine Kopie statt tags[:limit]: append auf ein Teilstück würde sonst // das darunterliegende Array von tags überschreiben. - kurz := make([]store.TagCount, limit, limit+1) - copy(kurz, tags[:limit]) - if aktiv == "" { - return kurz + short := make([]store.TagCount, limit, limit+1) + copy(short, tags[:limit]) + if active == "" { + return short } - for _, t := range kurz { - if t.Tag == aktiv { - return kurz + for _, t := range short { + if t.Tag == active { + return short } } for _, t := range tags[limit:] { - if t.Tag == aktiv { - return append(kurz, t) + if t.Tag == active { + return append(short, t) } } - return kurz + return short } // workspaceAndSession liest Workspace und Sitzung der aktuellen Anfrage. @@ -415,32 +415,32 @@ func (s *Server) workspaceAndSession(r *http.Request) (*workspace.Workspace, Ses // wer ohnehin Zugriff auf den Server hat. Nach außen geht nur, dass etwas // schiefging — Fehlermeldungen aus der Tiefe verraten oft Pfade, Tabellennamen // oder Versionsstände. -func (s *Server) serverError(w http.ResponseWriter, kontext string, err error) { - log.Printf("%s: %v", kontext, err) +func (s *Server) serverError(w http.ResponseWriter, context string, err error) { + log.Printf("%s: %v", context, err) http.Error(w, "Es ist ein Fehler aufgetreten. Details stehen im Server-Log.", http.StatusInternalServerError) } // templateFuncs stellt den Templates ein paar Hilfsfunktionen bereit. func templateFuncs() template.FuncMap { return template.FuncMap{ - // datum formatiert einen Zeitpunkt für die Anzeige. - "datum": func(t interface{ Format(string) string }) string { + // datetime formatiert einen Zeitpunkt für die Anzeige. + "datetime": func(t interface{ Format(string) string }) string { return t.Format("02.01.2006 15:04") }, - // bisTag zeigt ein Ablaufdatum als letzten gültigen Tag: Ein Key, der + // lastDay zeigt ein Ablaufdatum als letzten gültigen Tag: Ein Key, der // "bis einschließlich 31.12." gilt, läuft am 1.1. um 0 Uhr ab — // angezeigt wird der 31.12. - "bisTag": func(ablauf time.Time) string { - return ablauf.Add(-time.Second).Local().Format("02.01.2006") + "lastDay": func(expiry time.Time) string { + return expiry.Add(-time.Second).Local().Format("02.01.2006") }, - // groesse zeigt eine Zahl von Bytes lesbar an: "812 KB", "1,4 GB". - "groesse": groesse, + // byteSize zeigt eine Zahl von Bytes lesbar an: "812 KB", "1,4 GB". + "byteSize": byteSize, // join fügt Schlagworte zu einer Zeile zusammen. - "join": func(teile []string, trenner string) string { + "join": func(parts []string, sep string) string { out := "" - for i, t := range teile { + for i, t := range parts { if i > 0 { - out += trenner + out += sep } out += t } diff --git a/internal/web/tags_test.go b/internal/web/tags_test.go index f236911..180e9b5 100644 --- a/internal/web/tags_test.go +++ b/internal/web/tags_test.go @@ -12,26 +12,26 @@ import ( "munin/internal/store" ) -// holeSeite ruft eine Seite mit Lesezugang ab und gibt den HTML-Text zurück. -func holeSeite(t *testing.T, h http.Handler, cookie *http.Cookie, pfad string) string { +// getPage ruft eine Seite mit Lesezugang ab und gibt den HTML-Text zurück. +func getPage(t *testing.T, h http.Handler, cookie *http.Cookie, filePath string) string { t.Helper() - r := httptest.NewRequest(http.MethodGet, pfad, nil) + r := httptest.NewRequest(http.MethodGet, filePath, nil) r.AddCookie(cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusOK { - t.Fatalf("%s: Status = %d, erwartet 200", pfad, w.Code) + t.Fatalf("%s: Status = %d, erwartet 200", filePath, w.Code) } return w.Body.String() } -// legeSchlagworteAn erzeugt anzahl Einträge mit je einem eigenen Schlagwort +// createTags erzeugt count Einträge mit je einem eigenen Schlagwort // "tag-00", "tag-01", … — tag-00 kommt dabei am häufigsten vor, damit die // Rangfolge nach Häufigkeit eindeutig ist. -func legeSchlagworteAn(t *testing.T, db *store.Store, projectID string, anzahl int) { +func createTags(t *testing.T, db *store.Store, projectID string, count int) { t.Helper() ctx := context.Background() - for i := 0; i < anzahl; i++ { + for i := 0; i < count; i++ { tags := []string{fmt.Sprintf("tag-%02d", i)} if i > 0 { tags = append(tags, "tag-00") @@ -44,27 +44,27 @@ func legeSchlagworteAn(t *testing.T, db *store.Store, projectID string, anzahl i } } -// TestNavZeigtNurHaeufigsteSchlagworte prüft die Kürzung der Seitenleiste: +// TestNavShowsOnlyMostFrequentTags prüft die Kürzung der Seitenleiste: // höchstens navTagLimit Schlagworte plus Link auf die vollständige Liste. -func TestNavZeigtNurHaeufigsteSchlagworte(t *testing.T) { - srv, db, projekt := testServer(t) +func TestNavShowsOnlyMostFrequentTags(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - legeSchlagworteAn(t, db, projekt.ID, navTagLimit+5) + createTags(t, db, project.ID, navTagLimit+5) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) // Geprüft wird auf den Text der Seitenleiste (nav-text) — die // Eintragsliste selbst verlinkt die Schlagworte ihrer Einträge auch. - inLeiste := func(body string, i int) bool { + inSidebar := func(body string, i int) bool { return strings.Contains(body, fmt.Sprintf(`<span class="nav-text">tag-%02d</span>`, i)) } - body := holeSeite(t, h, cookie, "/p/testprojekt/") - if !inLeiste(body, 0) { + body := getPage(t, h, cookie, "/p/testprojekt/") + if !inSidebar(body, 0) { t.Error("das häufigste Schlagwort fehlt in der Seitenleiste") } - if inLeiste(body, navTagLimit+4) { + if inSidebar(body, navTagLimit+4) { t.Error("die Seitenleiste zeigt mehr als navTagLimit Schlagworte") } if !strings.Contains(body, `href="/p/testprojekt/tags"`) { @@ -72,39 +72,39 @@ func TestNavZeigtNurHaeufigsteSchlagworte(t *testing.T) { } // Ist ein seltenes Schlagwort gewählt, muss es trotzdem sichtbar sein. - body = holeSeite(t, h, cookie, fmt.Sprintf("/p/testprojekt/?tag=tag-%02d", navTagLimit+4)) - if !inLeiste(body, navTagLimit+4) { + body = getPage(t, h, cookie, fmt.Sprintf("/p/testprojekt/?tag=tag-%02d", navTagLimit+4)) + if !inSidebar(body, navTagLimit+4) { t.Error("das gewählte, seltene Schlagwort fehlt in der Seitenleiste") } } -// TestNavOhneLinkBeiWenigenSchlagworten: Passen alle Schlagworte in die +// TestNavWithoutLinkForFewTags: Passen alle Schlagworte in die // Seitenleiste, braucht es keinen Link auf die vollständige Liste. -func TestNavOhneLinkBeiWenigenSchlagworten(t *testing.T) { - srv, db, projekt := testServer(t) +func TestNavWithoutLinkForFewTags(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - legeSchlagworteAn(t, db, projekt.ID, 3) + createTags(t, db, project.ID, 3) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) - body := holeSeite(t, h, cookie, "/p/testprojekt/") + body := getPage(t, h, cookie, "/p/testprojekt/") if strings.Contains(body, `href="/p/testprojekt/tags"`) { t.Error("Link auf alle Schlagworte, obwohl schon alle in der Seitenleiste stehen") } } -// TestTagListSeite prüft die Seite mit allen Schlagworten in beiden +// TestTagListPage prüft die Seite mit allen Schlagworten in beiden // Sortierungen. -func TestTagListSeite(t *testing.T) { - srv, db, projekt := testServer(t) +func TestTagListPage(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - legeSchlagworteAn(t, db, projekt.ID, navTagLimit+5) + createTags(t, db, project.ID, navTagLimit+5) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) - body := holeSeite(t, h, cookie, "/p/testprojekt/tags") + body := getPage(t, h, cookie, "/p/testprojekt/tags") for i := 0; i < navTagLimit+5; i++ { if !strings.Contains(body, fmt.Sprintf(`data-tag="tag-%02d"`, i)) { t.Errorf("tag-%02d fehlt auf der Übersichtsseite", i) @@ -114,25 +114,25 @@ func TestTagListSeite(t *testing.T) { t.Error("das Filterfeld fehlt") } - body = holeSeite(t, h, cookie, "/p/testprojekt/tags?sort=name") + body = getPage(t, h, cookie, "/p/testprojekt/tags?sort=name") if !strings.Contains(body, "<h2>T</h2>") { t.Error("die alphabetische Ansicht zeigt keine Buchstabengruppe") } } -func TestKuerzeTags(t *testing.T) { +func TestTruncateTags(t *testing.T) { tags := []store.TagCount{{Tag: "a", Count: 5}, {Tag: "b", Count: 4}, {Tag: "c", Count: 3}, {Tag: "d", Count: 1}} - if got := kuerzeTags(tags, 10, ""); len(got) != 4 { + if got := truncateTags(tags, 10, ""); len(got) != 4 { t.Errorf("unter dem Limit: %d Schlagworte, erwartet alle 4", len(got)) } - if got := kuerzeTags(tags, 2, ""); len(got) != 2 || got[1].Tag != "b" { + if got := truncateTags(tags, 2, ""); len(got) != 2 || got[1].Tag != "b" { t.Errorf("gekürzt: %v, erwartet a, b", got) } - if got := kuerzeTags(tags, 2, "d"); len(got) != 3 || got[2].Tag != "d" { + if got := truncateTags(tags, 2, "d"); len(got) != 3 || got[2].Tag != "d" { t.Errorf("mit seltenem aktivem Schlagwort: %v, erwartet a, b, d", got) } - if got := kuerzeTags(tags, 2, "a"); len(got) != 2 { + if got := truncateTags(tags, 2, "a"); len(got) != 2 { t.Errorf("aktives Schlagwort schon enthalten: %v, erwartet a, b", got) } // Die Eingabe darf nicht verändert werden. @@ -141,19 +141,19 @@ func TestKuerzeTags(t *testing.T) { } } -func TestGruppiereTags(t *testing.T) { +func TestGroupTags(t *testing.T) { tags := []store.TagCount{{Tag: "zebra"}, {Tag: "übersicht"}, {Tag: "api"}, {Tag: "2fa"}, {Tag: "umzug"}} - gruppen := gruppiereTags(tags) + groups := groupTags(tags) - var reihenfolge []string - for _, g := range gruppen { - var namen []string + var order []string + for _, g := range groups { + var names []string for _, tc := range g.Tags { - namen = append(namen, tc.Tag) + names = append(names, tc.Tag) } - reihenfolge = append(reihenfolge, g.Buchstabe+":"+strings.Join(namen, ",")) + order = append(order, g.Letter+":"+strings.Join(names, ",")) } - got := strings.Join(reihenfolge, " ") + got := strings.Join(order, " ") want := "#:2fa A:api U:übersicht,umzug Z:zebra" if got != want { t.Errorf("Gruppen = %q, erwartet %q", got, want) diff --git a/internal/web/ui_test.go b/internal/web/ui_test.go new file mode 100644 index 0000000..fb51001 --- /dev/null +++ b/internal/web/ui_test.go @@ -0,0 +1,388 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "munin/internal/auth" + "munin/internal/store" +) + +// Tests zu den Ergänzungen der Weboberfläche vom 2026-10-16 (AUSBAUSTUFEN.md +// 4.7 Stufe 2 und 5.7–5.14): veraltete Einträge, „Auch verwandt", „Gibt es +// schon?", ungespeicherte Änderungen, Tastenkürzel, Protokollfilter, +// Systemseite, Ablaufdatum für Keys und Suche über alle Projekte. + +// create schreibt einen Eintrag direkt in den Store. +func create(t *testing.T, db *store.Store, project store.Project, w store.EntryWrite) { + t.Helper() + w.ProjectID = project.ID + if w.Source == "" { + w.Source = store.SourceHuman + } + if _, err := db.PutEntry(context.Background(), w); err != nil { + t.Fatal(err) + } +} + +// TestStaleEntries: Die Liste markiert Einträge, die seit über einem +// Jahr unverändert sind, nennt ihre Zahl als Filterlink und filtert auf +// Wunsch. Die Nutzungsseite nennt die Zahl ebenfalls. +func TestStaleEntries(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + cookie := login(t, h, createKey(t, srv, db, project.ID, "pflege", auth.RoleWrite)) + + twoYearsAgo := time.Now().Add(-2 * 365 * 24 * time.Hour) + create(t, db, project, store.EntryWrite{Slug: "alt", Title: "Uralter Eintrag", Content: "x", + CreatedAt: twoYearsAgo, UpdatedAt: twoYearsAgo}) + create(t, db, project, store.EntryWrite{Slug: "neu", Title: "Frischer Eintrag", Content: "y"}) + + list := fetchPage(h, cookie, "/p/"+project.Slug+"/").Body.String() + if !strings.Contains(list, ">1 veraltet</a>") { + t.Error("Filterlink „1 veraltet“ fehlt in der Liste") + } + if strings.Count(list, `class="badge warning"`) != 1 { + t.Error("genau ein Eintrag sollte als veraltet markiert sein") + } + + filtered := fetchPage(h, cookie, "/p/"+project.Slug+"/?veraltet=1").Body.String() + if !strings.Contains(filtered, "Uralter Eintrag") || strings.Contains(filtered, "Frischer Eintrag") { + t.Error("Filter veraltet zeigt nicht genau den alten Eintrag") + } + + usage := fetchPage(h, cookie, "/p/"+project.Slug+"/usage").Body.String() + if !strings.Contains(usage, `href="/p/`+project.Slug+`/?veraltet=1">1</a>`) { + t.Error("Nutzungsseite nennt die Zahl der veralteten Einträge nicht") + } +} + +// TestAlsoRelated: Die Eintragsansicht zeigt die zweite Reihe im +// Verweisgraphen samt Zwischeneintrag — a verweist auf b, b auf c, also ist +// c über b mit a verwandt. +func TestAlsoRelated(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + cookie := login(t, h, createKey(t, srv, db, project.ID, "lesen", auth.RoleRead)) + + create(t, db, project, store.EntryWrite{Slug: "a", Title: "Eintrag A", Content: "siehe [[b]]"}) + create(t, db, project, store.EntryWrite{Slug: "b", Title: "Eintrag B", Content: "siehe [[c]]"}) + create(t, db, project, store.EntryWrite{Slug: "c", Title: "Eintrag C", Content: "Ende"}) + + page := fetchPage(h, cookie, "/p/"+project.Slug+"/e/a").Body.String() + _, box, found := strings.Cut(page, "Auch verwandt") + if !found { + t.Fatal("Kasten „Auch verwandt“ fehlt") + } + if !strings.Contains(box, "Eintrag C") || !strings.Contains(box, `über <a href="/p/`+project.Slug+`/e/b">b</a>`) { + t.Errorf("C über B fehlt im Kasten") + } + + // Ohne zweite Reihe kein Kasten: c verweist auf nichts, b auf c, a auf b + // — von b aus sind a und c direkte Nachbarn. + if strings.Contains(fetchPage(h, cookie, "/p/"+project.Slug+"/e/b").Body.String(), "Auch verwandt") { + t.Error("Kasten erscheint, obwohl es keine zweite Reihe gibt") + } +} + +// TestSimilarEntries: Das Formular fragt beim Tippen nach ähnlichen +// Einträgen — dieselbe Prüfung wie "remember" über MCP. +func TestSimilarEntries(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + write := login(t, h, createKey(t, srv, db, project.ID, "pflege", auth.RoleWrite)) + read := login(t, h, createKey(t, srv, db, project.ID, "lesen", auth.RoleRead)) + + create(t, db, project, store.EntryWrite{Slug: "deployment-prozess", Title: "Deployment-Prozess", Content: "Pipeline"}) + + question := func(cookie *http.Cookie, title, slug string) (int, []similarItem) { + w := fetchPage(h, cookie, "/p/"+project.Slug+"/entries/similar?title="+title+"&slug="+slug) + var answer []similarItem + if w.Code == http.StatusOK { + if err := json.Unmarshal(w.Body.Bytes(), &answer); err != nil { + t.Fatalf("keine JSON-Liste: %v (%s)", err, w.Body.String()) + } + } + return w.Code, answer + } + + code, answer := question(write, "Deployment-Ablauf", "") + if code != http.StatusOK || len(answer) != 1 || answer[0].Slug != "deployment-prozess" { + t.Fatalf("Antwort = %d %+v, erwartet deployment-prozess", code, answer) + } + if strings.Join(answer[0].Shared, ",") != "deployment" { + t.Errorf("gemeinsame Wörter = %v, erwartet deployment", answer[0].Shared) + } + + if _, answer := question(write, "VPN-Zugang", ""); len(answer) != 0 { + t.Errorf("unähnlicher Titel liefert Kandidaten: %+v", answer) + } + // Der Bezeichner gibt es schon: Das wäre eine Aktualisierung, kein Hinweis. + if _, answer := question(write, "Deployment-Ablauf", "deployment-prozess"); len(answer) != 0 { + t.Errorf("bei vorhandenem Bezeichner kommen Kandidaten: %+v", answer) + } + // Leserechte reichen nicht — das Formular gibt es für sie auch nicht. + if code, _ := question(read, "Deployment-Ablauf", ""); code == http.StatusOK { + t.Error("Lesezugang bekommt ähnliche Einträge") + } +} + +// TestFormAndShortcuts: Das Bearbeitungsformular trägt die Attribute für +// Rückfrage, Speichern per Tastatur und „Gibt es schon?"; die Kürzel n und e +// erscheinen nur, wo geschrieben werden darf. +func TestFormAndShortcuts(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + write := login(t, h, createKey(t, srv, db, project.ID, "pflege", auth.RoleWrite)) + read := login(t, h, createKey(t, srv, db, project.ID, "lesen", auth.RoleRead)) + create(t, db, project, store.EntryWrite{Slug: "x", Title: "X", Content: "x"}) + + newer := fetchPage(h, write, "/p/"+project.Slug+"/new").Body.String() + for _, attr := range []string{"data-unsaved", "data-shortcut-save", "data-similar-url"} { + if !strings.Contains(newer, attr) { + t.Errorf("%s fehlt im Formular für neue Einträge", attr) + } + } + // Beim Bearbeiten gibt es nichts „schon": kein Hinweis auf ähnliche. + if strings.Contains(fetchPage(h, write, "/p/"+project.Slug+"/e/x/edit").Body.String(), "data-similar-url") { + t.Error("„Gibt es schon?“ erscheint beim Bearbeiten") + } + + if !strings.Contains(fetchPage(h, write, "/p/"+project.Slug+"/").Body.String(), `data-shortcut="n"`) { + t.Error("Kürzel n fehlt mit Schreibrecht") + } + if strings.Contains(fetchPage(h, read, "/p/"+project.Slug+"/").Body.String(), `data-shortcut="n"`) { + t.Error("Kürzel n erscheint ohne Schreibrecht") + } + if strings.Contains(fetchPage(h, read, "/p/"+project.Slug+"/e/x").Body.String(), `data-shortcut="e"`) { + t.Error("Kürzel e erscheint ohne Schreibrecht") + } + + for _, file := range []string{"unsaved.js", "shortcuts.js", "similar.js"} { + if w := fetchPage(h, read, "/static/"+file); w.Code != http.StatusOK { + t.Errorf("/static/%s = %d", file, w.Code) + } + } +} + +// TestAuditFilterForm: Die Protokollseite filtert nach Vorgang und Zugang +// und behält die Auswahl im Formular. +func TestAuditFilterForm(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + cookie := login(t, h, createKey(t, srv, db, project.ID, "pflege", auth.RoleWrite)) + ctx := context.Background() + + db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent-a", Action: store.ActionEntryWrite, + EntrySlug: "geschrieben-von-a", Source: store.SourceMCP}) + db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent-b", Action: store.ActionEntryDelete, + EntrySlug: "geloescht-von-b", Source: store.SourceMCP}) + + page := fetchPage(h, cookie, "/p/"+project.Slug+"/audit?vorgang=entry_delete&wer=agent-b").Body.String() + if !strings.Contains(page, "geloescht-von-b") || strings.Contains(page, "geschrieben-von-a") { + t.Error("Filter zeigt nicht genau den Löschvorgang von agent-b") + } + if !strings.Contains(page, `<option value="agent-b" selected>`) || + !strings.Contains(page, `<option value="entry_delete" selected>`) { + t.Error("Auswahl bleibt im Formular nicht erhalten") + } + + // Unsinn in der Adresse: ungefiltert statt Fehlerseite. + w := fetchPage(h, cookie, "/p/"+project.Slug+"/audit?vorgang=gibts-nicht&von=gestern") + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "geschrieben-von-a") { + t.Errorf("kaputter Filter: %d, erwartet das ungefilterte Protokoll", w.Code) + } +} + +// TestSystemPage: nur für den Plattform-Admin; zeigt Speicher, Bereitschaft +// und die geöffneten Workspaces mit ihrem Speicherort. +func TestSystemPage(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + + admin := loginAsPlatformAdmin(t, h, project.WorkspaceID) + w := fetchPage(h, admin, "/admin/system") + if w.Code != http.StatusOK { + t.Fatalf("/admin/system = %d", w.Code) + } + page := w.Body.String() + for _, want := range []string{"SQLite", "bereit", "keins — jede SQLite-Datei", project.WorkspaceID + ".db", "Kontroll-Datenbank"} { + if !strings.Contains(page, want) { + t.Errorf("%q fehlt auf der Systemseite", want) + } + } + + // Ein Projektzugang kommt nicht dran. + key := login(t, h, createKey(t, srv, db, project.ID, "admin-key", auth.RoleAdmin)) + if code := fetchPage(h, key, "/admin/system").Code; code == http.StatusOK { + t.Error("Projekt-Admin sieht die Systemseite") + } +} + +// TestSystemPageState: Zustand, Aktivität und Bestand (AUSBAUSTUFEN.md +// 5.16). Ein bald ablaufender Key erscheint als Hinweis, ein geschriebener +// Eintrag im Diagramm und in der Bestandstabelle. +func TestSystemPageState(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + if _, err := db.CreateAPIKeyWithExpiry(ctx, project.ID, "praktikum", "hash-praktikum", string(auth.RoleRead), + time.Now().Add(5*24*time.Hour)); err != nil { + t.Fatal(err) + } + if _, err := db.PutEntry(ctx, store.EntryWrite{ + ProjectID: project.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceHuman, + }); err != nil { + t.Fatal(err) + } + db.LogAudit(ctx, store.AuditEvent{ + ProjectID: project.ID, Actor: "test", Action: store.ActionEntryWrite, Source: store.SourceWeb, + }) + + admin := loginAsPlatformAdmin(t, h, project.WorkspaceID) + page := fetchPage(h, admin, "/admin/system").Body.String() + + for _, want := range []string{ + "Zustand", + "1 Key läuft in den nächsten 14 Tagen ab", + "Aktivität der letzten 30 Tage", + `<svg class="chart"`, + "1 Schreibvorgang (Agenten 0, Menschen 1)", // Tooltip des heutigen Tages + "Bestand je Workspace", + "Schema", + } { + if !strings.Contains(page, want) { + t.Errorf("%q fehlt auf der Systemseite", want) + } + } + // Ohne Suchen bleibt das zweite Diagramm leer und sagt das. + if !strings.Contains(page, "Keine im ganzen Zeitraum") { + t.Error("leeres Suchdiagramm ohne Hinweis") + } +} + +// TestKeyWithExpiryDate: Ein Key lässt sich mit "gültig bis" anlegen, die +// Liste zeigt das Datum und markiert ihn als bald ablaufend. Läuft er ab, +// endet auch eine bestehende Sitzung. +func TestKeyWithExpiryDate(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + admin := login(t, h, createKey(t, srv, db, project.ID, "verwalter", auth.RoleAdmin)) + + // Ein Datum in der Vergangenheit wird abgelehnt. (Zuerst, weil + // submitForm das CSRF-Token mit dem jüngsten Key des Projekts bildet + // — das muss noch der angemeldete sein.) + yesterday := time.Now().AddDate(0, 0, -2).Format("2006-01-02") + w := submitForm(t, h, db, project, admin, "/p/"+project.Slug+"/keys/create", url.Values{ + "name": {"zu-spaet"}, "role": {"read"}, "expires": {yesterday}, + }) + if w.Code != http.StatusBadRequest { + t.Errorf("Ablauf in der Vergangenheit: %d, erwartet 400", w.Code) + } + + tomorrow := time.Now().AddDate(0, 0, 1) + w = submitForm(t, h, db, project, admin, "/p/"+project.Slug+"/keys/create", url.Values{ + "name": {"praktikum"}, "role": {"read"}, "expires": {tomorrow.Format("2006-01-02")}, + }) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Key angelegt") { + t.Fatalf("Anlegen: %d", w.Code) + } + list := fetchPage(h, admin, "/p/"+project.Slug+"/keys").Body.String() + if !strings.Contains(list, tomorrow.Format("02.01.2006")) || !strings.Contains(list, "läuft bald ab") { + t.Error("Liste zeigt das Ablaufdatum oder „läuft bald ab“ nicht") + } + + // Sitzung eines Keys, der gleich abläuft: danach kein Zugriff mehr. + plaintext, _ := auth.GenerateKey() + short, err := db.CreateAPIKeyWithExpiry(context.Background(), project.ID, "kurz", auth.HashKey(plaintext), "read", + time.Now().Add(1500*time.Millisecond)) + if err != nil { + t.Fatal(err) + } + if err := srv.control.IndexKey(context.Background(), short.Hash, project.WorkspaceID); err != nil { + t.Fatal(err) + } + session := login(t, h, plaintext) + if code := fetchPage(h, session, "/p/"+project.Slug+"/").Code; code != http.StatusOK { + t.Fatalf("vor dem Ablauf: %d", code) + } + time.Sleep(2 * time.Second) + if code := fetchPage(h, session, "/p/"+project.Slug+"/").Code; code == http.StatusOK { + t.Error("Sitzung gilt nach dem Ablauf des Keys weiter") + } +} + +// TestSearchAllProjects: Workspace- und Plattform-Admin finden Einträge +// aus allen Projekten des Workspace, nach Projekt gruppiert. Ein +// Projektzugang kommt nicht dran — er sieht weiter nur sein Projekt. +func TestSearchAllProjects(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + second, err := db.CreateProject(ctx, project.WorkspaceID, "vertrieb", "Vertrieb", "") + if err != nil { + t.Fatal(err) + } + create(t, db, project, store.EntryWrite{Slug: "vpn", Title: "VPN-Zugang Technik", Content: "Zugang über das Firmen-VPN."}) + create(t, db, second, store.EntryWrite{Slug: "vpn-vertrieb", Title: "VPN unterwegs", Content: "VPN für Außendienst."}) + create(t, db, second, store.EntryWrite{Slug: "preise", Title: "Preisliste", Content: "Rabatte."}) + + check := func(name string, page string) { + t.Helper() + for _, want := range []string{"VPN-Zugang Technik", "VPN unterwegs", ">Testprojekt</a>", ">Vertrieb</a>"} { + if !strings.Contains(page, want) { + t.Errorf("%s: %q fehlt", name, want) + } + } + if strings.Contains(page, "Preisliste") { + t.Errorf("%s: unpassender Eintrag „Preisliste“ in den Treffern", name) + } + } + + platform := loginAsPlatformAdmin(t, h, project.WorkspaceID) + check("Plattform-Admin", fetchPage(h, platform, "/admin/search?q=vpn").Body.String()) + + wsAdmin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) + check("Workspace-Admin", wsAdmin.get(h, "/admin/search?q=vpn").Body.String()) + + key := login(t, h, createKey(t, srv, db, project.ID, "admin-key", auth.RoleAdmin)) + if w := fetchPage(h, key, "/admin/search?q=vpn"); w.Code == http.StatusOK && strings.Contains(w.Body.String(), "VPN unterwegs") { + t.Error("Projektzugang findet Einträge aus einem anderen Projekt") + } +} + +// TestHelpPerRole: Die Seite für Workspace-Admins sehen Workspace- und +// Plattform-Admin, die für Plattform-Admins nur dieser. Ein Projektzugang +// sieht keine von beiden — auch nicht über die Adresse. +func TestHelpPerRole(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + + key := login(t, h, createKey(t, srv, db, project.ID, "admin-key", auth.RoleAdmin)) + wsAdmin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) + platform := loginAsPlatformAdmin(t, h, project.WorkspaceID) + + for _, f := range []struct { + name string + fetch func(string) int + workspace, platformPage int + }{ + {"Projektzugang", func(p string) int { return fetchPage(h, key, p).Code }, http.StatusNotFound, http.StatusNotFound}, + {"Workspace-Admin", func(p string) int { return wsAdmin.get(h, p).Code }, http.StatusOK, http.StatusNotFound}, + {"Plattform-Admin", func(p string) int { return fetchPage(h, platform, p).Code }, http.StatusOK, http.StatusOK}, + } { + if code := f.fetch("/hilfe/workspace-admin"); code != f.workspace { + t.Errorf("%s: /hilfe/workspace-admin = %d, erwartet %d", f.name, code, f.workspace) + } + if code := f.fetch("/hilfe/plattform-admin"); code != f.platformPage { + t.Errorf("%s: /hilfe/plattform-admin = %d, erwartet %d", f.name, code, f.platformPage) + } + } +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 447d6ac..afddb4d 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -58,7 +58,7 @@ func testServer(t *testing.T) (*Server, *store.Store, store.Project) { } db := w.Store - projekt, err := db.CreateProject(ctx, ws.ID, "testprojekt", "Testprojekt", "") + project, err := db.CreateProject(ctx, ws.ID, "testprojekt", "Testprojekt", "") if err != nil { t.Fatal(err) } @@ -79,7 +79,7 @@ func testServer(t *testing.T) (*Server, *store.Store, store.Project) { t.Fatalf("Server: %v", err) } - return srv, db, projekt + return srv, db, project } // templatesForTest liefert das Template-Verzeichnis der Projektwurzel. @@ -97,50 +97,50 @@ func testHandler(t *testing.T, srv *Server) http.Handler { return mux } -// erzeugeKey legt einen API-Key an und gibt den Klartext zurück. +// createKey legt einen API-Key an und gibt den Klartext zurück. // // Trägt den Key auch im Routing-Index der Kontroll-Datenbank ein — ohne den // würden Login und APIAuth ihn nicht mehr finden (siehe ControlStore.IndexKey // und die entsprechenden Aufrufe in handlers_admin.go). -func erzeugeKey(t *testing.T, srv *Server, db *store.Store, projectID, name string, rolle auth.Role) string { +func createKey(t *testing.T, srv *Server, db *store.Store, projectID, name string, role auth.Role) string { t.Helper() - klartext, err := auth.GenerateKey() + plaintext, err := auth.GenerateKey() if err != nil { t.Fatal(err) } - hash := auth.HashKey(klartext) - if _, err := db.CreateAPIKey(context.Background(), projectID, name, hash, string(rolle)); err != nil { + hash := auth.HashKey(plaintext) + if _, err := db.CreateAPIKey(context.Background(), projectID, name, hash, string(role)); err != nil { t.Fatal(err) } - projekt, err := db.ProjectByID(context.Background(), projectID) + project, err := db.ProjectByID(context.Background(), projectID) if err != nil { t.Fatal(err) } - if err := srv.control.IndexKey(context.Background(), hash, projekt.WorkspaceID); err != nil { + if err := srv.control.IndexKey(context.Background(), hash, project.WorkspaceID); err != nil { t.Fatal(err) } - return klartext + return plaintext } -// plattformSubjekt gibt das Session-Subject des Plattform-Admins zurück, so wie +// platformSubject gibt das Session-Subject des Plattform-Admins zurück, so wie // der Testserver es nach der Anmeldung mit testAdminToken vergibt — gebraucht, // um passende CSRF-Tokens zu berechnen. -func plattformSubjekt(workspaceID string) string { +func platformSubject(workspaceID string) string { signer := auth.NewSigner([]byte(testSecret)) return auth.PlatformSubject(signer.Fingerprint(auth.HashKey(testAdminToken)), workspaceID) } -// waehleWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und +// selectWorkspace wählt für eine Plattform-Admin-Sitzung einen Workspace und // gibt den neuen Session-Cookie zurück — der Schritt, den handleWorkspaceList // in der Oberfläche anbietet (siehe handlers_workspaces.go). -func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie { +func selectWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspaceID string) *http.Cookie { t.Helper() - token := auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt("")) - formular := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(formular.Encode())) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(platformSubject("")) + form := url.Values{"id": {workspaceID}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/select", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -156,21 +156,21 @@ func waehleWorkspace(t *testing.T, h http.Handler, cookie *http.Cookie, workspac return nil } -// anmeldenAlsPlattformAdmin meldet den Plattform-Admin an UND wählt gleich den +// loginAsPlatformAdmin meldet den Plattform-Admin an UND wählt gleich den // gegebenen Workspace — die meisten Tests brauchen genau das, nicht den // Zwischenschritt über die Auswahlseite. -func anmeldenAlsPlattformAdmin(t *testing.T, h http.Handler, workspaceID string) *http.Cookie { +func loginAsPlatformAdmin(t *testing.T, h http.Handler, workspaceID string) *http.Cookie { t.Helper() - cookie := anmelden(t, h, testAdminToken) - return waehleWorkspace(t, h, cookie, workspaceID) + cookie := login(t, h, testAdminToken) + return selectWorkspace(t, h, cookie, workspaceID) } -// anmelden führt eine Anmeldung durch und gibt den Session-Cookie zurück. -func anmelden(t *testing.T, h http.Handler, key string) *http.Cookie { +// login führt eine Anmeldung durch und gibt den Session-Cookie zurück. +func login(t *testing.T, h http.Handler, key string) *http.Cookie { t.Helper() - formular := url.Values{"key": {key}} - r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode())) + form := url.Values{"key": {key}} + r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() @@ -191,12 +191,12 @@ func TestLoginRequiredForEverything(t *testing.T) { srv, _, _ := testServer(t) h := testHandler(t, srv) - geschuetzt := []string{"/", "/admin/projects", "/p/testprojekt/", "/p/testprojekt/keys"} + protected := []string{"/", "/admin/projects", "/p/testprojekt/", "/p/testprojekt/keys"} - for _, pfad := range geschuetzt { - t.Run(pfad, func(t *testing.T) { + for _, filePath := range protected { + t.Run(filePath, func(t *testing.T) { w := httptest.NewRecorder() - h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, pfad, nil)) + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, filePath, nil)) if w.Code != http.StatusSeeOther { t.Errorf("Status = %d, erwartet %d (Weiterleitung zur Anmeldung)", w.Code, http.StatusSeeOther) @@ -206,11 +206,11 @@ func TestLoginRequiredForEverything(t *testing.T) { } func TestLoginWithProjectKey(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "test", auth.RoleWrite) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil) r.AddCookie(cookie) @@ -227,8 +227,8 @@ func TestLoginRejectsBadKey(t *testing.T) { srv, _, _ := testServer(t) h := testHandler(t, srv) - formular := url.Values{"key": {"wk_" + strings.Repeat("0", 64)}} - r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode())) + form := url.Values{"key": {"wk_" + strings.Repeat("0", 64)}} + r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() @@ -244,17 +244,95 @@ func TestLoginRejectsBadKey(t *testing.T) { } } +// postLogin schickt das Anmeldeformular ab und gibt die Antwort zurück — ohne +// wie login auf Erfolg zu bestehen. +func postLogin(h http.Handler, key string) *httptest.ResponseRecorder { + form := url.Values{"key": {key}} + r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// TestLoginLockoutAppliesToCorrectKey prüft, dass eine gesperrte IP auch mit +// dem richtigen Token nicht hereinkommt. Sonst änderte die Sperre nur den +// Statuscode, und Raten ginge in voller Geschwindigkeit weiter. +func TestLoginLockoutAppliesToCorrectKey(t *testing.T) { + srv, _, _ := testServer(t) + h := testHandler(t, srv) + + for i := 0; i < 5; i++ { + postLogin(h, "falsch-geraten") + } + + w := postLogin(h, testAdminToken) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("Status = %d, erwartet 429 trotz richtigem Token", w.Code) + } + for _, c := range w.Result().Cookies() { + if c.Name == auth.SessionCookieName && c.Value != "" { + t.Error("gesperrte IP hat eine Sitzung bekommen") + } + } +} + +// TestValidLoginDoesNotResetLockout: Eine erfolgreiche Anmeldung (etwa mit dem +// eigenen Key) darf die Fehlversuche nicht löschen — sonst ließe sich die +// Sperre damit nach jedem vierten Versuch aushebeln. +func TestValidLoginDoesNotResetLockout(t *testing.T) { + srv, _, _ := testServer(t) + h := testHandler(t, srv) + + for i := 0; i < 4; i++ { + postLogin(h, "falsch-geraten") + } + if w := postLogin(h, testAdminToken); w.Code != http.StatusSeeOther { + t.Fatalf("Anmeldung nach 4 Fehlversuchen: Status %d, erwartet 303", w.Code) + } + postLogin(h, "falsch-geraten") // der fünfte zählt weiter, statt von vorn + + if w := postLogin(h, testAdminToken); w.Code != http.StatusTooManyRequests { + t.Errorf("Status = %d, erwartet 429 — die erfolgreiche Anmeldung hat den Zähler zurückgesetzt", w.Code) + } +} + +// TestHintRequiresSignature prüft, dass nur vom Server signierte Meldungen +// erscheinen. Ein präparierter Link mit eigenem Text zeigt nichts an. +func TestHintRequiresSignature(t *testing.T) { + srv, db, project := testServer(t) + h := testHandler(t, srv) + cookie := login(t, h, createKey(t, srv, db, project.ID, "test", auth.RoleWrite)) + list := "/p/" + project.Slug + "/" + + forged := fetchPage(h, cookie, list+"?hinweis="+url.QueryEscape("Bitte Key an boese@example.org senden")).Body.String() + if strings.Contains(forged, "boese@example.org") { + t.Error("unsignierte Meldung wird angezeigt") + } + + // Signatur zu einem anderen Text: ebenso nichts. + other := srv.withHint(list, "Echte Meldung") + tampered := strings.Replace(other, url.QueryEscape("Echte Meldung"), url.QueryEscape("Andere Meldung"), 1) + if strings.Contains(fetchPage(h, cookie, tampered).Body.String(), "Andere Meldung") { + t.Error("Meldung mit fremder Signatur wird angezeigt") + } + + if !strings.Contains(fetchPage(h, cookie, other).Body.String(), "Echte Meldung") { + t.Error("signierte Meldung fehlt") + } +} + // TestRevokedKeyEndsSession prüft, dass ein zurückgezogener Key sofort wirkt — // die Sitzung darf ihn nicht überleben. func TestRevokedKeyEndsSession(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "test", auth.RoleWrite) + cookie := login(t, h, key) - keys, _ := db.ListAPIKeys(ctx, projekt.ID) + keys, _ := db.ListAPIKeys(ctx, project.ID) if err := db.RevokeAPIKey(ctx, keys[0].ID); err != nil { t.Fatal(err) } @@ -276,14 +354,14 @@ func TestRevokedKeyEndsSession(t *testing.T) { // POST, der Browser hängt den Session-Cookie automatisch an — nur das Token kann // sie nicht liefern. func TestCSRFBlocksForgedPost(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin) + cookie := login(t, h, key) - formular := url.Values{"name": {"geklaut"}, "role": {"admin"}} - r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(formular.Encode())) + form := url.Values{"name": {"geklaut"}, "role": {"admin"}} + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -294,26 +372,26 @@ func TestCSRFBlocksForgedPost(t *testing.T) { t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden) } - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) + keys, _ := db.ListAPIKeys(context.Background(), project.ID) if len(keys) != 1 { t.Errorf("%d Keys — es wurde trotz fehlendem Token einer angelegt", len(keys)) } } func TestCSRFAllowsRealForm(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin) + cookie := login(t, h, key) // Das Token so berechnen, wie es das Template tut. - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - subject := auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID + keys, _ := db.ListAPIKeys(context.Background(), project.ID) + subject := auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID token := auth.NewSigner([]byte(testSecret)).CSRFToken(subject) - formular := url.Values{"name": {"zweiter"}, "role": {"read"}, auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(formular.Encode())) + form := url.Values{"name": {"zweiter"}, "role": {"read"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/keys/create", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -324,9 +402,9 @@ func TestCSRFAllowsRealForm(t *testing.T) { t.Fatalf("Status = %d, erwartet 200", w.Code) } - neue, _ := db.ListAPIKeys(context.Background(), projekt.ID) - if len(neue) != 2 { - t.Errorf("%d Keys, erwartet 2", len(neue)) + fresh, _ := db.ListAPIKeys(context.Background(), project.ID) + if len(fresh) != 2 { + t.Errorf("%d Keys, erwartet 2", len(fresh)) } // Der Klartext des neuen Keys muss genau einmal in der Antwort stehen. if !strings.Contains(w.Body.String(), auth.KeyPrefix) { @@ -348,39 +426,39 @@ func TestCSRFAllowsRealForm(t *testing.T) { // TestProjectIsolation ist der wichtigste Test des Web-Teils: Ein Key kommt // ausschließlich an sein eigenes Projekt. func TestProjectIsolation(t *testing.T) { - srv, db, projektA := testServer(t) + srv, db, projectA := testServer(t) h := testHandler(t, srv) ctx := context.Background() - projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "fremd", "Fremdes Projekt", "") + projectB, err := db.CreateProject(ctx, projectA.WorkspaceID, "fremd", "Fremdes Projekt", "") if err != nil { t.Fatal(err) } // Ein Eintrag, den Projekt A auf keinen Fall sehen darf. if _, err := db.PutEntry(ctx, store.EntryWrite{ - ProjectID: projektB.ID, Slug: "geheim", Title: "Geheim", + ProjectID: projectB.ID, Slug: "geheim", Title: "Geheim", Content: "Vertrauliches aus Projekt B", Source: store.SourceHuman, }); err != nil { t.Fatal(err) } - keyA := erzeugeKey(t, srv, db, projektA.ID, "a", auth.RoleAdmin) - cookie := anmelden(t, h, keyA) + keyA := createKey(t, srv, db, projectA.ID, "a", auth.RoleAdmin) + cookie := login(t, h, keyA) // Jede Route des fremden Projekts muss als "nicht vorhanden" antworten — // nicht als "verboten". Sonst ließe sich über die Statuscodes herausfinden, // welche Projekte es sonst noch gibt. - fremdeRouten := []string{ + foreignRoutes := []string{ "/p/fremd/", "/p/fremd/e/geheim", "/p/fremd/keys", "/p/fremd/audit", } - for _, pfad := range fremdeRouten { - t.Run(pfad, func(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, pfad, nil) + for _, filePath := range foreignRoutes { + t.Run(filePath, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, filePath, nil) r.AddCookie(cookie) w := httptest.NewRecorder() @@ -400,7 +478,7 @@ func TestProjectIsolation(t *testing.T) { // gleichzeitig offene Workspace-Dateien im selben Prozess dürfen sich // strukturell nicht sehen — auch nicht über einen fremden, aber gültigen Key. func TestWorkspaceIsolation(t *testing.T) { - srv, _, projektA := testServer(t) + srv, _, projectA := testServer(t) h := testHandler(t, srv) ctx := context.Background() @@ -414,24 +492,24 @@ func TestWorkspaceIsolation(t *testing.T) { if err != nil { t.Fatal(err) } - projektB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "") + projectB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "") if err != nil { t.Fatal(err) } if _, err := workspaceB.Store.PutEntry(ctx, store.EntryWrite{ - ProjectID: projektB.ID, Slug: "geheim", Title: "Geheim", + ProjectID: projectB.ID, Slug: "geheim", Title: "Geheim", Content: "Vertrauliches aus Workspace B", Source: store.SourceHuman, }); err != nil { t.Fatal(err) } - keyB := erzeugeKey(t, srv, workspaceB.Store, projektB.ID, "b", auth.RoleAdmin) + keyB := createKey(t, srv, workspaceB.Store, projectB.ID, "b", auth.RoleAdmin) // Mit dem Key aus Workspace B angemeldet: Projekt A (Workspace A) darf // unter keinen Umständen erreichbar sein, obwohl der Prozess beide // Dateien gleichzeitig offen hält. - cookie := anmelden(t, h, keyB) + cookie := login(t, h, keyB) - r := httptest.NewRequest(http.MethodGet, "/p/"+projektA.Slug+"/", nil) + r := httptest.NewRequest(http.MethodGet, "/p/"+projectA.Slug+"/", nil) r.AddCookie(cookie) w := httptest.NewRecorder() @@ -447,15 +525,15 @@ func TestWorkspaceIsolation(t *testing.T) { // und der Admin landet direkt bei der Projektverwaltung — ohne den Umweg über // die Auswahlseite. func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) token := auth.NewSigner([]byte(testSecret)). - CSRFToken(plattformSubjekt(projekt.WorkspaceID)) + CSRFToken(platformSubject(project.WorkspaceID)) - formular := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(formular.Encode())) + form := url.Values{"name": {"Marketing"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/create", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -471,31 +549,31 @@ func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) { if err != nil { t.Fatal(err) } - var gefunden bool + var found bool for _, ws := range workspaces { if ws.Name == "Marketing" { - gefunden = true + found = true } } - if !gefunden { + if !found { t.Error("neuer Workspace \"Marketing\" steht nicht in der Kontroll-Datenbank") } // Die neue Sitzung muss den neuen Workspace tragen — ein Aufruf von // /admin/projects mit dem zurückgegebenen Cookie darf nicht mehr zur // Auswahlseite zurückschicken. - var neuerCookie *http.Cookie + var newCookie *http.Cookie for _, c := range w.Result().Cookies() { if c.Name == auth.SessionCookieName { - neuerCookie = c + newCookie = c } } - if neuerCookie == nil { + if newCookie == nil { t.Fatal("keine neue Sitzung nach dem Anlegen ausgestellt") } r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) - r2.AddCookie(neuerCookie) + r2.AddCookie(newCookie) w2 := httptest.NewRecorder() h.ServeHTTP(w2, r2) @@ -508,14 +586,14 @@ func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) { // tatsächliche Projektzahl je Workspace zeigt, nicht nur Name und Datum — // und unter dem Namen den Speicherort (Datei bzw. Schema). func TestWorkspaceListShowsProjectCount(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - if _, err := db.CreateProject(context.Background(), projekt.WorkspaceID, "zweites", "Zweites", ""); err != nil { + if _, err := db.CreateProject(context.Background(), project.WorkspaceID, "zweites", "Zweites", ""); err != nil { t.Fatal(err) } - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) r := httptest.NewRequest(http.MethodGet, "/admin/workspaces", nil) r.AddCookie(cookie) @@ -525,29 +603,29 @@ func TestWorkspaceListShowsProjectCount(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("Status = %d, erwartet 200", w.Code) } - if !strings.Contains(w.Body.String(), "<td class=\"zahl\">2</td>") { + if !strings.Contains(w.Body.String(), "<td class=\"num\">2</td>") { t.Error("Projektzahl \"2\" steht nicht in der Übersicht") } // Unter dem Namen steht, wo die Daten liegen — hier die SQLite-Datei. - ort := "workspaces/" + projekt.WorkspaceID + ".db" - if !strings.Contains(w.Body.String(), ort) { - t.Errorf("Speicherort %q steht nicht in der Übersicht", ort) + location := "workspaces/" + project.WorkspaceID + ".db" + if !strings.Contains(w.Body.String(), location) { + t.Errorf("Speicherort %q steht nicht in der Übersicht", location) } } // TestWorkspaceRename prüft das Umbenennen über /admin/workspaces/{id}/edit. func TestWorkspaceRename(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) token := auth.NewSigner([]byte(testSecret)). - CSRFToken(plattformSubjekt(projekt.WorkspaceID)) + CSRFToken(platformSubject(project.WorkspaceID)) - formular := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+projekt.WorkspaceID+"/edit", - strings.NewReader(formular.Encode())) + form := url.Values{"name": {"Umbenannt"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/"+project.WorkspaceID+"/edit", + strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -558,7 +636,7 @@ func TestWorkspaceRename(t *testing.T) { t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther) } - ws, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID) + ws, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID) if err != nil { t.Fatal(err) } @@ -570,15 +648,15 @@ func TestWorkspaceRename(t *testing.T) { // TestWorkspaceDeleteRequiresConfirmation prüft die Bestätigungsabfrage — ein // Klick allein darf nicht reichen, dahinter verschwindet alles im Workspace. func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) token := auth.NewSigner([]byte(testSecret)). - CSRFToken(plattformSubjekt(projekt.WorkspaceID)) + CSRFToken(platformSubject(project.WorkspaceID)) - formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode())) + form := url.Values{"id": {project.WorkspaceID}, "confirm": {"falsch"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -588,7 +666,7 @@ func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) { if w.Code != http.StatusBadRequest { t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest) } - if _, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID); err != nil { + if _, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID); err != nil { t.Error("Workspace wurde trotz falscher Bestätigung gelöscht") } } @@ -598,19 +676,19 @@ func TestWorkspaceDeleteRequiresConfirmation(t *testing.T) { // gelöschten Workspace wird auf "kein Workspace gewählt" zurückgesetzt statt // den Admin einfach auszusperren. func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - ws, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID) + ws, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID) if err != nil { t.Fatal(err) } - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) token := auth.NewSigner([]byte(testSecret)). - CSRFToken(plattformSubjekt(projekt.WorkspaceID)) + CSRFToken(platformSubject(project.WorkspaceID)) - formular := url.Values{"id": {projekt.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(formular.Encode())) + form := url.Values{"id": {project.WorkspaceID}, "confirm": {ws.Name}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/workspaces/delete", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -621,24 +699,24 @@ func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) { t.Fatalf("Status = %d, erwartet %d", w.Code, http.StatusSeeOther) } - if _, err := srv.control.WorkspaceByID(context.Background(), projekt.WorkspaceID); !errors.Is(err, store.ErrNotFound) { + if _, err := srv.control.WorkspaceByID(context.Background(), project.WorkspaceID); !errors.Is(err, store.ErrNotFound) { t.Errorf("Workspace steht nach dem Löschen noch in der Kontroll-Datenbank (err=%v)", err) } // Die neue Sitzung ist die des Admins ohne Workspace — /admin/projects // muss jetzt zur Auswahl zurückschicken statt eine tote Sitzung zu zeigen. - var neuerCookie *http.Cookie + var newCookie *http.Cookie for _, c := range w.Result().Cookies() { if c.Name == auth.SessionCookieName { - neuerCookie = c + newCookie = c } } - if neuerCookie == nil { + if newCookie == nil { t.Fatal("keine neue Sitzung nach dem Löschen ausgestellt") } r2 := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) - r2.AddCookie(neuerCookie) + r2.AddCookie(newCookie) w2 := httptest.NewRecorder() h.ServeHTTP(w2, r2) @@ -651,11 +729,11 @@ func TestWorkspaceDeleteRemovesEverythingAndResetsSession(t *testing.T) { // TestPlatformAdminOnly prüft, dass die Projektverwaltung dem Plattform-Admin // vorbehalten bleibt. func TestPlatformAdminOnly(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) r.AddCookie(cookie) @@ -669,10 +747,10 @@ func TestPlatformAdminOnly(t *testing.T) { } func TestPlatformAdminCanAccessAnyProject(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/", nil) r.AddCookie(cookie) @@ -685,105 +763,123 @@ func TestPlatformAdminCanAccessAnyProject(t *testing.T) { } } -// statusMitCookie ruft eine Seite mit dem gegebenen Cookie ab und gibt den +// statusWithCookie ruft eine Seite mit dem gegebenen Cookie ab und gibt den // Status zurück. -func statusMitCookie(h http.Handler, pfad string, cookie *http.Cookie) int { - r := httptest.NewRequest(http.MethodGet, pfad, nil) +func statusWithCookie(h http.Handler, filePath string, cookie *http.Cookie) int { + r := httptest.NewRequest(http.MethodGet, filePath, nil) r.AddCookie(cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w.Code } -// TestNeuesAdminTokenBeendetAdminSitzungen stellt den Ernstfall nach: Das +// TestNewAdminTokenEndsAdminSessions stellt den Ernstfall nach: Das // ADMIN_TOKEN ist geleakt und wird ersetzt, der Server neu gestartet. Wer sich // mit dem alten Token angemeldet hat, muss sofort draußen sein — Teams mit // ihren Keys dürfen davon nichts merken. -func TestNeuesAdminTokenBeendetAdminSitzungen(t *testing.T) { - srv, db, projekt := testServer(t) +func TestNewAdminTokenEndsAdminSessions(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - ohneWorkspace := anmelden(t, h, testAdminToken) - mitWorkspace := waehleWorkspace(t, h, ohneWorkspace, projekt.WorkspaceID) - team := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "team", auth.RoleAdmin)) + withoutWorkspace := login(t, h, testAdminToken) + withWs := selectWorkspace(t, h, withoutWorkspace, project.WorkspaceID) + team := login(t, h, createKey(t, srv, db, project.ID, "team", auth.RoleAdmin)) - if code := statusMitCookie(h, "/p/testprojekt/", mitWorkspace); code != http.StatusOK { + if code := statusWithCookie(h, "/p/testprojekt/", withWs); code != http.StatusOK { t.Fatalf("vor dem Wechsel: Status %d, erwartet 200", code) } // Neustart mit neuem ADMIN_TOKEN, aber demselben SESSION_SECRET und // denselben Daten. - const neuesToken = "ein-ganz-neues-admin-token" - neu, err := NewServer(Config{ + const newToken = "ein-ganz-neues-admin-token" + newer, err := NewServer(Config{ Workspaces: srv.workspaces, Control: srv.control, Signer: srv.signer, Cookies: srv.cookies, Limiter: auth.NewRateLimiter(), Templates: templatesForTest(t), - AdminToken: neuesToken, + AdminToken: newToken, WriteBudget: srv.writeBudget, }) if err != nil { t.Fatal(err) } - hNeu := testHandler(t, neu) + hNew := testHandler(t, newer) for name, cookie := range map[string]*http.Cookie{ - "ohne Workspace": ohneWorkspace, - "mit Workspace": mitWorkspace, + "ohne Workspace": withoutWorkspace, + "mit Workspace": withWs, } { - code := statusMitCookie(hNeu, "/admin/workspaces", cookie) + code := statusWithCookie(hNew, "/admin/workspaces", cookie) if code != http.StatusSeeOther { t.Errorf("alte Admin-Sitzung %s: Status %d, erwartet Weiterleitung zur Anmeldung", name, code) } } - if code := statusMitCookie(hNeu, "/p/testprojekt/", team); code != http.StatusOK { + if code := statusWithCookie(hNew, "/p/testprojekt/", team); code != http.StatusOK { t.Errorf("Sitzung eines Teams: Status %d, erwartet 200 — ein neues ADMIN_TOKEN darf Teams nicht abmelden", code) } - frisch := anmelden(t, hNeu, neuesToken) - if code := statusMitCookie(hNeu, "/admin/workspaces", frisch); code != http.StatusOK { + fresh := login(t, hNew, newToken) + if code := statusWithCookie(hNew, "/admin/workspaces", fresh); code != http.StatusOK { t.Errorf("Anmeldung mit dem neuen Token: Status %d, erwartet 200", code) } } -// TestAlteAdminSitzungOhneFingerabdruckGiltNicht: Cookies aus der Zeit vor dem +// TestOldAdminSessionWithoutFingerprintIsInvalid: Cookies aus der Zeit vor dem // Fingerabdruck ("platform", "platform:<id>") sind korrekt signiert, dürfen // aber nicht mehr gelten — sonst bliebe genau die Lücke offen, die der // Fingerabdruck schließt. -func TestAlteAdminSitzungOhneFingerabdruckGiltNicht(t *testing.T) { - srv, _, projekt := testServer(t) +func TestOldAdminSessionWithoutFingerprintIsInvalid(t *testing.T) { + srv, _, project := testServer(t) h := testHandler(t, srv) signer := auth.NewSigner([]byte(testSecret)) - for _, subject := range []string{"platform", "platform:" + projekt.WorkspaceID} { - cookie := &http.Cookie{Name: auth.SessionCookieName, Value: signer.SignSession(subject)} - if code := statusMitCookie(h, "/admin/workspaces", cookie); code != http.StatusSeeOther { + for _, subject := range []string{"platform", "platform:" + project.WorkspaceID} { + cookie := &http.Cookie{Name: auth.SessionCookieName, Value: signer.SignSession(subject, time.Now().Add(time.Hour))} + if code := statusWithCookie(h, "/admin/workspaces", cookie); code != http.StatusSeeOther { t.Errorf("%q: Status %d, erwartet Weiterleitung zur Anmeldung", subject, code) } } } +// TestPlatformAdminSessionIsShort: Die Sitzung des Plattform-Admins gilt 12 +// Stunden, und ein Workspace-Wechsel verlängert sie nicht — sonst ließe sich +// ein gestohlener Cookie durch Umschalten ewig am Leben halten. +func TestPlatformAdminSessionIsShort(t *testing.T) { + srv, _, project := testServer(t) + h := testHandler(t, srv) + + cookie := login(t, h, testAdminToken) + if left := time.Until(cookie.Expires); left < auth.PlatformAdminSessionTTL-time.Minute || left > auth.PlatformAdminSessionTTL { + t.Fatalf("Admin-Sitzung gilt noch %v, erwartet %v", left.Round(time.Minute), auth.PlatformAdminSessionTTL) + } + + switched := selectWorkspace(t, h, cookie, project.WorkspaceID) + if !switched.Expires.Equal(cookie.Expires) { + t.Errorf("Workspace-Wechsel hat den Ablauf verschoben: %v statt %v", switched.Expires, cookie.Expires) + } +} + // ── Rollen ─────────────────────────────────────────────────────────────────── // TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang keine Einträge ändern kann. func TestReadRoleCannotWrite(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "nur-lesen", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "nur-lesen", auth.RoleRead) + cookie := login(t, h, key) - schreibrouten := []struct{ methode, pfad string }{ + writeRoutes := []struct{ method, filePath string }{ {http.MethodGet, "/p/testprojekt/new"}, {http.MethodPost, "/p/testprojekt/save"}, } - for _, tc := range schreibrouten { - t.Run(tc.pfad, func(t *testing.T) { - r := httptest.NewRequest(tc.methode, tc.pfad, strings.NewReader("")) + for _, tc := range writeRoutes { + t.Run(tc.filePath, func(t *testing.T) { + r := httptest.NewRequest(tc.method, tc.filePath, strings.NewReader("")) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -799,11 +895,11 @@ func TestReadRoleCannotWrite(t *testing.T) { // TestWriteRoleCannotManageKeys prüft die Abgrenzung zwischen write und admin. func TestWriteRoleCannotManageKeys(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "schreiber", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "schreiber", auth.RoleWrite) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil) r.AddCookie(cookie) @@ -820,12 +916,12 @@ func TestWriteRoleCannotManageKeys(t *testing.T) { // frisch angelegter, noch nie benutzter Key nicht als „ungenutzt“ erscheint. // Die Grenzfälle der Regel selbst prüft TestAPIKeyUnused im Store. func TestKeyListFreshKeyNotUnused(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - admin := erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin) - erzeugeKey(t, srv, db, projekt.ID, "neue-pipeline", auth.RoleRead) - cookie := anmelden(t, h, admin) + admin := createKey(t, srv, db, project.ID, "verwalterin", auth.RoleAdmin) + createKey(t, srv, db, project.ID, "neue-pipeline", auth.RoleRead) + cookie := login(t, h, admin) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/keys", nil) r.AddCookie(cookie) @@ -852,23 +948,27 @@ func TestSecurityHeaders(t *testing.T) { w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) - erwartet := map[string]string{ + want := map[string]string{ "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "same-origin", } - for name, wert := range erwartet { - if got := w.Header().Get(name); got != wert { - t.Errorf("%s = %q, erwartet %q", name, got, wert) + for name, value := range want { + if got := w.Header().Get(name); got != value { + t.Errorf("%s = %q, erwartet %q", name, got, value) } } csp := w.Header().Get("Content-Security-Policy") - for _, teil := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'"} { - if !strings.Contains(csp, teil) { - t.Errorf("CSP enthält %q nicht", teil) + for _, part := range []string{"default-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "img-src 'self' data:;"} { + if !strings.Contains(csp, part) { + t.Errorf("CSP enthält %q nicht", part) } } + // Externe Bilder wären ein Zählpixel für jeden Leser. + if strings.Contains(csp, "https:") { + t.Errorf("CSP erlaubt externe Quellen: %q", csp) + } // Ohne den Schalter darf HSTS nicht gesetzt sein — sonst sperrt man sich auf // einer Adresse ohne Zertifikat selbst aus. @@ -878,13 +978,13 @@ func TestSecurityHeaders(t *testing.T) { } func TestLimitBody(t *testing.T) { - var leseFehler error + var readErr error inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { buf := make([]byte, 512) for { if _, err := r.Body.Read(buf); err != nil { if err.Error() != "EOF" { - leseFehler = err + readErr = err } return } @@ -895,7 +995,7 @@ func TestLimitBody(t *testing.T) { h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("x", 5000)))) - if leseFehler == nil { + if readErr == nil { t.Error("ein zu großer Body wurde ohne Fehler durchgelassen") } } @@ -905,19 +1005,19 @@ func TestLimitBody(t *testing.T) { func TestAPIAuthRejectsBadKey(t *testing.T) { srv, _, _ := testServer(t) - var erreicht bool - h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true })) + var reached bool + h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) - faelle := map[string]string{ + cases := map[string]string{ "ohne Header": "", "falsches Schema": "Basic abc", "unbekannter Key": "Bearer wk_" + strings.Repeat("0", 64), "kaputte Form": "Bearer nonsens", } - for name, header := range faelle { + for name, header := range cases { t.Run(name, func(t *testing.T) { - erreicht = false + reached = false r := httptest.NewRequest(http.MethodPost, "/mcp", nil) if header != "" { r.Header.Set("Authorization", header) @@ -927,7 +1027,7 @@ func TestAPIAuthRejectsBadKey(t *testing.T) { w := httptest.NewRecorder() h.ServeHTTP(w, r) - if erreicht { + if reached { t.Error("der Request kam durch") } if w.Code != http.StatusUnauthorized && w.Code != http.StatusTooManyRequests { @@ -938,12 +1038,12 @@ func TestAPIAuthRejectsBadKey(t *testing.T) { } func TestAPIAuthAcceptsValidKey(t *testing.T) { - srv, db, projekt := testServer(t) - key := erzeugeKey(t, srv, db, projekt.ID, "agent", auth.RoleWrite) + srv, db, project := testServer(t) + key := createKey(t, srv, db, project.ID, "agent", auth.RoleWrite) - var gesehen store.Project + var seen store.Project h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gesehen, _ = ProjectFrom(r) + seen, _ = ProjectFrom(r) })) r := httptest.NewRequest(http.MethodPost, "/mcp", nil) @@ -951,7 +1051,7 @@ func TestAPIAuthAcceptsValidKey(t *testing.T) { h.ServeHTTP(httptest.NewRecorder(), r) - if gesehen.ID != projekt.ID { + if seen.ID != project.ID { t.Error("das Projekt wurde nicht im Context abgelegt") } } @@ -960,11 +1060,11 @@ func TestAPIAuthAcceptsValidKey(t *testing.T) { // Fehlversuche trifft — sonst würde ein falsch konfigurierter Client alle // anderen hinter derselben Adresse mitsperren. func TestAPIAuthValidKeyBypassesRateLimit(t *testing.T) { - srv, db, projekt := testServer(t) - key := erzeugeKey(t, srv, db, projekt.ID, "agent", auth.RoleWrite) + srv, db, project := testServer(t) + key := createKey(t, srv, db, project.ID, "agent", auth.RoleWrite) - var erreicht bool - h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true })) + var reached bool + h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) // Erst die Adresse in die Sperre laufen lassen … for i := 0; i < 15; i++ { @@ -982,7 +1082,7 @@ func TestAPIAuthValidKeyBypassesRateLimit(t *testing.T) { w := httptest.NewRecorder() h.ServeHTTP(w, r) - if !erreicht { + if !reached { t.Errorf("gültiger Key wurde wegen der IP-Sperre abgewiesen (Status %d)", w.Code) } } @@ -990,22 +1090,22 @@ func TestAPIAuthValidKeyBypassesRateLimit(t *testing.T) { // ── Einträge über die Weboberfläche ────────────────────────────────────────── func TestEntryCreateAndView(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite) + cookie := login(t, h, key) - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + keys, _ := db.ListAPIKeys(context.Background(), project.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID) - formular := url.Values{ + form := url.Values{ "title": {"Deployment-Prozess"}, "content": {"Wir deployen über [[server-zugang]]."}, "tags": {"ops, deployment"}, auth.CSRFFieldName: {token}, } - r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/save", strings.NewReader(formular.Encode())) + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/save", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -1017,7 +1117,7 @@ func TestEntryCreateAndView(t *testing.T) { } // Der Bezeichner wurde aus dem Titel abgeleitet. - e, err := db.EntryBySlug(context.Background(), projekt.ID, "deployment-prozess") + e, err := db.EntryBySlug(context.Background(), project.ID, "deployment-prozess") if err != nil { t.Fatalf("Eintrag nicht gefunden: %v", err) } @@ -1032,20 +1132,20 @@ func TestEntryCreateAndView(t *testing.T) { } // Das Protokoll vermerkt, dass der Eintrag neu angelegt wurde. - ereignisse, err := db.ListAudit(context.Background(), store.AuditFilter{ProjectID: projekt.ID}) + events, err := db.ListAudit(context.Background(), store.AuditFilter{ProjectID: project.ID}) if err != nil { t.Fatal(err) } - var gefunden bool - for _, ev := range ereignisse { + var found bool + for _, ev := range events { if ev.Action == store.ActionEntryWrite && ev.EntrySlug == "deployment-prozess" { - gefunden = true + found = true if ev.Detail != store.DetailEntryCreated { t.Errorf("Detail = %q, erwartet %q", ev.Detail, store.DetailEntryCreated) } } } - if !gefunden { + if !found { t.Error("Schreibvorgang fehlt im Protokoll") } @@ -1068,30 +1168,30 @@ func TestEntryCreateAndView(t *testing.T) { // bekommen ein Schlagwort dazu und eines weg, ein dritter, nicht ausgewählter // bleibt unangetastet. func TestEntryBulkTags(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", + a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Tags: []string{"alt"}, Source: store.SourceHuman}) - b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B", + b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B", Tags: []string{"alt"}, Source: store.SourceHuman}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "c", Title: "C", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "c", Title: "C", Tags: []string{"alt"}, Source: store.SourceHuman}) - key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite) + cookie := login(t, h, key) - keys, _ := db.ListAPIKeys(ctx, projekt.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + keys, _ := db.ListAPIKeys(ctx, project.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID) - formular := url.Values{ + form := url.Values{ "ids": {a.ID, b.ID}, "add_tags": {"neu"}, "remove_tags": {"alt"}, auth.CSRFFieldName: {token}, } - r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-tags", strings.NewReader(formular.Encode())) + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-tags", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -1102,42 +1202,42 @@ func TestEntryBulkTags(t *testing.T) { t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String()) } - neuA, _ := db.EntryBySlug(ctx, projekt.ID, "a") - if len(neuA.Tags) != 1 || neuA.Tags[0] != "neu" { - t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags) + freshA, _ := db.EntryBySlug(ctx, project.ID, "a") + if len(freshA.Tags) != 1 || freshA.Tags[0] != "neu" { + t.Errorf("Tags von A = %v, erwartet [neu]", freshA.Tags) } - unveraendertC, _ := db.EntryBySlug(ctx, projekt.ID, "c") - if len(unveraendertC.Tags) != 1 || unveraendertC.Tags[0] != "alt" { - t.Errorf("Tags von C = %v — C war nicht ausgewählt", unveraendertC.Tags) + unchangedC, _ := db.EntryBySlug(ctx, project.ID, "c") + if len(unchangedC.Tags) != 1 || unchangedC.Tags[0] != "alt" { + t.Errorf("Tags von C = %v — C war nicht ausgewählt", unchangedC.Tags) } } -// sammelLoeschen schickt die Sammelauswahl der Eintragsliste an bulk-delete. -// rolle bestimmt den Key, mit dem angemeldet wird; extra ergänzt das Formular +// bulkDelete schickt die Sammelauswahl der Eintragsliste an bulk-delete. +// role bestimmt den Key, mit dem angemeldet wird; extra ergänzt das Formular // (etwa um tag oder q). -func sammelLoeschen(t *testing.T, srv *Server, db *store.Store, projekt store.Project, - rolle auth.Role, ids []string, extra url.Values) *httptest.ResponseRecorder { +func bulkDelete(t *testing.T, srv *Server, db *store.Store, project store.Project, + role auth.Role, ids []string, extra url.Values) *httptest.ResponseRecorder { t.Helper() h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "mensch-"+string(rolle), rolle) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "mensch-"+string(role), role) + cookie := login(t, h, key) - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) + keys, _ := db.ListAPIKeys(context.Background(), project.ID) var keyID string for _, k := range keys { - if k.Name == "mensch-"+string(rolle) { + if k.Name == "mensch-"+string(role) { keyID = k.ID } } - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keyID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keyID) - formular := url.Values{"ids": ids, auth.CSRFFieldName: {token}} + form := url.Values{"ids": ids, auth.CSRFFieldName: {token}} for k, v := range extra { - formular[k] = v + form[k] = v } - r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-delete", strings.NewReader(formular.Encode())) + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-delete", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -1150,84 +1250,84 @@ func sammelLoeschen(t *testing.T, srv *Server, db *store.Store, projekt store.Pr // je Eintrag steht eine Zeile im Protokoll, der Rest bleibt — und die // Weiterleitung führt zurück in dieselbe gefilterte Liste. func TestEntryBulkDelete(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) ctx := context.Background() - a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman}) - b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B", Source: store.SourceHuman}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "c", Title: "C", Source: store.SourceHuman}) + a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman}) + b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B", Source: store.SourceHuman}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "c", Title: "C", Source: store.SourceHuman}) - w := sammelLoeschen(t, srv, db, projekt, auth.RoleWrite, []string{a.ID, b.ID}, url.Values{"tag": {"ops"}}) + w := bulkDelete(t, srv, db, project, auth.RoleWrite, []string{a.ID, b.ID}, url.Values{"tag": {"ops"}}) if w.Code != http.StatusSeeOther { t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String()) } - if ziel := w.Header().Get("Location"); !strings.Contains(ziel, "tag=ops") || !strings.Contains(ziel, "hinweis=") { - t.Errorf("Weiterleitung nach %q — Filter oder Hinweis fehlt", ziel) + if target := w.Header().Get("Location"); !strings.Contains(target, "tag=ops") || !strings.Contains(target, "hinweis=") { + t.Errorf("Weiterleitung nach %q — Filter oder Hinweis fehlt", target) } - papierkorb, _ := db.ListTrash(ctx, projekt.ID, "") - if len(papierkorb) != 2 { - t.Errorf("%d Einträge im Papierkorb, erwartet 2", len(papierkorb)) + trash, _ := db.ListTrash(ctx, project.ID, "") + if len(trash) != 2 { + t.Errorf("%d Einträge im Papierkorb, erwartet 2", len(trash)) } - if _, err := db.EntryBySlug(ctx, projekt.ID, "c"); err != nil { + if _, err := db.EntryBySlug(ctx, project.ID, "c"); err != nil { t.Errorf("C war nicht ausgewählt und ist trotzdem weg: %v", err) } - ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) - loeschungen := 0 - for _, e := range ereignisse { + events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID}) + deletions := 0 + for _, e := range events { if e.Action == store.ActionEntryDelete { - loeschungen++ + deletions++ } } - if loeschungen != 2 { - t.Errorf("%d Löschungen im Protokoll, erwartet eine je Eintrag (2)", loeschungen) + if deletions != 2 { + t.Errorf("%d Löschungen im Protokoll, erwartet eine je Eintrag (2)", deletions) } } -// TestEntryBulkDeleteNurMitSchreibrecht: Mit Leserecht wird nichts gelöscht. -func TestEntryBulkDeleteNurMitSchreibrecht(t *testing.T) { - srv, db, projekt := testServer(t) +// TestEntryBulkDeleteRequiresWriteRole: Mit Leserecht wird nichts gelöscht. +func TestEntryBulkDeleteRequiresWriteRole(t *testing.T) { + srv, db, project := testServer(t) ctx := context.Background() - a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman}) + a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman}) - w := sammelLoeschen(t, srv, db, projekt, auth.RoleRead, []string{a.ID}, nil) + w := bulkDelete(t, srv, db, project, auth.RoleRead, []string{a.ID}, nil) if w.Code == http.StatusSeeOther { t.Errorf("Leserecht wurde weitergeleitet wie nach Erfolg (Status %d)", w.Code) } - if _, err := db.EntryBySlug(ctx, projekt.ID, "a"); err != nil { + if _, err := db.EntryBySlug(ctx, project.ID, "a"); err != nil { t.Errorf("mit Leserecht wurde gelöscht: %v", err) } } -// TestEintragslisteZeigtSammelloeschen: Der Knopf steht mit Rückfrage in der +// TestEntryListShowsBulkDelete: Der Knopf steht mit Rückfrage in der // Liste — und zwar hinter „Anwenden", damit Enter in einem Schlagwortfeld nie // löscht. -func TestEintragslisteZeigtSammelloeschen(t *testing.T) { - srv, db, projekt := testServer(t) +func TestEntryListShowsBulkDelete(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - db.PutEntry(context.Background(), store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman}) + db.PutEntry(context.Background(), store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Source: store.SourceHuman}) - schreiber := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) - body := seiteHolen(h, schreiber, "/p/testprojekt/").Body.String() + writer := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)) + body := fetchPage(h, writer, "/p/testprojekt/").Body.String() - anwenden := strings.Index(body, ">Anwenden<") - loeschen := strings.Index(body, `formaction="/p/testprojekt/entries/bulk-delete"`) - if anwenden < 0 || loeschen < 0 { + apply := strings.Index(body, ">Anwenden<") + deleteIt := strings.Index(body, `formaction="/p/testprojekt/entries/bulk-delete"`) + if apply < 0 || deleteIt < 0 { t.Fatal("Sammelleiste ohne Anwenden oder ohne Löschknopf") } - if loeschen < anwenden { + if deleteIt < apply { t.Error("der Löschknopf steht vor „Anwenden“ — Enter in einem Textfeld würde löschen") } if !strings.Contains(body, "30 Tage lang wiederherstellen") { t.Error("die Rückfrage nennt die Frist des Papierkorbs nicht") } - leser := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) - if strings.Contains(seiteHolen(h, leser, "/p/testprojekt/").Body.String(), "bulk-delete") { + reader := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead)) + if strings.Contains(fetchPage(h, reader, "/p/testprojekt/").Body.String(), "bulk-delete") { t.Error("die Leserolle sieht den Löschknopf") } } @@ -1236,17 +1336,17 @@ func TestEintragslisteZeigtSammelloeschen(t *testing.T) { // Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung", // wenn es keine spätere abgelöste Fassung mehr gibt. func TestEntryVersionsAndDiff(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Content: "Zeile 1\nZeile 2", Source: store.SourceHuman, Author: "tom"}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Content: "Zeile 1\nGeändert", Source: store.SourceHuman, Author: "tom"}) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) // Verlaufsseite: Fassung 1 muss als vergleichbar auftauchen. rv := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions", nil) @@ -1290,16 +1390,16 @@ func TestEntryVersionsAndDiff(t *testing.T) { } } -// wiederherstellenAnfrage baut den POST auf den Wiederherstellen-Knopf — +// restoreRequest baut den POST auf den Wiederherstellen-Knopf — // so, wie ihn das Formular in entry-diff.html abschickt. -func wiederherstellenAnfrage(t *testing.T, db *store.Store, projekt store.Project, cookie *http.Cookie, fassung, aktuell string) *http.Request { +func restoreRequest(t *testing.T, db *store.Store, project store.Project, cookie *http.Cookie, version, current string) *http.Request { t.Helper() - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + keys, _ := db.ListAPIKeys(context.Background(), project.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID) - formular := url.Values{"current_version": {aktuell}, auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/e/e/versions/"+fassung+"/restore", - strings.NewReader(formular.Encode())) + form := url.Values{"current_version": {current}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/e/e/versions/"+version+"/restore", + strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) return r @@ -1309,17 +1409,17 @@ func wiederherstellenAnfrage(t *testing.T, db *store.Store, projekt store.Projec // dem alten Inhalt an — der bisherige Stand wandert in den Verlauf, und das // Protokoll vermerkt, woher der Inhalt kam. func TestEntryRestore(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Alt", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Alt", Content: "guter Text", Tags: []string{"a"}, Source: store.SourceHuman, Author: "tom"}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Kaputt", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Kaputt", Content: "von einem Agenten zerstört", Tags: []string{"b"}, Source: store.SourceAgent, Author: "agent"}) - key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite) + cookie := login(t, h, key) // Die Vergleichsseite zeigt den Knopf für Schreibberechtigte. rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil) @@ -1331,12 +1431,12 @@ func TestEntryRestore(t *testing.T) { } w := httptest.NewRecorder() - h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2")) + h.ServeHTTP(w, restoreRequest(t, db, project, cookie, "1", "2")) if w.Code != http.StatusSeeOther { t.Fatalf("Status = %d, erwartet 303 (%s)", w.Code, w.Body.String()) } - e, err := db.EntryBySlug(ctx, projekt.ID, "e") + e, err := db.EntryBySlug(ctx, project.ID, "e") if err != nil { t.Fatal(err) } @@ -1351,19 +1451,19 @@ func TestEntryRestore(t *testing.T) { } // Der verdrängte Stand ist nicht verloren, sondern jetzt Fassung 2 im Verlauf. - zwei, err := db.EntryVersionByNumber(ctx, e.ID, 2) - if err != nil || zwei.Content != "von einem Agenten zerstört" { - t.Errorf("verdrängte Fassung fehlt im Verlauf: %v / %q", err, zwei.Content) + two, err := db.EntryVersionByNumber(ctx, e.ID, 2) + if err != nil || two.Content != "von einem Agenten zerstört" { + t.Errorf("verdrängte Fassung fehlt im Verlauf: %v / %q", err, two.Content) } - ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) - gefunden := false - for _, ev := range ereignisse { + events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID}) + found := false + for _, ev := range events { if ev.Action == store.ActionEntryWrite && ev.Detail == store.EntryRestoreDetail(1) { - gefunden = true + found = true } } - if !gefunden { + if !found { t.Error("das Protokoll vermerkt die Wiederherstellung nicht") } } @@ -1371,33 +1471,33 @@ func TestEntryRestore(t *testing.T) { // TestEntryRestoreConflict: Hat sich der Eintrag seit dem Anzeigen geändert, // wird nichts überschrieben. func TestEntryRestoreConflict(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - for _, inhalt := range []string{"eins", "zwei", "drei"} { - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", - Content: inhalt, Source: store.SourceHuman}) + for _, content := range []string{"eins", "zwei", "drei"} { + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", + Content: content, Source: store.SourceHuman}) } - key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite) + cookie := login(t, h, key) // Die Seite wurde bei Fassung 2 angezeigt, inzwischen gilt Fassung 3. w := httptest.NewRecorder() - h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2")) + h.ServeHTTP(w, restoreRequest(t, db, project, cookie, "1", "2")) if w.Code != http.StatusConflict { t.Fatalf("Status = %d, erwartet 409", w.Code) } - e, _ := db.EntryBySlug(ctx, projekt.ID, "e") + e, _ := db.EntryBySlug(ctx, project.ID, "e") if e.Version != 3 || e.Content != "drei" { t.Errorf("Eintrag wurde trotz Konflikt verändert: Fassung %d, %q", e.Version, e.Content) } // Ohne Fassungsangabe wird ebenfalls abgelehnt, statt blind zu schreiben. w2 := httptest.NewRecorder() - h.ServeHTTP(w2, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "")) + h.ServeHTTP(w2, restoreRequest(t, db, project, cookie, "1", "")) if w2.Code != http.StatusBadRequest { t.Errorf("ohne current_version: Status %d, erwartet 400", w2.Code) } @@ -1406,15 +1506,15 @@ func TestEntryRestoreConflict(t *testing.T) { // TestEntryRestoreNeedsWriteRole: Lesende sehen keinen Knopf und kommen auch // direkt nicht durch. func TestEntryRestoreNeedsWriteRole(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Content: "eins", Source: store.SourceHuman}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Content: "zwei", Source: store.SourceHuman}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Content: "eins", Source: store.SourceHuman}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Content: "zwei", Source: store.SourceHuman}) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) rd := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/e/versions/1", nil) rd.AddCookie(cookie) @@ -1425,11 +1525,11 @@ func TestEntryRestoreNeedsWriteRole(t *testing.T) { } w := httptest.NewRecorder() - h.ServeHTTP(w, wiederherstellenAnfrage(t, db, projekt, cookie, "1", "2")) + h.ServeHTTP(w, restoreRequest(t, db, project, cookie, "1", "2")) if w.Code != http.StatusForbidden { t.Errorf("Status = %d, erwartet 403", w.Code) } - e, _ := db.EntryBySlug(ctx, projekt.ID, "e") + e, _ := db.EntryBySlug(ctx, project.ID, "e") if e.Content != "zwei" { t.Error("Leserolle konnte eine Fassung wiederherstellen") } @@ -1438,20 +1538,20 @@ func TestEntryRestoreNeedsWriteRole(t *testing.T) { // TestEntryPreviewRendersMarkdown prüft den Vorschau-Endpunkt: gleicher // Renderer wie die Detailansicht, per POST ohne dass etwas gespeichert wird. func TestEntryPreviewRendersMarkdown(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite) + cookie := login(t, h, key) - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + keys, _ := db.ListAPIKeys(context.Background(), project.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID) - formular := url.Values{ + form := url.Values{ "content": {"# Überschrift\n\nEin Verweis auf [[anderer-eintrag]]."}, auth.CSRFFieldName: {token}, } - r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(formular.Encode())) + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -1470,26 +1570,26 @@ func TestEntryPreviewRendersMarkdown(t *testing.T) { } // Es wurde nichts angelegt — die Vorschau speichert nicht. - anzahl, _ := db.CountEntries(context.Background(), projekt.ID) - if anzahl != 0 { - t.Errorf("%d Einträge nach reiner Vorschau, erwartet 0", anzahl) + count, _ := db.CountEntries(context.Background(), project.ID) + if count != 0 { + t.Errorf("%d Einträge nach reiner Vorschau, erwartet 0", count) } } // TestEntryPreviewRequiresWriteRole: ein Nur-Lese-Zugang darf die Vorschau // nicht aufrufen — konsistent mit dem Bearbeitungsformular selbst. func TestEntryPreviewRequiresWriteRole(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + keys, _ := db.ListAPIKeys(context.Background(), project.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID) - formular := url.Values{"content": {"Text"}, auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(formular.Encode())) + form := url.Values{"content": {"Text"}, auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/preview", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -1504,17 +1604,17 @@ func TestEntryPreviewRequiresWriteRole(t *testing.T) { // TestEntrySuggest prüft die Autovervollständigung: Suche mit Begriff liefert // den passenden Eintrag, ohne Begriff die zuletzt geänderten. func TestEntrySuggest(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "deployment-prozess", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "deployment-prozess", Title: "Deployment-Prozess", Content: "Wie wir deployen", Source: store.SourceHuman}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "urlaubsantrag", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "urlaubsantrag", Title: "Urlaubsantrag", Content: "Formular für Urlaub", Source: store.SourceHuman}) - key := erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/entries/suggest?q=deploy", nil) r.AddCookie(cookie) @@ -1524,12 +1624,12 @@ func TestEntrySuggest(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String()) } - var treffer []suggestItem - if err := json.Unmarshal(w.Body.Bytes(), &treffer); err != nil { + var hits []suggestItem + if err := json.Unmarshal(w.Body.Bytes(), &hits); err != nil { t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String()) } - if len(treffer) != 1 || treffer[0].Slug != "deployment-prozess" { - t.Errorf("Treffer = %+v, erwartet genau \"deployment-prozess\"", treffer) + if len(hits) != 1 || hits[0].Slug != "deployment-prozess" { + t.Errorf("Treffer = %+v, erwartet genau \"deployment-prozess\"", hits) } // Ohne Suchbegriff: beide Einträge kommen zurück, keine leere Antwort. @@ -1538,12 +1638,12 @@ func TestEntrySuggest(t *testing.T) { w2 := httptest.NewRecorder() h.ServeHTTP(w2, r2) - var alle []suggestItem - if err := json.Unmarshal(w2.Body.Bytes(), &alle); err != nil { + var all []suggestItem + if err := json.Unmarshal(w2.Body.Bytes(), &all); err != nil { t.Fatalf("Antwort ist kein gültiges JSON: %v", err) } - if len(alle) != 2 { - t.Errorf("%d Vorschläge ohne Suchbegriff, erwartet 2", len(alle)) + if len(all) != 2 { + t.Errorf("%d Vorschläge ohne Suchbegriff, erwartet 2", len(all)) } } @@ -1551,20 +1651,20 @@ func TestEntrySuggest(t *testing.T) { // Einträge als Knoten, ein Verweis auf einen fehlenden Bezeichner erzeugt // trotzdem einen (nicht existierenden) Knoten. func TestEntryGraphData(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "A", Content: "verweist auf [[b]] und [[fehlt]]", Source: store.SourceHuman}) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "B", Content: "kein Verweis", Source: store.SourceHuman}) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) - // Nur Lesen reicht — die Graph-Ansicht selbst hängt an imProjekt, nicht - // an mitSchreibrecht. + // Nur Lesen reicht — die Graph-Ansicht selbst hängt an inProject, nicht + // an withWriteAccess. r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil) r.AddCookie(cookie) w := httptest.NewRecorder() @@ -1574,29 +1674,29 @@ func TestEntryGraphData(t *testing.T) { t.Fatalf("Status = %d, erwartet 200 (%s)", w.Code, w.Body.String()) } - var daten graphResponse - if err := json.Unmarshal(w.Body.Bytes(), &daten); err != nil { + var data graphResponse + if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil { t.Fatalf("Antwort ist kein gültiges JSON: %v (%s)", err, w.Body.String()) } - if len(daten.Nodes) != 3 { - t.Fatalf("%d Knoten, erwartet 3 (a, b, fehlt)", len(daten.Nodes)) + if len(data.Nodes) != 3 { + t.Fatalf("%d Knoten, erwartet 3 (a, b, fehlt)", len(data.Nodes)) } - if len(daten.Edges) != 2 { - t.Errorf("%d Kanten, erwartet 2", len(daten.Edges)) + if len(data.Edges) != 2 { + t.Errorf("%d Kanten, erwartet 2", len(data.Edges)) } - nachSlug := make(map[string]graphNode, len(daten.Nodes)) - for _, n := range daten.Nodes { - nachSlug[n.Slug] = n + toSlug := make(map[string]graphNode, len(data.Nodes)) + for _, n := range data.Nodes { + toSlug[n.Slug] = n } - if !nachSlug["a"].Exists { + if !toSlug["a"].Exists { t.Error("Knoten \"a\" sollte Exists = true tragen") } - if nachSlug["fehlt"].Exists { + if toSlug["fehlt"].Exists { t.Error("Knoten \"fehlt\" sollte Exists = false tragen — es gibt keinen Eintrag dazu") } - if daten.Truncated { + if data.Truncated { t.Error("Truncated = true bei nur drei Knoten") } } @@ -1604,11 +1704,11 @@ func TestEntryGraphData(t *testing.T) { // TestEntryGraphPageRenders prüft, dass die Graph-Seite selbst ausliefert // und das Skript samt Endpunkt-Adresse einbindet. func TestEntryGraphPageRenders(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph", nil) r.AddCookie(cookie) @@ -1631,56 +1731,56 @@ func TestEntryGraphPageRenders(t *testing.T) { // (graphNodeLimit): Mehr Einträge als das Limit führen zu Truncated = true // und genau graphNodeLimit Knoten in der Antwort, nicht mehr. func TestEntryGraphDataTruncatesLargeProjects(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() for i := 0; i < graphNodeLimit+5; i++ { slug := fmt.Sprintf("eintrag-%03d", i) - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: slug, Title: slug, + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: slug, Title: slug, Content: "kein Verweis", Source: store.SourceHuman}) } - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/graph/data", nil) r.AddCookie(cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) - var daten graphResponse - if err := json.Unmarshal(w.Body.Bytes(), &daten); err != nil { + var data graphResponse + if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil { t.Fatalf("Antwort ist kein gültiges JSON: %v", err) } - if !daten.Truncated { + if !data.Truncated { t.Error("Truncated = false, obwohl mehr Einträge als graphNodeLimit existieren") } - if len(daten.Nodes) != graphNodeLimit { - t.Errorf("%d Knoten in der Antwort, erwartet genau graphNodeLimit (%d)", len(daten.Nodes), graphNodeLimit) + if len(data.Nodes) != graphNodeLimit { + t.Errorf("%d Knoten in der Antwort, erwartet genau graphNodeLimit (%d)", len(data.Nodes), graphNodeLimit) } - if daten.Total != graphNodeLimit+5 { - t.Errorf("Total = %d, erwartet %d", daten.Total, graphNodeLimit+5) + if data.Total != graphNodeLimit+5 { + t.Errorf("Total = %d, erwartet %d", data.Total, graphNodeLimit+5) } } // TestEntryContentIsEscaped ist ein Sicherheitstest: Eingeschleustes HTML darf // nicht ausgeführt werden. func TestEntryContentIsEscaped(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() if _, err := db.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "boese", Title: "Test", + ProjectID: project.ID, Slug: "boese", Title: "Test", Content: "<script>alert(1)</script>\n\nText", Source: store.SourceHuman, }); err != nil { t.Fatal(err) } - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/e/boese", nil) r.AddCookie(cookie) @@ -1695,10 +1795,10 @@ func TestEntryContentIsEscaped(t *testing.T) { // ── Zugriffsprotokoll ──────────────────────────────────────────────────────── -// TestLogRequestsSchreibtZeile prüft, dass jede Anfrage protokolliert wird. -func TestLogRequestsSchreibtZeile(t *testing.T) { - var puffer bytes.Buffer - log.SetOutput(&puffer) +// TestLogRequestsWritesLine prüft, dass jede Anfrage protokolliert wird. +func TestLogRequestsWritesLine(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) log.SetFlags(0) defer func() { log.SetOutput(os.Stderr) @@ -1710,77 +1810,77 @@ func TestLogRequestsSchreibtZeile(t *testing.T) { })) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/p/test/save", nil)) - zeile := puffer.String() - for _, teil := range []string{"418", "POST", "/p/test/save"} { - if !strings.Contains(zeile, teil) { - t.Errorf("%q fehlt in der Protokollzeile: %s", teil, zeile) + line := buf.String() + for _, part := range []string{"418", "POST", "/p/test/save"} { + if !strings.Contains(line, part) { + t.Errorf("%q fehlt in der Protokollzeile: %s", part, line) } } } -// TestLogRequestsSchweigtBeiRauschen prüft, dass statische Dateien und der +// TestLogRequestsSilentOnNoise prüft, dass statische Dateien und der // Healthcheck nicht protokolliert werden — der Healthcheck läuft alle 30 // Sekunden und würde das Log fluten. -func TestLogRequestsSchweigtBeiRauschen(t *testing.T) { - var puffer bytes.Buffer - log.SetOutput(&puffer) +func TestLogRequestsSilentOnNoise(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) defer log.SetOutput(os.Stderr) h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - for _, pfad := range []string{"/live", "/ready", "/healthz", "/static/style.css"} { - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, pfad, nil)) + for _, filePath := range []string{"/live", "/ready", "/healthz", "/static/style.css"} { + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, filePath, nil)) } - if puffer.Len() > 0 { - t.Errorf("Rauschen wurde protokolliert: %s", puffer.String()) + if buf.Len() > 0 { + t.Errorf("Rauschen wurde protokolliert: %s", buf.String()) } } -// TestLogRequestsProtokolliertKeineSuchbegriffe ist ein Datenschutztest: +// TestLogRequestsDoesNotLogSearchTerms ist ein Datenschutztest: // Die Query-Zeichenkette enthält Suchbegriffe, und die sind Inhalt, kein // Betriebsdatum. -func TestLogRequestsProtokolliertKeineSuchbegriffe(t *testing.T) { - var puffer bytes.Buffer - log.SetOutput(&puffer) +func TestLogRequestsDoesNotLogSearchTerms(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) defer log.SetOutput(os.Stderr) h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/p/test/?q=gehaltsverhandlung", nil)) - if strings.Contains(puffer.String(), "gehaltsverhandlung") { - t.Errorf("der Suchbegriff steht im Protokoll: %s", puffer.String()) + if strings.Contains(buf.String(), "gehaltsverhandlung") { + t.Errorf("der Suchbegriff steht im Protokoll: %s", buf.String()) } } -// TestLogRequestsNenntDenZugang prüft, dass der verwendete Zugang mit +// TestLogRequestsNamesTheKey prüft, dass der verwendete Zugang mit // protokolliert wird — sonst sieht man nicht, welcher Agent Last erzeugt. -func TestLogRequestsNenntDenZugang(t *testing.T) { - var puffer bytes.Buffer - log.SetOutput(&puffer) +func TestLogRequestsNamesTheKey(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) defer log.SetOutput(os.Stderr) // Reihenfolge wie im Betrieb: Der Protokollierer liegt AUSSEN, die Anmeldung // geschieht weiter innen. Der Name muss trotzdem in der Zeile landen. - innen := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + inside := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { withSession(r.Context(), Session{ Subject: "key:1", Key: store.APIKey{Name: "claude-desktop"}, }) }) - LogRequests(innen).ServeHTTP(httptest.NewRecorder(), + LogRequests(inside).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/p/test/", nil)) - if !strings.Contains(puffer.String(), "claude-desktop") { - t.Errorf("der Zugang fehlt im Protokoll: %s", puffer.String()) + if !strings.Contains(buf.String(), "claude-desktop") { + t.Errorf("der Zugang fehlt im Protokoll: %s", buf.String()) } } -// TestLogRequestsOhneAnmeldung prüft die Gegenrichtung: Eine abgewiesene +// TestLogRequestsWithoutLogin prüft die Gegenrichtung: Eine abgewiesene // Anfrage wird protokolliert, nur eben ohne Namen. -func TestLogRequestsOhneAnmeldung(t *testing.T) { - var puffer bytes.Buffer - log.SetOutput(&puffer) +func TestLogRequestsWithoutLogin(t *testing.T) { + var buf bytes.Buffer + log.SetOutput(&buf) defer log.SetOutput(os.Stderr) h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1788,29 +1888,29 @@ func TestLogRequestsOhneAnmeldung(t *testing.T) { })) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/mcp", nil)) - zeile := puffer.String() - if !strings.Contains(zeile, "401") { - t.Errorf("abgewiesene Anfrage wurde nicht protokolliert: %s", zeile) + line := buf.String() + if !strings.Contains(line, "401") { + t.Errorf("abgewiesene Anfrage wurde nicht protokolliert: %s", line) } - if !strings.Contains(zeile, "—") { - t.Errorf("fehlender Zugang wird nicht als \"—\" dargestellt: %s", zeile) + if !strings.Contains(line, "—") { + t.Errorf("fehlender Zugang wird nicht als \"—\" dargestellt: %s", line) } } -// TestStatusSchreiberReichtFlusherDurch prüft, dass der Mantel um den +// TestStatusWriterPassesFlusherThrough prüft, dass der Mantel um den // ResponseWriter dessen Fähigkeiten nicht verdeckt. // // Der MCP-Transport streamt Antworten und braucht dafür http.Flusher. Ginge der // beim Einwickeln verloren, blieben MCP-Antworten im Puffer hängen — ein Fehler, // den man erst im Betrieb bemerkt. -func TestStatusSchreiberReichtFlusherDurch(t *testing.T) { - var gefunden bool +func TestStatusWriterPassesFlusherThrough(t *testing.T) { + var found bool h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, gefunden = w.(http.Flusher) + _, found = w.(http.Flusher) })) h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/mcp", nil)) - if !gefunden { + if !found { t.Error("http.Flusher ist durch den Protokoll-Mantel verlorengegangen") } } @@ -1821,10 +1921,10 @@ func TestStatusSchreiberReichtFlusherDurch(t *testing.T) { // Plattform-Admin ändert Name, Beschreibung und Obergrenze eines Projekts, und // alles drei steht danach wirklich in der Datenbank. func TestPlatformAdminEditsProject(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ + w := adminPost(t, h, project.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ "name": {"Neuer Name"}, "description": {"Neue Beschreibung"}, "write_limit_hourly": {"250"}, @@ -1833,18 +1933,18 @@ func TestPlatformAdminEditsProject(t *testing.T) { t.Fatalf("Status = %d, erwartet %d — Antwort: %s", w.Code, http.StatusSeeOther, w.Body.String()) } - gelesen, err := db.ProjectByID(context.Background(), projekt.ID) + read, err := db.ProjectByID(context.Background(), project.ID) if err != nil { t.Fatal(err) } - if gelesen.Name != "Neuer Name" { - t.Errorf("Name = %q, erwartet \"Neuer Name\"", gelesen.Name) + if read.Name != "Neuer Name" { + t.Errorf("Name = %q, erwartet \"Neuer Name\"", read.Name) } - if gelesen.Description != "Neue Beschreibung" { - t.Errorf("Description = %q, erwartet \"Neue Beschreibung\"", gelesen.Description) + if read.Description != "Neue Beschreibung" { + t.Errorf("Description = %q, erwartet \"Neue Beschreibung\"", read.Description) } - if gelesen.WriteLimitHourly != 250 { - t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly) + if read.WriteLimitHourly != 250 { + t.Errorf("WriteLimitHourly = %d, erwartet 250", read.WriteLimitHourly) } } @@ -1853,16 +1953,16 @@ func TestPlatformAdminEditsProject(t *testing.T) { // zeigen. Sonst überschreibt ein Speichern ohne Änderung die Werte mit den // Vorgaben des Formulars. func TestProjectEditFormShowsCurrentValues(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - if err := db.UpdateProject(context.Background(), projekt.ID, store.ProjectUpdate{ + if err := db.UpdateProject(context.Background(), project.ID, store.ProjectUpdate{ Name: "Testprojekt", Description: "Eine Beschreibung", WriteLimitHourly: 250, }); err != nil { t.Fatal(err) } - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) r := httptest.NewRequest(http.MethodGet, "/admin/projects/testprojekt/edit", nil) r.AddCookie(cookie) @@ -1873,10 +1973,10 @@ func TestProjectEditFormShowsCurrentValues(t *testing.T) { t.Fatalf("Status = %d, erwartet 200", w.Code) } - koerper := w.Body.String() - for _, erwartet := range []string{`value="Eine Beschreibung"`, `value="250"`} { - if !strings.Contains(koerper, erwartet) { - t.Errorf("das Formular enthält %s nicht", erwartet) + body := w.Body.String() + for _, want := range []string{`value="Eine Beschreibung"`, `value="250"`} { + if !strings.Contains(body, want) { + t.Errorf("das Formular enthält %s nicht", want) } } } @@ -1885,11 +1985,11 @@ func TestProjectEditFormShowsCurrentValues(t *testing.T) { // Auswahlfeld-Formulars ab: Ein Speichern, das die Obergrenze nicht anfasst, // darf sie nicht auf 0 zurücksetzen. func TestProjectEditKeepsUnchangedValues(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - if err := db.UpdateProject(ctx, projekt.ID, store.ProjectUpdate{ + if err := db.UpdateProject(ctx, project.ID, store.ProjectUpdate{ Name: "Testprojekt", Description: "", WriteLimitHourly: 250, }); err != nil { t.Fatal(err) @@ -1897,15 +1997,15 @@ func TestProjectEditKeepsUnchangedValues(t *testing.T) { // Nur den Namen ändern — die Obergrenze kommt so zurück, wie das Formular // sie ausgeliefert hat. - adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ + adminPost(t, h, project.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ "name": {"Anderer Name"}, "description": {""}, "write_limit_hourly": {"250"}, }) - gelesen, _ := db.ProjectByID(ctx, projekt.ID) - if gelesen.WriteLimitHourly != 250 { - t.Errorf("WriteLimitHourly = %d — die Obergrenze ging beim Speichern verloren", gelesen.WriteLimitHourly) + read, _ := db.ProjectByID(ctx, project.ID) + if read.WriteLimitHourly != 250 { + t.Errorf("WriteLimitHourly = %d — die Obergrenze ging beim Speichern verloren", read.WriteLimitHourly) } } @@ -1913,21 +2013,21 @@ func TestProjectEditKeepsUnchangedValues(t *testing.T) { // beim Plattform-Admin liegt: Ein Projekt-Admin könnte sich sonst selbst mehr // Budget geben — dann wäre die Grenze keine. func TestProjectAdminCannotEditProject(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "test", auth.RoleAdmin) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "test", auth.RoleAdmin) + cookie := login(t, h, key) - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID) + keys, _ := db.ListAPIKeys(context.Background(), project.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID) - formular := url.Values{ + form := url.Values{ "name": {"Gekapert"}, "write_limit_hourly": {"999999"}, auth.CSRFFieldName: {token}, } - r := httptest.NewRequest(http.MethodPost, "/admin/projects/testprojekt/edit", strings.NewReader(formular.Encode())) + r := httptest.NewRequest(http.MethodPost, "/admin/projects/testprojekt/edit", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -1938,26 +2038,26 @@ func TestProjectAdminCannotEditProject(t *testing.T) { t.Errorf("Status = %d, erwartet %d — ein Projekt-Admin durfte sein Projekt bearbeiten", w.Code, http.StatusForbidden) } - gelesen, _ := db.ProjectByID(context.Background(), projekt.ID) - if gelesen.WriteLimitHourly != 0 { - t.Errorf("WriteLimitHourly = %d — die Obergrenze wurde trotzdem geändert", gelesen.WriteLimitHourly) + read, _ := db.ProjectByID(context.Background(), project.ID) + if read.WriteLimitHourly != 0 { + t.Errorf("WriteLimitHourly = %d — die Obergrenze wurde trotzdem geändert", read.WriteLimitHourly) } } // TestProjectEditRejectsGarbage prüft, dass eine unbrauchbare Eingabe eine // Fehlermeldung ergibt und nicht etwa eine 0 (also "Standard") schreibt. func TestProjectEditRejectsGarbage(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - if err := db.UpdateProject(ctx, projekt.ID, store.ProjectUpdate{ + if err := db.UpdateProject(ctx, project.ID, store.ProjectUpdate{ Name: "Testprojekt", WriteLimitHourly: 42, }); err != nil { t.Fatal(err) } - w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ + w := adminPost(t, h, project.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ "name": {"Testprojekt"}, "write_limit_hourly": {"viele"}, }) @@ -1965,19 +2065,19 @@ func TestProjectEditRejectsGarbage(t *testing.T) { t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest) } - gelesen, _ := db.ProjectByID(ctx, projekt.ID) - if gelesen.WriteLimitHourly != 42 { - t.Errorf("WriteLimitHourly = %d — die alte Obergrenze wurde überschrieben", gelesen.WriteLimitHourly) + read, _ := db.ProjectByID(ctx, project.ID) + if read.WriteLimitHourly != 42 { + t.Errorf("WriteLimitHourly = %d — die alte Obergrenze wurde überschrieben", read.WriteLimitHourly) } } // TestProjectEditRejectsEmptyName: Ein Projekt ohne Namen wäre in der Übersicht // eine leere Zeile. func TestProjectEditRejectsEmptyName(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - w := adminPost(t, h, projekt.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ + w := adminPost(t, h, project.WorkspaceID, "/admin/projects/testprojekt/edit", url.Values{ "name": {" "}, "write_limit_hourly": {"0"}, }) @@ -1985,18 +2085,18 @@ func TestProjectEditRejectsEmptyName(t *testing.T) { t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusBadRequest) } - gelesen, _ := db.ProjectByID(context.Background(), projekt.ID) - if gelesen.Name != projekt.Name { - t.Errorf("Name = %q — der leere Name wurde gespeichert", gelesen.Name) + read, _ := db.ProjectByID(context.Background(), project.ID) + if read.Name != project.Name { + t.Errorf("Name = %q — der leere Name wurde gespeichert", read.Name) } } // TestProjectEditUnknownSlug: ein Bezeichner, den es nicht gibt. func TestProjectEditUnknownSlug(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) r := httptest.NewRequest(http.MethodGet, "/admin/projects/gibtesnicht/edit", nil) r.AddCookie(cookie) @@ -2010,14 +2110,14 @@ func TestProjectEditUnknownSlug(t *testing.T) { // adminPost schickt ein Formular als Plattform-Admin im gegebenen Workspace, // samt gültigem CSRF-Token. -func adminPost(t *testing.T, h http.Handler, workspaceID, pfad string, formular url.Values) *httptest.ResponseRecorder { +func adminPost(t *testing.T, h http.Handler, workspaceID, filePath string, form url.Values) *httptest.ResponseRecorder { t.Helper() - cookie := anmeldenAlsPlattformAdmin(t, h, workspaceID) - formular.Set(auth.CSRFFieldName, - auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(workspaceID))) + cookie := loginAsPlatformAdmin(t, h, workspaceID) + form.Set(auth.CSRFFieldName, + auth.NewSigner([]byte(testSecret)).CSRFToken(platformSubject(workspaceID))) - r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode())) + r := httptest.NewRequest(http.MethodPost, filePath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) @@ -2028,7 +2128,7 @@ func adminPost(t *testing.T, h http.Handler, workspaceID, pfad string, formular // ── Hilfe ──────────────────────────────────────────────────────────────────── -func TestHilfeBrauchtAnmeldung(t *testing.T) { +func TestHelpRequiresLogin(t *testing.T) { srv, _, _ := testServer(t) h := testHandler(t, srv) @@ -2040,15 +2140,15 @@ func TestHilfeBrauchtAnmeldung(t *testing.T) { } } -// TestHilfeFuerLeseRolle prüft, dass auch die schwächste Rolle die Hilfe +// TestHelpForReadRole prüft, dass auch die schwächste Rolle die Hilfe // sieht, und dass die Platzhalter durch Server-Adresse und Projekt ersetzt // werden — die Beispiele sollen direkt kopierbar sein. -func TestHilfeFuerLeseRolle(t *testing.T) { - srv, db, projekt := testServer(t) +func TestHelpForReadRole(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - key := erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead) - cookie := anmelden(t, h, key) + key := createKey(t, srv, db, project.ID, "leser", auth.RoleRead) + cookie := login(t, h, key) r := httptest.NewRequest(http.MethodGet, "/hilfe/mcp-einrichten", nil) r.Host = "munin.example.org" @@ -2075,11 +2175,11 @@ func TestHilfeFuerLeseRolle(t *testing.T) { } } -func TestHilfeOhneSeiteZeigtStart(t *testing.T) { - srv, db, projekt := testServer(t) +func TestHelpWithoutPageShowsStart(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead)) r := httptest.NewRequest(http.MethodGet, "/hilfe", nil) r.AddCookie(cookie) @@ -2094,11 +2194,11 @@ func TestHilfeOhneSeiteZeigtStart(t *testing.T) { } } -func TestHilfeUnbekannteSeite(t *testing.T) { - srv, db, projekt := testServer(t) +func TestHelpUnknownPage(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead)) r := httptest.NewRequest(http.MethodGet, "/hilfe/gibt-es-nicht", nil) r.AddCookie(cookie) @@ -2110,13 +2210,13 @@ func TestHilfeUnbekannteSeite(t *testing.T) { } } -// TestHilfeFuerAdminOhneWorkspace: Ein frisch angemeldeter Plattform-Admin hat +// TestHelpForAdminWithoutWorkspace: Ein frisch angemeldeter Plattform-Admin hat // noch keinen Workspace gewählt. Die Hilfe muss trotzdem erreichbar sein. -func TestHilfeFuerAdminOhneWorkspace(t *testing.T) { +func TestHelpForAdminWithoutWorkspace(t *testing.T) { srv, _, _ := testServer(t) h := testHandler(t, srv) - cookie := anmelden(t, h, testAdminToken) + cookie := login(t, h, testAdminToken) r := httptest.NewRequest(http.MethodGet, "/hilfe/plattform-admin", nil) r.AddCookie(cookie) @@ -2128,80 +2228,80 @@ func TestHilfeFuerAdminOhneWorkspace(t *testing.T) { } } -// hilfeZielMuster findet in den Templates jedes Ziel innerhalb der Hilfe: -// sowohl {{template "hilfelink" "seite#abschnitt"}} als auch einfache Links +// helpTargetPattern findet in den Templates jedes Ziel innerhalb der Hilfe: +// sowohl {{template "helplink" "seite#abschnitt"}} als auch einfache Links // href="/hilfe/seite". Die runden Klammern sind die "Fanggruppen" — daraus // liest der Test Seite und (optional) Abschnitt. -var hilfeZielMuster = regexp.MustCompile(`(?:template "hilfelink" "|href="/hilfe/)([a-z0-9-]+)(?:#([a-z0-9-]+))?"`) +var helpTargetPattern = regexp.MustCompile(`(?:template "helplink" "|href="/hilfe/)([a-z0-9-]+)(?:#([a-z0-9-]+))?"`) -// TestHilfelinksZeigenAufVorhandeneAbschnitte prüft jedes Hilfe-Ziel in allen +// TestHelpLinksPointToExistingSections prüft jedes Hilfe-Ziel in allen // Templates gegen die echten Hilfeseiten (AUSBAUSTUFEN.md 5.4). // // Die Abschnitts-ids entstehen aus den Überschriften der Hilfeseiten. Benennt // jemand eine Überschrift um, zeigt der "?"-Link ins Leere — ohne diesen Test // würde das niemand bemerken, denn der Browser springt dann einfach an den // Seitenanfang, ganz ohne Fehlermeldung. -func TestHilfelinksZeigenAufVorhandeneAbschnitte(t *testing.T) { +func TestHelpLinksPointToExistingSections(t *testing.T) { srv, _, _ := testServer(t) h := testHandler(t, srv) // Als Plattform-Admin, damit auch die Admin-Hilfeseite erreichbar ist. - cookie := anmelden(t, h, testAdminToken) + cookie := login(t, h, testAdminToken) - dateien, err := filepath.Glob("../../templates/*.html") - if err != nil || len(dateien) == 0 { + files, err := filepath.Glob("../../templates/*.html") + if err != nil || len(files) == 0 { t.Fatalf("keine Templates gefunden: %v", err) } // Jede Hilfeseite nur einmal abrufen, auch wenn sie mehrfach verlinkt ist. - seiten := map[string]string{} - gefunden := 0 + pages := map[string]string{} + found := 0 - for _, datei := range dateien { - inhalt, err := os.ReadFile(datei) + for _, file := range files { + content, err := os.ReadFile(file) if err != nil { t.Fatal(err) } - for _, treffer := range hilfeZielMuster.FindAllStringSubmatch(string(inhalt), -1) { - // treffer[0] ist der ganze Fund, treffer[1] die Seite, - // treffer[2] der Abschnitt (leer, wenn es keinen gibt). - seite, abschnitt := treffer[1], treffer[2] - gefunden++ + for _, hits := range helpTargetPattern.FindAllStringSubmatch(string(content), -1) { + // hits[0] ist der ganze Fund, treffer[1] die Seite, + // hits[2] der Abschnitt (leer, wenn es keinen gibt). + page, section := hits[1], hits[2] + found++ - html, schonGeholt := seiten[seite] - if !schonGeholt { - r := httptest.NewRequest(http.MethodGet, "/hilfe/"+seite, nil) + html, alreadyFetched := pages[page] + if !alreadyFetched { + r := httptest.NewRequest(http.MethodGet, "/hilfe/"+page, nil) r.AddCookie(cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusOK { - t.Errorf("%s: Hilfeseite %q antwortet mit %d", filepath.Base(datei), seite, w.Code) + t.Errorf("%s: Hilfeseite %q antwortet mit %d", filepath.Base(file), page, w.Code) continue } html = w.Body.String() - seiten[seite] = html + pages[page] = html } - if abschnitt != "" && !strings.Contains(html, `id="`+abschnitt+`"`) { - t.Errorf("%s: Abschnitt %q gibt es auf /hilfe/%s nicht", filepath.Base(datei), abschnitt, seite) + if section != "" && !strings.Contains(html, `id="`+section+`"`) { + t.Errorf("%s: Abschnitt %q gibt es auf /hilfe/%s nicht", filepath.Base(file), section, page) } } } // Findet das Muster gar nichts, prüft der Test auch nichts — dann ist eher // das Muster kaputt als die Templates. - if gefunden < 5 { - t.Errorf("nur %d Hilfe-Ziele in den Templates gefunden — stimmt das Suchmuster noch?", gefunden) + if found < 5 { + t.Errorf("nur %d Hilfe-Ziele in den Templates gefunden — stimmt das Suchmuster noch?", found) } } -// TestHilfelinkImHTML: Das Partial erzeugt einen echten Link mit Abschnitt — +// TestHelpLinkInHTML: Das Partial erzeugt einen echten Link mit Abschnitt — // html/template darf das "#" dabei nicht kaputt-maskieren. -func TestHilfelinkImHTML(t *testing.T) { - srv, db, projekt := testServer(t) +func TestHelpLinkInHTML(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)) r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/new", nil) r.AddCookie(cookie) @@ -2214,8 +2314,8 @@ func TestHilfelinkImHTML(t *testing.T) { } func TestServerURL(t *testing.T) { - faelle := []struct { - host, erwartet string + cases := []struct { + host, want string }{ {"munin.intern", "https://munin.intern"}, {"localhost:8080", "http://localhost:8080"}, @@ -2224,52 +2324,52 @@ func TestServerURL(t *testing.T) { {"böse<script>", "https://DEIN-SERVER"}, {"", "https://DEIN-SERVER"}, } - for _, f := range faelle { + for _, f := range cases { r := httptest.NewRequest(http.MethodGet, "/hilfe", nil) r.Host = f.host - if got := serverURL(r); got != f.erwartet { - t.Errorf("serverURL(%q) = %q, erwartet %q", f.host, got, f.erwartet) + if got := serverURL(r); got != f.want { + t.Errorf("serverURL(%q) = %q, erwartet %q", f.host, got, f.want) } } } -// TestHilfeAdminSeiteNurFuerAdmins: Mit Projekt-Key ist die Admin-Seite weder +// TestHelpAdminPageOnlyForAdmins: Mit Projekt-Key ist die Admin-Seite weder // verlinkt noch direkt aufrufbar; der Plattform-Admin sieht sie in der Leiste. -func TestHilfeAdminSeiteNurFuerAdmins(t *testing.T) { - srv, db, projekt := testServer(t) +func TestHelpAdminPageOnlyForAdmins(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - holen := func(cookie *http.Cookie, pfad string) *httptest.ResponseRecorder { - r := httptest.NewRequest(http.MethodGet, pfad, nil) + fetch := func(cookie *http.Cookie, filePath string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, filePath, nil) r.AddCookie(cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } - team := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "chefin", auth.RoleAdmin)) - if w := holen(team, "/hilfe/plattform-admin"); w.Code != http.StatusNotFound { + team := login(t, h, createKey(t, srv, db, project.ID, "chefin", auth.RoleAdmin)) + if w := fetch(team, "/hilfe/plattform-admin"); w.Code != http.StatusNotFound { t.Errorf("Projekt-Key: Status = %d, erwartet 404", w.Code) } - if strings.Contains(holen(team, "/hilfe").Body.String(), `href="/hilfe/plattform-admin"`) { + if strings.Contains(fetch(team, "/hilfe").Body.String(), `href="/hilfe/plattform-admin"`) { t.Error("Projekt-Key sieht den Link auf die Admin-Seite") } - admin := anmelden(t, h, testAdminToken) - if !strings.Contains(holen(admin, "/hilfe").Body.String(), `href="/hilfe/plattform-admin"`) { + admin := login(t, h, testAdminToken) + if !strings.Contains(fetch(admin, "/hilfe").Body.String(), `href="/hilfe/plattform-admin"`) { t.Error("Plattform-Admin sieht den Link auf die Admin-Seite nicht") } } -// TestExportKnopfImZweitenWorkspace: Der Knopf "Jetzt exportieren" muss den +// TestExportButtonInSecondWorkspace: Der Knopf "Jetzt exportieren" muss den // gerade gewählten Workspace exportieren — auch wenn es nicht der erste ist, // und auch wenn dort ein Projekt denselben Bezeichner trägt wie im ersten. // Vorher war der Exporter fest an die Datei des ersten Workspace gebunden und // meldete hier Erfolg, ohne eine Datei zu schreiben. -func TestExportKnopfImZweitenWorkspace(t *testing.T) { - srv, _, projektA := testServer(t) - ziel := t.TempDir() - srv.exporter = export.New(ziel) +func TestExportButtonInSecondWorkspace(t *testing.T) { + srv, _, projectA := testServer(t) + target := t.TempDir() + srv.exporter = export.New(target) h := testHandler(t, srv) ctx := context.Background() @@ -2277,26 +2377,26 @@ func TestExportKnopfImZweitenWorkspace(t *testing.T) { if err != nil { t.Fatal(err) } - offenB, err := srv.workspaces.Get(wsB.ID) + openedB, err := srv.workspaces.Get(wsB.ID) if err != nil { t.Fatal(err) } // Absichtlich derselbe Bezeichner wie das Projekt im ersten Workspace. - projektB, err := offenB.Store.CreateProject(ctx, wsB.ID, projektA.Slug, "Vertriebsprojekt", "") + projectB, err := openedB.Store.CreateProject(ctx, wsB.ID, projectA.Slug, "Vertriebsprojekt", "") if err != nil { t.Fatal(err) } - if _, err := offenB.Store.PutEntry(ctx, store.EntryWrite{ - ProjectID: projektB.ID, Slug: "angebote", Title: "Angebote", + if _, err := openedB.Store.PutEntry(ctx, store.EntryWrite{ + ProjectID: projectB.ID, Slug: "angebote", Title: "Angebote", Content: "Wissen aus dem Vertrieb", Source: store.SourceHuman, }); err != nil { t.Fatal(err) } - cookie := anmeldenAlsPlattformAdmin(t, h, wsB.ID) - token := auth.NewSigner([]byte(testSecret)).CSRFToken(plattformSubjekt(wsB.ID)) - formular := url.Values{auth.CSRFFieldName: {token}} - r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(formular.Encode())) + cookie := loginAsPlatformAdmin(t, h, wsB.ID) + token := auth.NewSigner([]byte(testSecret)).CSRFToken(platformSubject(wsB.ID)) + form := url.Values{auth.CSRFFieldName: {token}} + r := httptest.NewRequest(http.MethodPost, "/admin/export", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) w := httptest.NewRecorder() @@ -2306,30 +2406,30 @@ func TestExportKnopfImZweitenWorkspace(t *testing.T) { t.Fatalf("Status = %d, erwartet 303", w.Code) } - inhalt, err := os.ReadFile(filepath.Join(ziel, wsB.ID, projektB.Slug, "angebote.md")) + content, err := os.ReadFile(filepath.Join(target, wsB.ID, projectB.Slug, "angebote.md")) if err != nil { t.Fatalf("Eintrag aus dem zweiten Workspace wurde nicht exportiert: %v", err) } - if !strings.Contains(string(inhalt), "Wissen aus dem Vertrieb") { + if !strings.Contains(string(content), "Wissen aus dem Vertrieb") { t.Error("exportierte Datei hat den falschen Inhalt") } // Der Knopf exportiert nur den gewählten Workspace, nicht den ersten. - if _, err := os.Stat(filepath.Join(ziel, projektA.WorkspaceID)); err == nil { + if _, err := os.Stat(filepath.Join(target, projectA.WorkspaceID)); err == nil { t.Error("der Knopf hat auch den nicht gewählten Workspace exportiert") } } // ── Papierkorb ─────────────────────────────────────────────────────────────── -// formularSenden schickt ein Formular mit gültigem CSRF-Token ab, so wie es +// submitForm schickt ein Formular mit gültigem CSRF-Token ab, so wie es // ein Mensch mit dem ersten Key des Projekts im Browser täte. -func formularSenden(t *testing.T, h http.Handler, db *store.Store, projekt store.Project, cookie *http.Cookie, pfad string, formular url.Values) *httptest.ResponseRecorder { +func submitForm(t *testing.T, h http.Handler, db *store.Store, project store.Project, cookie *http.Cookie, filePath string, form url.Values) *httptest.ResponseRecorder { t.Helper() - keys, _ := db.ListAPIKeys(context.Background(), projekt.ID) - formular.Set(auth.CSRFFieldName, - auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix+projekt.WorkspaceID+":"+keys[0].ID)) + keys, _ := db.ListAPIKeys(context.Background(), project.ID) + form.Set(auth.CSRFFieldName, + auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix+project.WorkspaceID+":"+keys[0].ID)) - r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode())) + r := httptest.NewRequest(http.MethodPost, filePath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(cookie) w := httptest.NewRecorder() @@ -2337,175 +2437,175 @@ func formularSenden(t *testing.T, h http.Handler, db *store.Store, projekt store return w } -// seiteHolen ruft eine Seite ab. -func seiteHolen(h http.Handler, cookie *http.Cookie, pfad string) *httptest.ResponseRecorder { - r := httptest.NewRequest(http.MethodGet, pfad, nil) +// fetchPage ruft eine Seite ab. +func fetchPage(h http.Handler, cookie *http.Cookie, filePath string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, filePath, nil) r.AddCookie(cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } -// TestPapierkorbAblauf spielt den ganzen Weg durch: löschen, im Papierkorb +// TestTrashExpiry spielt den ganzen Weg durch: löschen, im Papierkorb // sehen, wiederherstellen, erneut löschen, endgültig löschen. -func TestPapierkorbAblauf(t *testing.T) { - srv, db, projekt := testServer(t) +func TestTrashExpiry(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Wichtiges Wissen", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Wichtiges Wissen", Content: "x", Source: store.SourceHuman}) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)) // 1. Löschen über die Detailansicht → Papierkorb, mit Hinweis. - w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/delete", url.Values{}) + w := submitForm(t, h, db, project, cookie, "/p/testprojekt/e/e/delete", url.Values{}) if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "hinweis=") { t.Fatalf("Löschen: Status %d, Ziel %q", w.Code, w.Header().Get("Location")) } - if seiteHolen(h, cookie, "/p/testprojekt/e/e").Code != http.StatusNotFound { + if fetchPage(h, cookie, "/p/testprojekt/e/e").Code != http.StatusNotFound { t.Error("der gelöschte Eintrag ist noch aufrufbar") } // 2. Die Papierkorb-Seite zeigt ihn samt Zugang. - seite := seiteHolen(h, cookie, "/p/testprojekt/trash") - if seite.Code != http.StatusOK { - t.Fatalf("Papierkorb: Status %d", seite.Code) + page := fetchPage(h, cookie, "/p/testprojekt/trash") + if page.Code != http.StatusOK { + t.Fatalf("Papierkorb: Status %d", page.Code) } - if !strings.Contains(seite.Body.String(), "Wichtiges Wissen") || !strings.Contains(seite.Body.String(), "mensch") { + if !strings.Contains(page.Body.String(), "Wichtiges Wissen") || !strings.Contains(page.Body.String(), "mensch") { t.Error("der Papierkorb zeigt Eintrag oder löschenden Zugang nicht") } // 3. Wiederherstellen. - formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}}) - if seiteHolen(h, cookie, "/p/testprojekt/e/e").Code != http.StatusOK { + submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}}) + if fetchPage(h, cookie, "/p/testprojekt/e/e").Code != http.StatusOK { t.Fatal("der wiederhergestellte Eintrag ist nicht wieder aufrufbar") } // 4. Erneut löschen und endgültig entfernen. - formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/delete", url.Values{}) - formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"e"}}) - if liste, _ := db.ListTrash(ctx, projekt.ID, ""); len(liste) != 0 { + submitForm(t, h, db, project, cookie, "/p/testprojekt/e/e/delete", url.Values{}) + submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"e"}}) + if list, _ := db.ListTrash(ctx, project.ID, ""); len(list) != 0 { t.Error("der Eintrag liegt nach endgültigem Löschen noch im Papierkorb") } // Jeder Schritt steht im Protokoll. - ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) - gezaehlt := map[string]int{} - for _, ev := range ereignisse { - gezaehlt[ev.Action]++ + events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID}) + counted := map[string]int{} + for _, ev := range events { + counted[ev.Action]++ } - if gezaehlt[store.ActionEntryDelete] != 2 || gezaehlt[store.ActionEntryRestore] != 1 || gezaehlt[store.ActionEntryPurge] != 1 { - t.Errorf("Protokoll: %v", gezaehlt) + if counted[store.ActionEntryDelete] != 2 || counted[store.ActionEntryRestore] != 1 || counted[store.ActionEntryPurge] != 1 { + t.Errorf("Protokoll: %v", counted) } } -// TestPapierkorbPurgeNurAusDemPapierkorb: Über das Formular zum endgültigen +// TestTrashPurgeOnlyFromTrash: Über das Formular zum endgültigen // Löschen lässt sich kein Eintrag entfernen, der nicht im Papierkorb liegt. -func TestPapierkorbPurgeNurAusDemPapierkorb(t *testing.T) { - srv, db, projekt := testServer(t) +func TestTrashPurgeOnlyFromTrash(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "lebt", Title: "T", Source: store.SourceHuman}) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "lebt", Title: "T", Source: store.SourceHuman}) + cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)) - formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"lebt"}}) + submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/purge", url.Values{"slugs": {"lebt"}}) - if _, err := db.EntryBySlug(ctx, projekt.ID, "lebt"); err != nil { + if _, err := db.EntryBySlug(ctx, project.ID, "lebt"); err != nil { t.Error("ein Eintrag außerhalb des Papierkorbs wurde endgültig gelöscht") } - ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) - for _, ev := range ereignisse { + events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID}) + for _, ev := range events { if ev.Action == store.ActionEntryPurge { t.Error("das Protokoll vermerkt eine endgültige Löschung, die nicht stattfand") } } } -// TestPapierkorbNurMitSchreibrecht: Lesende sehen weder den Link noch die +// TestTrashRequiresWriteRole: Lesende sehen weder den Link noch die // Seite — der Papierkorb kann Inhalte enthalten, die gerade deshalb gelöscht // wurden, weil sie nicht jeder sehen soll. -func TestPapierkorbNurMitSchreibrecht(t *testing.T) { - srv, db, projekt := testServer(t) +func TestTrashRequiresWriteRole(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "T", Source: store.SourceHuman}) - db.MoveEntryToTrash(ctx, projekt.ID, "e", "tom") + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "T", Source: store.SourceHuman}) + db.MoveEntryToTrash(ctx, project.ID, "e", "tom") - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead)) - if w := seiteHolen(h, cookie, "/p/testprojekt/trash"); w.Code != http.StatusForbidden { + if w := fetchPage(h, cookie, "/p/testprojekt/trash"); w.Code != http.StatusForbidden { t.Errorf("Papierkorb für Leserolle: Status %d, erwartet 403", w.Code) } - if strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/").Body.String(), "/trash") { + if strings.Contains(fetchPage(h, cookie, "/p/testprojekt/").Body.String(), "/trash") { t.Error("die Leserolle sieht den Link zum Papierkorb") } - if w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}}); w.Code != http.StatusForbidden { + if w := submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"e"}}); w.Code != http.StatusForbidden { t.Errorf("Wiederherstellen mit Leserolle: Status %d, erwartet 403", w.Code) } } -// TestPapierkorbFilterNachZugang: Nach einem Zugang gefiltert, lässt sich +// TestTrashFilterByKey: Nach einem Zugang gefiltert, lässt sich // alles, was er gelöscht hat, in einem Zug zurückholen — ohne das, was andere // gelöscht haben. -func TestPapierkorbFilterNachZugang(t *testing.T) { - srv, db, projekt := testServer(t) +func TestTrashFilterByKey(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() for _, slug := range []string{"a", "b", "c"} { - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: slug, Title: "Titel " + slug, Source: store.SourceHuman}) + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: slug, Title: "Titel " + slug, Source: store.SourceHuman}) } - db.MoveEntryToTrash(ctx, projekt.ID, "a", "agent-1") - db.MoveEntryToTrash(ctx, projekt.ID, "b", "agent-1") - db.MoveEntryToTrash(ctx, projekt.ID, "c", "tom") + db.MoveEntryToTrash(ctx, project.ID, "a", "agent-1") + db.MoveEntryToTrash(ctx, project.ID, "b", "agent-1") + db.MoveEntryToTrash(ctx, project.ID, "c", "tom") - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)) - seite := seiteHolen(h, cookie, "/p/testprojekt/trash?von=agent-1").Body.String() - if strings.Contains(seite, "Titel c") { + page := fetchPage(h, cookie, "/p/testprojekt/trash?von=agent-1").Body.String() + if strings.Contains(page, "Titel c") { t.Error("der Filter zeigt auch, was ein anderer Zugang gelöscht hat") } - if !strings.Contains(seite, "Alle 2 von") { + if !strings.Contains(page, "Alle 2 von") { t.Error("der Knopf zum Wiederherstellen aller gefilterten Einträge fehlt") } // So, wie das Formular "Alle … wiederherstellen" abschickt — mit einem // doppelten Bezeichner, der nur einmal zählen darf. - formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/trash/restore", + submitForm(t, h, db, project, cookie, "/p/testprojekt/trash/restore", url.Values{"slugs": {"a", "b", "a"}, "von": {"agent-1"}}) - if liste, _ := db.ListTrash(ctx, projekt.ID, ""); len(liste) != 1 || liste[0].Slug != "c" { - t.Errorf("im Papierkorb übrig: %v, erwartet nur „c“", liste) + if list, _ := db.ListTrash(ctx, project.ID, ""); len(list) != 1 || list[0].Slug != "c" { + t.Errorf("im Papierkorb übrig: %v, erwartet nur „c“", list) } - ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID}) - wiederhergestellt := 0 - for _, ev := range ereignisse { + events, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID}) + restored := 0 + for _, ev := range events { if ev.Action == store.ActionEntryRestore { - wiederhergestellt++ + restored++ } } - if wiederhergestellt != 2 { - t.Errorf("%d Wiederherstellungen protokolliert, erwartet 2", wiederhergestellt) + if restored != 2 { + t.Errorf("%d Wiederherstellungen protokolliert, erwartet 2", restored) } } -// TestSpeichernHoltAusDemPapierkorb: Legt ein Mensch einen "neuen" Eintrag mit +// TestSaveRestoresFromTrash: Legt ein Mensch einen "neuen" Eintrag mit // einem Bezeichner an, der im Papierkorb liegt, erfährt er, dass er ihn // zurückgeholt und überschrieben hat. -func TestSpeichernHoltAusDemPapierkorb(t *testing.T) { - srv, db, projekt := testServer(t) +func TestSaveRestoresFromTrash(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "e", Title: "Alt", Content: "alt", Source: store.SourceHuman}) - db.MoveEntryToTrash(ctx, projekt.ID, "e", "agent-1") + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "e", Title: "Alt", Content: "alt", Source: store.SourceHuman}) + db.MoveEntryToTrash(ctx, project.ID, "e", "agent-1") - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)) - w := formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/save", + w := submitForm(t, h, db, project, cookie, "/p/testprojekt/save", url.Values{"slug": {"e"}, "title": {"Neu"}, "content": {"neu"}, "version": {"0"}}) if w.Code != http.StatusSeeOther { t.Fatalf("Speichern: Status %d (%s)", w.Code, w.Body.String()) @@ -2516,8 +2616,8 @@ func TestSpeichernHoltAusDemPapierkorb(t *testing.T) { // Ein Formular, das noch den Stand vor dem Löschen trug, bekommt dagegen // einen Konflikt statt die Löschung stillschweigend aufzuheben. - db.MoveEntryToTrash(ctx, projekt.ID, "e", "agent-1") - w = formularSenden(t, h, db, projekt, cookie, "/p/testprojekt/e/e/save", + db.MoveEntryToTrash(ctx, project.ID, "e", "agent-1") + w = submitForm(t, h, db, project, cookie, "/p/testprojekt/e/e/save", url.Values{"slug": {"e"}, "title": {"Neu"}, "content": {"noch neuer"}, "version": {"2"}}) if w.Code != http.StatusBadRequest { t.Errorf("Speichern eines inzwischen gelöschten Eintrags: Status %d, erwartet 400 (zurück ins Formular)", w.Code) @@ -2526,60 +2626,60 @@ func TestSpeichernHoltAusDemPapierkorb(t *testing.T) { // ── Nutzungsübersicht ──────────────────────────────────────────────────────── -// TestNutzungsseite: Die Seite zeigt Bestand, Schreibvorgänge und Verweise ins +// TestUsagePage: Die Seite zeigt Bestand, Schreibvorgänge und Verweise ins // Leere — auch für die Leserolle, aber ohne den Link zum Anlegen. -func TestNutzungsseite(t *testing.T) { - srv, db, projekt := testServer(t) +func TestUsagePage(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "Ohne Schlagwort", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "Ohne Schlagwort", Content: "siehe [[fehlt-noch]]", Source: store.SourceAgent}) - db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite, + db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP, Detail: store.DetailEntryCreated}) - db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite, + db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP, Detail: store.DetailEntryUpdated}) - leser := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) - w := seiteHolen(h, leser, "/p/testprojekt/usage") + reader := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead)) + w := fetchPage(h, reader, "/p/testprojekt/usage") if w.Code != http.StatusOK { t.Fatalf("Status %d: %s", w.Code, w.Body.String()) } body := w.Body.String() - for _, erwartet := range []string{"Ohne Schlagwort", "fehlt-noch", "50 %"} { - if !strings.Contains(body, erwartet) { - t.Errorf("%q fehlt auf der Nutzungsseite", erwartet) + for _, want := range []string{"Ohne Schlagwort", "fehlt-noch", "50 %"} { + if !strings.Contains(body, want) { + t.Errorf("%q fehlt auf der Nutzungsseite", want) } } if strings.Contains(body, "/new?slug=") { t.Error("die Leserolle bekommt einen Link zum Anlegen") } - schreiber := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) - if !strings.Contains(seiteHolen(h, schreiber, "/p/testprojekt/usage").Body.String(), "/p/testprojekt/new?slug=fehlt-noch") { + writer := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)) + if !strings.Contains(fetchPage(h, writer, "/p/testprojekt/usage").Body.String(), "/p/testprojekt/new?slug=fehlt-noch") { t.Error("mit Schreibrecht fehlt der Link zum Anlegen des fehlenden Eintrags") } } -// TestNutzungsseiteZeigtSuchen: Suchen ohne Treffer erscheinen mit Anzahl — +// TestUsagePageShowsSearches: Suchen ohne Treffer erscheinen mit Anzahl — // und eine Anfrage mit HTML darin wird als Text ausgegeben, nicht ausgeführt. -func TestNutzungsseiteZeigtSuchen(t *testing.T) { - srv, db, projekt := testServer(t) +func TestUsagePageShowsSearches(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "vpn zugang", Results: 0}) - db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "vpn zugang", Results: 0}) - db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "<script>x</script>", Results: 0}) - db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "deployment", + db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "vpn zugang", Results: 0}) + db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "vpn zugang", Results: 0}) + db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "<script>x</script>", Results: 0}) + db.LogSearch(ctx, store.SearchLogEntry{ProjectID: project.ID, Query: "deployment", Synonyms: []string{"release"}, Results: 1}) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) - body := seiteHolen(h, cookie, "/p/testprojekt/usage").Body.String() + cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead)) + body := fetchPage(h, cookie, "/p/testprojekt/usage").Body.String() - for _, erwartet := range []string{"vpn zugang", "2×", "75 %", "25 %"} { - if !strings.Contains(body, erwartet) { - t.Errorf("%q fehlt auf der Nutzungsseite", erwartet) + for _, want := range []string{"vpn zugang", "2×", "75 %", "25 %"} { + if !strings.Contains(body, want) { + t.Errorf("%q fehlt auf der Nutzungsseite", want) } } if strings.Contains(body, "<script>x</script>") { @@ -2590,36 +2690,36 @@ func TestNutzungsseiteZeigtSuchen(t *testing.T) { } } -// TestNutzungsseiteZeigtZurPruefung: Ein kuratierter Eintrag, den ein Agent +// TestUsagePageShowsForReview: Ein kuratierter Eintrag, den ein Agent // geändert hat, steht mit Begründung und Link zum Verlauf oben auf der Seite. -func TestNutzungsseiteZeigtZurPruefung(t *testing.T) { - srv, db, projekt := testServer(t) +func TestUsagePageShowsForReview(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "vpn", Title: "VPN-Zugang", + db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "vpn", Title: "VPN-Zugang", Content: "neu", Source: store.SourceAgent}) - db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-x", Action: store.ActionEntryWrite, + db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent-x", Action: store.ActionEntryWrite, EntrySlug: "vpn", Source: store.SourceMCP, Detail: store.CuratedOverrideDetail("Server umgezogen")}) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) - body := seiteHolen(h, cookie, "/p/testprojekt/usage").Body.String() + cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead)) + body := fetchPage(h, cookie, "/p/testprojekt/usage").Body.String() - for _, erwartet := range []string{"Zur Prüfung", "VPN-Zugang", "/p/testprojekt/e/vpn/versions", "Server umgezogen", "agent-x"} { - if !strings.Contains(body, erwartet) { - t.Errorf("%q fehlt auf der Nutzungsseite", erwartet) + for _, want := range []string{"Zur Prüfung", "VPN-Zugang", "/p/testprojekt/e/vpn/versions", "Server umgezogen", "agent-x"} { + if !strings.Contains(body, want) { + t.Errorf("%q fehlt auf der Nutzungsseite", want) } } } -// TestNutzungsseiteLeeresProjekt: Ein Projekt ganz ohne Daten darf nicht +// TestUsagePageEmptyProject: Ein Projekt ganz ohne Daten darf nicht // stolpern (es fehlt dann in der map der Kennzahlen). -func TestNutzungsseiteLeeresProjekt(t *testing.T) { - srv, db, projekt := testServer(t) +func TestUsagePageEmptyProject(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead)) - w := seiteHolen(h, cookie, "/p/testprojekt/usage") + cookie := login(t, h, createKey(t, srv, db, project.ID, "leser", auth.RoleRead)) + w := fetchPage(h, cookie, "/p/testprojekt/usage") if w.Code != http.StatusOK { t.Fatalf("Status %d", w.Code) } @@ -2628,29 +2728,29 @@ func TestNutzungsseiteLeeresProjekt(t *testing.T) { } } -// TestNeuerEintragMitVorbelegtemBezeichner: ?slug= füllt das Feld, ein +// TestNewEntryWithPrefilledSlug: ?slug= füllt das Feld, ein // ungültiger Wert wird ignoriert. -func TestNeuerEintragMitVorbelegtemBezeichner(t *testing.T) { - srv, db, projekt := testServer(t) +func TestNewEntryWithPrefilledSlug(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite)) + cookie := login(t, h, createKey(t, srv, db, project.ID, "mensch", auth.RoleWrite)) - if !strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/new?slug=fehlt-noch").Body.String(), `value="fehlt-noch"`) { + if !strings.Contains(fetchPage(h, cookie, "/p/testprojekt/new?slug=fehlt-noch").Body.String(), `value="fehlt-noch"`) { t.Error("der Bezeichner wurde nicht vorbelegt") } - if strings.Contains(seiteHolen(h, cookie, "/p/testprojekt/new?slug=B%C3%B6se+Sache").Body.String(), "Böse") { + if strings.Contains(fetchPage(h, cookie, "/p/testprojekt/new?slug=B%C3%B6se+Sache").Body.String(), "Böse") { t.Error("ein ungültiger Bezeichner wurde ins Formular übernommen") } } -// TestAdminProjektlisteZeigtNutzung: Die Projektübersicht des Plattform-Admins +// TestAdminProjectListShowsUsage: Die Projektübersicht des Plattform-Admins // zeigt, welches Projekt gar nicht schreibt. -func TestAdminProjektlisteZeigtNutzung(t *testing.T) { - srv, db, projekt := testServer(t) +func TestAdminProjectListShowsUsage(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) - w := seiteHolen(h, cookie, "/admin/projects") + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) + w := fetchPage(h, cookie, "/admin/projects") if w.Code != http.StatusOK { t.Fatalf("Status %d", w.Code) } @@ -2662,44 +2762,44 @@ func TestAdminProjektlisteZeigtNutzung(t *testing.T) { t.Error("die Projektliste verlinkt nicht auf die Nutzungsseite") } - db.LogAudit(context.Background(), store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", + db.LogAudit(context.Background(), store.AuditEvent{ProjectID: project.ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP, Detail: store.DetailEntryCreated}) - if !strings.Contains(seiteHolen(h, cookie, "/admin/projects").Body.String(), "1 neu") { + if !strings.Contains(fetchPage(h, cookie, "/admin/projects").Body.String(), "1 neu") { t.Error("der Schreibvorgang erscheint nicht in der Projektliste") } } -// TestLiveUndReady prüft die beiden Prüfungen für Container und +// TestLiveAndReady prüft die beiden Prüfungen für Container und // Load-Balancer: Beide antworten ohne Anmeldung. /ready meldet 503, sobald die // Datenbank nicht antwortet, /live bleibt dabei ok — sonst würde der // Container neu gestartet, was gegen eine fehlende Datenbank nicht hilft. -func TestLiveUndReady(t *testing.T) { +func TestLiveAndReady(t *testing.T) { srv, _, _ := testServer(t) h := testHandler(t, srv) - abfragen := func(pfad string) (int, string) { + queries := func(filePath string) (int, string) { w := httptest.NewRecorder() - h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, pfad, nil)) + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, filePath, nil)) return w.Code, w.Body.String() } - for _, pfad := range []string{"/live", "/healthz", "/ready"} { - if code, body := abfragen(pfad); code != http.StatusOK || body != "ok\n" { - t.Errorf("%s = %d %q, erwartet 200 ok", pfad, code, body) + for _, filePath := range []string{"/live", "/healthz", "/ready"} { + if code, body := queries(filePath); code != http.StatusOK || body != "ok\n" { + t.Errorf("%s = %d %q, erwartet 200 ok", filePath, code, body) } } // Datenbank weg: Die Kontroll-Datenbank wird geschlossen. srv.control.Close() - code, body := abfragen("/ready") + code, body := queries("/ready") if code != http.StatusServiceUnavailable { t.Errorf("/ready ohne Datenbank = %d, erwartet 503", code) } if body != "nicht bereit\n" { t.Errorf("/ready verrät mehr als nötig: %q", body) } - if code, _ := abfragen("/live"); code != http.StatusOK { + if code, _ := queries("/live"); code != http.StatusOK { t.Errorf("/live ohne Datenbank = %d, erwartet weiter 200", code) } } diff --git a/internal/web/workspace_admin_test.go b/internal/web/workspace_admin_test.go index 3fb4f4f..46e7b5a 100644 --- a/internal/web/workspace_admin_test.go +++ b/internal/web/workspace_admin_test.go @@ -28,10 +28,10 @@ type workspaceAdmin struct { csrf string } -// legeWorkspaceAdminAn erzeugt einen Workspace-Admin direkt in der +// createWorkspaceAdmin erzeugt einen Workspace-Admin direkt in der // Kontroll-Datenbank und gibt das Token im Klartext zurück — wie es der // Plattform-Admin auf der Bearbeitungsseite des Workspace bekäme. -func legeWorkspaceAdminAn(t *testing.T, srv *Server, workspaceID, name string) (string, store.WorkspaceAdmin) { +func createWorkspaceAdmin(t *testing.T, srv *Server, workspaceID, name string) (string, store.WorkspaceAdmin) { t.Helper() token, err := auth.GenerateWorkspaceAdminToken() @@ -45,24 +45,24 @@ func legeWorkspaceAdminAn(t *testing.T, srv *Server, workspaceID, name string) ( return token, admin } -// anmeldenAlsWorkspaceAdmin legt einen Workspace-Admin an, meldet ihn an und +// loginAsWorkspaceAdmin legt einen Workspace-Admin an, meldet ihn an und // berechnet sein CSRF-Token. -func anmeldenAlsWorkspaceAdmin(t *testing.T, srv *Server, h http.Handler, workspaceID string) workspaceAdmin { +func loginAsWorkspaceAdmin(t *testing.T, srv *Server, h http.Handler, workspaceID string) workspaceAdmin { t.Helper() - token, admin := legeWorkspaceAdminAn(t, srv, workspaceID, "Erika") + token, admin := createWorkspaceAdmin(t, srv, workspaceID, "Erika") subject := auth.SubjectWorkspaceAdminPrefix + workspaceID + ":" + admin.ID return workspaceAdmin{ token: token, - cookie: anmelden(t, h, token), + cookie: login(t, h, token), csrf: auth.NewSigner([]byte(testSecret)).CSRFToken(subject), } } // get ruft eine Seite mit der Sitzung des Workspace-Admins auf. -func (a workspaceAdmin) get(h http.Handler, pfad string) *httptest.ResponseRecorder { - r := httptest.NewRequest(http.MethodGet, pfad, nil) +func (a workspaceAdmin) get(h http.Handler, filePath string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, filePath, nil) r.AddCookie(a.cookie) w := httptest.NewRecorder() h.ServeHTTP(w, r) @@ -71,13 +71,13 @@ func (a workspaceAdmin) get(h http.Handler, pfad string) *httptest.ResponseRecor // post schickt ein Formular mit gültigem CSRF-Token — abgewiesen werden darf // es also nur an der Rechteprüfung, nicht am Token. -func (a workspaceAdmin) post(h http.Handler, pfad string, formular url.Values) *httptest.ResponseRecorder { - if formular == nil { - formular = url.Values{} +func (a workspaceAdmin) post(h http.Handler, filePath string, form url.Values) *httptest.ResponseRecorder { + if form == nil { + form = url.Values{} } - formular.Set(auth.CSRFFieldName, a.csrf) + form.Set(auth.CSRFFieldName, a.csrf) - r := httptest.NewRequest(http.MethodPost, pfad, strings.NewReader(formular.Encode())) + r := httptest.NewRequest(http.MethodPost, filePath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.AddCookie(a.cookie) w := httptest.NewRecorder() @@ -88,9 +88,9 @@ func (a workspaceAdmin) post(h http.Handler, pfad string, formular url.Values) * // ── Was er darf ────────────────────────────────────────────────────────────── func TestWorkspaceAdminLandsOnProjects(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) w := admin.get(h, "/") if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/admin/projects" { @@ -102,7 +102,7 @@ func TestWorkspaceAdminLandsOnProjects(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("Projektliste: Status %d, erwartet 200", w.Code) } - if !strings.Contains(w.Body.String(), projekt.Name) { + if !strings.Contains(w.Body.String(), project.Name) { t.Error("die Projektliste zeigt das Projekt des Workspace nicht") } if strings.Contains(w.Body.String(), `action="/admin/export"`) { @@ -111,9 +111,9 @@ func TestWorkspaceAdminLandsOnProjects(t *testing.T) { } func TestWorkspaceAdminManagesProjects(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) ctx := context.Background() // Anlegen @@ -121,7 +121,7 @@ func TestWorkspaceAdminManagesProjects(t *testing.T) { if w.Code != http.StatusSeeOther { t.Fatalf("Anlegen: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) } - neu, err := db.ProjectBySlug(ctx, projekt.WorkspaceID, "neu") + newer, err := db.ProjectBySlug(ctx, project.WorkspaceID, "neu") if err != nil { t.Fatalf("das angelegte Projekt fehlt: %v", err) } @@ -131,46 +131,46 @@ func TestWorkspaceAdminManagesProjects(t *testing.T) { if w.Code != http.StatusSeeOther { t.Fatalf("Bearbeiten: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) } - if p, _ := db.ProjectByID(ctx, neu.ID); p.Name != "Umbenannt" { + if p, _ := db.ProjectByID(ctx, newer.ID); p.Name != "Umbenannt" { t.Errorf("Name = %q, erwartet \"Umbenannt\"", p.Name) } // Löschen - w = admin.post(h, "/admin/projects/delete", url.Values{"id": {neu.ID}, "confirm": {"neu"}}) + w = admin.post(h, "/admin/projects/delete", url.Values{"id": {newer.ID}, "confirm": {"neu"}}) if w.Code != http.StatusSeeOther { t.Fatalf("Löschen: Status %d, erwartet 303", w.Code) } - if _, err := db.ProjectByID(ctx, neu.ID); err == nil { + if _, err := db.ProjectByID(ctx, newer.ID); err == nil { t.Error("das Projekt ist nach dem Löschen noch da") } // Im Protokoll steht der Workspace-Admin mit Namen, nicht "platform-admin". - ereignisse, err := db.ListAudit(ctx, store.AuditFilter{}) + events, err := db.ListAudit(ctx, store.AuditFilter{}) if err != nil { t.Fatal(err) } - gefunden := false - for _, e := range ereignisse { + found := false + for _, e := range events { if e.Action == store.ActionProjectCreated { - gefunden = true + found = true if e.Actor != "workspace-admin:Erika" { t.Errorf("Actor = %q, erwartet \"workspace-admin:Erika\"", e.Actor) } } } - if !gefunden { + if !found { t.Error("das Anlegen des Projekts fehlt im Protokoll") } } func TestWorkspaceAdminManagesKeysAndReads(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) - for _, pfad := range []string{"/p/testprojekt/", "/p/testprojekt/keys", "/p/testprojekt/audit", "/admin/audit"} { - if w := admin.get(h, pfad); w.Code != http.StatusOK { - t.Errorf("GET %s: Status %d, erwartet 200", pfad, w.Code) + for _, filePath := range []string{"/p/testprojekt/", "/p/testprojekt/keys", "/p/testprojekt/audit", "/admin/audit"} { + if w := admin.get(h, filePath); w.Code != http.StatusOK { + t.Errorf("GET %s: Status %d, erwartet 200", filePath, w.Code) } } @@ -179,7 +179,7 @@ func TestWorkspaceAdminManagesKeysAndReads(t *testing.T) { t.Fatalf("Key anlegen: Status %d, oder kein Key in der Antwort", w.Code) } - keys, err := db.ListAPIKeys(context.Background(), projekt.ID) + keys, err := db.ListAPIKeys(context.Background(), project.ID) if err != nil || len(keys) != 1 { t.Fatalf("erwartet genau einen Key, gefunden %d (%v)", len(keys), err) } @@ -198,18 +198,18 @@ func TestWorkspaceAdminManagesKeysAndReads(t *testing.T) { // TestWorkspaceAdminCannotReachPlatformRoutes geht jede Route durch, die dem // Plattform-Admin vorbehalten bleibt. func TestWorkspaceAdminCannotReachPlatformRoutes(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) - id := projekt.WorkspaceID + admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) + id := project.WorkspaceID gets := []string{ "/admin/workspaces", "/admin/workspaces/" + id + "/edit", } - for _, pfad := range gets { - if w := admin.get(h, pfad); w.Code != http.StatusForbidden { - t.Errorf("GET %s: Status %d, erwartet 403", pfad, w.Code) + for _, filePath := range gets { + if w := admin.get(h, filePath); w.Code != http.StatusForbidden { + t.Errorf("GET %s: Status %d, erwartet 403", filePath, w.Code) } } @@ -222,9 +222,9 @@ func TestWorkspaceAdminCannotReachPlatformRoutes(t *testing.T) { "/admin/workspaces/" + id + "/admins/revoke": {"id": {"egal"}}, "/admin/export": nil, } - for pfad, formular := range posts { - if w := admin.post(h, pfad, formular); w.Code != http.StatusForbidden { - t.Errorf("POST %s: Status %d, erwartet 403", pfad, w.Code) + for filePath, form := range posts { + if w := admin.post(h, filePath, form); w.Code != http.StatusForbidden { + t.Errorf("POST %s: Status %d, erwartet 403", filePath, w.Code) } } @@ -241,13 +241,13 @@ func TestWorkspaceAdminCannotReachPlatformRoutes(t *testing.T) { // TestWorkspaceAdminCannotWriteEntries: Lesen ja, Schreiben nein — auch nicht // über Import, Papierkorb oder Wiederherstellen. func TestWorkspaceAdminCannotWriteEntries(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) - for _, pfad := range []string{"/p/testprojekt/new", "/p/testprojekt/import", "/p/testprojekt/trash"} { - if w := admin.get(h, pfad); w.Code != http.StatusForbidden { - t.Errorf("GET %s: Status %d, erwartet 403", pfad, w.Code) + for _, filePath := range []string{"/p/testprojekt/new", "/p/testprojekt/import", "/p/testprojekt/trash"} { + if w := admin.get(h, filePath); w.Code != http.StatusForbidden { + t.Errorf("GET %s: Status %d, erwartet 403", filePath, w.Code) } } @@ -270,15 +270,15 @@ func TestWorkspaceAdminCannotWriteEntries(t *testing.T) { // TestWorkspaceAdminCannotChangeWriteBudget: Das Feld wird nicht angeboten, // und ein von Hand nachgebautes Formular ändert trotzdem nichts. func TestWorkspaceAdminCannotChangeWriteBudget(t *testing.T) { - srv, db, projekt := testServer(t) + srv, db, project := testServer(t) h := testHandler(t, srv) - admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) - formularSeite := admin.get(h, "/admin/projects/testprojekt/edit") - if formularSeite.Code != http.StatusOK { - t.Fatalf("Bearbeitungsseite: Status %d, erwartet 200", formularSeite.Code) + formPage := admin.get(h, "/admin/projects/testprojekt/edit") + if formPage.Code != http.StatusOK { + t.Fatalf("Bearbeitungsseite: Status %d, erwartet 200", formPage.Code) } - if strings.Contains(formularSeite.Body.String(), `name="write_limit_hourly"`) { + if strings.Contains(formPage.Body.String(), `name="write_limit_hourly"`) { t.Error("die Bearbeitungsseite bietet dem Workspace-Admin das Schreibbudget an") } @@ -289,22 +289,22 @@ func TestWorkspaceAdminCannotChangeWriteBudget(t *testing.T) { t.Fatalf("Speichern: Status %d, erwartet 303 — %s", w.Code, w.Body.String()) } - gelesen, err := db.ProjectByID(context.Background(), projekt.ID) + read, err := db.ProjectByID(context.Background(), project.ID) if err != nil { t.Fatal(err) } - if gelesen.WriteLimitHourly != projekt.WriteLimitHourly { - t.Errorf("WriteLimitHourly = %d, erwartet unverändert %d", gelesen.WriteLimitHourly, projekt.WriteLimitHourly) + if read.WriteLimitHourly != project.WriteLimitHourly { + t.Errorf("WriteLimitHourly = %d, erwartet unverändert %d", read.WriteLimitHourly, project.WriteLimitHourly) } - if gelesen.Name != "Neuer Name" { - t.Errorf("Name = %q — die übrigen Felder sollen trotzdem gespeichert werden", gelesen.Name) + if read.Name != "Neuer Name" { + t.Errorf("Name = %q — die übrigen Felder sollen trotzdem gespeichert werden", read.Name) } } // TestWorkspaceAdminStaysInOwnWorkspace: Projekte eines anderen Workspace // sind für ihn nicht vorhanden — weder unter /p/ noch in der Verwaltung. func TestWorkspaceAdminStaysInOwnWorkspace(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() @@ -316,23 +316,23 @@ func TestWorkspaceAdminStaysInOwnWorkspace(t *testing.T) { if err != nil { t.Fatal(err) } - projektB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "") + projectB, err := workspaceB.Store.CreateProject(ctx, wsB.ID, "fremd", "Fremdes Projekt", "") if err != nil { t.Fatal(err) } - admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) - for _, pfad := range []string{"/p/fremd/", "/p/fremd/keys", "/admin/projects/fremd/edit"} { - if w := admin.get(h, pfad); w.Code != http.StatusNotFound { - t.Errorf("GET %s: Status %d, erwartet 404", pfad, w.Code) + for _, filePath := range []string{"/p/fremd/", "/p/fremd/keys", "/admin/projects/fremd/edit"} { + if w := admin.get(h, filePath); w.Code != http.StatusNotFound { + t.Errorf("GET %s: Status %d, erwartet 404", filePath, w.Code) } } - if w := admin.post(h, "/admin/projects/delete", url.Values{"id": {projektB.ID}, "confirm": {"fremd"}}); w.Code != http.StatusNotFound { + if w := admin.post(h, "/admin/projects/delete", url.Values{"id": {projectB.ID}, "confirm": {"fremd"}}); w.Code != http.StatusNotFound { t.Errorf("fremdes Projekt löschen: Status %d, erwartet 404", w.Code) } - if _, err := workspaceB.Store.ProjectByID(ctx, projektB.ID); err != nil { + if _, err := workspaceB.Store.ProjectByID(ctx, projectB.ID); err != nil { t.Error("das Projekt aus Workspace B wurde gelöscht") } @@ -344,18 +344,18 @@ func TestWorkspaceAdminStaysInOwnWorkspace(t *testing.T) { // TestWorkspaceAdminTokenRejectedByAPI: Das Token taugt nur für die // Weboberfläche, nicht für MCP. func TestWorkspaceAdminTokenRejectedByAPI(t *testing.T) { - srv, _, projekt := testServer(t) - token, _ := legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Erika") + srv, _, project := testServer(t) + token, _ := createWorkspaceAdmin(t, srv, project.WorkspaceID, "Erika") - var erreicht bool - h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { erreicht = true })) + var reached bool + h := srv.APIAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) r := httptest.NewRequest(http.MethodPost, "/mcp", nil) r.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() h.ServeHTTP(w, r) - if erreicht { + if reached { t.Fatal("ein Workspace-Admin-Token kam an der MCP-Schnittstelle durch") } if w.Code != http.StatusUnauthorized { @@ -366,11 +366,11 @@ func TestWorkspaceAdminTokenRejectedByAPI(t *testing.T) { // ── Anlegen und Zurückziehen durch den Plattform-Admin ────────────────────── func TestPlatformAdminIssuesAndRevokesWorkspaceAdmin(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) - pfad := "/admin/workspaces/" + projekt.WorkspaceID + "/admins/" + filePath := "/admin/workspaces/" + project.WorkspaceID + "/admins/" - w := adminPost(t, h, projekt.WorkspaceID, pfad+"create", url.Values{"name": {"Erika"}}) + w := adminPost(t, h, project.WorkspaceID, filePath+"create", url.Values{"name": {"Erika"}}) if w.Code != http.StatusOK { t.Fatalf("Anlegen: Status %d, erwartet 200 — %s", w.Code, w.Body.String()) } @@ -386,14 +386,14 @@ func TestPlatformAdminIssuesAndRevokesWorkspaceAdmin(t *testing.T) { t.Fatalf("%q sieht nicht wie ein Workspace-Admin-Token aus", token) } - cookie := anmelden(t, h, token) + cookie := login(t, h, token) - admins, err := srv.control.ListWorkspaceAdmins(context.Background(), projekt.WorkspaceID) + admins, err := srv.control.ListWorkspaceAdmins(context.Background(), project.WorkspaceID) if err != nil || len(admins) != 1 { t.Fatalf("erwartet genau einen Workspace-Admin, gefunden %d (%v)", len(admins), err) } - w = adminPost(t, h, projekt.WorkspaceID, pfad+"revoke", url.Values{"id": {admins[0].ID}}) + w = adminPost(t, h, project.WorkspaceID, filePath+"revoke", url.Values{"id": {admins[0].ID}}) if w.Code != http.StatusOK { t.Fatalf("Zurückziehen: Status %d, erwartet 200", w.Code) } @@ -409,8 +409,8 @@ func TestPlatformAdminIssuesAndRevokesWorkspaceAdmin(t *testing.T) { } // Und eine neue Anmeldung klappt nicht mehr. - formular := url.Values{"key": {token}} - r = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(formular.Encode())) + form := url.Values{"key": {token}} + r = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w = httptest.NewRecorder() h.ServeHTTP(w, r) @@ -422,18 +422,18 @@ func TestPlatformAdminIssuesAndRevokesWorkspaceAdmin(t *testing.T) { // TestWorkspaceListCountsActiveAdmins: Die Übersicht zählt nur aktive // Workspace-Admins, und ein frisch angelegter gilt nicht als „ungenutzt“. func TestWorkspaceListCountsActiveAdmins(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() - legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Erika") - legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Max") - _, weg := legeWorkspaceAdminAn(t, srv, projekt.WorkspaceID, "Ehemalig") - if err := srv.control.RevokeWorkspaceAdmin(ctx, projekt.WorkspaceID, weg.ID); err != nil { + createWorkspaceAdmin(t, srv, project.WorkspaceID, "Erika") + createWorkspaceAdmin(t, srv, project.WorkspaceID, "Max") + _, gone := createWorkspaceAdmin(t, srv, project.WorkspaceID, "Ehemalig") + if err := srv.control.RevokeWorkspaceAdmin(ctx, project.WorkspaceID, gone.ID); err != nil { t.Fatal(err) } - cookie := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + cookie := loginAsPlatformAdmin(t, h, project.WorkspaceID) r := httptest.NewRequest(http.MethodGet, "/admin/workspaces", nil) r.AddCookie(cookie) w := httptest.NewRecorder() @@ -443,7 +443,7 @@ func TestWorkspaceListCountsActiveAdmins(t *testing.T) { t.Fatalf("Status = %d, erwartet 200", w.Code) } body := w.Body.String() - if !regexp.MustCompile(`<td class="zahl">\s*2\s*</td>`).MatchString(body) { + if !regexp.MustCompile(`<td class="num">\s*2\s*</td>`).MatchString(body) { t.Error("die Übersicht zeigt nicht 2 aktive Workspace-Admins") } if strings.Contains(body, "ungenutzt</a>") { @@ -454,104 +454,104 @@ func TestWorkspaceListCountsActiveAdmins(t *testing.T) { // TestWorkspaceAdminRevokeRejectsForeignID:Über die Bearbeitungsseite von // Workspace A lässt sich kein Zugang aus Workspace B zurückziehen. func TestWorkspaceAdminRevokeRejectsForeignID(t *testing.T) { - srv, _, projekt := testServer(t) + srv, _, project := testServer(t) h := testHandler(t, srv) wsB, err := srv.control.CreateWorkspace(context.Background(), "Andere Abteilung") if err != nil { t.Fatal(err) } - _, fremd := legeWorkspaceAdminAn(t, srv, wsB.ID, "Fremd") + _, foreign := createWorkspaceAdmin(t, srv, wsB.ID, "Fremd") - w := adminPost(t, h, projekt.WorkspaceID, - "/admin/workspaces/"+projekt.WorkspaceID+"/admins/revoke", url.Values{"id": {fremd.ID}}) + w := adminPost(t, h, project.WorkspaceID, + "/admin/workspaces/"+project.WorkspaceID+"/admins/revoke", url.Values{"id": {foreign.ID}}) if w.Code != http.StatusNotFound { t.Errorf("Status = %d, erwartet 404", w.Code) } - gelesen, err := srv.control.WorkspaceAdminByID(context.Background(), fremd.ID) + read, err := srv.control.WorkspaceAdminByID(context.Background(), foreign.ID) if err != nil { t.Fatal(err) } - if gelesen.Revoked() { + if read.Revoked() { t.Error("der Zugang aus Workspace B wurde zurückgezogen") } } -// TestWorkspaceAdminUeberblick: Der Überblick (AUSBAUSTUFEN.md 5.17) nennt je +// TestWorkspaceAdminOverview: Der Überblick (AUSBAUSTUFEN.md 5.17) nennt je // Projekt, was zu tun ist — mit Link dorthin —, zeigt die Projekttabelle und // die Aktivität. Ein Projektzugang kommt nicht dran. -func TestWorkspaceAdminUeberblick(t *testing.T) { - srv, db, projekt := testServer(t) +func TestWorkspaceAdminOverview(t *testing.T) { + srv, db, project := testServer(t) h := testHandler(t, srv) ctx := context.Background() // Projekt 1: ein admin-Key, der in fünf Tagen abläuft, und ein Eintrag. - if _, err := db.CreateAPIKeyWithExpiry(ctx, projekt.ID, "praktikum", "hash-praktikum", string(auth.RoleAdmin), + if _, err := db.CreateAPIKeyWithExpiry(ctx, project.ID, "praktikum", "hash-praktikum", string(auth.RoleAdmin), time.Now().Add(5*24*time.Hour)); err != nil { t.Fatal(err) } if _, err := db.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent, + ProjectID: project.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent, }); err != nil { t.Fatal(err) } - db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP}) + db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP}) // Projekt 2: nur ein Lese-Key, nie benutzt. - ruhig, err := db.CreateProject(ctx, projekt.WorkspaceID, "ruhig", "Ruhiges Projekt", "") + idle, err := db.CreateProject(ctx, project.WorkspaceID, "ruhig", "Ruhiges Projekt", "") if err != nil { t.Fatal(err) } - if _, err := db.CreateAPIKey(ctx, ruhig.ID, "leser", "hash-leser", string(auth.RoleRead)); err != nil { + if _, err := db.CreateAPIKey(ctx, idle.ID, "leser", "hash-leser", string(auth.RoleRead)); err != nil { t.Fatal(err) } // Projekt 3: gar kein Key. - if _, err := db.CreateProject(ctx, projekt.WorkspaceID, "leer", "Leeres Projekt", ""); err != nil { + if _, err := db.CreateProject(ctx, project.WorkspaceID, "leer", "Leeres Projekt", ""); err != nil { t.Fatal(err) } - admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + admin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID) w := admin.get(h, "/admin/overview") if w.Code != http.StatusOK { t.Fatalf("/admin/overview = %d", w.Code) } - seite := w.Body.String() + page := w.Body.String() - for _, erwartet := range []string{ + for _, want := range []string{ "Der Key „praktikum“ gilt nur noch bis", - "/p/" + projekt.Slug + "/keys", + "/p/" + project.Slug + "/keys", "Kein gültiger Key", "Kein Key mit der Rolle admin", "In 30 Tagen weder geschrieben noch gesucht", "/p/ruhig/usage", - `<span class="zustand-art">Ruhiges Projekt</span>`, // Name über den Befunden - `<svg class="diagramm"`, + `<span class="status-kind">Ruhiges Projekt</span>`, // Name über den Befunden + `<svg class="chart"`, } { - if !strings.Contains(seite, erwartet) { - t.Errorf("%q fehlt im Überblick", erwartet) + if !strings.Contains(page, want) { + t.Errorf("%q fehlt im Überblick", want) } } // Das Projekt ohne Key ist schon rot gewarnt; „ohne Aktivität“ wäre // dort nur Wiederholung. Genau einmal heißt: nur für "ruhig". - if n := strings.Count(seite, "weder geschrieben noch gesucht"); n != 1 { + if n := strings.Count(page, "weder geschrieben noch gesucht"); n != 1 { t.Errorf("„ohne Aktivität“ %d-mal, erwartet einmal (nur das Projekt mit Key)", n) } // Warnungen stehen vor Hinweisen. - if strings.Index(seite, "gilt nur noch bis") > strings.Index(seite, "Kein Key mit der Rolle admin") { + if strings.Index(page, "gilt nur noch bis") > strings.Index(page, "Kein Key mit der Rolle admin") { t.Error("Warnung steht hinter einem Hinweis") } // Technisches gehört auf die Systemseite, nicht hierher. - for _, fehlt := range []string{"max_connections", "WAL", "Kontroll-Datenbank"} { - if strings.Contains(seite, fehlt) { - t.Errorf("%q steht im Überblick des Workspace-Admins", fehlt) + for _, missing := range []string{"max_connections", "WAL", "Kontroll-Datenbank"} { + if strings.Contains(page, missing) { + t.Errorf("%q steht im Überblick des Workspace-Admins", missing) } } // Ein Projektzugang kommt nicht dran, auch nicht mit Rolle admin. - key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "projekt-admin", auth.RoleAdmin)) - if code := seiteHolen(h, key, "/admin/overview").Code; code == http.StatusOK { + key := login(t, h, createKey(t, srv, db, project.ID, "projekt-admin", auth.RoleAdmin)) + if code := fetchPage(h, key, "/admin/overview").Code; code == http.StatusOK { t.Error("Projekt-Admin sieht den Überblick des Workspace") } } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 76c1c7c..6a0178a 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -3,7 +3,7 @@ // // Jede Abteilung hat ihren eigenen, abgeschotteten Bereich (siehe // AUSBAUSTUFEN.md 4.4): eine SQLite-Datei oder ein PostgreSQL-Schema — welches -// von beiden, entscheidet der store.Speicher (AUSBAUSTUFEN.md 4.10). Ein +// von beiden, entscheidet der store.Backend (AUSBAUSTUFEN.md 4.10). Ein // Manager öffnet die Bereiche bei Bedarf und hält sie offen, statt bei jedem // Request neu zu öffnen — das Öffnen (inklusive Migrationsprüfung) kostet // spürbar mehr als ein Map-Zugriff. @@ -34,15 +34,15 @@ type Workspace struct { // Verdrängung selten gebrauchter Dateien würde erst bei einer deutlich // höheren Zahl nötig, siehe AUSBAUSTUFEN.md 1.2. type Manager struct { - speicher store.Speicher // SQLite-Dateien oder PostgreSQL-Schemas + backend store.Backend // SQLite-Dateien oder PostgreSQL-Schemas mu sync.Mutex open map[string]*Workspace } -// NewManager legt einen Manager an, der seine Workspaces über speicher öffnet. -func NewManager(speicher store.Speicher) *Manager { - return &Manager{speicher: speicher, open: make(map[string]*Workspace)} +// NewManager legt einen Manager an, der seine Workspaces über backend öffnet. +func NewManager(backend store.Backend) *Manager { + return &Manager{backend: backend, open: make(map[string]*Workspace)} } // Get liefert den Workspace zu einer ID und öffnet ihn beim ersten Zugriff. @@ -54,7 +54,7 @@ func (m *Manager) Get(workspaceID string) (*Workspace, error) { return w, nil } - s, err := m.speicher.OpenWorkspace(workspaceID) + s, err := m.backend.OpenWorkspace(workspaceID) if err != nil { return nil, fmt.Errorf("Workspace %s konnte nicht geöffnet werden: %w", workspaceID, err) } @@ -66,7 +66,7 @@ func (m *Manager) Get(workspaceID string) (*Workspace, error) { // Delete schließt den Workspace, falls offen, und entfernt seinen Bereich // unwiederbringlich — die Datei samt Nebendateien bzw. das Schema samt -// Tabellen (siehe store.Speicher.DeleteWorkspace). +// Tabellen (siehe store.Backend.DeleteWorkspace). // // Räumt nur den Bereich ab — die Kontroll-Datenbank (workspaces, key_index) // muss getrennt bereinigt werden, siehe ControlStore.DeleteWorkspace. @@ -80,31 +80,31 @@ func (m *Manager) Delete(workspaceID string) error { } m.mu.Unlock() - return m.speicher.DeleteWorkspace(workspaceID) + return m.backend.DeleteWorkspace(workspaceID) } -// Speicher gibt den Speicher zurück, über den der Manager öffnet — für die +// Backend gibt den Speicher zurück, über den der Manager öffnet — für die // Systemseite (Beschreibung, Kapazität). -func (m *Manager) Speicher() store.Speicher { return m.speicher } +func (m *Manager) Backend() store.Backend { return m.backend } -// Offen liefert die gerade geöffneten Workspaces, nach ID sortiert. Eine +// OpenWorkspaces liefert die gerade geöffneten Workspaces, nach ID sortiert. Eine // Momentaufnahme: Währenddessen kann ein weiterer geöffnet werden. -func (m *Manager) Offen() []*Workspace { +func (m *Manager) OpenWorkspaces() []*Workspace { m.mu.Lock() defer m.mu.Unlock() - liste := make([]*Workspace, 0, len(m.open)) + list := make([]*Workspace, 0, len(m.open)) for _, w := range m.open { - liste = append(liste, w) + list = append(list, w) } - sort.Slice(liste, func(i, j int) bool { return liste[i].ID < liste[j].ID }) - return liste + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + return list } -// Ort nennt, wo die Daten eines Workspace liegen — Schema oder Datei (siehe -// store.Speicher.Ort). Öffnet dafür nichts. -func (m *Manager) Ort(workspaceID string) string { - return m.speicher.Ort(workspaceID) +// Location nennt, wo die Daten eines Workspace liegen — Schema oder Datei (siehe +// store.Backend.Ort). Öffnet dafür nichts. +func (m *Manager) Location(workspaceID string) string { + return m.backend.Location(workspaceID) } // Close schließt alle offenen Workspace-Dateien. @@ -112,12 +112,12 @@ func (m *Manager) Close() error { m.mu.Lock() defer m.mu.Unlock() - var erster error + var first error for id, w := range m.open { - if err := w.Store.Close(); err != nil && erster == nil { - erster = err + if err := w.Store.Close(); err != nil && first == nil { + first = err } delete(m.open, id) } - return erster + return first } diff --git a/last_test.go b/last_test.go index 2d28fb3..84c6382 100644 --- a/last_test.go +++ b/last_test.go @@ -50,12 +50,12 @@ import ( ) var ( - lastAgenten = flag.Int("last.agenten", 32, "Zahl der gleichzeitigen Agenten je Aufstellung") - lastDauer = flag.Duration("last.dauer", 10*time.Second, "Laufzeit je Aufstellung") - lastProjekte = flag.Int("last.projekte", 4, "Projekte bzw. Workspaces in den Aufstellungen mit mehreren") - lastEintraege = flag.Int("last.eintraege", 500, "Einträge, die jedes Projekt vorab bekommt") - lastSlugs = flag.Int("last.slugs", 20, "Bezeichner, auf die die Agenten je Projekt schreiben — klein heißt viel Gedränge auf denselben Einträgen") - lastSchreiben = flag.Int("last.schreibanteil", 30, "Anteil schreibender Aufrufe in Prozent, der Rest ist recall und get") + lastAgents = flag.Int("last.agenten", 32, "Zahl der gleichzeitigen Agenten je Aufstellung") + lastDuration = flag.Duration("last.dauer", 10*time.Second, "Laufzeit je Aufstellung") + lastProjects = flag.Int("last.projekte", 4, "Projekte bzw. Workspaces in den Aufstellungen mit mehreren") + lastEntries = flag.Int("last.eintraege", 500, "Einträge, die jedes Projekt vorab bekommt") + lastSlugs = flag.Int("last.slugs", 20, "Bezeichner, auf die die Agenten je Projekt schreiben — klein heißt viel Gedränge auf denselben Einträgen") + lastWrites = flag.Int("last.schreibanteil", 30, "Anteil schreibender Aufrufe in Prozent, der Rest ist recall und get") ) func TestLast(t *testing.T) { @@ -64,32 +64,32 @@ func TestLast(t *testing.T) { log.SetOutput(io.Discard) slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) - n := *lastProjekte - aufstellungen := []struct { + n := *lastProjects + setups := []struct { name string - workspaces, projekte int // projekte = je Workspace + workspaces, projects int // projekte = je Workspace }{ {"ein Projekt", 1, 1}, {fmt.Sprintf("ein Workspace, %d Projekte", n), 1, n}, {fmt.Sprintf("%d Workspaces, je 1 Projekt", n), n, 1}, } - for _, a := range aufstellungen { + for _, a := range setups { t.Run(a.name, func(t *testing.T) { - lauf(t, a.workspaces, a.projekte) + run(t, a.workspaces, a.projects) }) } } -// ziel ist ein Projekt samt den Keys, mit denen Agenten es ansprechen. -type ziel struct { +// target ist ein Projekt samt den Keys, mit denen Agenten es ansprechen. +type target struct { ws *workspace.Workspace - projekt store.Project + project store.Project keys []string } -// lauf baut eine Aufstellung auf, lässt die Agenten laufen und wertet aus. -func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) { +// run baut eine Aufstellung auf, lässt die Agenten laufen und wertet aus. +func run(t *testing.T, numWorkspaces, projectsPerWorkspace int) { ctx := context.Background() dir := t.TempDir() @@ -103,7 +103,7 @@ func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) { // Dieselbe Verdrahtung wie im Betrieb. Schreibbudget 0 = unbegrenzt — es // soll eine Schleife stoppen und würde hier die Messung abwürgen. - handler, err := baueHandler(control, workspaces, nil, config{ + handler, err := buildHandler(control, workspaces, nil, config{ sessionSecret: strings.Repeat("x", 32), adminToken: strings.Repeat("y", 32), maxAPIBody: defaultMaxAPIBodyBytes, @@ -116,31 +116,31 @@ func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) { t.Cleanup(server.Close) // ── Daten anlegen ──────────────────────────────────────────────────────── - agenten := *lastAgenten - anzahlZiele := anzahlWorkspaces * projekteJeWorkspace - var ziele []*ziel + agents := *lastAgents + numTargets := numWorkspaces * projectsPerWorkspace + var targets []*target - for w := 0; w < anzahlWorkspaces; w++ { - wsEintrag, err := control.CreateWorkspace(ctx, fmt.Sprintf("Workspace %d", w)) + for w := 0; w < numWorkspaces; w++ { + wsEntry, err := control.CreateWorkspace(ctx, fmt.Sprintf("Workspace %d", w)) if err != nil { t.Fatal(err) } - ws, err := workspaces.Get(wsEintrag.ID) + ws, err := workspaces.Get(wsEntry.ID) if err != nil { t.Fatal(err) } - for p := 0; p < projekteJeWorkspace; p++ { - projekt, err := ws.Store.CreateProject(ctx, wsEintrag.ID, fmt.Sprintf("projekt-%d", p), "Projekt", "") + for p := 0; p < projectsPerWorkspace; p++ { + project, err := ws.Store.CreateProject(ctx, wsEntry.ID, fmt.Sprintf("projekt-%d", p), "Projekt", "") if err != nil { t.Fatal(err) } - z := &ziel{ws: ws, projekt: projekt} - ziele = append(ziele, z) + z := &target{ws: ws, project: project} + targets = append(targets, z) - for i := 0; i < *lastEintraege; i++ { + for i := 0; i < *lastEntries; i++ { if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: fmt.Sprintf("vorab-%04d", i), Title: fmt.Sprintf("Vorab %d", i), - Content: fuellText(i), Source: store.SourceHuman, + ProjectID: project.ID, Slug: fmt.Sprintf("vorab-%04d", i), Title: fmt.Sprintf("Vorab %d", i), + Content: fillerText(i), Source: store.SourceHuman, }); err != nil { t.Fatal(err) } @@ -149,20 +149,20 @@ func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) { } // Jeder Agent bekommt einen eigenen Key, wie ein echter Nutzer. - for a := 0; a < agenten; a++ { - z := ziele[a%anzahlZiele] - klartext, err := auth.GenerateKey() + for a := 0; a < agents; a++ { + z := targets[a%numTargets] + plaintext, err := auth.GenerateKey() if err != nil { t.Fatal(err) } - hash := auth.HashKey(klartext) - if _, err := z.ws.Store.CreateAPIKey(ctx, z.projekt.ID, fmt.Sprintf("agent-%d", a), hash, string(auth.RoleWrite)); err != nil { + hash := auth.HashKey(plaintext) + if _, err := z.ws.Store.CreateAPIKey(ctx, z.project.ID, fmt.Sprintf("agent-%d", a), hash, string(auth.RoleWrite)); err != nil { t.Fatal(err) } if err := control.IndexKey(ctx, hash, z.ws.ID); err != nil { t.Fatal(err) } - z.keys = append(z.keys, klartext) + z.keys = append(z.keys, plaintext) } // ── Agenten laufen lassen ──────────────────────────────────────────────── @@ -173,39 +173,39 @@ func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) { // verschiedenen Rechnern und kennen das Problem nicht — es wäre also ein // Fehler der Messung, nicht des Servers. httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConnsPerHost = agenten + httpTransport.MaxIdleConnsPerHost = agents t.Cleanup(httpTransport.CloseIdleConnections) - messung := newMessung() - ende := time.Now().Add(*lastDauer) + measurement := newMeasurement() + deadline := time.Now().Add(*lastDuration) var wg sync.WaitGroup - for a := 0; a < agenten; a++ { - z := ziele[a%anzahlZiele] - key := z.keys[a/anzahlZiele] + for a := 0; a < agents; a++ { + z := targets[a%numTargets] + key := z.keys[a/numTargets] wg.Add(1) go func(a int) { defer wg.Done() - agent(ctx, httpTransport, server.URL, key, a, ende, messung) + agent(ctx, httpTransport, server.URL, key, a, deadline, measurement) }(a) } wg.Wait() // ── Auswerten ──────────────────────────────────────────────────────────── - messung.bericht(t, *lastDauer) + measurement.report(t, *lastDuration) - if anzahl, erste := messung.fehler(); anzahl > 0 { - t.Errorf("%d fehlgeschlagene Aufrufe, erste: %v", anzahl, erste) + if count, first := measurement.failures(); count > 0 { + t.Errorf("%d fehlgeschlagene Aufrufe, erste: %v", count, first) } - pruefeBestand(t, ziele, messung.erfolgreicheSchreibvorgaenge()) + checkInventory(t, targets, measurement.successfulWrites()) } // agent verhält sich wie ein Agent in einer Schleife: suchen, lesen, // gelegentlich schreiben — bis die Zeit um ist. -func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string, nr int, ende time.Time, m *messung) { +func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string, num int, deadline time.Time, m *measurement) { httpClient := &http.Client{ - Transport: &mitKey{key: key, next: httpTransport}, + Transport: &withKey{key: key, next: httpTransport}, Timeout: 30 * time.Second, } transport := &mcp.StreamableClientTransport{ @@ -215,24 +215,24 @@ func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string // gebraucht und würde nur eine weitere offene Verbindung je Agent kosten. DisableStandaloneSSE: true, } - client := mcp.NewClient(&mcp.Implementation{Name: fmt.Sprintf("agent-%d", nr), Version: "1"}, nil) + client := mcp.NewClient(&mcp.Implementation{Name: fmt.Sprintf("agent-%d", num), Version: "1"}, nil) start := time.Now() - sitzung, err := client.Connect(ctx, transport, nil) - m.erfasse("verbinden", time.Since(start), err) + session, err := client.Connect(ctx, transport, nil) + m.record("verbinden", time.Since(start), err) if err != nil { return } - defer sitzung.Close() + defer session.Close() - zufall := rand.New(rand.NewPCG(uint64(nr), 42)) - for i := 0; time.Now().Before(ende); i++ { - var werkzeug string + rng := rand.New(rand.NewPCG(uint64(num), 42)) + for i := 0; time.Now().Before(deadline); i++ { + var tool string var args map[string]any - switch w := zufall.IntN(100); { - case w < *lastSchreiben: - werkzeug = "remember" + switch w := rng.IntN(100); { + case w < *lastWrites: + tool = "remember" // Ein eindeutiges Wort je Schreibvorgang — daran erkennt // pruefeBestand, ob der Suchindex den letzten Stand kennt. // @@ -241,29 +241,29 @@ func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string // ersten an — der Aufruf zählte als erfolgreich, ohne dass eine // Fassung entsteht, und die Bilanz in pruefeBestand ginge nicht auf. args = map[string]any{ - "slug": fmt.Sprintf("gemeinsam-%02d", zufall.IntN(*lastSlugs)), + "slug": fmt.Sprintf("gemeinsam-%02d", rng.IntN(*lastSlugs)), "title": "Gemeinsamer Eintrag", - "content": "Stand " + eindeutigesWort(nr, i) + "\n\n" + fuellText(i), + "content": "Stand " + uniqueWord(num, i) + "\n\n" + fillerText(i), "force": true, } - case w < *lastSchreiben+(100-*lastSchreiben)*2/3: - werkzeug = "recall" - args = map[string]any{"query": suchbegriffe[zufall.IntN(len(suchbegriffe))]} + case w < *lastWrites+(100-*lastWrites)*2/3: + tool = "recall" + args = map[string]any{"query": searchTerms[rng.IntN(len(searchTerms))]} default: - werkzeug = "get" - args = map[string]any{"slug": fmt.Sprintf("vorab-%04d", zufall.IntN(max(1, *lastEintraege)))} + tool = "get" + args = map[string]any{"slug": fmt.Sprintf("vorab-%04d", rng.IntN(max(1, *lastEntries)))} } start := time.Now() - erg, err := sitzung.CallTool(ctx, &mcp.CallToolParams{Name: werkzeug, Arguments: args}) + erg, err := session.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: args}) if err == nil && erg.IsError { - err = fmt.Errorf("%s: %s", werkzeug, ergebnisText(erg)) + err = fmt.Errorf("%s: %s", tool, resultText(erg)) } - m.erfasse(werkzeug, time.Since(start), err) + m.record(tool, time.Since(start), err) } } -// pruefeBestand vergleicht am Ende Datenbank und Suchindex. +// checkInventory vergleicht am Ende Datenbank und Suchindex. // // Zwei Dinge müssen stimmen: // - Kein Schreibvorgang ging verloren: Jeder erfolgreiche remember-Aufruf @@ -271,131 +271,131 @@ func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string // Einträge muss also genau die Zahl der erfolgreichen Aufrufe sein. // - Der Suchindex kennt den letzten Stand: Das eindeutige Wort der letzten // Fassung muss den Eintrag finden. -func pruefeBestand(t *testing.T, ziele []*ziel, erfolgreich int) { +func checkInventory(t *testing.T, targets []*target, succeeded int) { t.Helper() ctx := context.Background() - summeFassungen, veraltet := 0, 0 - for _, z := range ziele { - idx, err := z.ws.Search.For(ctx, z.projekt.ID) + totalVersions, stale := 0, 0 + for _, z := range targets { + idx, err := z.ws.Search.For(ctx, z.project.ID) if err != nil { t.Fatal(err) } for s := 0; s < *lastSlugs; s++ { slug := fmt.Sprintf("gemeinsam-%02d", s) - e, err := z.ws.Store.EntryBySlug(ctx, z.projekt.ID, slug) + e, err := z.ws.Store.EntryBySlug(ctx, z.project.ID, slug) if err != nil { continue // nie beschrieben } - summeFassungen += e.Version + totalVersions += e.Version wort := strings.Fields(e.Content)[1] - gefunden := slices.ContainsFunc(idx.Search(wort, 5), func(r search.Result) bool { return r.DocID == slug }) - if !gefunden { - veraltet++ + found := slices.ContainsFunc(idx.Search(wort, 5), func(r search.Result) bool { return r.DocID == slug }) + if !found { + stale++ } } } - if summeFassungen != erfolgreich { - t.Errorf("%d erfolgreiche remember-Aufrufe, aber %d Fassungen gespeichert", erfolgreich, summeFassungen) + if totalVersions != succeeded { + t.Errorf("%d erfolgreiche remember-Aufrufe, aber %d Fassungen gespeichert", succeeded, totalVersions) } - if veraltet > 0 { - t.Errorf("%d Einträge: Der Suchindex kennt die letzte Fassung nicht", veraltet) + if stale > 0 { + t.Errorf("%d Einträge: Der Suchindex kennt die letzte Fassung nicht", stale) } - t.Logf("Bestand geprüft: %d Fassungen, Suchindex %s", summeFassungen, - map[bool]string{true: "aktuell", false: "VERALTET"}[veraltet == 0]) + t.Logf("Bestand geprüft: %d Fassungen, Suchindex %s", totalVersions, + map[bool]string{true: "aktuell", false: "VERALTET"}[stale == 0]) } // ── Messung ────────────────────────────────────────────────────────────────── -// messung sammelt Dauer und Fehler aller Aufrufe, getrennt nach Werkzeug. -type messung struct { - mu sync.Mutex - dauern map[string][]time.Duration - fehl map[string]int - erste []error +// measurement sammelt Dauer und Fehler aller Aufrufe, getrennt nach Werkzeug. +type measurement struct { + mu sync.Mutex + durations map[string][]time.Duration + failed map[string]int + first []error } -func newMessung() *messung { - return &messung{dauern: map[string][]time.Duration{}, fehl: map[string]int{}} +func newMeasurement() *measurement { + return &measurement{durations: map[string][]time.Duration{}, failed: map[string]int{}} } -func (m *messung) erfasse(werkzeug string, d time.Duration, err error) { +func (m *measurement) record(tool string, d time.Duration, err error) { m.mu.Lock() defer m.mu.Unlock() if err != nil { - m.fehl[werkzeug]++ - if len(m.erste) < 10 { - m.erste = append(m.erste, err) + m.failed[tool]++ + if len(m.first) < 10 { + m.first = append(m.first, err) } return } - m.dauern[werkzeug] = append(m.dauern[werkzeug], d) + m.durations[tool] = append(m.durations[tool], d) } -// fehler gibt die Zahl aller fehlgeschlagenen Aufrufe und die ersten davon +// failures gibt die Zahl aller fehlgeschlagenen Aufrufe und die ersten davon // zurück. -func (m *messung) fehler() (anzahl int, erste []error) { +func (m *measurement) failures() (count int, first []error) { m.mu.Lock() defer m.mu.Unlock() - for _, n := range m.fehl { - anzahl += n + for _, n := range m.failed { + count += n } - return anzahl, m.erste + return count, m.first } -func (m *messung) erfolgreicheSchreibvorgaenge() int { +func (m *measurement) successfulWrites() int { m.mu.Lock() defer m.mu.Unlock() - return len(m.dauern["remember"]) + return len(m.durations["remember"]) } -// bericht schreibt eine Tabelle: Aufrufe je Sekunde und wie lange die +// report schreibt eine Tabelle: Aufrufe je Sekunde und wie lange die // typische (p50), die langsamsten 5 % (p95) und 1 % (p99) Aufrufe dauerten. -func (m *messung) bericht(t *testing.T, dauer time.Duration) { +func (m *measurement) report(t *testing.T, duration time.Duration) { m.mu.Lock() defer m.mu.Unlock() var b strings.Builder fmt.Fprintf(&b, "\n%-10s %8s %8s %9s %9s %9s %9s %7s\n", "Werkzeug", "Aufrufe", "je s", "p50", "p95", "p99", "max", "Fehler") for _, w := range []string{"verbinden", "recall", "get", "remember"} { - d := m.dauern[w] + d := m.durations[w] slices.Sort(d) - je := float64(len(d)) / dauer.Seconds() + je := float64(len(d)) / duration.Seconds() if w == "verbinden" { je = 0 // einmal je Agent, keine Rate } fmt.Fprintf(&b, "%-10s %8d %8.0f %9s %9s %9s %9s %7d\n", w, len(d), je, - perzentil(d, 50), perzentil(d, 95), perzentil(d, 99), perzentil(d, 100), m.fehl[w]) + percentile(d, 50), percentile(d, 95), percentile(d, 99), percentile(d, 100), m.failed[w]) } t.Log(b.String()) } -func perzentil(sortiert []time.Duration, p int) string { - if len(sortiert) == 0 { +func percentile(sorted []time.Duration, p int) string { + if len(sorted) == 0 { return "-" } - i := (len(sortiert) - 1) * p / 100 - return sortiert[i].Round(10 * time.Microsecond).String() + i := (len(sorted) - 1) * p / 100 + return sorted[i].Round(10 * time.Microsecond).String() } // ── Hilfen ─────────────────────────────────────────────────────────────────── -// mitKey hängt den Bearer-Key an jede Anfrage — so wie es ein MCP-Client mit +// withKey hängt den Bearer-Key an jede Anfrage — so wie es ein MCP-Client mit // "headers" in seiner Konfiguration tut. -type mitKey struct { +type withKey struct { key string next http.RoundTripper } -func (m *mitKey) RoundTrip(r *http.Request) (*http.Response, error) { +func (m *withKey) RoundTrip(r *http.Request) (*http.Response, error) { r = r.Clone(r.Context()) r.Header.Set("Authorization", "Bearer "+m.key) return m.next.RoundTrip(r) } -func ergebnisText(r *mcp.CallToolResult) string { +func resultText(r *mcp.CallToolResult) string { var b strings.Builder for _, c := range r.Content { if t, ok := c.(*mcp.TextContent); ok { @@ -405,24 +405,24 @@ func ergebnisText(r *mcp.CallToolResult) string { return b.String() } -// eindeutigesWort bildet aus Agent und Durchlauf ein Wort nur aus Buchstaben, +// uniqueWord bildet aus Agent und Durchlauf ein Wort nur aus Buchstaben, // damit der Tokenizer es nicht an Ziffern zerlegt. -func eindeutigesWort(agent, durchlauf int) string { - s := []byte(fmt.Sprintf("%04d%07d", agent, durchlauf)) +func uniqueWord(agent, iteration int) string { + s := []byte(fmt.Sprintf("%04d%07d", agent, iteration)) for i := range s { s[i] = 'a' + (s[i] - '0') } return "marke" + string(s) } -var suchbegriffe = []string{"deployment", "server", "datenbank", "zugang", "backup", "monitoring", "release", "fehler"} +var searchTerms = []string{"deployment", "server", "datenbank", "zugang", "backup", "monitoring", "release", "fehler"} -// fuellText erzeugt einen Absatz mit wechselnden Fachbegriffen, damit der +// fillerText erzeugt einen Absatz mit wechselnden Fachbegriffen, damit der // Suchindex realistisch viele Begriffe und Treffer hat. -func fuellText(i int) string { +func fillerText(i int) string { var b strings.Builder for j := 0; j < 40; j++ { - b.WriteString(suchbegriffe[(i+j*3)%len(suchbegriffe)]) + b.WriteString(searchTerms[(i+j*3)%len(searchTerms)]) b.WriteString(" wird hier beschrieben, mit Details zu Schritt ") b.WriteString(fmt.Sprint(j)) b.WriteString(". ") diff --git a/main.go b/main.go index 31f51d0..9ef76e5 100644 --- a/main.go +++ b/main.go @@ -115,7 +115,7 @@ func main() { // Mit -export läuft nur der Export und das Programm endet danach. Gedacht // für Sicherungsskripte und Cronjobs, die einen aktuellen Stand brauchen, // ohne den Server anzufassen. - nurExport := flag.Bool("export", false, "Nur den Markdown-Export ausführen und beenden") + exportOnly := flag.Bool("export", false, "Nur den Markdown-Export ausführen und beenden") flag.Parse() // .env laden, falls vorhanden. Echte Umgebungsvariablen haben Vorrang. @@ -137,13 +137,13 @@ func main() { // Wo diese Bereiche liegen — SQLite-Dateien oder PostgreSQL-Schemas — // entscheidet DB_DRIVER (AUSBAUSTUFEN.md 4.10). Ab hier weiß davon nur // noch der Speicher. - speicher, err := oeffneSpeicher(ctx, cfg) + backend, err := openBackend(ctx, cfg) if err != nil { log.Fatal(err) } - defer speicher.Close() + defer backend.Close() - control, err := speicher.OpenControl() + control, err := backend.OpenControl() if err != nil { log.Fatal(err) } @@ -152,7 +152,7 @@ func main() { // workspaces öffnet und cached die Workspaces bei Bedarf — jede Abteilung // bekommt so ihre eigene Datenbank samt Suchindex, siehe // internal/workspace. - workspaces := workspace.NewManager(speicher) + workspaces := workspace.NewManager(backend) defer workspaces.Close() // Beim allerersten Start gibt es noch keinen Workspace — dann wird einer @@ -165,9 +165,9 @@ func main() { // lieber jetzt mit klarer Meldung nicht starten als später unter Last mit // abgelehnten Verbindungen scheitern. Unter SQLite gibt es diese Grenze // nicht. - if alle, err := control.ListWorkspaces(ctx); err != nil { + if all, err := control.ListWorkspaces(ctx); err != nil { log.Fatal(err) - } else if err := speicher.PruefeKapazitaet(ctx, len(alle)); err != nil { + } else if err := backend.CheckCapacity(ctx, len(all)); err != nil { log.Fatal(err) } @@ -177,22 +177,22 @@ func main() { exporter = export.New(cfg.exportDir) } - if *nurExport { + if *exportOnly { if exporter == nil { log.Fatal("EXPORT_DIR ist nicht gesetzt — ohne Zielverzeichnis gibt es nichts zu exportieren.") } - stats, err := exportiereAlle(ctx, exporter, control, workspaces) + stats, err := exportAll(ctx, exporter, control, workspaces) log.Printf("Export abgeschlossen: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed) if err != nil { // Die übrigen Workspaces sind trotzdem exportiert (siehe - // fuerJedenWorkspace). Der Exit-Code muss den Fehler aber melden, + // forEachWorkspace). Der Exit-Code muss den Fehler aber melden, // sonst hält ein Cronjob die Sicherung für vollständig. log.Fatal("Export unvollständig: ", err) } return } - handler, err := baueHandler(control, workspaces, exporter, cfg) + handler, err := buildHandler(control, workspaces, exporter, cfg) if err != nil { log.Fatal(err) } @@ -201,8 +201,8 @@ func main() { // // Beide laufen bei jedem Durchgang über ALLE Workspaces — auch über die, // die erst nach dem Start angelegt wurden. Deshalb wird die Liste jedes - // Mal frisch aus der Kontroll-Datenbank geholt (siehe fuerJedenWorkspace). - go aufraeumenRegelmaessig(control, workspaces) + // Mal frisch aus der Kontroll-Datenbank geholt (siehe forEachWorkspace). + go cleanupRegularly(control, workspaces) if exporter != nil { go exportRegularly(exporter, control, workspaces) log.Printf("Markdown-Export aktiv: %s (täglich)", cfg.exportDir) @@ -226,9 +226,9 @@ func main() { defer stop() go func() { - alle, _ := control.ListWorkspaces(ctx) - log.Printf("Munin läuft auf http://localhost:%s (%d Workspaces)", cfg.port, len(alle)) - log.Printf("Speicher: %s", speicher.Beschreibung()) + all, _ := control.ListWorkspaces(ctx) + log.Printf("Munin läuft auf http://localhost:%s (%d Workspaces)", cfg.port, len(all)) + log.Printf("Speicher: %s", backend.Description()) if !cfg.enableHSTS { log.Println("Hinweis: ENABLE_HSTS ist aus — bei Betrieb unter HTTPS auf true setzen.") } @@ -256,13 +256,13 @@ func main() { log.Println("Server gestoppt.") } -// baueHandler setzt Weboberfläche, MCP-Endpunkt und die Middleware davor zu +// buildHandler setzt Weboberfläche, MCP-Endpunkt und die Middleware davor zu // einem HTTP-Handler zusammen. // // Eine eigene Funktion statt Teil von main, damit der Lasttest (last_test.go) // über genau dieselbe Verdrahtung läuft wie der Betrieb — und nicht über einen // nachgebauten Weg, der sich irgendwann unbemerkt unterscheidet. -func baueHandler(control *store.ControlStore, workspaces *workspace.Manager, exporter *export.Exporter, cfg config) (http.Handler, error) { +func buildHandler(control *store.ControlStore, workspaces *workspace.Manager, exporter *export.Exporter, cfg config) (http.Handler, error) { // ── Bestandteile ───────────────────────────────────────────────────────── signer := auth.NewSigner([]byte(cfg.sessionSecret)) cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE")) @@ -383,8 +383,8 @@ func readConfig() config { if cfg.adminToken == "" { log.Fatal("ADMIN_TOKEN ist nicht gesetzt — der Plattform-Admin legt damit Projekte an. Erzeugen mit: openssl rand -hex 24") } - if len(cfg.adminToken) < 16 { - log.Fatal("ADMIN_TOKEN ist zu kurz — mindestens 16 Zeichen. Erzeugen mit: openssl rand -hex 24") + if len(cfg.adminToken) < 32 { + log.Fatal("ADMIN_TOKEN ist zu kurz — mindestens 32 Zeichen. Erzeugen mit: openssl rand -hex 24") } // Hinter einem Reverse-Proxy muss der Server wissen, wem er die echte @@ -405,12 +405,12 @@ const ( dbDriverPostgres = "postgres" ) -// oeffneSpeicher wählt anhand von DB_DRIVER, wo die Daten liegen. +// openBackend wählt anhand von DB_DRIVER, wo die Daten liegen. // // Bei SQLite wird der Ordner für die Workspace-Dateien angelegt, falls er // fehlt. Bei PostgreSQL wartet NewPostgres nötigenfalls, bis die Datenbank // erreichbar ist, und legt das Schema der Kontroll-Datenbank an. -func oeffneSpeicher(ctx context.Context, cfg config) (store.Speicher, error) { +func openBackend(ctx context.Context, cfg config) (store.Backend, error) { if cfg.dbDriver == dbDriverPostgres { return store.NewPostgres(ctx, cfg.databaseURL) } @@ -422,7 +422,7 @@ func oeffneSpeicher(ctx context.Context, cfg config) (store.Speicher, error) { return store.NewSQLite(filepath.Join(cfg.dataDir, "control.db"), workspacesDir), nil } -// fuerJedenWorkspace ruft fn für jeden Workspace mit dessen geöffneter Datei +// forEachWorkspace ruft fn für jeden Workspace mit dessen geöffneter Datei // auf. // // Die Liste kommt bei jedem Aufruf frisch aus der Kontroll-Datenbank. Ein @@ -433,38 +433,38 @@ func oeffneSpeicher(ctx context.Context, cfg config) (store.Speicher, error) { // einzelner kaputter Workspace soll nicht verhindern, dass bei allen anderen // das Protokoll aufgeräumt oder der Export geschrieben wird. Die Fehler werden // gesammelt und zusammen zurückgegeben (errors.Join). -func fuerJedenWorkspace(ctx context.Context, control *store.ControlStore, workspaces *workspace.Manager, +func forEachWorkspace(ctx context.Context, control *store.ControlStore, workspaces *workspace.Manager, fn func(store.Workspace, *workspace.Workspace) error) error { - alle, err := control.ListWorkspaces(ctx) + all, err := control.ListWorkspaces(ctx) if err != nil { return fmt.Errorf("Workspaces konnten nicht gelesen werden: %w", err) } - var fehler []error - for _, w := range alle { - offen, err := workspaces.Get(w.ID) + var errs []error + for _, w := range all { + opened, err := workspaces.Get(w.ID) if err == nil { - err = fn(w, offen) + err = fn(w, opened) } if err != nil { - fehler = append(fehler, fmt.Errorf("Workspace %q (%s): %w", w.Name, w.ID, err)) + errs = append(errs, fmt.Errorf("Workspace %q (%s): %w", w.Name, w.ID, err)) } } - return errors.Join(fehler...) + return errors.Join(errs...) } -// exportiereAlle exportiert jeden Workspace in seinen eigenen Unterordner und +// exportAll exportiert jeden Workspace in seinen eigenen Unterordner und // zählt die Ergebnisse zusammen. -func exportiereAlle(ctx context.Context, e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) (export.Stats, error) { - var gesamt export.Stats - err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error { - stats, err := e.ExportWorkspace(ctx, offen.Store, w) - gesamt.Written += stats.Written - gesamt.Removed += stats.Removed +func exportAll(ctx context.Context, e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) (export.Stats, error) { + var total export.Stats + err := forEachWorkspace(ctx, control, workspaces, func(w store.Workspace, opened *workspace.Workspace) error { + stats, err := e.ExportWorkspace(ctx, opened.Store, w) + total.Written += stats.Written + total.Removed += stats.Removed return err }) - return gesamt, err + return total, err } // exportRegularly schreibt den Bestand regelmäßig als Markdown-Dateien. @@ -472,17 +472,17 @@ func exportiereAlle(ctx context.Context, e *export.Exporter, control *store.Cont // Einmal direkt beim Start, damit auch ein Server, der nie einen ganzen Tag // durchläuft, einen aktuellen Stand hinterlässt. func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) { - lauf := func() { - beginn := time.Now() - stats, err := exportiereAlle(context.Background(), e, control, workspaces) + run := func() { + begin := time.Now() + stats, err := exportAll(context.Background(), e, control, workspaces) // Für die Systemseite: Dort steht, wann der letzte Lauf endete und ob // er vollständig war. - ergebnis := export.Lauf{Ende: time.Now(), Dauer: time.Since(beginn), Stats: stats} + result := export.Run{End: time.Now(), Duration: time.Since(begin), Stats: stats} if err != nil { - ergebnis.Fehler = err.Error() + result.Err = err.Error() } - e.MerkeLauf(ergebnis) + e.RecordRun(result) if err != nil { log.Printf("Export fehlgeschlagen: %v", err) @@ -492,16 +492,16 @@ func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces } } - lauf() + run() - ticker := time.NewTicker(export.Intervall) + ticker := time.NewTicker(export.Interval) defer ticker.Stop() for range ticker.C { - lauf() + run() } } -// aufraeumenRegelmaessig räumt täglich in allen Workspaces auf: das Protokoll +// cleanupRegularly räumt täglich in allen Workspaces auf: das Protokoll // (nach 90 Tagen), das Suchprotokoll (nach store.SearchLogRetention) und den // Papierkorb (nach store.TrashRetention). // @@ -509,51 +509,51 @@ func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces // Fristen gelten also nur dann, wenn wirklich jede Datei aufgeräumt wird — ein // vergessener Workspace hieße: Adressen, Zugangsnamen und gelöschte Einträge // bleiben dort unbegrenzt liegen. -func aufraeumenRegelmaessig(control *store.ControlStore, workspaces *workspace.Manager) { +func cleanupRegularly(control *store.ControlStore, workspaces *workspace.Manager) { // Einmal direkt beim Start, damit auch ein Server aufräumt, der nie 24 // Stunden am Stück läuft. - pruneAlle(control, workspaces) - papierkorbLeerenAlle(control, workspaces) + pruneAll(control, workspaces) + purgeTrashAll(control, workspaces) ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for range ticker.C { - pruneAlle(control, workspaces) - papierkorbLeerenAlle(control, workspaces) + pruneAll(control, workspaces) + purgeTrashAll(control, workspaces) } } -// papierkorbLeerenAlle entfernt in jedem Workspace die Einträge endgültig, die +// purgeTrashAll entfernt in jedem Workspace die Einträge endgültig, die // länger als die Frist im Papierkorb liegen (AUSBAUSTUFEN.md 3.9). // // Jeder entfernte Eintrag bekommt eine eigene Protokollzeile mit Herkunft // "system". So lässt sich später auch für einen nach Ablauf verschwundenen // Eintrag nachvollziehen, wann und warum er weg ist. -func papierkorbLeerenAlle(control *store.ControlStore, workspaces *workspace.Manager) { +func purgeTrashAll(control *store.ControlStore, workspaces *workspace.Manager) { ctx := context.Background() - grenze := time.Now().UTC().Add(-store.TrashRetention).Format(time.RFC3339Nano) + limit := time.Now().UTC().Add(-store.TrashRetention).Format(time.RFC3339Nano) - err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error { - entfernt, err := offen.Store.PurgeTrash(ctx, grenze) + err := forEachWorkspace(ctx, control, workspaces, func(w store.Workspace, opened *workspace.Workspace) error { + removed, err := opened.Store.PurgeTrash(ctx, limit) if err != nil { return err } // entfernt ordnet jeder Projekt-ID die Liste ihrer entfernten // Bezeichner zu. "range" über eine map liefert Schlüssel und Wert. - anzahl := 0 - for projektID, slugs := range entfernt { + count := 0 + for projectID, slugs := range removed { for _, slug := range slugs { - offen.Store.LogAudit(ctx, store.AuditEvent{ - ProjectID: projektID, Actor: "system", + opened.Store.LogAudit(ctx, store.AuditEvent{ + ProjectID: projectID, Actor: "system", Action: store.ActionEntryPurge, EntrySlug: slug, Source: store.SourceSystem, Detail: fmt.Sprintf("nach %d Tagen im Papierkorb", store.TrashRetentionDays), }) - anzahl++ + count++ } } - if anzahl > 0 { - log.Printf("Papierkorb von %q aufgeräumt: %d Einträge endgültig entfernt", w.Name, anzahl) + if count > 0 { + log.Printf("Papierkorb von %q aufgeräumt: %d Einträge endgültig entfernt", w.Name, count) } return nil }) @@ -562,13 +562,13 @@ func papierkorbLeerenAlle(control *store.ControlStore, workspaces *workspace.Man } } -// pruneAlle räumt einmal das Protokoll jedes Workspace auf. -func pruneAlle(control *store.ControlStore, workspaces *workspace.Manager) { +// pruneAll räumt einmal das Protokoll jedes Workspace auf. +func pruneAll(control *store.ControlStore, workspaces *workspace.Manager) { ctx := context.Background() - grenze := time.Now().UTC().Add(-auditRetention).Format(time.RFC3339Nano) + limit := time.Now().UTC().Add(-auditRetention).Format(time.RFC3339Nano) - err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error { - n, err := offen.Store.PruneAudit(ctx, grenze) + err := forEachWorkspace(ctx, control, workspaces, func(w store.Workspace, opened *workspace.Workspace) error { + n, err := opened.Store.PruneAudit(ctx, limit) if err != nil { return err } @@ -579,8 +579,8 @@ func pruneAlle(control *store.ControlStore, workspaces *workspace.Manager) { // Das Suchprotokoll hat eine eigene, kürzere Frist: Anfragen sind // Inhalt, und die Auswertung braucht ohnehin nur 30 Tage. - suchgrenze := time.Now().UTC().Add(-store.SearchLogRetention).Format(time.RFC3339Nano) - n, err = offen.Store.PruneSearchLog(ctx, suchgrenze) + searchLimit := time.Now().UTC().Add(-store.SearchLogRetention).Format(time.RFC3339Nano) + n, err = opened.Store.PruneSearchLog(ctx, searchLimit) if err != nil { return err } @@ -606,17 +606,17 @@ func envOr(key, standard string) string { // envBytes liest eine Byte-Angabe. Ein unbrauchbarer Wert führt zu einer Warnung // und dem Standardwert — ein Tippfehler soll den Start nicht verhindern. func envBytes(key string, standard int64) int64 { - roh := os.Getenv(key) - if roh == "" { + raw := os.Getenv(key) + if raw == "" { return standard } - wert, err := strconv.ParseInt(roh, 10, 64) - if err != nil || wert <= 0 { - log.Printf("Hinweis: %s=%q ist keine gültige Byte-Angabe — es gilt der Standardwert %d", key, roh, standard) + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value <= 0 { + log.Printf("Hinweis: %s=%q ist keine gültige Byte-Angabe — es gilt der Standardwert %d", key, raw, standard) return standard } - return wert + return value } // envInt liest eine Zahl. Wie envBytes führt ein unbrauchbarer Wert zu einer @@ -626,17 +626,17 @@ func envBytes(key string, standard int64) int64 { // ab. Deshalb eine eigene Funktion statt eines Umbaus von envBytes: Dort wäre 0 // ein Tippfehler, hier eine Ansage. func envInt(key string, standard int) int { - roh := os.Getenv(key) - if roh == "" { + raw := os.Getenv(key) + if raw == "" { return standard } - wert, err := strconv.Atoi(roh) - if err != nil || wert < 0 { - log.Printf("Hinweis: %s=%q ist keine gültige Zahl — es gilt der Standardwert %d", key, roh, standard) + value, err := strconv.Atoi(raw) + if err != nil || value < 0 { + log.Printf("Hinweis: %s=%q ist keine gültige Zahl — es gilt der Standardwert %d", key, raw, standard) return standard } - return wert + return value } // isTrue erkennt die üblichen Schreibweisen für "an". diff --git a/main_test.go b/main_test.go index df9b913..1ab710a 100644 --- a/main_test.go +++ b/main_test.go @@ -13,9 +13,9 @@ import ( "munin/internal/workspace" ) -// aufbauZweiWorkspaces legt eine Kontroll-Datenbank mit zwei Workspaces an, +// setupTwoWorkspaces legt eine Kontroll-Datenbank mit zwei Workspaces an, // jeder mit einem Projekt "plattform" und einem Eintrag. -func aufbauZweiWorkspaces(t *testing.T) (*store.ControlStore, *workspace.Manager, []store.Workspace) { +func setupTwoWorkspaces(t *testing.T) (*store.ControlStore, *workspace.Manager, []store.Workspace) { t.Helper() ctx := context.Background() dir := t.TempDir() @@ -29,94 +29,94 @@ func aufbauZweiWorkspaces(t *testing.T) (*store.ControlStore, *workspace.Manager workspaces := workspace.NewManager(store.NewSQLite("", dir)) t.Cleanup(func() { workspaces.Close() }) - var alle []store.Workspace + var all []store.Workspace for _, name := range []string{"Intern", "Vertrieb"} { w, err := control.CreateWorkspace(ctx, name) if err != nil { t.Fatal(err) } - offen, err := workspaces.Get(w.ID) + opened, err := workspaces.Get(w.ID) if err != nil { t.Fatal(err) } - projekt, err := offen.Store.CreateProject(ctx, w.ID, "plattform", "Plattform", "") + project, err := opened.Store.CreateProject(ctx, w.ID, "plattform", "Plattform", "") if err != nil { t.Fatal(err) } - if _, err := offen.Store.PutEntry(ctx, store.EntryWrite{ - ProjectID: projekt.ID, Slug: "wissen", Title: "Wissen", + if _, err := opened.Store.PutEntry(ctx, store.EntryWrite{ + ProjectID: project.ID, Slug: "wissen", Title: "Wissen", Content: "aus " + name, Source: store.SourceHuman, }); err != nil { t.Fatal(err) } - alle = append(alle, w) + all = append(all, w) } - return control, workspaces, alle + return control, workspaces, all } -// TestFuerJedenWorkspaceErreichtAlle: Die wiederkehrenden Aufgaben liefen +// TestForEachWorkspaceReachesAll: Die wiederkehrenden Aufgaben liefen // früher nur für den ältesten Workspace. Jetzt muss jeder drankommen. -func TestFuerJedenWorkspaceErreichtAlle(t *testing.T) { - control, workspaces, alle := aufbauZweiWorkspaces(t) +func TestForEachWorkspaceReachesAll(t *testing.T) { + control, workspaces, all := setupTwoWorkspaces(t) - gesehen := map[string]bool{} - err := fuerJedenWorkspace(context.Background(), control, workspaces, + seen := map[string]bool{} + err := forEachWorkspace(context.Background(), control, workspaces, func(w store.Workspace, _ *workspace.Workspace) error { - gesehen[w.ID] = true + seen[w.ID] = true return nil }) if err != nil { t.Fatal(err) } - for _, w := range alle { - if !gesehen[w.ID] { + for _, w := range all { + if !seen[w.ID] { t.Errorf("Workspace %q wurde übersprungen", w.Name) } } } -// TestFuerJedenWorkspaceMachtNachFehlerWeiter: Ein kaputter Workspace darf die +// TestForEachWorkspaceContinuesAfterError: Ein kaputter Workspace darf die // übrigen nicht aufhalten, der Fehler muss aber beim Aufrufer ankommen. -func TestFuerJedenWorkspaceMachtNachFehlerWeiter(t *testing.T) { - control, workspaces, alle := aufbauZweiWorkspaces(t) - kaputt := errors.New("kaputt") +func TestForEachWorkspaceContinuesAfterError(t *testing.T) { + control, workspaces, all := setupTwoWorkspaces(t) + broken := errors.New("kaputt") - var aufrufe int - err := fuerJedenWorkspace(context.Background(), control, workspaces, + var calls int + err := forEachWorkspace(context.Background(), control, workspaces, func(w store.Workspace, _ *workspace.Workspace) error { - aufrufe++ - if w.ID == alle[0].ID { - return kaputt + calls++ + if w.ID == all[0].ID { + return broken } return nil }) - if aufrufe != len(alle) { - t.Errorf("%d Aufrufe, erwartet %d — nach dem Fehler wurde abgebrochen", aufrufe, len(alle)) + if calls != len(all) { + t.Errorf("%d Aufrufe, erwartet %d — nach dem Fehler wurde abgebrochen", calls, len(all)) } - if !errors.Is(err, kaputt) { + if !errors.Is(err, broken) { t.Errorf("Fehler ging verloren: %v", err) } } -// TestExportiereAlleSchreibtJedenWorkspace prüft den Weg von -export und dem +// TestExportAllWritesEveryWorkspace prüft den Weg von -export und dem // täglichen Lauf: jeder Workspace in seinem eigenen Ordner, obwohl beide ein // Projekt "plattform" haben. -func TestExportiereAlleSchreibtJedenWorkspace(t *testing.T) { - control, workspaces, alle := aufbauZweiWorkspaces(t) - ziel := t.TempDir() +func TestExportAllWritesEveryWorkspace(t *testing.T) { + control, workspaces, all := setupTwoWorkspaces(t) + target := t.TempDir() - if _, err := exportiereAlle(context.Background(), export.New(ziel), control, workspaces); err != nil { + if _, err := exportAll(context.Background(), export.New(target), control, workspaces); err != nil { t.Fatal(err) } - for _, w := range alle { - inhalt, err := os.ReadFile(filepath.Join(ziel, w.ID, "plattform", "wissen.md")) + for _, w := range all { + content, err := os.ReadFile(filepath.Join(target, w.ID, "plattform", "wissen.md")) if err != nil { t.Errorf("Workspace %q fehlt im Export: %v", w.Name, err) continue } - if want := "aus " + w.Name; !strings.Contains(string(inhalt), want) { + if want := "aus " + w.Name; !strings.Contains(string(content), want) { t.Errorf("Workspace %q: %q fehlt im Inhalt", w.Name, want) } } diff --git a/static/aehnlich.js b/static/aehnlich.js deleted file mode 100644 index 44e3a27..0000000 --- a/static/aehnlich.js +++ /dev/null @@ -1,96 +0,0 @@ -// „Gibt es schon?" — zeigt beim Anlegen eines Eintrags vorhandene Einträge mit -// ähnlichem Titel an (AUSBAUSTUFEN.md 5.7). -// -// Agenten bekommen bei "remember" eine Rückfrage, wenn es schon einen -// ähnlichen Eintrag gibt. Menschen im Formular bekommen denselben Hinweis, -// nur ohne Sperre: Die Liste erscheint unter dem Titel, speichern lässt sich -// trotzdem. Geprüft wird auf dem Server mit derselben Logik wie bei MCP -// (internal/dubletten), damit beide Wege gleich urteilen. -// -// Gefragt wird erst, wenn eine Weile nicht getippt wurde (wartezeit), nicht -// bei jedem Buchstaben. Antworten, die zu spät für den aktuellen Titel -// kommen, werden verworfen. -(function () { - "use strict"; - - var titel = document.querySelector("input[data-aehnlich-url]"); - var kasten = document.querySelector("[data-aehnlich-liste]"); - if (!titel || !kasten) { - return; - } - var liste = kasten.querySelector("ul"); - var bezeichner = document.querySelector("input[name=slug]"); - var url = titel.getAttribute("data-aehnlich-url"); - // Die Adresse eines Eintrags: aus der Such-URL ".../entries/similar" wird - // ".../e/<slug>". - var eintragsBasis = url.replace(/entries\/similar$/, "e/"); - - var wartezeit = 400; // Millisekunden ohne Tippen, bevor gefragt wird - var zeitgeber = null; - var letzteFrage = ""; - - function zeige(kandidaten) { - liste.textContent = ""; - kandidaten.forEach(function (k) { - var li = document.createElement("li"); - var a = document.createElement("a"); - a.href = eintragsBasis + encodeURIComponent(k.slug); - a.target = "_blank"; - a.rel = "noopener"; - a.textContent = k.title; - li.appendChild(a); - - // Die gemeinsamen Wörter zeigen, warum der Eintrag hier steht. - // Ist es nur ein allgemeines Wort, sieht man sofort: anderes Thema. - if (k.gemeinsam && k.gemeinsam.length) { - var grund = document.createElement("span"); - grund.className = "hilfe"; - grund.textContent = " — gemeinsam: " + k.gemeinsam.join(", "); - li.appendChild(grund); - } - liste.appendChild(li); - }); - kasten.hidden = kandidaten.length === 0; - } - - function frage() { - var t = titel.value.trim(); - var s = bezeichner ? bezeichner.value.trim() : ""; - var schluessel = t + "\u0000" + s; - if (schluessel === letzteFrage) { - return; - } - letzteFrage = schluessel; - - if (t.length < 3) { - zeige([]); - return; - } - - var adresse = url + "?title=" + encodeURIComponent(t) + "&slug=" + encodeURIComponent(s); - fetch(adresse, { credentials: "same-origin", headers: { "Accept": "application/json" } }) - .then(function (antwort) { return antwort.ok ? antwort.json() : []; }) - .then(function (kandidaten) { - // Inzwischen weitergetippt? Dann gehört die Antwort zu einem - // alten Titel. - if (schluessel === letzteFrage) { - zeige(kandidaten || []); - } - }) - .catch(function () { /* nur ein Hinweis — ohne ihn geht es auch */ }); - } - - function spaeterFragen() { - clearTimeout(zeitgeber); - zeitgeber = setTimeout(frage, wartezeit); - } - - titel.addEventListener("input", spaeterFragen); - if (bezeichner) { - bezeichner.addEventListener("input", spaeterFragen); - } - // Vorbelegter Titel (z.B. nach einem Fehler zurück im Formular). - if (titel.value.trim() !== "") { - frage(); - } -})(); diff --git a/static/autocomplete.js b/static/autocomplete.js index b644bb3..59bbce1 100644 --- a/static/autocomplete.js +++ b/static/autocomplete.js @@ -9,46 +9,46 @@ (function () { "use strict"; - var AKTIV = "aktiv"; + var ACTIVE = "active"; var timer = null; document.addEventListener("input", function (event) { - if (!istEditorfeld(event.target)) { + if (!isEditorField(event.target)) { return; } clearTimeout(timer); timer = setTimeout(function () { - aktualisiereVorschlaege(event.target); + updateSuggestions(event.target); }, 150); }); document.addEventListener("keydown", function (event) { - if (!istEditorfeld(event.target)) { + if (!isEditorField(event.target)) { return; } - var liste = listeFuer(event.target); - if (!liste || liste.hidden) { + var list = listFor(event.target); + if (!list || list.hidden) { return; } switch (event.key) { case "Escape": - liste.hidden = true; + list.hidden = true; break; case "ArrowDown": event.preventDefault(); - bewegeAuswahl(liste, 1); + moveSelection(list, 1); break; case "ArrowUp": event.preventDefault(); - bewegeAuswahl(liste, -1); + moveSelection(list, -1); break; case "Enter": case "Tab": { - var gewaehlt = liste.querySelector("li." + AKTIV) || liste.firstElementChild; - if (gewaehlt) { + var selected = list.querySelector("li." + ACTIVE) || list.firstElementChild; + if (selected) { event.preventDefault(); - uebernehmen(event.target, liste, gewaehlt); + accept(event.target, list, selected); } break; } @@ -59,15 +59,15 @@ // Textarea den Fokus, BEVOR der Klick verarbeitet wird, und die Liste // wäre durch den Blur-Handler unten schon verschwunden. document.addEventListener("mousedown", function (event) { - var eintrag = event.target.closest("[data-autovervollstaendigung] li"); - if (!eintrag) { + var entry = event.target.closest("[data-autocomplete] li"); + if (!entry) { return; } event.preventDefault(); - var liste = eintrag.closest("[data-autovervollstaendigung]"); - var textarea = textareaFuer(liste); + var list = entry.closest("[data-autocomplete]"); + var textarea = textareaFor(list); if (textarea) { - uebernehmen(textarea, liste, eintrag); + accept(textarea, list, entry); } }); @@ -75,124 +75,124 @@ document.addEventListener( "blur", function (event) { - if (!istEditorfeld(event.target)) { + if (!isEditorField(event.target)) { return; } - var liste = listeFuer(event.target); - if (liste) { - liste.hidden = true; + var list = listFor(event.target); + if (list) { + list.hidden = true; } }, true, ); - function istEditorfeld(el) { + function isEditorField(el) { return !!(el && el.tagName === "TEXTAREA" && el.hasAttribute("data-suggest-url")); } - function listeFuer(textarea) { - return textarea.parentElement.querySelector("[data-autovervollstaendigung]"); + function listFor(textarea) { + return textarea.parentElement.querySelector("[data-autocomplete]"); } - function textareaFuer(liste) { - return liste.parentElement.querySelector("textarea[data-suggest-url]"); + function textareaFor(list) { + return list.parentElement.querySelector("textarea[data-suggest-url]"); } // Sucht rückwärts von der Schreibmarke aus das letzte "[[", das noch // nicht mit "]]" geschlossen wurde. Kein Treffer heißt: die Schreibmarke // steht gerade nicht in einem offenen Verweis. - function offeneKlammer(wert, position) { - var start = wert.lastIndexOf("[[", position - 1); + function openBracket(val, position) { + var start = val.lastIndexOf("[[", position - 1); if (start === -1) { return null; } - var dazwischen = wert.slice(start + 2, position); - if (dazwischen.indexOf("]]") !== -1 || dazwischen.indexOf("\n") !== -1 || dazwischen.length > 60) { + var between = val.slice(start + 2, position); + if (between.indexOf("]]") !== -1 || between.indexOf("\n") !== -1 || between.length > 60) { return null; } - return { start: start, suche: dazwischen }; + return { start: start, search: between }; } - function aktualisiereVorschlaege(textarea) { - var liste = listeFuer(textarea); - if (!liste) { + function updateSuggestions(textarea) { + var list = listFor(textarea); + if (!list) { return; } - var klammer = offeneKlammer(textarea.value, textarea.selectionStart); - if (!klammer) { - liste.hidden = true; + var bracket = openBracket(textarea.value, textarea.selectionStart); + if (!bracket) { + list.hidden = true; return; } - var url = textarea.getAttribute("data-suggest-url") + "?q=" + encodeURIComponent(klammer.suche); + var url = textarea.getAttribute("data-suggest-url") + "?q=" + encodeURIComponent(bracket.search); fetch(url, { headers: { Accept: "application/json" } }) - .then(function (antwort) { - return antwort.ok ? antwort.json() : []; + .then(function (answer) { + return answer.ok ? answer.json() : []; }) - .then(function (treffer) { + .then(function (hits) { // Zwischenzeitlich könnte weitergetippt oder das Feld // verlassen worden sein — dann passt die Antwort nicht mehr // zur aktuellen Schreibmarke. - var jetzt = offeneKlammer(textarea.value, textarea.selectionStart); - if (!jetzt || jetzt.start !== klammer.start) { + var now = openBracket(textarea.value, textarea.selectionStart); + if (!now || now.start !== bracket.start) { return; } - zeigeVorschlaege(liste, treffer, klammer.start); + showSuggestions(list, hits, bracket.start); }) .catch(function () { - liste.hidden = true; + list.hidden = true; }); } - function zeigeVorschlaege(liste, treffer, klammerStart) { - liste.innerHTML = ""; - if (!treffer.length) { - liste.hidden = true; + function showSuggestions(list, hits, bracketStart) { + list.innerHTML = ""; + if (!hits.length) { + list.hidden = true; return; } - treffer.forEach(function (t, i) { + hits.forEach(function (t, i) { var li = document.createElement("li"); - li.className = i === 0 ? AKTIV : ""; + li.className = i === 0 ? ACTIVE : ""; li.dataset.slug = t.slug; - li.dataset.klammerStart = String(klammerStart); + li.dataset.bracketStart = String(bracketStart); var slug = document.createElement("code"); slug.textContent = t.slug; - var titel = document.createElement("span"); - titel.textContent = t.title; + var titleText = document.createElement("span"); + titleText.textContent = t.title; li.appendChild(slug); - li.appendChild(titel); - liste.appendChild(li); + li.appendChild(titleText); + list.appendChild(li); }); - liste.hidden = false; + list.hidden = false; } - function bewegeAuswahl(liste, richtung) { - var eintraege = Array.prototype.slice.call(liste.children); - if (!eintraege.length) { + function moveSelection(list, direction) { + var entries = Array.prototype.slice.call(list.children); + if (!entries.length) { return; } - var index = eintraege.findIndex(function (e) { - return e.classList.contains(AKTIV); + var index = entries.findIndex(function (e) { + return e.classList.contains(ACTIVE); }); - eintraege.forEach(function (e) { - e.classList.remove(AKTIV); + entries.forEach(function (e) { + e.classList.remove(ACTIVE); }); - var naechster = (index + richtung + eintraege.length) % eintraege.length; - eintraege[naechster].classList.add(AKTIV); + var next = (index + direction + entries.length) % entries.length; + entries[next].classList.add(ACTIVE); } // Ersetzt den Text zwischen der offenen Klammer und der Schreibmarke // durch "bezeichner]]" und setzt die Schreibmarke direkt dahinter. - function uebernehmen(textarea, liste, eintrag) { - var start = parseInt(eintrag.dataset.klammerStart, 10); + function accept(textarea, list, entry) { + var start = parseInt(entry.dataset.bracketStart, 10); var cursor = textarea.selectionStart; - textarea.setRangeText(eintrag.dataset.slug + "]]", start + 2, cursor, "end"); - liste.hidden = true; + textarea.setRangeText(entry.dataset.slug + "]]", start + 2, cursor, "end"); + list.hidden = true; textarea.focus(); } })(); diff --git a/static/confirm.js b/static/confirm.js index f40a93d..476e3d0 100644 --- a/static/confirm.js +++ b/static/confirm.js @@ -22,8 +22,8 @@ document.addEventListener("submit", function (event) { // Formular mehrere Knöpfe (etwa im Papierkorb: "wiederherstellen" und // "endgültig löschen"), kann so jeder Knopf seine eigene Rückfrage // haben — oder gar keine. Steht am Knopf nichts, gilt die des Formulars. - var knopf = event.submitter; - var message = (knopf && knopf.getAttribute("data-confirm")) || + var btn = event.submitter; + var message = (btn && btn.getAttribute("data-confirm")) || (form.getAttribute && form.getAttribute("data-confirm")); if (!message) { return; diff --git a/static/graph.js b/static/graph.js index 999eb9d..ca4423b 100644 --- a/static/graph.js +++ b/static/graph.js @@ -18,7 +18,7 @@ // 2. Beschriftet wird nur, was Platz hat. Die am stärksten verknüpften // Knoten kommen zuerst an die Reihe; eine Beschriftung, die eine schon // gesetzte oder einen fremden Punkt überdecken würde, bleibt weg (siehe -// beschrifte()). Je weiter man hineinzoomt, desto mehr Namen erscheinen. +// label()). Je weiter man hineinzoomt, desto mehr Namen erscheinen. // 3. Zeigt die Maus auf einen Knoten, treten er und seine direkten Nachbarn // hervor und werden bevorzugt beschriftet; alles andere wird blass. (function () { @@ -27,68 +27,68 @@ var SVG_NS = "http://www.w3.org/2000/svg"; // Ungefähre Höhe einer Beschriftungszeile in Bildschirmpixeln, samt - // etwas Luft — nur für die Überlappungsprüfung in beschrifte(). - var ZEILENHOEHE = 16; + // etwas Luft — nur für die Überlappungsprüfung in label(). + var LINE_HEIGHT = 16; // Abstand zwischen Punkt und Beschriftung. - var TEXTABSTAND = 4; + var TEXT_GAP = 4; document.querySelectorAll("svg[data-graph]").forEach(function (svg) { - ladeUndZeichne(svg); + loadAndDraw(svg); }); - function ladeUndZeichne(svg) { + function loadAndDraw(svg) { var url = svg.getAttribute("data-graph"); - var basisUrl = svg.getAttribute("data-graph-base"); - var hinweis = document.querySelector("[data-graph-hinweis]"); + var baseUrl = svg.getAttribute("data-graph-base"); + var hint = document.querySelector("[data-graph-hint]"); fetch(url, { headers: { Accept: "application/json" } }) - .then(function (antwort) { - if (!antwort.ok) { + .then(function (answer) { + if (!answer.ok) { throw new Error("Graph konnte nicht geladen werden"); } - return antwort.json(); + return answer.json(); }) - .then(function (daten) { - if (hinweis && daten.truncated) { - hinweis.hidden = false; - hinweis.textContent = - "Zeigt die " + daten.nodes.length + " am stärksten verknüpften von " + - daten.total + " Einträgen."; + .then(function (data) { + if (hint && data.truncated) { + hint.hidden = false; + hint.textContent = + "Zeigt die " + data.nodes.length + " am stärksten verknüpften von " + + data.total + " Einträgen."; } - zeichne(svg, daten.nodes || [], daten.edges || [], basisUrl); + draw(svg, data.nodes || [], data.edges || [], baseUrl); }) .catch(function () { - svg.replaceWith(meldung("Graph konnte nicht geladen werden.")); + svg.replaceWith(notice("Graph konnte nicht geladen werden.")); }); } - function meldung(text) { + function notice(text) { var div = document.createElement("div"); - div.className = "leer"; + div.className = "empty"; var p = document.createElement("p"); p.textContent = text; div.appendChild(p); return div; } - function zeichne(svg, knoten, kanten, basisUrl) { - if (!knoten.length) { - svg.replaceWith(meldung("Noch keine Einträge mit Verweisen.")); + function draw(svg, nodeItems, edgeItems, baseUrl) { + if (!nodeItems.length) { + svg.replaceWith(notice("Noch keine Einträge mit Verweisen.")); return; } // Die viewBox entspricht der tatsächlichen Größe beim Laden — eine // viewBox-Einheit ist damit ein Bildschirmpixel, und Schriftgrößen // sowie Radien unten lassen sich direkt in Pixeln denken. - var breite = svg.clientWidth || 800; - var hoehe = svg.clientHeight || 560; - svg.setAttribute("viewBox", "0 0 " + breite + " " + hoehe); + var boxWidth = svg.clientWidth || 800; + var boxHeight = svg.clientHeight || 560; + svg.setAttribute("viewBox", "0 0 " + boxWidth + " " + boxHeight); var indexBySlug = {}; - knoten.forEach(function (k, i) { + nodeItems.forEach(function (k, i) { indexBySlug[k.slug] = i; }); - var kantenIdx = kanten + var edgeIdx = edgeItems .map(function (e) { return [indexBySlug[e.from], indexBySlug[e.to]]; }) @@ -97,29 +97,29 @@ }); // Nachbarn je Knoten — für die Hervorhebung beim Überfahren. - var nachbarn = knoten.map(function () { + var neighbors = nodeItems.map(function () { return []; }); - kantenIdx.forEach(function (paar) { - nachbarn[paar[0]].push(paar[1]); - nachbarn[paar[1]].push(paar[0]); + edgeIdx.forEach(function (pair) { + neighbors[pair[0]].push(pair[1]); + neighbors[pair[1]].push(pair[0]); }); - var radien = knoten.map(function (k) { + var radii = nodeItems.map(function (k) { return 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3); }); - // rangfolge: Knotenindizes, am stärksten verknüpfte zuerst. Diese + // ranking: Knotenindizes, am stärksten verknüpfte zuerst. Diese // Reihenfolge entscheidet, wer bei Platzmangel beschriftet wird. // Vorhandene Einträge gehen fehlenden Verweiszielen vor, bei // Gleichstand entscheidet der Titel — so bleibt die Auswahl bei // jedem Laden dieselbe. - var rangfolge = knoten.map(function (_, i) { + var ranking = nodeItems.map(function (_, i) { return i; }); - rangfolge.sort(function (a, b) { - var ka = knoten[a]; - var kb = knoten[b]; + ranking.sort(function (a, b) { + var ka = nodeItems[a]; + var kb = nodeItems[b]; if ((kb.degree || 0) !== (ka.degree || 0)) { return (kb.degree || 0) - (ka.degree || 0); } @@ -129,106 +129,106 @@ return ka.title < kb.title ? -1 : ka.title > kb.title ? 1 : 0; }); - var slugs = knoten.map(function (k) { + var slugs = nodeItems.map(function (k) { return k.slug; }); - var positionen = simuliere(knoten.length, kantenIdx, radien, slugs); + var positions = simulate(nodeItems.length, edgeIdx, radii, slugs); - var kantenGruppe = svgEl("g", { class: "graph-kanten" }); - var knotenGruppe = svgEl("g", { class: "graph-knoten" }); - svg.appendChild(kantenGruppe); - svg.appendChild(knotenGruppe); + var edgeGroup = svgEl("g", { class: "graph-edges" }); + var nodeGroup = svgEl("g", { class: "graph-nodes" }); + svg.appendChild(edgeGroup); + svg.appendChild(nodeGroup); - var kantenEls = kantenIdx.map(function (paar) { - var linie = svgEl("line", { class: "graph-kante" }); - kantenGruppe.appendChild(linie); - return linie; + var edgeEls = edgeIdx.map(function (pair) { + var line = svgEl("line", { class: "graph-edge" }); + edgeGroup.appendChild(line); + return line; }); - var knotenEls = knoten.map(function (k, i) { - var gruppe = svgEl("g", { - class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"), + var nodeEls = nodeItems.map(function (k, i) { + var group = svgEl("g", { + class: "graph-node-group" + (k.exists ? "" : " graph-node-missing"), "data-index": i, }); // <title> zeigt der Browser als Tooltip — so ist der volle Name // auch dann lesbar, wenn die Beschriftung gerade ausgeblendet ist. - var titelEl = document.createElementNS(SVG_NS, "title"); - titelEl.textContent = k.title; - gruppe.appendChild(titelEl); + var titleEl = document.createElementNS(SVG_NS, "title"); + titleEl.textContent = k.title; + group.appendChild(titleEl); - gruppe.appendChild(svgEl("circle", { r: radien[i], class: "graph-punkt" })); + group.appendChild(svgEl("circle", { r: radii[i], class: "graph-dot" })); - var text = svgEl("text", { x: radien[i] + TEXTABSTAND, y: 0, class: "graph-beschriftung" }); - text.textContent = kuerze(k.title, 28); - gruppe.appendChild(text); + var text = svgEl("text", { x: radii[i] + TEXT_GAP, y: 0, class: "graph-label" }); + text.textContent = shorten(k.title, 28); + group.appendChild(text); if (k.exists) { - gruppe.classList.add("graph-klickbar"); - gruppe.setAttribute("data-slug", k.slug); + group.classList.add("graph-clickable"); + group.setAttribute("data-slug", k.slug); } - knotenGruppe.appendChild(gruppe); - return { gruppe: gruppe, text: text }; + nodeGroup.appendChild(group); + return { group: group, text: text }; }); // Die Textbreite einmal messen, solange alles sichtbar ist (ein // ausgeblendetes Element hat keine messbare Breite). Scheitert die // Messung, hilft eine Schätzung über die Zeichenzahl. - var textBreiten = knotenEls.map(function (el) { - var gemessen = 0; + var textWidths = nodeEls.map(function (el) { + var measured = 0; try { - gemessen = el.text.getComputedTextLength(); + measured = el.text.getComputedTextLength(); } catch (e) { - gemessen = 0; + measured = 0; } - return gemessen || el.text.textContent.length * 6.5; + return measured || el.text.textContent.length * 6.5; }); - // ansicht beschreibt, wie Layout-Koordinaten (aus simuliere) auf den - // Bildschirm kommen: bildschirm = layout * massstab + verschiebung. - var ansicht = { massstab: 1, x: 0, y: 0 }; - var fokus = -1; - var bildschirm = positionen.map(function () { + // view beschreibt, wie Layout-Koordinaten (aus simulate) auf den + // Bildschirm kommen: screenPoint = layout * scale + verschiebung. + var view = { scale: 1, x: 0, y: 0 }; + var focused = -1; + var screenPoint = positions.map(function () { return { x: 0, y: 0 }; }); - // aktualisiere setzt alle Elemente an ihre Bildschirmposition und + // update setzt alle Elemente an ihre Bildschirmposition und // wählt die Beschriftungen neu aus. Wird über requestAnimationFrame // höchstens einmal je Bildschirmbild ausgeführt, auch wenn Mausrad // oder Ziehen viel häufiger Ereignisse liefern. - var geplant = false; - function aktualisiereBald() { - if (geplant) { + var scheduled = false; + function updateSoon() { + if (scheduled) { return; } - geplant = true; + scheduled = true; window.requestAnimationFrame(function () { - geplant = false; - aktualisiere(); + scheduled = false; + update(); }); } - function aktualisiere() { - positionen.forEach(function (pos, i) { - var bx = pos.x * ansicht.massstab + ansicht.x; - var by = pos.y * ansicht.massstab + ansicht.y; - bildschirm[i].x = bx; - bildschirm[i].y = by; - knotenEls[i].gruppe.setAttribute("transform", "translate(" + bx.toFixed(1) + "," + by.toFixed(1) + ")"); + function update() { + positions.forEach(function (pos, i) { + var bx = pos.x * view.scale + view.x; + var by = pos.y * view.scale + view.y; + screenPoint[i].x = bx; + screenPoint[i].y = by; + nodeEls[i].group.setAttribute("transform", "translate(" + bx.toFixed(1) + "," + by.toFixed(1) + ")"); }); - kantenIdx.forEach(function (paar, i) { - var von = bildschirm[paar[0]]; - var nach = bildschirm[paar[1]]; - kantenEls[i].setAttribute("x1", von.x.toFixed(1)); - kantenEls[i].setAttribute("y1", von.y.toFixed(1)); - kantenEls[i].setAttribute("x2", nach.x.toFixed(1)); - kantenEls[i].setAttribute("y2", nach.y.toFixed(1)); + edgeIdx.forEach(function (pair, i) { + var src = screenPoint[pair[0]]; + var dest = screenPoint[pair[1]]; + edgeEls[i].setAttribute("x1", src.x.toFixed(1)); + edgeEls[i].setAttribute("y1", src.y.toFixed(1)); + edgeEls[i].setAttribute("x2", dest.x.toFixed(1)); + edgeEls[i].setAttribute("y2", dest.y.toFixed(1)); }); - beschrifte(); + label(); } - // beschrifte entscheidet, welche Knoten gerade einen Namen tragen. + // label entscheidet, welche Knoten gerade einen Namen tragen. // // Gierig in Rangfolge: Jede Beschriftung wird zuerst rechts, dann // links vom Punkt versucht. Überdeckt sie an beiden Stellen eine @@ -239,120 +239,120 @@ // Aufwand: je Knoten ein Vergleich mit allen Punkten und allen schon // gesetzten Namen — bei 150 Knoten einige zehntausend Rechtecktests, // was auch beim flüssigen Zoomen nicht ins Gewicht fällt. - function beschrifte() { - var punktKaesten = bildschirm.map(function (p, i) { - var r = radien[i]; + function label() { + var pointBoxes = screenPoint.map(function (p, i) { + var r = radii[i]; return { x1: p.x - r, y1: p.y - r, x2: p.x + r, y2: p.y + r }; }); - var gesetzt = []; + var isSet = []; - var reihenfolge = rangfolge; - var erlaubt = null; - if (fokus >= 0) { + var order = ranking; + var allowed = null; + if (focused >= 0) { // Im Fokus: nur der Knoten selbst und seine Nachbarn, er // selbst zuerst. - erlaubt = {}; - erlaubt[fokus] = true; - nachbarn[fokus].forEach(function (n) { - erlaubt[n] = true; + allowed = {}; + allowed[focused] = true; + neighbors[focused].forEach(function (n) { + allowed[n] = true; }); - reihenfolge = [fokus].concat( - rangfolge.filter(function (i) { - return i !== fokus && erlaubt[i]; + order = [focused].concat( + ranking.filter(function (i) { + return i !== focused && allowed[i]; }), ); } - var zeigen = {}; - reihenfolge.forEach(function (i) { - var p = bildschirm[i]; + var display = {}; + order.forEach(function (i) { + var p = screenPoint[i]; // Knoten außerhalb des sichtbaren Bereichs brauchen keinen // Namen und sollen auch keinen Platz belegen. - if (p.x < -50 || p.y < -50 || p.x > breite + 50 || p.y > hoehe + 50) { + if (p.x < -50 || p.y < -50 || p.x > boxWidth + 50 || p.y > boxHeight + 50) { return; } - var r = radien[i]; - var w = textBreiten[i]; - var y1 = p.y - ZEILENHOEHE / 2; - var y2 = p.y + ZEILENHOEHE / 2; - var rechts = { x1: p.x + r + TEXTABSTAND, y1: y1, x2: p.x + r + TEXTABSTAND + w, y2: y2 }; - var links = { x1: p.x - r - TEXTABSTAND - w, y1: y1, x2: p.x - r - TEXTABSTAND, y2: y2 }; + var r = radii[i]; + var w = textWidths[i]; + var y1 = p.y - LINE_HEIGHT / 2; + var y2 = p.y + LINE_HEIGHT / 2; + var right = { x1: p.x + r + TEXT_GAP, y1: y1, x2: p.x + r + TEXT_GAP + w, y2: y2 }; + var links = { x1: p.x - r - TEXT_GAP - w, y1: y1, x2: p.x - r - TEXT_GAP, y2: y2 }; // Der hervorgehobene Knoten bekommt seinen Namen immer. - var immer = i === fokus; - if (immer || istFrei(rechts, i, punktKaesten, gesetzt)) { - gesetzt.push(rechts); - zeigen[i] = "rechts"; - } else if (istFrei(links, i, punktKaesten, gesetzt)) { - gesetzt.push(links); - zeigen[i] = "links"; + var always = i === focused; + if (always || isFree(right, i, pointBoxes, isSet)) { + isSet.push(right); + display[i] = "right"; + } else if (isFree(links, i, pointBoxes, isSet)) { + isSet.push(links); + display[i] = "links"; } }); - knotenEls.forEach(function (el, i) { - var seite = zeigen[i]; - el.text.classList.toggle("graph-beschriftung-aus", !seite); - if (seite === "links") { - el.text.setAttribute("x", -(radien[i] + TEXTABSTAND)); + nodeEls.forEach(function (el, i) { + var page = display[i]; + el.text.classList.toggle("graph-label-hidden", !page); + if (page === "links") { + el.text.setAttribute("x", -(radii[i] + TEXT_GAP)); el.text.setAttribute("text-anchor", "end"); - } else if (seite === "rechts") { - el.text.setAttribute("x", radien[i] + TEXTABSTAND); + } else if (page === "right") { + el.text.setAttribute("x", radii[i] + TEXT_GAP); el.text.removeAttribute("text-anchor"); } }); } - // setzeFokus hebt Knoten i und seine Nachbarn hervor; -1 hebt die + // setFocus hebt Knoten i und seine Nachbarn hervor; -1 hebt die // Hervorhebung auf. - function setzeFokus(i) { - if (i === fokus) { + function setFocus(i) { + if (i === focused) { return; } - fokus = i; - svg.classList.toggle("graph-fokus", fokus >= 0); - var nah = {}; - if (fokus >= 0) { - nah[fokus] = true; - nachbarn[fokus].forEach(function (n) { - nah[n] = true; + focused = i; + svg.classList.toggle("graph-focus", focused >= 0); + var near = {}; + if (focused >= 0) { + near[focused] = true; + neighbors[focused].forEach(function (n) { + near[n] = true; }); } - knotenEls.forEach(function (el, k) { - el.gruppe.classList.toggle("graph-nachbar", !!nah[k]); + nodeEls.forEach(function (el, k) { + el.group.classList.toggle("graph-neighbor", !!near[k]); }); - kantenIdx.forEach(function (paar, k) { - kantenEls[k].classList.toggle("graph-kante-aktiv", paar[0] === fokus || paar[1] === fokus); + edgeIdx.forEach(function (pair, k) { + edgeEls[k].classList.toggle("graph-edge-active", pair[0] === focused || pair[1] === focused); }); // Knoten im Fokus ans Ende ihrer Gruppe hängen: In SVG liegt // oben, was zuletzt kommt — so verdeckt kein blasser Nachbar den // hervorgehobenen Namen. - if (fokus >= 0) { - knotenGruppe.appendChild(knotenEls[fokus].gruppe); + if (focused >= 0) { + nodeGroup.appendChild(nodeEls[focused].group); } - beschrifte(); + label(); } svg.addEventListener("pointerover", function (event) { // Während des Ziehens nicht umschalten — sonst flackert die // Hervorhebung über alle Knoten, die unter der Maus durchlaufen. - if (svg.classList.contains("graph-zieht")) { + if (svg.classList.contains("graph-dragging")) { return; } - var gruppe = event.target.closest(".graph-knoten-gruppe"); - setzeFokus(gruppe ? Number(gruppe.getAttribute("data-index")) : -1); + var group = event.target.closest(".graph-node-group"); + setFocus(group ? Number(group.getAttribute("data-index")) : -1); }); svg.addEventListener("pointerleave", function () { - setzeFokus(-1); + setFocus(-1); }); // Einpassen: den ganzen Graphen mittig in die Fläche zoomen. - var grenzen = { min: 0.1, max: 4 }; - function einpassen() { + var bounds = { min: 0.1, max: 4 }; + function fitToView() { var minX = Infinity; var minY = Infinity; var maxX = -Infinity; var maxY = -Infinity; - positionen.forEach(function (p) { + positions.forEach(function (p) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); @@ -360,43 +360,43 @@ }); // Rand in Bildschirmpixeln: Platz für die Punkte selbst und für // Beschriftungen am äußeren Rand. - var rand = 48; - var spanneX = Math.max(maxX - minX, 1); - var spanneY = Math.max(maxY - minY, 1); - var massstab = Math.min((breite - 2 * rand) / spanneX, (hoehe - 2 * rand) / spanneY); + var margin = 48; + var spanX = Math.max(maxX - minX, 1); + var spanY = Math.max(maxY - minY, 1); + var scale = Math.min((boxWidth - 2 * margin) / spanX, (boxHeight - 2 * margin) / spanY); // Kleine Graphen nicht künstlich aufblasen. - massstab = Math.min(massstab, 1.5); + scale = Math.min(scale, 1.5); - ansicht.massstab = massstab; - ansicht.x = breite / 2 - ((minX + maxX) / 2) * massstab; - ansicht.y = hoehe / 2 - ((minY + maxY) / 2) * massstab; + view.scale = scale; + view.x = boxWidth / 2 - ((minX + maxX) / 2) * scale; + view.y = boxHeight / 2 - ((minY + maxY) / 2) * scale; // Heraus nur bis zur halben Einpass-Größe, hinein weit genug, // dass auch dichte Knäuel auseinanderfallen. - grenzen.min = massstab * 0.5; - grenzen.max = Math.max(massstab * 12, 3); - aktualisiereBald(); + bounds.min = scale * 0.5; + bounds.max = Math.max(scale * 12, 3); + updateSoon(); } - einpassen(); - aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, aktualisiereBald); + fitToView(); + enableZoomAndPan(svg, view, bounds, boxWidth, boxHeight, baseUrl, updateSoon); - document.querySelectorAll("[data-graph-einpassen]").forEach(function (knopf) { - knopf.addEventListener("click", einpassen); + document.querySelectorAll("[data-graph-fit]").forEach(function (btn) { + btn.addEventListener("click", fitToView); }); } - // istFrei prüft, ob das Rechteck kasten weder einen fremden Punkt (alle - // außer dem eigenen, Index eigener) noch eine schon gesetzte + // isFree prüft, ob das Rechteck box weder einen fremden Punkt (alle + // außer dem eigenen, Index own) noch eine schon gesetzte // Beschriftung überdeckt. - function istFrei(kasten, eigener, punktKaesten, gesetzt) { - for (var i = 0; i < punktKaesten.length; i++) { - if (i !== eigener && ueberlappt(kasten, punktKaesten[i])) { + function isFree(box, own, pointBoxes, isSet) { + for (var i = 0; i < pointBoxes.length; i++) { + if (i !== own && overlaps(box, pointBoxes[i])) { return false; } } - for (var j = 0; j < gesetzt.length; j++) { - if (ueberlappt(kasten, gesetzt[j])) { + for (var j = 0; j < isSet.length; j++) { + if (overlaps(box, isSet[j])) { return false; } } @@ -404,7 +404,7 @@ } // Zwei Rechtecke überlappen, wenn sie sich auf beiden Achsen überlappen. - function ueberlappt(a, b) { + function overlaps(a, b) { return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1; } @@ -446,73 +446,73 @@ // // Das Ergebnis sind Layout-Koordinaten um den Nullpunkt, unabhängig von // der Größe der Zeichenfläche — auf den Bildschirm bringt sie erst - // einpassen() in zeichne(). + // fitToView() in draw(). // // O(Knoten²) je Durchlauf — bei der serverseitigen Obergrenze von 150 // Knoten sind das rund 11.000 Paare, mit 300 Durchläufen im // einstelligen Millionenbereich an Rechenschritten. Das läuft im Browser // in Bruchteilen einer Sekunde. // - // slugsFuerSeed bestimmt nur den Startwert der Zufallsfolge (siehe + // slugsForSeed bestimmt nur den Startwert der Zufallsfolge (siehe // mulberry32) — Zwei Aufrufe mit derselben Knotenmenge ergeben dieselben // Startpositionen und damit, weil die Kräfte selbst keine Zufallszahlen // mehr ziehen, dasselbe Endlayout. Ändert sich die Knotenmenge, ändert // sich auch der Seed, und das Layout darf sich neu einpendeln. - function simuliere(n, kantenIdx, radien, slugsFuerSeed) { - var zufall = mulberry32(fnv1a(slugsFuerSeed.join("\n"))); + function simulate(n, edgeIdx, radii, slugsForSeed) { + var rng = mulberry32(fnv1a(slugsForSeed.join("\n"))); - var ABSTOSSUNG = 3000; - var FEDER = 0.02; - var ZIELLAENGE = 110; - var DAEMPFUNG = 0.85; - var ITERATIONEN = 300; + var REPULSION = 3000; + var SPRING = 0.02; + var TARGET_LENGTH = 110; + var DAMPING = 0.85; + var ITERATIONS = 300; // Mindestabstand zwischen zwei Punkten zusätzlich zu ihren Radien. - var LUFT = 24; + var PADDING = 24; // Startfläche wächst mit der Wurzel der Knotenzahl — so beginnt ein // großer Graph nicht zusammengequetscht auf derselben Fläche wie ein // kleiner. - var spanne = ZIELLAENGE * Math.sqrt(n); - var positionen = []; + var span = TARGET_LENGTH * Math.sqrt(n); + var positions = []; for (var i = 0; i < n; i++) { - positionen.push({ - x: (zufall() - 0.5) * spanne, - y: (zufall() - 0.5) * spanne, + positions.push({ + x: (rng() - 0.5) * span, + y: (rng() - 0.5) * span, vx: 0, vy: 0, }); } - for (var iter = 0; iter < ITERATIONEN; iter++) { + for (var iter = 0; iter < ITERATIONS; iter++) { for (var a = 0; a < n; a++) { for (var b = a + 1; b < n; b++) { - var dx = positionen[a].x - positionen[b].x; - var dy = positionen[a].y - positionen[b].y; + var dx = positions[a].x - positions[b].x; + var dy = positions[a].y - positions[b].y; var distQ = dx * dx + dy * dy || 0.01; var dist = Math.sqrt(distQ); - var kraft = ABSTOSSUNG / distQ; - var mindest = radien[a] + radien[b] + LUFT; - if (dist < mindest) { - kraft += (mindest - dist) * 0.5; + var force = REPULSION / distQ; + var minimum = radii[a] + radii[b] + PADDING; + if (dist < minimum) { + force += (minimum - dist) * 0.5; } - var fx = (dx / dist) * kraft; - var fy = (dy / dist) * kraft; - positionen[a].vx += fx; - positionen[a].vy += fy; - positionen[b].vx -= fx; - positionen[b].vy -= fy; + var fx = (dx / dist) * force; + var fy = (dy / dist) * force; + positions[a].vx += fx; + positions[a].vy += fy; + positions[b].vx -= fx; + positions[b].vy -= fy; } } - kantenIdx.forEach(function (paar) { - var p1 = positionen[paar[0]]; - var p2 = positionen[paar[1]]; + edgeIdx.forEach(function (pair) { + var p1 = positions[pair[0]]; + var p2 = positions[pair[1]]; var dx = p2.x - p1.x; var dy = p2.y - p1.y; var dist = Math.sqrt(dx * dx + dy * dy) || 0.01; - var kraft = (dist - ZIELLAENGE) * FEDER; - var fx = (dx / dist) * kraft; - var fy = (dy / dist) * kraft; + var force = (dist - TARGET_LENGTH) * SPRING; + var fx = (dx / dist) * force; + var fy = (dy / dist) * force; p1.vx += fx; p1.vy += fy; p2.vx -= fx; @@ -520,23 +520,23 @@ }); for (var k = 0; k < n; k++) { - positionen[k].vx -= positionen[k].x * 0.002; - positionen[k].vy -= positionen[k].y * 0.002; + positions[k].vx -= positions[k].x * 0.002; + positions[k].vy -= positions[k].y * 0.002; } for (var m = 0; m < n; m++) { - positionen[m].vx *= DAEMPFUNG; - positionen[m].vy *= DAEMPFUNG; - positionen[m].x += positionen[m].vx; - positionen[m].y += positionen[m].vy; + positions[m].vx *= DAMPING; + positions[m].vy *= DAMPING; + positions[m].x += positions[m].vx; + positions[m].y += positions[m].vy; } } - return positionen; + return positions; } - // Zoom (Mausrad) und Pan (Ziehen). Verändert nur das Objekt ansicht - // (massstab, x, y) und ruft danach beiAenderung auf — das Umsetzen auf - // die SVG-Elemente übernimmt zeichne(). + // Zoom (Mausrad) und Pan (Ziehen). Verändert nur das Objekt view + // (scale, x, y) und ruft danach onChange auf — das Umsetzen auf + // die SVG-Elemente übernimmt draw(). // // Der naheliegende Ansatz — Mausbewegung in Bildschirm-Pixeln direkt als // Verschiebung im viewBox-Koordinatensystem übernehmen — geht nur auf, @@ -545,25 +545,25 @@ // kommt von svg.clientWidth/clientHeight), aber nicht mehr nach einer // Fenstergröße-Änderung (die SVG-Breite ist "100%" und wächst/schrumpft // mit), und auch sonst nicht zuverlässig (abweichende rem-Basis, - // Browser-Zoom). zuViewBox() rechnet deshalb bei jeder Bewegung über + // Browser-Zoom). toViewBox() rechnet deshalb bei jeder Bewegung über // getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung // zu verlassen. - function aktiviereZoomUndPan(svg, ansicht, grenzen, breite, hoehe, basisUrl, beiAenderung) { - var ziehtGerade = false; - var startPunkt = { x: 0, y: 0 }; + function enableZoomAndPan(svg, view, bounds, boxWidth, boxHeight, baseUrl, onChange) { + var dragging = false; + var startPoint = { x: 0, y: 0 }; var startX = 0; var startY = 0; - var bewegungsstrecke = 0; - var gedruecktesElement = null; + var moveDistance = 0; + var pressedElement = null; // Ab dieser Strecke (in viewBox-Einheiten) zählt eine Geste als // Ziehen statt als Klick. - var ZIEHSCHWELLE = 6; + var DRAG_THRESHOLD = 6; - function zuViewBox(clientX, clientY) { + function toViewBox(clientX, clientY) { var rect = svg.getBoundingClientRect(); return { - x: ((clientX - rect.left) / rect.width) * breite, - y: ((clientY - rect.top) / rect.height) * hoehe, + x: ((clientX - rect.left) / rect.width) * boxWidth, + y: ((clientY - rect.top) / rect.height) * boxHeight, }; } @@ -571,23 +571,23 @@ if (event.button !== 0) { return; } - ziehtGerade = true; - bewegungsstrecke = 0; - gedruecktesElement = event.target.closest(".graph-klickbar"); - startPunkt = zuViewBox(event.clientX, event.clientY); - startX = ansicht.x; - startY = ansicht.y; - svg.classList.add("graph-zieht"); + dragging = true; + moveDistance = 0; + pressedElement = event.target.closest(".graph-clickable"); + startPoint = toViewBox(event.clientX, event.clientY); + startX = view.x; + startY = view.y; + svg.classList.add("graph-dragging"); svg.setPointerCapture(event.pointerId); }); svg.addEventListener("pointermove", function (event) { - if (!ziehtGerade) { + if (!dragging) { return; } - var jetzt = zuViewBox(event.clientX, event.clientY); + var now = toViewBox(event.clientX, event.clientY); // Die Verschiebung lebt im Bildschirm-Koordinatensystem der - // viewBox — deshalb hier ohne weitere Umrechnung durch massstab: + // viewBox — deshalb hier ohne weitere Umrechnung durch scale: // Eine Bewegung um 10 viewBox-Einheiten verschiebt den Inhalt um // genau 10 Einheiten, unabhängig vom aktuellen Zoomstand. // @@ -596,19 +596,19 @@ // Zahl der pointermove-Ereignisse (auch minimales Zittern der // Maus feuert mehrere) die Schwelle überschreiten und selbst // einen stillstehenden Klick als Ziehen werten. - var abstand = Math.abs(jetzt.x - startPunkt.x) + Math.abs(jetzt.y - startPunkt.y); - bewegungsstrecke = Math.max(bewegungsstrecke, abstand); - ansicht.x = startX + (jetzt.x - startPunkt.x); - ansicht.y = startY + (jetzt.y - startPunkt.y); - beiAenderung(); + var gap = Math.abs(now.x - startPoint.x) + Math.abs(now.y - startPoint.y); + moveDistance = Math.max(moveDistance, gap); + view.x = startX + (now.x - startPoint.x); + view.y = startY + (now.y - startPoint.y); + onChange(); }); - function beendeZiehen(event) { - if (!ziehtGerade) { + function endDrag(event) { + if (!dragging) { return; } - ziehtGerade = false; - svg.classList.remove("graph-zieht"); + dragging = false; + svg.classList.remove("graph-dragging"); if (event && svg.hasPointerCapture(event.pointerId)) { svg.releasePointerCapture(event.pointerId); } @@ -621,35 +621,35 @@ // Stattdessen wird beim Loslassen nachgesehen, ob (a) kaum // Bewegung stattfand und (b) das Drücken auf einem anklickbaren // Knoten begann. - if (bewegungsstrecke <= ZIEHSCHWELLE && gedruecktesElement) { - window.location.href = basisUrl + encodeURIComponent(gedruecktesElement.getAttribute("data-slug")); + if (moveDistance <= DRAG_THRESHOLD && pressedElement) { + window.location.href = baseUrl + encodeURIComponent(pressedElement.getAttribute("data-slug")); } - gedruecktesElement = null; + pressedElement = null; } - svg.addEventListener("pointerup", beendeZiehen); + svg.addEventListener("pointerup", endDrag); svg.addEventListener("pointercancel", function () { - gedruecktesElement = null; - beendeZiehen.apply(null, arguments); + pressedElement = null; + endDrag.apply(null, arguments); }); svg.addEventListener( "wheel", function (event) { event.preventDefault(); - var faktor = event.deltaY < 0 ? 1.15 : 1 / 1.15; - var neuerMassstab = Math.min(grenzen.max, Math.max(grenzen.min, ansicht.massstab * faktor)); - if (neuerMassstab === ansicht.massstab) { + var factor = event.deltaY < 0 ? 1.15 : 1 / 1.15; + var newScale = Math.min(bounds.max, Math.max(bounds.min, view.scale * factor)); + if (newScale === view.scale) { return; } // Zoomt zur Cursor-Position statt zur Bildecke: Der Punkt // unter dem Mauszeiger bleibt an derselben Stelle stehen, // statt dass der Inhalt beim Zoomen "wegläuft". - var zeiger = zuViewBox(event.clientX, event.clientY); - var verhaeltnis = neuerMassstab / ansicht.massstab; - ansicht.x = zeiger.x - (zeiger.x - ansicht.x) * verhaeltnis; - ansicht.y = zeiger.y - (zeiger.y - ansicht.y) * verhaeltnis; - ansicht.massstab = neuerMassstab; - beiAenderung(); + var pointer = toViewBox(event.clientX, event.clientY); + var ratio = newScale / view.scale; + view.x = pointer.x - (pointer.x - view.x) * ratio; + view.y = pointer.y - (pointer.y - view.y) * ratio; + view.scale = newScale; + onChange(); }, { passive: false }, ); @@ -663,7 +663,7 @@ return el; } - function kuerze(text, max) { + function shorten(text, max) { if (!text) { return ""; } diff --git a/static/preview.js b/static/preview.js index b681537..3cecbdf 100644 --- a/static/preview.js +++ b/static/preview.js @@ -12,37 +12,37 @@ // die Zahl der Aufrufe klein und macht unnötig, über Verzögerung // (Debouncing) nachzudenken. document.addEventListener("click", function (event) { - var knopf = event.target.closest("[data-editor-tab]"); - if (!knopf) { + var btn = event.target.closest("[data-editor-tab]"); + if (!btn) { return; } - var editor = knopf.closest("[data-editor]"); + var editor = btn.closest("[data-editor]"); if (!editor) { return; } - var ziel = knopf.getAttribute("data-editor-tab"); + var dest = btn.getAttribute("data-editor-tab"); editor.querySelectorAll("[data-editor-tab]").forEach(function (b) { - var aktiv = b === knopf; - b.classList.toggle("aktiv", aktiv); - b.setAttribute("aria-selected", aktiv ? "true" : "false"); + var active = b === btn; + b.classList.toggle("active", active); + b.setAttribute("aria-selected", active ? "true" : "false"); }); editor.querySelectorAll("[data-editor-panel]").forEach(function (p) { - p.hidden = p.getAttribute("data-editor-panel") !== ziel; + p.hidden = p.getAttribute("data-editor-panel") !== dest; }); - if (ziel === "vorschau") { - ladeVorschau(editor); + if (dest === "preview") { + loadPreview(editor); } }); -function ladeVorschau(editor) { +function loadPreview(editor) { var textarea = editor.querySelector("textarea[data-editor-panel]"); - var vorschau = editor.querySelector("[data-editor-panel='vorschau']"); + var preview = editor.querySelector("[data-editor-panel='preview']"); var form = editor.closest("form"); - if (!textarea || !vorschau || !form) { + if (!textarea || !preview || !form) { return; } @@ -57,30 +57,30 @@ function ladeVorschau(editor) { body += "&csrf_token=" + encodeURIComponent(csrf.value); } - vorschau.textContent = "Wird geladen …"; - vorschau.classList.add("vorschau-laedt"); + preview.textContent = "Wird geladen …"; + preview.classList.add("preview-loading"); fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body, }) - .then(function (antwort) { - if (!antwort.ok) { + .then(function (answer) { + if (!answer.ok) { throw new Error("Vorschau fehlgeschlagen"); } - return antwort.text(); + return answer.text(); }) .then(function (html) { - vorschau.classList.remove("vorschau-laedt"); + preview.classList.remove("preview-loading"); if (html.trim() === "") { - vorschau.innerHTML = '<p class="hilfe">Noch kein Inhalt.</p>'; + preview.innerHTML = '<p class="help">Noch kein Inhalt.</p>'; return; } - vorschau.innerHTML = html; + preview.innerHTML = html; }) .catch(function () { - vorschau.classList.remove("vorschau-laedt"); - vorschau.textContent = "Vorschau konnte nicht geladen werden."; + preview.classList.remove("preview-loading"); + preview.textContent = "Vorschau konnte nicht geladen werden."; }); } diff --git a/static/tastenkuerzel.js b/static/shortcuts.js similarity index 72% rename from static/tastenkuerzel.js rename to static/shortcuts.js index e3f60f4..e2d5946 100644 --- a/static/tastenkuerzel.js +++ b/static/shortcuts.js @@ -6,7 +6,7 @@ // Strg+Enter Formular speichern (Cmd+Enter auf dem Mac) // // Welches Ziel eine Taste hat, steht in der Seite selbst: Ein Link oder Knopf -// mit data-kuerzel="n" wird bei "n" ausgelöst. Gibt es keinen — weil die +// mit data-shortcut="n" wird bei "n" ausgelöst. Gibt es keinen — weil die // Rolle nicht schreiben darf oder die Seite keinen Eintrag zeigt —, passiert // nichts. So bleiben die Rechte an genau einer Stelle, im Template. // @@ -16,7 +16,7 @@ (function () { "use strict"; - function imEingabefeld(element) { + function inInputField(element) { if (!element) { return false; } @@ -26,36 +26,36 @@ document.addEventListener("keydown", function (event) { // Strg+Enter / Cmd+Enter: das Formular abschicken, in dem man gerade - // schreibt — nur bei Formularen, die das wollen (data-kuerzel-speichern). + // schreibt — nur bei Formularen, die das wollen (data-shortcut-save). if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { - var form = event.target.closest && event.target.closest("form[data-kuerzel-speichern]"); + var form = event.target.closest && event.target.closest("form[data-shortcut-save]"); if (form) { event.preventDefault(); // requestSubmit löst wie ein Klick die Prüfung der Felder und - // das submit-Ereignis aus (wichtig für ungespeichert.js). + // das submit-Ereignis aus (wichtig für unsaved.js). form.requestSubmit(); } return; } - if (event.ctrlKey || event.metaKey || event.altKey || imEingabefeld(event.target)) { + if (event.ctrlKey || event.metaKey || event.altKey || inInputField(event.target)) { return; } if (event.key === "/") { - var suche = document.querySelector("input[type=search]"); - if (suche) { + var search = document.querySelector("input[type=search]"); + if (search) { event.preventDefault(); - suche.focus(); - suche.select(); + search.focus(); + search.select(); } return; } - var ziel = document.querySelector('[data-kuerzel="' + event.key + '"]'); - if (ziel) { + var dest = document.querySelector('[data-shortcut="' + event.key + '"]'); + if (dest) { event.preventDefault(); - ziel.click(); + dest.click(); } }); })(); diff --git a/static/similar.js b/static/similar.js new file mode 100644 index 0000000..01559a5 --- /dev/null +++ b/static/similar.js @@ -0,0 +1,96 @@ +// „Gibt es schon?" — zeigt beim Anlegen eines Eintrags vorhandene Einträge mit +// ähnlichem Titel an (AUSBAUSTUFEN.md 5.7). +// +// Agenten bekommen bei "remember" eine Rückfrage, wenn es schon einen +// ähnlichen Eintrag gibt. Menschen im Formular bekommen denselben Hinweis, +// nur ohne Sperre: Die Liste erscheint unter dem Titel, speichern lässt sich +// trotzdem. Geprüft wird auf dem Server mit derselben Logik wie bei MCP +// (internal/duplicates), damit beide Wege gleich urteilen. +// +// Gefragt wird erst, wenn eine Weile nicht getippt wurde (delay), nicht +// bei jedem Buchstaben. Antworten, die zu spät für den aktuellen Titel +// kommen, werden verworfen. +(function () { + "use strict"; + + var titleInput = document.querySelector("input[data-similar-url]"); + var box = document.querySelector("[data-similar-list]"); + if (!titleInput || !box) { + return; + } + var list = box.querySelector("ul"); + var slugField = document.querySelector("input[name=slug]"); + var url = titleInput.getAttribute("data-similar-url"); + // Die Adresse eines Eintrags: aus der Such-URL ".../entries/similar" wird + // ".../e/<slug>". + var entryBase = url.replace(/entries\/similar$/, "e/"); + + var delay = 400; // Millisekunden ohne Tippen, bevor gefragt wird + var timeoutId = null; + var lastQuery = ""; + + function show(candidates) { + list.textContent = ""; + candidates.forEach(function (k) { + var li = document.createElement("li"); + var a = document.createElement("a"); + a.href = entryBase + encodeURIComponent(k.slug); + a.target = "_blank"; + a.rel = "noopener"; + a.textContent = k.title; + li.appendChild(a); + + // Die gemeinsamen Wörter zeigen, warum der Eintrag hier steht. + // Ist es nur ein allgemeines Wort, sieht man sofort: anderes Thema. + if (k.shared && k.shared.length) { + var reason = document.createElement("span"); + reason.className = "help"; + reason.textContent = " — gemeinsam: " + k.shared.join(", "); + li.appendChild(reason); + } + list.appendChild(li); + }); + box.hidden = candidates.length === 0; + } + + function ask() { + var t = titleInput.value.trim(); + var s = slugField ? slugField.value.trim() : ""; + var cacheKey = t + "\u0000" + s; + if (cacheKey === lastQuery) { + return; + } + lastQuery = cacheKey; + + if (t.length < 3) { + show([]); + return; + } + + var address = url + "?title=" + encodeURIComponent(t) + "&slug=" + encodeURIComponent(s); + fetch(address, { credentials: "same-origin", headers: { "Accept": "application/json" } }) + .then(function (answer) { return answer.ok ? answer.json() : []; }) + .then(function (candidates) { + // Inzwischen weitergetippt? Dann gehört die Antwort zu einem + // alten Titel. + if (cacheKey === lastQuery) { + show(candidates || []); + } + }) + .catch(function () { /* nur ein Hinweis — ohne ihn geht es auch */ }); + } + + function askLater() { + clearTimeout(timeoutId); + timeoutId = setTimeout(ask, delay); + } + + titleInput.addEventListener("input", askLater); + if (slugField) { + slugField.addEventListener("input", askLater); + } + // Vorbelegter Titel (z.B. nach einem Fehler zurück im Formular). + if (titleInput.value.trim() !== "") { + ask(); + } +})(); diff --git a/static/style.css b/static/style.css index 1ec02c0..315f8ed 100644 --- a/static/style.css +++ b/static/style.css @@ -150,7 +150,7 @@ pre code { background: none; border: none; padding: 0; } /* Kleiner erklärender Text. Als Blockelement UND innerhalb von Beschriftungen * verwendbar, deshalb kein eigenes display. */ -.hilfe { +.help { color: var(--text-leise); font-size: var(--t-meta); font-weight: normal; @@ -159,7 +159,7 @@ pre code { background: none; border: none; padding: 0; } /* Speicherort eines Workspace (Schema oder Datei) als blasse zweite Zeile in * der Workspace-Übersicht. Festbreitenschrift, weil es ein Bezeichner zum * Abtippen ist; markierbar, damit er sich kopieren lässt. */ -.speicherort { +.storage-location { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: var(--t-klein); user-select: all; @@ -169,14 +169,14 @@ pre code { background: none; border: none; padding: 0; } /* Schmale Kopfleiste über die ganze Breite. Sie trägt nur Marke und Abmelden — * die eigentliche Navigation steht in der Seitenleiste. */ -.kopfleiste { +.topbar { background: var(--flaeche); border-bottom: 1px solid var(--rahmen); position: sticky; top: 0; z-index: 20; } -.kopfleiste-innen { +.topbar-inner { max-width: var(--breite-seite); margin-inline: auto; padding-inline: var(--s5); @@ -187,17 +187,17 @@ pre code { background: none; border: none; padding: 0; } gap: var(--s4); } -.marke-wort { +.badge-word { font-weight: 600; color: var(--text); letter-spacing: -0.01em; } -.marke-wort:hover { text-decoration: none; } +.badge-word:hover { text-decoration: none; } /* Workspace-Umschalter in der Kopfleiste: Name des aktuellen Workspace, * klickbar zur Auswahl-/Verwaltungsseite. Dezent gehalten — die Marke bleibt * der optische Ankerpunkt, das hier ist Kontext, kein zweites Logo. */ -.arbeitsbereich { +.workspace { display: inline-flex; align-items: center; gap: var(--s1); @@ -209,17 +209,17 @@ pre code { background: none; border: none; padding: 0; } } /* Nur der Umschalter (Link) reagiert auf die Maus — beim Workspace-Admin * steht der Name als reiner Text ohne Ziel. */ -a.arbeitsbereich:hover { +a.workspace:hover { background: var(--flaeche-leis); color: var(--text); text-decoration: none; } -.arbeitsbereich-trenner { color: var(--rahmen-stark); font-weight: 400; } -.arbeitsbereich-name { color: inherit; } -.arbeitsbereich-pfeil { font-size: 0.7em; opacity: 0.6; } +.workspace-separator { color: var(--rahmen-stark); font-weight: 400; } +.workspace-name { color: inherit; } +.workspace-arrow { font-size: 0.7em; opacity: 0.6; } /* Das Gerüst: Seitenleiste links, Inhalt rechts. */ -.gerüst { +.layout { max-width: var(--breite-seite); margin-inline: auto; padding-inline: var(--s5); @@ -234,7 +234,7 @@ a.arbeitsbereich:hover { * position: sticky hält sie beim Scrollen sichtbar. Der Wert für top entspricht * der Höhe der Kopfleiste, damit sie genau darunter stehen bleibt und nicht * dahinter verschwindet. */ -.seitenleiste { +.sidebar { position: sticky; top: var(--hoehe-kopfleiste); max-height: calc(100dvh - var(--hoehe-kopfleiste)); @@ -245,9 +245,9 @@ a.arbeitsbereich:hover { gap: var(--s5); } -.nav-gruppe { display: flex; flex-direction: column; gap: var(--s1); } +.nav-group { display: flex; flex-direction: column; gap: var(--s1); } -.nav-titel { +.nav-title { font-size: var(--t-klein); font-weight: 600; letter-spacing: 0.04em; @@ -257,8 +257,8 @@ a.arbeitsbereich:hover { padding-inline: var(--s2); } -.nav-liste { list-style: none; margin: 0; padding: 0; } -.nav-liste a { +.nav-list { list-style: none; margin: 0; padding: 0; } +.nav-list a { display: flex; align-items: center; gap: var(--s2); @@ -268,70 +268,70 @@ a.arbeitsbereich:hover { font-size: var(--t-meta); line-height: 1.5; } -.nav-liste a:hover { background: var(--flaeche-leis); text-decoration: none; } +.nav-list a:hover { background: var(--flaeche-leis); text-decoration: none; } /* Der aktive Punkt wird durch Fläche markiert, nicht durch Farbe allein — * so bleibt er auch bei eingeschränktem Farbsehen erkennbar. */ -.nav-liste a.aktiv { +.nav-list a.active { background: var(--akzent-flach); color: var(--akzent-text); font-weight: 500; } -.nav-liste.leise a { color: var(--text-leise); } +.nav-list.quiet a { color: var(--text-leise); } /* Schlagwort und Anzahl: Name links, Zahl rechtsbündig. */ .nav-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.nav-zahl { color: var(--text-leise); font-size: var(--t-klein); font-variant-numeric: tabular-nums; } -.nav-liste a.aktiv .nav-zahl { color: inherit; } +.nav-count { color: var(--text-leise); font-size: var(--t-klein); font-variant-numeric: tabular-nums; } +.nav-list a.active .nav-count { color: inherit; } /* Inhaltsbereich. */ -.inhalt { padding-block: var(--s6) var(--s8); min-width: 0; } +.content { padding-block: var(--s6) var(--s8); min-width: 0; } /* Fließtext auf Lesebreite. Nur der Text selbst — Überschrift, Metazeile und * Tabellen dürfen die volle Breite nutzen. */ -.lesebreite { max-width: var(--breite-lesen); } +.reading-width { max-width: var(--breite-lesen); } /* Seitenkopf: Titel links, Aktionen rechts — auf einer Linie. * * align-items: center statt flex-start, damit der Knopf mittig zur Überschrift * sitzt und nicht an deren Oberkante klebt. */ -.seitenkopf { +.page-header { display: flex; align-items: center; gap: var(--s4); flex-wrap: wrap; margin-bottom: var(--s5); } -.seitenkopf-titel { flex: 1; min-width: 15rem; } -.seitenkopf-titel .hilfe { display: block; margin-top: var(--s1); } -.seitenkopf-aktionen { display: flex; gap: var(--s2); align-items: center; } +.page-header-title { flex: 1; min-width: 15rem; } +.page-header-title .help { display: block; margin-top: var(--s1); } +.page-header-actions { display: flex; gap: var(--s2); align-items: center; } /* Abschnitt innerhalb einer Seite. */ -.abschnitt { margin-top: var(--s7); } -.abschnitt > h2 { margin-bottom: var(--s2); } -.abschnitt > .hilfe { display: block; margin-bottom: var(--s4); } +.section { margin-top: var(--s7); } +.section > h2 { margin-bottom: var(--s2); } +.section > .help { display: block; margin-bottom: var(--s4); } /* ── 4. Bausteine ────────────────────────────────────────────────────────── */ /* Karte: der Standardbehälter für alles Gruppierte. */ -.karte { +.card { background: var(--flaeche); border: 1px solid var(--rahmen); border-radius: var(--radius-gross); padding: var(--s5); box-shadow: var(--schatten); } -.karte + .karte { margin-top: var(--s4); } +.card + .card { margin-top: var(--s4); } /* Kennzahlen (Nutzungsseite): eine Reihe kleiner Kacheln, die auf schmalen * Bildschirmen umbrechen. auto-fit + minmax verteilt sie ohne Media-Query: * so viele Spalten, wie mit mindestens 9rem Breite hineinpassen. */ -.kennzahlen { +.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--s3); } -.kennzahl { +.metric { display: flex; flex-direction: column; gap: var(--s1); @@ -340,19 +340,19 @@ a.arbeitsbereich:hover { border-radius: var(--radius-gross); padding: var(--s4); } -.kennzahl-wert { font-size: var(--t-h2); font-weight: 600; font-variant-numeric: tabular-nums; } -.kennzahl-name { font-size: var(--t-meta); color: var(--text-leise); } +.metric-value { font-size: var(--t-h2); font-weight: 600; font-variant-numeric: tabular-nums; } +.metric-name { font-size: var(--t-meta); color: var(--text-leise); } /* Kurze Listen ohne Aufzählungspunkte, etwa "Verweise ins Leere". */ -.kompakt-liste { list-style: none; padding: 0; margin: 0; } -.kompakt-liste li { padding: var(--s1) 0; border-bottom: 1px solid var(--rahmen); } -.kompakt-liste li:last-child { border-bottom: none; } +.compact-list { list-style: none; padding: 0; margin: 0; } +.compact-list li { padding: var(--s1) 0; border-bottom: 1px solid var(--rahmen); } +.compact-list li:last-child { border-bottom: none; } /* Summenzeile am Ende einer Tabelle. */ -table.tabelle tr.summe td { font-weight: 600; border-top: 2px solid var(--rahmen); } +table.styled-table tr.total td { font-weight: 600; border-top: 2px solid var(--rahmen); } /* Meldungen. */ -.meldung { +.message { padding: var(--s3) var(--s4); border: 1px solid var(--rahmen); border-left-width: 3px; @@ -360,23 +360,23 @@ table.tabelle tr.summe td { font-weight: 600; border-top: 2px solid var(--rahmen margin-bottom: var(--s5); font-size: var(--t-meta); } -.meldung.hinweis { background: var(--akzent-flach); border-left-color: var(--akzent); } -.meldung.fehler { background: var(--warn-flach); border-left-color: var(--warn); } +.message.hint { background: var(--akzent-flach); border-left-color: var(--akzent); } +.message.error { background: var(--warn-flach); border-left-color: var(--warn); } /* Leerzustand: nicht nur ein grauer Absatz, sondern ein erkennbarer Bereich mit * einem Hinweis, was als Nächstes zu tun wäre. */ -.leer { +.empty { text-align: center; padding: var(--s7) var(--s5); border: 1px dashed var(--rahmen-stark); border-radius: var(--radius-gross); color: var(--text-leise); } -.leer p { margin-bottom: var(--s4); } -.leer p:last-child { margin-bottom: 0; } +.empty p { margin-bottom: var(--s4); } +.empty p:last-child { margin-bottom: 0; } /* Marken: eine einzige visuelle Familie für alle kleinen Auszeichnungen. */ -.marke { +.badge { display: inline-flex; align-items: center; gap: var(--s1); @@ -388,18 +388,18 @@ table.tabelle tr.summe td { font-weight: 600; border-top: 2px solid var(--rahmen color: var(--text-leise); white-space: nowrap; } -a.marke:hover { +a.badge:hover { border-color: var(--rahmen-stark); color: var(--text); text-decoration: none; } -.marke.betont { +.badge.emphasis { background: var(--akzent-flach); border-color: transparent; color: var(--akzent-text); } /* Etwas, um das sich jemand kümmern sollte, etwa ein seit Langem ungenutzter Key. */ -.marke.warnung { +.badge.warning { background: var(--warn-flach); border-color: transparent; color: var(--warn); @@ -416,7 +416,7 @@ a.marke:hover { } /* Knöpfe. Drei Ausprägungen, mehr braucht es nicht. */ -button, .knopf { +button, .button { display: inline-flex; align-items: center; justify-content: center; @@ -434,28 +434,28 @@ button, .knopf { text-decoration: none; white-space: nowrap; } -button:hover, .knopf:hover { filter: brightness(1.08); text-decoration: none; } +button:hover, .button:hover { filter: brightness(1.08); text-decoration: none; } -.knopf.zweitrangig, button.zweitrangig { +.button.secondary, button.secondary { background: var(--flaeche); color: var(--text); border-color: var(--rahmen-stark); } -.knopf.zweitrangig:hover, button.zweitrangig:hover { +.button.secondary:hover, button.secondary:hover { background: var(--flaeche-leis); filter: none; } -button.warnung { +button.warning { background: var(--flaeche); color: var(--warn); border-color: var(--warn); } -button.warnung:hover { background: var(--warn-flach); filter: none; } +button.warning:hover { background: var(--warn-flach); filter: none; } /* Ein Knopf, der wie ein Link aussieht — nötig, weil Abmelden ein POST sein muss * (siehe CSRF-Schutz), aber wie ein Menüpunkt wirken soll. */ -.wie-link { +.link-button { background: none; border: none; padding: 0; @@ -466,36 +466,36 @@ button.warnung:hover { background: var(--warn-flach); filter: none; } color: var(--text-leise); cursor: pointer; } -.wie-link:hover { filter: none; text-decoration: underline; } +.link-button:hover { filter: none; text-decoration: underline; } /* Rechte Seite der Kopfleiste: Hilfe und Abmelden, im selben leisen Ton. - * Doppelte Klasse, weil .reihe weiter unten sonst den Abstand überschreibt. */ -.reihe.kopfleiste-aktionen { gap: var(--s5); } -.kopfleiste-link { + * Doppelte Klasse, weil .row weiter unten sonst den Abstand überschreibt. */ +.row.topbar-actions { gap: var(--s5); } +.topbar-link { font-size: var(--t-meta); color: var(--text-leise); } -.kopfleiste-link:hover { color: var(--text); } -.kopfleiste-link.aktiv { color: var(--text); font-weight: 500; } +.topbar-link:hover { color: var(--text); } +.topbar-link.active { color: var(--text); font-weight: 500; } -.reihe { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; } -form.reihe { display: inline-flex; } +.row { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; } +form.row { display: inline-flex; } /* In rechtsbündigen Tabellenspalten (z. B. Verlauf) sollen die Aktionen * ebenfalls rechts stehen — ein Flex-Container ignoriert text-align. */ -.rechts > .reihe { justify-content: flex-end; } +.right > .row { justify-content: flex-end; } /* Formulare. * - * Jedes Feld ist ein .feld mit Beschriftung, optionalem Hinweis und Bedienelement. + * Jedes Feld ist ein .field mit Beschriftung, optionalem Hinweis und Bedienelement. * Die Breite des BEDIENELEMENTS richtet sich nach dem erwarteten Inhalt statt * immer 100 % zu sein: Ein Feld für einen Bezeichner, das über die halbe Seite * läuft, sieht aus wie ein Fehler. Beschriftung und Hinweis dürfen breiter * sein (bis zur Lesebreite) — sonst bricht ein Hinweis in eine schmale Spalte * um und wirkt gedrückt (so war es bis 2026-10-16). */ -.formular { display: flex; flex-direction: column; gap: var(--s5); } -.feld { display: flex; flex-direction: column; gap: var(--s1); } -.feld > label { font-weight: 500; font-size: var(--t-meta); } -.feld > .hilfe { +.form { display: flex; flex-direction: column; gap: var(--s5); } +.field { display: flex; flex-direction: column; gap: var(--s1); } +.field > label { font-weight: 500; font-size: var(--t-meta); } +.field > .help { max-width: var(--breite-lesen); line-height: 1.5; margin-bottom: var(--s1); @@ -547,13 +547,13 @@ textarea { } /* Reiter "Schreiben"/"Vorschau" über dem Inhaltsfeld. */ -.editor-reiter { +.editor-tabs { display: flex; gap: var(--s1); margin-bottom: var(--s2); border-bottom: 1px solid var(--rahmen); } -.editor-reiter-knopf { +.editor-tab { padding: var(--s2) var(--s3); background: none; border: none; @@ -566,34 +566,34 @@ textarea { cursor: pointer; margin-bottom: -1px; } -.editor-reiter-knopf:hover { color: var(--text); filter: none; } -.editor-reiter-knopf.aktiv { +.editor-tab:hover { color: var(--text); filter: none; } +.editor-tab.active { color: var(--text); border-bottom-color: var(--akzent); } /* Vorschau-Fläche: dieselbe Textdarstellung wie die Detailansicht - * (.eintrag-text), aber als Formularfeld gerahmt, damit der Wechsel + * (.entry-text), aber als Formularfeld gerahmt, damit der Wechsel * zwischen Schreiben und Vorschau nicht die Höhe des Formulars springen * lässt. */ -.eintrag-text.vorschau-flaeche { +.entry-text.preview-area { min-height: 20rem; max-width: none; padding: var(--s3); background: var(--flaeche); border: 1px solid var(--rahmen-stark); border-radius: var(--radius); - /* Überschreibt den großzügigen margin-top von .eintrag-text (siehe + /* Überschreibt den großzügigen margin-top von .entry-text (siehe * weiter unten) — hier steckt es in einem Formularfeld, nicht unter * einer Überschrift. Die doppelte Klasse hebt die Reihenfolge im * Stylesheet auf: diese Regel gewinnt unabhängig davon, wo sie steht. */ margin-top: 0; } -.vorschau-flaeche.vorschau-laedt { color: var(--text-leise); } +.preview-area.preview-loading { color: var(--text-leise); } /* Vorschlagsliste der Autovervollständigung für [[…]] — direkt unter dem * Feld statt an der Schreibmarke ausgerichtet, siehe autocomplete.js. */ -.autovervollstaendigung { +.autocomplete { list-style: none; margin: var(--s2) 0 0; padding: 0; @@ -604,7 +604,7 @@ textarea { border-radius: var(--radius); box-shadow: var(--schatten); } -.autovervollstaendigung li { +.autocomplete li { display: flex; align-items: baseline; gap: var(--s2); @@ -612,31 +612,31 @@ textarea { cursor: pointer; font-size: var(--t-meta); } -.autovervollstaendigung li:not(:last-child) { border-bottom: 1px solid var(--rahmen); } -.autovervollstaendigung li span { color: var(--text-leise); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.autovervollstaendigung li.aktiv { background: var(--akzent-flach); } -.autovervollstaendigung li.aktiv span { color: var(--akzent-text); } +.autocomplete li:not(:last-child) { border-bottom: 1px solid var(--rahmen); } +.autocomplete li span { color: var(--text-leise); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.autocomplete li.active { background: var(--akzent-flach); } +.autocomplete li.active span { color: var(--akzent-text); } /* Breiten der Bedienelemente nach Inhalt — nur das Eingabefeld wird * begrenzt, nicht Beschriftung und Hinweis (siehe oben). */ -.feld.kurz > :is(input, select) { max-width: 18rem; } -.feld.mittel > :is(input, select) { max-width: 28rem; } -.feld.lang > :is(input, select) { max-width: 40rem; } +.field.short > :is(input, select) { max-width: 18rem; } +.field.medium > :is(input, select) { max-width: 28rem; } +.field.long > :is(input, select) { max-width: 40rem; } /* Ankreuzfeld: Kästchen und Text nebeneinander, Text nicht fett. */ -.ankreuz { +.checkbox { flex-direction: row; align-items: flex-start; gap: var(--s2); } /* Das Kästchen sitzt etwas tiefer als die Oberkante des Textes, damit es * optisch auf der ersten Textzeile liegt statt darüber. */ -.ankreuz input { width: auto; min-height: 0; margin-top: 0.3rem; } -.ankreuz label { font-weight: normal; } +.checkbox input { width: auto; min-height: 0; margin-top: 0.3rem; } +.checkbox label { font-weight: normal; } /* Der Hinweis steht unter der Beschriftung, nicht in derselben Zeile. */ -.ankreuz label .hilfe { display: block; margin-top: var(--s1); max-width: var(--breite-lesen); } +.checkbox label .help { display: block; margin-top: var(--s1); max-width: var(--breite-lesen); } -.formular-aktionen { +.form-actions { display: flex; flex-wrap: wrap; gap: var(--s2); @@ -645,43 +645,43 @@ textarea { } /* Tabellen. */ -.tabelle-huelle { +.table-wrap { background: var(--flaeche); border: 1px solid var(--rahmen); border-radius: var(--radius-gross); box-shadow: var(--schatten); overflow-x: auto; /* breite Tabellen scrollen in sich, nicht die Seite */ } -table.tabelle { +table.styled-table { width: 100%; border-collapse: collapse; font-size: var(--t-meta); } -.tabelle th, .tabelle td { +.styled-table th, .styled-table td { text-align: left; padding: var(--s3) var(--s4); border-bottom: 1px solid var(--rahmen); vertical-align: middle; } -.tabelle thead th { +.styled-table thead th { background: var(--flaeche-leis); color: var(--text-leise); font-weight: 500; font-size: var(--t-klein); white-space: nowrap; } -.tabelle tbody tr:last-child td { border-bottom: none; } -.tabelle tbody tr:hover { background: var(--flaeche-leis); } -.tabelle .zahl { text-align: right; font-variant-numeric: tabular-nums; } -.tabelle .schmal { width: 1%; white-space: nowrap; } -.tabelle .rechts { text-align: right; } -.tabelle tr.gedaempft { color: var(--text-leise); } -.tabelle tr.gedaempft td:first-child { text-decoration: line-through; } +.styled-table tbody tr:last-child td { border-bottom: none; } +.styled-table tbody tr:hover { background: var(--flaeche-leis); } +.styled-table .num { text-align: right; font-variant-numeric: tabular-nums; } +.styled-table .narrow { width: 1%; white-space: nowrap; } +.styled-table .right { text-align: right; } +.styled-table tr.muted { color: var(--text-leise); } +.styled-table tr.muted td:first-child { text-decoration: line-through; } /* ── 5. Seiten ───────────────────────────────────────────────────────────── */ /* Anmeldung: eigenständige, ruhige Seite ohne Navigation. */ -.anmeldeseite { +.login-page { min-height: 100dvh; display: flex; align-items: center; @@ -690,50 +690,50 @@ table.tabelle { * mathematische Mitte. Genau mittig wirkt er optisch zu tief. */ padding: var(--s5) var(--s5) 12vh; } -.anmeldeseite .karte { width: 100%; padding: var(--s6); } -.anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); } +.login-page .card { width: 100%; padding: var(--s6); } +.login-page .help { display: block; margin-bottom: var(--s5); } /* Logo zentriert über der Anmeldekarte. */ -.anmelde-logo { display: flex; justify-content: center; margin-bottom: var(--s2); } +.login-logo { display: flex; justify-content: center; margin-bottom: var(--s2); } /* 264px, damit auch die kleine Unterzeile im Logo ("MCP Server • Wiki / * Knowledge Base") lesbar bleibt. max-width fängt schmale Handys ab. */ -.anmelde-logo img { width: 264px; max-width: 100%; height: auto; } +.login-logo img { width: 264px; max-width: 100%; height: auto; } /* Werkzeugleiste über der Eintragsliste: Suche und Filter. */ -.werkzeugleiste { +.toolbar { display: flex; gap: var(--s2); margin-bottom: var(--s4); } -.werkzeugleiste input[type=search] { flex: 1; } +.toolbar input[type=search] { flex: 1; } /* Hinweis "Gibt es schon?" unter dem Titel eines neuen Eintrags. */ -.aehnlich-hinweis { +.similar-hint { margin-top: var(--s2); padding: var(--s2) var(--s3); border-left: 3px solid var(--akzent); background: var(--flaeche-leis); border-radius: var(--radius); } -.aehnlich-hinweis ul { margin: var(--s1) 0 0; padding-left: var(--s4); } -.aehnlich-hinweis li { font-size: var(--t-meta); } +.similar-hint ul { margin: var(--s1) 0 0; padding-left: var(--s4); } +.similar-hint li { font-size: var(--t-meta); } /* Eigenschaftsliste (Begriff: Wert), z.B. auf der Systemseite. */ -.eigenschaften { +.properties { display: grid; grid-template-columns: max-content 1fr; gap: var(--s2) var(--s4); margin: var(--s3) 0 0; } -.eigenschaften dt { color: var(--text-leise); } -.eigenschaften dd { margin: 0; } +.properties dt { color: var(--text-leise); } +.properties dd { margin: 0; } @media (max-width: 60rem) { - .eigenschaften { grid-template-columns: 1fr; gap: var(--s1); } - .eigenschaften dd { margin-bottom: var(--s2); } + .properties { grid-template-columns: 1fr; gap: var(--s1); } + .properties dd { margin-bottom: var(--s2); } } /* Systemseite: Kasten "Zustand". Warnungen rot, Hinweise grün — dieselben - * Farben wie .meldung, aber als Liste, damit mehrere untereinander passen. */ -.zustand { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s2); } -.zustand > li { + * Farben wie .message, aber als Liste, damit mehrere untereinander passen. */ +.status { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s2); } +.status > li { display: flex; gap: var(--s3); align-items: baseline; @@ -744,27 +744,27 @@ table.tabelle { font-size: var(--t-meta); line-height: 1.5; } -.zustand > li.zustand-warnung { background: var(--warn-flach); border-left-color: var(--warn); } -.zustand > li.zustand-hinweis { background: var(--akzent-flach); border-left-color: var(--akzent); } -.zustand-art { font-weight: 600; flex: none; min-width: 5rem; max-width: 12rem; overflow-wrap: anywhere; } -.zustand-warnung .zustand-art { color: var(--warn); } +.status > li.status-warning { background: var(--warn-flach); border-left-color: var(--warn); } +.status > li.status-hint { background: var(--akzent-flach); border-left-color: var(--akzent); } +.status-kind { font-weight: 600; flex: none; min-width: 5rem; max-width: 12rem; overflow-wrap: anywhere; } +.status-warning .status-kind { color: var(--warn); } /* Überblick: mehrere Befunde eines Projekts in einer Karte. */ -.zustand-punkte { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s1); flex: 1; min-width: 0; } -.zustand-punkte .dringend { font-weight: 600; } -.weiter-link { white-space: nowrap; font-weight: 400; } -.zustand-hinweis .zustand-art { color: var(--akzent-text); } +.status-items { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s1); flex: 1; min-width: 0; } +.status-items .urgent { font-weight: 600; } +.more-link { white-space: nowrap; font-weight: 400; } +.status-hint .status-kind { color: var(--akzent-text); } -/* Systemseite: Balkendiagramme (internal/web/diagramm.go). Untereinander +/* Systemseite: Balkendiagramme (internal/web/chart.go). Untereinander * und höchstens so breit wie Fließtext: Das SVG skaliert samt Schrift mit * seiner Breite — nebeneinander wären die Beschriftungen zu klein, über die * ganze Seite die Balken unnötig flach. */ -.diagramme { +.charts { display: grid; gap: var(--s4); max-width: var(--breite-lesen); margin-bottom: var(--s2); } -.diagramm-rahmen { +.chart-frame { margin: 0; background: var(--flaeche); border: 1px solid var(--rahmen); @@ -772,7 +772,7 @@ table.tabelle { padding: var(--s4); min-width: 0; } -.diagramm-rahmen figcaption { +.chart-frame figcaption { display: flex; flex-wrap: wrap; align-items: center; @@ -780,58 +780,58 @@ table.tabelle { margin-bottom: var(--s3); font-size: var(--t-meta); } -.legende { display: inline-flex; align-items: center; gap: var(--s1); color: var(--text-leise); } -.legende-farbe { display: inline-block; width: 0.75rem; height: 0.75rem; border-radius: 2px; } -.diagramm { display: block; width: 100%; height: auto; } -.diagramm-gitter { stroke: var(--rahmen); stroke-width: 1; } -.diagramm-text { fill: var(--text-leise); font-size: 11px; font-variant-numeric: tabular-nums; } +.legend { display: inline-flex; align-items: center; gap: var(--s1); color: var(--text-leise); } +.legend-swatch { display: inline-block; width: 0.75rem; height: 0.75rem; border-radius: 2px; } +.chart { display: block; width: 100%; height: auto; } +.chart-grid { stroke: var(--rahmen); stroke-width: 1; } +.chart-text { fill: var(--text-leise); font-size: 11px; font-variant-numeric: tabular-nums; } /* Unsichtbar, aber da: Über ihr erscheint der Tooltip eines Tages. */ -.diagramm-treffer { fill: transparent; } -.diagramm-treffer:hover { fill: var(--flaeche-leis); } -.diagramm-leer { margin: var(--s5) 0; text-align: center; } +.chart-hitarea { fill: transparent; } +.chart-hitarea:hover { fill: var(--flaeche-leis); } +.chart-empty { margin: var(--s5) 0; text-align: center; } /* Die Farben gelten für die Balken (SVG: fill) und die Legende (HTML: * background). Menschen in derselben Farbe wie Agenten, nur heller — beides * ist "Schreiben". */ -rect.balken-agent { fill: var(--akzent); } -rect.balken-mensch { fill: var(--akzent); fill-opacity: 0.4; } -rect.balken-suche { fill: var(--text-leise); } -.legende-farbe.balken-agent { background: var(--akzent); } -.legende-farbe.balken-mensch { background: var(--akzent); opacity: 0.4; } -.legende-farbe.balken-suche { background: var(--text-leise); } +rect.bar-agent { fill: var(--akzent); } +rect.bar-human { fill: var(--akzent); fill-opacity: 0.4; } +rect.bar-search { fill: var(--text-leise); } +.legend-swatch.bar-agent { background: var(--akzent); } +.legend-swatch.bar-human { background: var(--akzent); opacity: 0.4; } +.legend-swatch.bar-search { background: var(--text-leise); } /* Aufklappbarer Zusatz unter einer Tabelle oder einem Diagramm. */ -.aufklapper { margin-top: var(--s3); } -.aufklapper > summary { cursor: pointer; color: var(--akzent-text); font-size: var(--t-meta); } -.aufklapper[open] > summary { margin-bottom: var(--s3); } -.abschnitt h3 { margin: var(--s5) 0 var(--s2); } -.ruhig-liste { margin: var(--s2) 0 0; padding-left: var(--s5); columns: 2 16rem; font-size: var(--t-meta); } +.disclosure { margin-top: var(--s3); } +.disclosure > summary { cursor: pointer; color: var(--akzent-text); font-size: var(--t-meta); } +.disclosure[open] > summary { margin-bottom: var(--s3); } +.section h3 { margin: var(--s5) 0 var(--s2); } +.idle-list { margin: var(--s2) 0 0; padding-left: var(--s5); columns: 2 16rem; font-size: var(--t-meta); } /* Warnender Text in einer Kennzahl. */ .warn-text { color: var(--warn); } /* Filter über einer Tabelle, z.B. im Protokoll. Bricht auf schmalen * Bildschirmen um. */ -.filterleiste { +.filter-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--s2) var(--s3); margin-bottom: var(--s4); } -.filterleiste label { +.filter-bar label { display: flex; flex-direction: column; gap: var(--s1); font-size: var(--t-meta); color: var(--text-leise); } -.filterleiste a { align-self: center; font-size: var(--t-meta); } +.filter-bar a { align-self: center; font-size: var(--t-meta); } /* In der Filterleiste nehmen die Felder ihre natürliche Breite, nicht 100 % * des Etiketts — sonst schrumpfen sie auf die Breite der Beschriftung. */ -.filterleiste :is(input, select) { width: auto; min-width: 9rem; } +.filter-bar :is(input, select) { width: auto; min-width: 9rem; } /* Filterlink neben der Suche, z.B. "3 veraltet". */ -.werkzeugleiste .filter-link { +.toolbar .filter-link { align-self: center; white-space: nowrap; font-size: var(--t-klein); @@ -839,7 +839,7 @@ rect.balken-suche { fill: var(--text-leise); } /* Eintragsliste. */ -.eintraege { +.entries { list-style: none; margin: 0; padding: 0; @@ -849,28 +849,28 @@ rect.balken-suche { fill: var(--text-leise); } box-shadow: var(--schatten); overflow: hidden; } -.eintraege li { +.entries li { display: flex; align-items: flex-start; gap: var(--s3); padding: var(--s4); border-bottom: 1px solid var(--rahmen); } -.eintraege li:last-child { border-bottom: none; } -.eintraege li:hover { background: var(--flaeche-leis); } +.entries li:last-child { border-bottom: none; } +.entries li:hover { background: var(--flaeche-leis); } /* Auswahlkästchen der Sammelbearbeitung: etwas abgesetzt vom Titel, auf * dessen erster Zeile ausgerichtet statt an der Oberkante der ganzen Zeile. */ -.eintrag-auswahl { +.entry-select { display: flex; align-items: center; padding-top: 0.35rem; } -.eintrag-inhalt { flex: 1; min-width: 0; } +.entry-content { flex: 1; min-width: 0; } /* Sammelleiste: erscheint unter der Liste, sobald Einträge angehakt sind. * position: sticky hält sie beim Scrollen einer langen Liste in Sichtweite. */ -.sammelleiste { +.bulk-bar { display: flex; align-items: center; gap: var(--s2); @@ -886,12 +886,12 @@ rect.balken-suche { fill: var(--text-leise); } } /* Papierkorb: "Alle von … wiederherstellen" steht mit etwas Abstand unter der * Sammelleiste. */ -.papierkorb-alle { margin-top: var(--s3); } +.trash-all { margin-top: var(--s3); } /* Für Screenreader lesbar, aber unsichtbar — etwa die Überschrift einer * Spalte, die nur Kästchen enthält. display:none ginge nicht: Das würde den * Text auch vor Screenreadern verstecken. */ -.unsichtbar { +.invisible { position: absolute; width: 1px; height: 1px; @@ -900,21 +900,21 @@ rect.balken-suche { fill: var(--text-leise); } white-space: nowrap; } -.sammelleiste input[type=text] { +.bulk-bar input[type=text] { width: auto; flex: 1 1 12rem; min-height: 2rem; padding: var(--s1) var(--s2); } -.eintrag-titel { +.entry-title { display: block; font-size: var(--t-gross); font-weight: 500; color: var(--text); margin-bottom: var(--s2); } -.eintrag-ausschnitt { +.entry-excerpt { margin: var(--s2) 0 0; color: var(--text-leise); font-size: var(--t-meta); @@ -924,7 +924,7 @@ rect.balken-suche { fill: var(--text-leise); } -webkit-box-orient: vertical; overflow: hidden; } -.eintrag-ausschnitt mark { +.entry-excerpt mark { background: var(--akzent-flach); color: var(--akzent-text); padding: 0 0.15em; @@ -932,41 +932,41 @@ rect.balken-suche { fill: var(--text-leise); } } /* Einzelner Eintrag: Fließtext in Lesebreite. */ -.eintrag-text { +.entry-text { font-size: var(--t-gross); line-height: 1.75; margin-top: var(--s6); } -.eintrag-text > :first-child { margin-top: 0; } -.eintrag-text h1, .eintrag-text h2, .eintrag-text h3 { margin: var(--s6) 0 var(--s3); } -.eintrag-text p, .eintrag-text ul, .eintrag-text ol { margin: 0 0 var(--s4); } -.eintrag-text ul, .eintrag-text ol { padding-left: var(--s5); } -.eintrag-text li { margin-bottom: var(--s1); } -.eintrag-text img { max-width: 100%; border-radius: var(--radius); } -.eintrag-text blockquote { +.entry-text > :first-child { margin-top: 0; } +.entry-text h1, .entry-text h2, .entry-text h3 { margin: var(--s6) 0 var(--s3); } +.entry-text p, .entry-text ul, .entry-text ol { margin: 0 0 var(--s4); } +.entry-text ul, .entry-text ol { padding-left: var(--s5); } +.entry-text li { margin-bottom: var(--s1); } +.entry-text img { max-width: 100%; border-radius: var(--radius); } +.entry-text blockquote { margin: var(--s4) 0; padding: var(--s1) var(--s4); border-left: 3px solid var(--rahmen-stark); color: var(--text-leise); } -.eintrag-text pre { margin-bottom: var(--s4); font-size: var(--t-meta); } -.eintrag-text table { border-collapse: collapse; width: 100%; font-size: var(--t-meta); } -.eintrag-text th, .eintrag-text td { border: 1px solid var(--rahmen); padding: var(--s2) var(--s3); } -.eintrag-text hr { border: none; border-top: 1px solid var(--rahmen); margin: var(--s6) 0; } +.entry-text pre { margin-bottom: var(--s4); font-size: var(--t-meta); } +.entry-text table { border-collapse: collapse; width: 100%; font-size: var(--t-meta); } +.entry-text th, .entry-text td { border: 1px solid var(--rahmen); padding: var(--s2) var(--s3); } +.entry-text hr { border: none; border-top: 1px solid var(--rahmen); margin: var(--s6) 0; } /* Checklisten (- [ ] / - [x]): Das Kästchen ersetzt den Aufzählungspunkt. * :has() wählt nur Listenpunkte aus, die direkt ein Kästchen enthalten — * gewöhnliche Aufzählungen behalten ihren Punkt. */ -.eintrag-text li:has(> input[type="checkbox"]) { list-style: none; margin-left: calc(-1 * var(--s4)); } -.eintrag-text li > input[type="checkbox"] { margin: 0 var(--s2) 0 0; vertical-align: middle; } -.eintrag-text del { color: var(--text-leise); } +.entry-text li:has(> input[type="checkbox"]) { list-style: none; margin-left: calc(-1 * var(--s4)); } +.entry-text li > input[type="checkbox"] { margin: 0 var(--s2) 0 0; vertical-align: middle; } +.entry-text del { color: var(--text-leise); } -.rueckverweise { margin-top: var(--s7); } -.rueckverweise h2 { font-size: var(--t-meta); color: var(--text-leise); margin-bottom: var(--s3); } -.rueckverweise ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); } +.backlinks { margin-top: var(--s7); } +.backlinks h2 { font-size: var(--t-meta); color: var(--text-leise); margin-bottom: var(--s3); } +.backlinks ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); } /* Neu erzeugter Key: wird genau einmal angezeigt, also deutlich hervorheben. */ -.key-anzeige { border-color: var(--akzent); background: var(--akzent-flach); } -.key-wert { +.key-display { border-color: var(--akzent); background: var(--akzent-flach); } +.key-value { display: block; margin: var(--s3) 0; padding: var(--s3); @@ -978,22 +978,22 @@ rect.balken-suche { fill: var(--text-leise); } word-break: break-all; user-select: all; /* ein Klick markiert den ganzen Key */ } -.key-anleitung { margin-top: var(--s4); } -.key-anleitung summary { +.key-instructions { margin-top: var(--s4); } +.key-instructions summary { cursor: pointer; font-size: var(--t-meta); color: var(--akzent-text); } -.key-anleitung pre { margin-top: var(--s3); font-size: var(--t-klein); } +.key-instructions pre { margin-top: var(--s3); font-size: var(--t-klein); } /* Fassungsvergleich. */ -.diff-koepfe { +.diff-heads { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s4); margin-bottom: var(--s4); } -.diff-kopf { +.diff-head { display: flex; flex-direction: column; gap: var(--s1); @@ -1003,7 +1003,7 @@ rect.balken-suche { fill: var(--text-leise); } border-radius: var(--radius); } -.diff-huelle { +.diff-wrap { background: var(--flaeche); border: 1px solid var(--rahmen); border-radius: var(--radius-gross); @@ -1019,11 +1019,11 @@ rect.balken-suche { fill: var(--text-leise); } word-break: break-word; } .diff span { display: block; padding: 0 var(--s4); } -.diff-hinzugefuegt { background: var(--akzent-flach); color: var(--akzent-text); } -.diff-entfernt { background: var(--warn-flach); color: var(--warn); } +.diff-added { background: var(--akzent-flach); color: var(--akzent-text); } +.diff-removed { background: var(--warn-flach); color: var(--warn); } /* Graph-Ansicht der Verweise. */ -.graph-huelle { +.graph-wrap { background: var(--flaeche); border: 1px solid var(--rahmen); border-radius: var(--radius-gross); @@ -1031,7 +1031,7 @@ rect.balken-suche { fill: var(--text-leise); } overflow: hidden; margin-bottom: var(--s4); } -.graph-huelle svg { +.graph-wrap svg { display: block; width: 100%; /* Möglichst viel Fläche, aber auf kleinen und sehr hohen Bildschirmen @@ -1040,22 +1040,22 @@ rect.balken-suche { fill: var(--text-leise); } cursor: grab; touch-action: none; } -.graph-huelle svg.graph-zieht { cursor: grabbing; } +.graph-wrap svg.graph-dragging { cursor: grabbing; } -.graph-kante { stroke: var(--rahmen-stark); stroke-width: 1.5; transition: opacity 0.15s; } -.graph-knoten-gruppe { transition: opacity 0.15s; } +.graph-edge { stroke: var(--rahmen-stark); stroke-width: 1.5; transition: opacity 0.15s; } +.graph-node-group { transition: opacity 0.15s; } -.graph-punkt { +.graph-dot { fill: var(--akzent-flach); stroke: var(--akzent); stroke-width: 1.5; } -.graph-knoten-fehlend .graph-punkt { +.graph-node-missing .graph-dot { fill: var(--flaeche); stroke: var(--rahmen-stark); stroke-dasharray: 3 2; } -.graph-beschriftung { +.graph-label { font-size: 0.75rem; fill: var(--text); dominant-baseline: middle; @@ -1071,29 +1071,29 @@ rect.balken-suche { fill: var(--text-leise); } stroke-linejoin: round; } /* Welche Namen Platz haben, entscheidet static/graph.js (beschrifte()). */ -.graph-beschriftung-aus { display: none; } -.graph-knoten-fehlend .graph-beschriftung { fill: var(--text-leise); } -.graph-klickbar { cursor: pointer; } -.graph-klickbar:hover .graph-punkt { stroke-width: 2.5; } -.graph-klickbar:hover .graph-beschriftung { fill: var(--akzent-text); } +.graph-label-hidden { display: none; } +.graph-node-missing .graph-label { fill: var(--text-leise); } +.graph-clickable { cursor: pointer; } +.graph-clickable:hover .graph-dot { stroke-width: 2.5; } +.graph-clickable:hover .graph-label { fill: var(--akzent-text); } /* Hervorhebung beim Überfahren: der Knoten, seine Nachbarn und die * verbindenden Kanten bleiben kräftig, der Rest tritt zurück. */ -.graph-fokus .graph-knoten-gruppe:not(.graph-nachbar) { opacity: 0.2; } -.graph-fokus .graph-kante:not(.graph-kante-aktiv) { opacity: 0.1; } -.graph-kante-aktiv { stroke: var(--akzent); } +.graph-focus .graph-node-group:not(.graph-neighbor) { opacity: 0.2; } +.graph-focus .graph-edge:not(.graph-edge-active) { opacity: 0.1; } +.graph-edge-active { stroke: var(--akzent); } /* Seite "Alle Schlagworte": mehrspaltige Liste, die Spaltenzahl ergibt sich * aus der verfügbaren Breite. */ -.schlagwort-liste { +.tag-list { list-style: none; margin: 0 0 var(--s5); padding: 0; columns: 14rem; column-gap: var(--s5); } -.schlagwort-liste li { break-inside: avoid; } -.schlagwort-liste a { +.tag-list li { break-inside: avoid; } +.tag-list a { display: flex; align-items: center; gap: var(--s2); @@ -1102,8 +1102,8 @@ rect.balken-suche { fill: var(--text-leise); } color: var(--text); font-size: var(--t-meta); } -.schlagwort-liste a:hover { background: var(--flaeche-leis); text-decoration: none; } -.schlagwort-gruppe h2 { +.tag-list a:hover { background: var(--flaeche-leis); text-decoration: none; } +.tag-group h2 { font-size: var(--t-gross); color: var(--text-leise); border-bottom: 1px solid var(--rahmen); @@ -1115,12 +1115,12 @@ rect.balken-suche { fill: var(--text-leise); } * den harmlosen Aktionen. Vorher steckte das Löschen eines Projekts samt * Bestätigungsfeld in einer Tabellenzelle — zu eng und viel zu beiläufig für * eine Aktion, die alle Einträge mitnimmt. */ -.gefahrenzone { border-color: var(--warn); } -.gefahrenzone h2 { color: var(--warn); } +.danger-zone { border-color: var(--warn); } +.danger-zone h2 { color: var(--warn); } /* Hilfe: Themen als umbrechende Reiterleiste über dem Text, der Text selbst * wie ein Eintrag in Lesebreite. */ -.hilfe-navigation { +.help-nav { display: flex; flex-wrap: wrap; gap: var(--s2); @@ -1128,28 +1128,28 @@ rect.balken-suche { fill: var(--text-leise); } padding-bottom: var(--s4); border-bottom: 1px solid var(--rahmen); } -.hilfe-navigation a { +.help-nav a { padding: var(--s1) var(--s3); border: 1px solid var(--rahmen); border-radius: var(--radius); color: var(--text-leise); font-size: var(--t-meta); } -.hilfe-navigation a:hover { background: var(--flaeche-leis); color: var(--text); text-decoration: none; } -.hilfe-navigation a.aktiv { +.help-nav a:hover { background: var(--flaeche-leis); color: var(--text); text-decoration: none; } +.help-nav a.active { background: var(--akzent-flach); border-color: var(--akzent); color: var(--akzent-text); font-weight: 500; } -.hilfe-text { max-width: var(--breite-lesen); } +.help-text { max-width: var(--breite-lesen); } /* Das kleine "?" neben Überschriften und Feldern (Partial "hilfelink"). * Bewusst unaufdringlich: ein Kreis in Textgröße, der erst beim Überfahren * Farbe bekommt. Die Größe in rem (bezogen auf die Grundschrift, nicht auf * die Umgebung), damit der Kreis neben einer großen h1 genauso klein bleibt * wie neben einem Formular-Label. */ -.hilfe-link { +.help-link { display: inline-flex; align-items: center; justify-content: center; @@ -1164,7 +1164,7 @@ rect.balken-suche { fill: var(--text-leise); } color: var(--text-leise); vertical-align: middle; } -.hilfe-link:hover { +.help-link:hover { border-color: var(--akzent); color: var(--akzent-text); text-decoration: none; @@ -1177,21 +1177,21 @@ rect.balken-suche { fill: var(--text-leise); } * Bedarf seitlich scrollen. So bleibt alles erreichbar, ohne dass eine * ausklappbare Navigation samt JavaScript nötig wäre. */ @media (max-width: 60rem) { - .gerüst { + .layout { grid-template-columns: minmax(0, 1fr); gap: 0; padding-inline: var(--s4); } - .kopfleiste-innen { padding-inline: var(--s4); gap: var(--s3); } + .topbar-inner { padding-inline: var(--s4); gap: var(--s3); } /* Kopfleiste: Auf schmalen Bildschirmen reicht "Hilfe", und ein langer * Workspace-Name wird gekürzt statt die Leiste zu sprengen. */ - .nur-breit { display: none; } - .reihe.kopfleiste-aktionen { gap: var(--s4); flex-wrap: nowrap; } - .kopfleiste-innen > .reihe:first-child { min-width: 0; flex-wrap: nowrap; } - .arbeitsbereich { min-width: 0; } - .arbeitsbereich-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .wide-only { display: none; } + .row.topbar-actions { gap: var(--s4); flex-wrap: nowrap; } + .topbar-inner > .row:first-child { min-width: 0; flex-wrap: nowrap; } + .workspace { min-width: 0; } + .workspace-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .seitenleiste { + .sidebar { position: static; max-height: none; overflow: visible; @@ -1203,41 +1203,41 @@ rect.balken-suche { fill: var(--text-leise); } } /* Jede Gruppe wird zu einer Zeile: Überschrift links, Punkte daneben. */ - .nav-gruppe { + .nav-group { flex-direction: row; align-items: center; gap: var(--s2); min-width: 0; flex: 1 1 100%; } - .nav-titel { margin-bottom: 0; padding-inline: 0; white-space: nowrap; } + .nav-title { margin-bottom: 0; padding-inline: 0; white-space: nowrap; } /* min-width: 0, damit die Zeile in sich scrollt, statt die ganze Seite * zu verbreitern — ein Flex-Element gibt sonst nicht unter die Breite * seines Inhalts nach. */ - .nav-liste { + .nav-list { display: flex; gap: var(--s1); min-width: 0; overflow-x: auto; scrollbar-width: none; /* die Leiste scrollt, ohne einen Balken zu zeigen */ } - .nav-liste::-webkit-scrollbar { display: none; } - .nav-liste a { white-space: nowrap; } + .nav-list::-webkit-scrollbar { display: none; } + .nav-list a { white-space: nowrap; } /* Die Schlagwortliste kann lang werden — hier zählt jede Zeile. */ - .schlagworte .nav-liste a { padding-inline: var(--s2); } + .tags .nav-list a { padding-inline: var(--s2); } - .inhalt { padding-block: var(--s5) var(--s7); } + .content { padding-block: var(--s5) var(--s7); } - .seitenkopf { gap: var(--s3); } - .seitenkopf-aktionen { width: 100%; } - .seitenkopf-aktionen .knopf { flex: 1; } + .page-header { gap: var(--s3); } + .page-header-actions { width: 100%; } + .page-header-actions .button { flex: 1; } - .karte { padding: var(--s4); } - .tabelle th, .tabelle td { padding: var(--s2) var(--s3); } - .feld.kurz > :is(input, select), .feld.mittel > :is(input, select), - .feld.lang > :is(input, select) { max-width: none; } + .card { padding: var(--s4); } + .styled-table th, .styled-table td { padding: var(--s2) var(--s3); } + .field.short > :is(input, select), .field.medium > :is(input, select), + .field.long > :is(input, select) { max-width: none; } textarea { min-height: 14rem; } - .diff-koepfe { grid-template-columns: 1fr; } + .diff-heads { grid-template-columns: 1fr; } } diff --git a/static/tagfilter.js b/static/tagfilter.js index de99898..851818e 100644 --- a/static/tagfilter.js +++ b/static/tagfilter.js @@ -7,17 +7,17 @@ (function () { "use strict"; - var feld = document.querySelector("[data-tag-filter]"); - if (!feld) { + var field = document.querySelector("[data-tag-filter]"); + if (!field) { return; } - var eintraege = document.querySelectorAll("[data-tag-liste] [data-tag]"); - var gruppen = document.querySelectorAll("[data-tag-gruppe]"); - var leer = document.querySelector("[data-tag-leer]"); + var entries = document.querySelectorAll("[data-tag-list] [data-tag]"); + var groups = document.querySelectorAll("[data-tag-group]"); + var empty = document.querySelector("[data-tag-empty]"); - // vereinfache bringt Suchbegriff und Schlagwort in dieselbe Form: + // simplify bringt Suchbegriff und Schlagwort in dieselbe Form: // klein geschrieben und ohne Umlaute, damit "uber" auch "übersicht" findet. - function vereinfache(text) { + function simplify(text) { return text .toLowerCase() .replace(/ä/g, "a") @@ -26,42 +26,42 @@ .replace(/ß/g, "ss"); } - function filtere() { - var begriff = vereinfache(feld.value.trim()); - var sichtbar = 0; + function applyFilter() { + var term = simplify(field.value.trim()); + var visible = 0; - eintraege.forEach(function (li) { - var passt = vereinfache(li.getAttribute("data-tag")).indexOf(begriff) !== -1; - li.hidden = !passt; - if (passt) { - sichtbar++; + entries.forEach(function (li) { + var matches = simplify(li.getAttribute("data-tag")).indexOf(term) !== -1; + li.hidden = !matches; + if (matches) { + visible++; } }); - gruppen.forEach(function (gruppe) { - gruppe.hidden = !gruppe.querySelector("[data-tag]:not([hidden])"); + groups.forEach(function (group) { + group.hidden = !group.querySelector("[data-tag]:not([hidden])"); }); - if (leer) { - leer.hidden = sichtbar > 0; + if (empty) { + empty.hidden = visible > 0; } } - feld.addEventListener("input", filtere); + field.addEventListener("input", applyFilter); // Enter bei genau einem Treffer öffnet ihn direkt — spart den Griff zur Maus. - feld.addEventListener("keydown", function (event) { + field.addEventListener("keydown", function (event) { if (event.key !== "Enter") { return; } - var treffer = document.querySelectorAll("[data-tag-liste] [data-tag]:not([hidden]) a"); - if (treffer.length === 1) { + var hits = document.querySelectorAll("[data-tag-list] [data-tag]:not([hidden]) a"); + if (hits.length === 1) { event.preventDefault(); - window.location.href = treffer[0].href; + window.location.href = hits[0].href; } }); // Der Browser stellt beim Zurück-Navigieren den Feldinhalt wieder her — // die Liste muss dann dazu passen. - filtere(); + applyFilter(); })(); diff --git a/static/ungespeichert.js b/static/unsaved.js similarity index 66% rename from static/ungespeichert.js rename to static/unsaved.js index eefd7b9..a25f092 100644 --- a/static/ungespeichert.js +++ b/static/unsaved.js @@ -1,6 +1,6 @@ // Warnt, bevor ungespeicherte Änderungen verloren gehen. // -// Gilt für Formulare mit dem Attribut data-ungespeichert (das +// Gilt für Formulare mit dem Attribut data-unsaved (das // Bearbeitungsformular eines Eintrags). Hat jemand dort etwas geändert und // verlässt die Seite, ohne zu speichern — ein Klick in der Seitenleiste, Tab // schließen, zurück —, fragt der Browser nach. Den Text der Rückfrage legt der @@ -13,27 +13,27 @@ (function () { "use strict"; - // stand fasst alle Felder eines Formulars zu einer Zeichenkette zusammen. - function stand(form) { - var teile = []; - new FormData(form).forEach(function (wert, name) { + // state fasst alle Felder eines Formulars zu einer Zeichenkette zusammen. + function state(form) { + var parts = []; + new FormData(form).forEach(function (val, name) { if (name !== "csrf_token") { - teile.push(name + "=" + wert); + parts.push(name + "=" + val); } }); - return teile.join("\u0000"); + return parts.join("\u0000"); } - document.querySelectorAll("form[data-ungespeichert]").forEach(function (form) { - var ausgang = stand(form); - var wirdGespeichert = false; + document.querySelectorAll("form[data-unsaved]").forEach(function (form) { + var outgoing = state(form); + var saving = false; form.addEventListener("submit", function () { - wirdGespeichert = true; + saving = true; }); window.addEventListener("beforeunload", function (event) { - if (wirdGespeichert || stand(form) === ausgang) { + if (saving || state(form) === outgoing) { return; } // Beides nötig: preventDefault für aktuelle Browser, returnValue diff --git a/templates/audit.html b/templates/audit.html index 3f7f58b..4f811a3 100644 --- a/templates/audit.html +++ b/templates/audit.html @@ -1,12 +1,12 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> +<div class="page-header"> + <div class="page-header-title"> {{/* Das Protokoll über alle Projekte sieht nur der Plattform-Admin — für ihn gibt es einen eigenen Abschnitt auf seiner Hilfeseite. */}} - <h1>Protokoll {{if .Data.Global}}{{template "hilfelink" "plattform-admin#protokoll-ueber-alle-projekte"}}{{else}}{{template "hilfelink" "zugaenge#protokoll"}}{{end}}</h1> - <span class="hilfe"> - Die jüngsten {{len .Data.Events}} Vorgänge{{if .Data.Gefiltert}} zum Filter{{end}}, neueste zuerst (höchstens 200). + <h1>Protokoll {{if .Data.Global}}{{template "helplink" "plattform-admin#protokoll-ueber-alle-projekte"}}{{else}}{{template "helplink" "zugaenge#protokoll"}}{{end}}</h1> + <span class="help"> + Die jüngsten {{len .Data.Events}} Vorgänge{{if .Data.Filtered}} zum Filter{{end}}, neueste zuerst (höchstens 200). {{if .Data.Global}}Über alle Projekte hinweg.{{end}} Einträge werden nach 90 Tagen automatisch entfernt. </span> @@ -16,20 +16,20 @@ {{/* Filter: Vorgang, Zugang, Zeitraum (und im Protokoll über alle Projekte das Projekt). Ein GET-Formular, damit sich eine gefilterte Ansicht als Adresse weitergeben lässt. */}} -<form method="GET" class="filterleiste"> +<form method="GET" class="filter-bar"> <label>Vorgang <select name="vorgang"> <option value="">alle</option> - {{range .Data.Aktionen}} - <option value="{{.Action}}" {{if eq .Action $.Data.Filter.Vorgang}}selected{{end}}>{{.Label}}</option> + {{range .Data.Actions}} + <option value="{{.Action}}" {{if eq .Action $.Data.Filter.Event}}selected{{end}}>{{.Label}}</option> {{end}} </select> </label> <label>Wer <select name="wer"> <option value="">alle</option> - {{range .Data.Zugaenge}} - <option value="{{.}}" {{if eq . $.Data.Filter.Wer}}selected{{end}}>{{.}}</option> + {{range .Data.Keys}} + <option value="{{.}}" {{if eq . $.Data.Filter.Who}}selected{{end}}>{{.}}</option> {{end}} </select> </label> @@ -38,20 +38,20 @@ <select name="projekt"> <option value="">alle</option> {{range $id, $name := .Data.ProjectNames}} - <option value="{{$id}}" {{if eq $id $.Data.Filter.Projekt}}selected{{end}}>{{$name}}</option> + <option value="{{$id}}" {{if eq $id $.Data.Filter.Project}}selected{{end}}>{{$name}}</option> {{end}} </select> </label> {{end}} - <label>von <input type="date" name="von" value="{{.Data.Filter.Von}}"></label> - <label>bis <input type="date" name="bis" value="{{.Data.Filter.Bis}}"></label> - <button type="submit" class="zweitrangig">Filtern</button> - {{if .Data.Gefiltert}}<a href="?">Filter aufheben</a>{{end}} + <label>von <input type="date" name="von" value="{{.Data.Filter.From}}"></label> + <label>bis <input type="date" name="bis" value="{{.Data.Filter.Until}}"></label> + <button type="submit" class="secondary">Filtern</button> + {{if .Data.Filtered}}<a href="?">Filter aufheben</a>{{end}} </form> {{if .Data.Events}} -<div class="tabelle-huelle"> - <table class="tabelle"> +<div class="table-wrap"> + <table class="styled-table"> <thead> <tr> <th>Zeitpunkt</th> @@ -68,7 +68,7 @@ {{$projekte := .Data.ProjectNames}}{{$global := .Data.Global}} {{range .Data.Events}} <tr> - <td>{{datum .Timestamp}}</td> + <td>{{datetime .Timestamp}}</td> {{if $global}}<td>{{with index $projekte .ProjectID}}{{.}}{{else}}—{{end}}</td>{{end}} <td>{{.Actor}}</td> <td><code>{{.Action}}</code></td> @@ -82,8 +82,8 @@ </table> </div> {{else}} -<div class="leer"> - {{if .Data.Gefiltert}} +<div class="empty"> + {{if .Data.Filtered}} <p>Nichts gefunden für diesen Filter.</p> <p><a href="?">Filter aufheben</a></p> {{else}} @@ -92,4 +92,4 @@ </div> {{end}} -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/edit.html b/templates/edit.html index 85a7ec1..058b10b 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -1,20 +1,20 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> +<div class="page-header"> + <div class="page-header-title"> <h1>{{if .Data.IsNew}}Neuer Eintrag{{else}}Eintrag bearbeiten{{end}}</h1> - {{if not .Data.IsNew}}<span class="hilfe"><code>{{.Data.Entry.Slug}}</code> · Fassung {{.Data.Entry.Version}}</span>{{end}} + {{if not .Data.IsNew}}<span class="help"><code>{{.Data.Entry.Slug}}</code> · Fassung {{.Data.Entry.Version}}</span>{{end}} </div> </div> -<div class="karte"> - {{/* data-ungespeichert: Rückfrage beim Verlassen mit ungespeicherten - Änderungen (static/ungespeichert.js). data-kuerzel-speichern: - Strg+Enter speichert (static/tastenkuerzel.js). */}} - <form method="POST" class="formular" +<div class="card"> + {{/* data-unsaved: Rückfrage beim Verlassen mit ungespeicherten + Änderungen (static/unsaved.js). data-shortcut-save: + Strg+Enter speichert (static/shortcuts.js). */}} + <form method="POST" class="form" action="{{if .Data.IsNew}}/p/{{.Project.Slug}}/save{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/save{{end}}" data-preview-url="/p/{{.Project.Slug}}/preview" - data-ungespeichert data-kuerzel-speichern> + data-unsaved data-shortcut-save> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> {{/* Die Fassung wandert unverändert durchs Formular und zurück. Hat jemand @@ -22,16 +22,16 @@ und meldet einen Konflikt, statt die fremde Änderung zu überschreiben. */}} <input type="hidden" name="version" value="{{.Data.Entry.Version}}"> - <div class="feld lang"> + <div class="field long"> <label for="title">Titel</label> {{if .Data.IsNew}} {{/* Beim Tippen: Gibt es schon einen ähnlichen Eintrag? Dieselbe Prüfung wie bei "remember" über MCP, hier nur als Hinweis - (static/aehnlich.js). */}} + (static/similar.js). */}} <input type="text" id="title" name="title" value="{{.Data.Entry.Title}}" required autofocus - data-aehnlich-url="/p/{{.Project.Slug}}/entries/similar" autocomplete="off"> - <div class="aehnlich-hinweis" data-aehnlich-liste hidden aria-live="polite"> - <span class="hilfe">Gibt es schon? Lieber den vorhandenen Eintrag fortschreiben, statt einen zweiten daneben zu legen:</span> + data-similar-url="/p/{{.Project.Slug}}/entries/similar" autocomplete="off"> + <div class="similar-hint" data-similar-list hidden aria-live="polite"> + <span class="help">Gibt es schon? Lieber den vorhandenen Eintrag fortschreiben, statt einen zweiten daneben zu legen:</span> <ul></ul> </div> {{else}} @@ -40,9 +40,9 @@ </div> {{if .Data.IsNew}} - <div class="feld kurz"> + <div class="field short"> <label for="slug">Bezeichner</label> - <span class="hilfe">Leer lassen, um ihn aus dem Titel abzuleiten.</span> + <span class="help">Leer lassen, um ihn aus dem Titel abzuleiten.</span> <input type="text" id="slug" name="slug" pattern="[a-z0-9][a-z0-9_-]*" value="{{.Data.Entry.Slug}}" placeholder="wird-abgeleitet" autocomplete="off"> </div> @@ -50,49 +50,49 @@ <input type="hidden" name="slug" value="{{.Data.Entry.Slug}}"> {{end}} - <div class="feld mittel"> + <div class="field medium"> <label for="tags">Schlagworte</label> - <span class="hilfe">Durch Komma getrennt.</span> + <span class="help">Durch Komma getrennt.</span> <input type="text" id="tags" name="tags" value="{{.Data.TagsIn}}" placeholder="ops, deployment"> </div> - <div class="feld" data-editor> - <label for="content">Inhalt {{template "hilfelink" "weboberflaeche#markdown-im-inhalt"}}</label> - <span class="hilfe">Markdown. <code>[[bezeichner]]</code> verweist auf einen anderen Eintrag.</span> + <div class="field" data-editor> + <label for="content">Inhalt {{template "helplink" "weboberflaeche#markdown-im-inhalt"}}</label> + <span class="help">Markdown. <code>[[bezeichner]]</code> verweist auf einen anderen Eintrag.</span> {{/* Ohne JavaScript bleibt einfach der erste Reiter aktiv — das Formular funktioniert dann genau wie vorher, nur ohne Vorschau-Knopf-Wirkung. Kein <noscript>-Sonderfall nötig. */}} - <div class="editor-reiter" role="tablist"> - <button type="button" class="editor-reiter-knopf aktiv" - data-editor-tab="schreiben" role="tab" aria-selected="true">Schreiben</button> - <button type="button" class="editor-reiter-knopf" - data-editor-tab="vorschau" role="tab" aria-selected="false">Vorschau</button> + <div class="editor-tabs" role="tablist"> + <button type="button" class="editor-tab active" + data-editor-tab="write" role="tab" aria-selected="true">Schreiben</button> + <button type="button" class="editor-tab" + data-editor-tab="preview" role="tab" aria-selected="false">Vorschau</button> </div> <textarea id="content" name="content" rows="24" spellcheck="true" - data-editor-panel="schreiben" + data-editor-panel="write" data-suggest-url="/p/{{.Project.Slug}}/entries/suggest">{{.Data.Entry.Content}}</textarea> {{/* Erscheint nur, während "[[" getippt und noch nicht mit "]]" geschlossen wurde — siehe static/autocomplete.js. */}} - <ul class="autovervollstaendigung" data-autovervollstaendigung hidden></ul> - <div class="eintrag-text vorschau-flaeche" data-editor-panel="vorschau" hidden></div> + <ul class="autocomplete" data-autocomplete hidden></ul> + <div class="entry-text preview-area" data-editor-panel="preview" hidden></div> </div> - <div class="feld ankreuz"> + <div class="field checkbox"> <input type="checkbox" id="curated" name="curated" value="1" {{if .Data.Entry.Curated}}checked{{end}}> <label for="curated"> Kuratiert - <span class="hilfe">Geprüftes Wissen, keine Beobachtung eines Agenten.</span> + <span class="help">Geprüftes Wissen, keine Beobachtung eines Agenten.</span> </label> </div> - <div class="formular-aktionen"> + <div class="form-actions"> <button type="submit" title="Speichern (Strg+Enter)" aria-keyshortcuts="Control+Enter Meta+Enter">Speichern</button> - <a class="knopf zweitrangig" + <a class="button secondary" href="{{if .Data.IsNew}}/p/{{.Project.Slug}}/{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}{{end}}">Abbrechen</a> </div> </form> </div> -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/entries.html b/templates/entries.html index 7dab42e..37cdbdb 100644 --- a/templates/entries.html +++ b/templates/entries.html @@ -1,48 +1,48 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> - <h1>Einträge {{template "hilfelink" "weboberflaeche#finden"}}</h1> - <span class="hilfe"> +<div class="page-header"> + <div class="page-header-title"> + <h1>Einträge {{template "helplink" "weboberflaeche#finden"}}</h1> + <span class="help"> {{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}} </span> </div> {{if or .Session.CanWrite .Session.CanDownloadProject}} - <div class="seitenkopf-aktionen reihe"> + <div class="page-header-actions row"> {{/* Derselbe Platz wie "Alle als ZIP herunterladen" unter Projekte. download: Der Browser speichert das ZIP, statt die Seite zu wechseln. Wer das darf, steht in Session.CanDownloadProject. */}} {{if .Session.CanDownloadProject}} - <a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/export.zip" download>Als ZIP herunterladen</a> + <a class="button secondary" href="/p/{{.Project.Slug}}/export.zip" download>Als ZIP herunterladen</a> {{end}} {{if .Session.CanWrite}} - <a class="knopf" href="/p/{{.Project.Slug}}/new" - data-kuerzel="n" aria-keyshortcuts="n" title="Neuer Eintrag (Taste n)">Neuer Eintrag</a> + <a class="button" href="/p/{{.Project.Slug}}/new" + data-shortcut="n" aria-keyshortcuts="n" title="Neuer Eintrag (Taste n)">Neuer Eintrag</a> {{end}} </div> {{end}} </div> -<form method="GET" action="/p/{{.Project.Slug}}/" class="werkzeugleiste" role="search"> +<form method="GET" action="/p/{{.Project.Slug}}/" class="toolbar" role="search"> <input type="search" name="q" value="{{.Data.Query}}" placeholder="Im Projekt suchen… ( / )" autocomplete="off" aria-keyshortcuts="/"> - <button type="submit" class="zweitrangig">Suchen</button> + <button type="submit" class="secondary">Suchen</button> {{/* Filter "veraltet": nur sichtbar, wenn es welche gibt — ein Link auf eine leere Liste wäre nur Rauschen. */}} - {{if and .Data.VeraltetAnzahl (not .Data.Veraltet)}} + {{if and .Data.StaleCount (not .Data.Stale)}} <a class="filter-link" href="/p/{{.Project.Slug}}/?veraltet=1" - title="Seit über einem Jahr unverändert. Prüfen, ob das noch stimmt.">{{.Data.VeraltetAnzahl}} veraltet</a> + title="Seit über einem Jahr unverändert. Prüfen, ob das noch stimmt.">{{.Data.StaleCount}} veraltet</a> {{end}} </form> {{if .Data.Query}} -<p class="hilfe">{{len .Data.Entries}} Treffer für „{{.Data.Query}}“ · +<p class="help">{{len .Data.Entries}} Treffer für „{{.Data.Query}}“ · <a href="/p/{{.Project.Slug}}/">Suche zurücksetzen</a></p> {{else if .Data.Tag}} -<p class="hilfe">{{len .Data.Entries}} Einträge mit „{{.Data.Tag}}“ · +<p class="help">{{len .Data.Entries}} Einträge mit „{{.Data.Tag}}“ · <a href="/p/{{.Project.Slug}}/">Filter aufheben</a></p> -{{else if .Data.Veraltet}} -<p class="hilfe">{{len .Data.Entries}} Einträge seit über einem Jahr unverändert, älteste zuletzt. +{{else if .Data.Stale}} +<p class="help">{{len .Data.Entries}} Einträge seit über einem Jahr unverändert, älteste zuletzt. Jeweils prüfen: stimmt das noch? Dann einmal speichern (oder kuratieren), sonst korrigieren oder löschen. · <a href="/p/{{.Project.Slug}}/">Filter aufheben</a></p> @@ -57,67 +57,67 @@ {{if .Data.Query}}<input type="hidden" name="q" value="{{.Data.Query}}">{{end}} {{end}} -<ul class="eintraege"> +<ul class="entries"> {{$slug := .Project.Slug}} {{range .Data.Entries}} <li> {{if $sammelbearbeitung}} - <label class="eintrag-auswahl"> + <label class="entry-select"> <input type="checkbox" name="ids" value="{{.Entry.ID}}" aria-label="{{.Entry.Title}} auswählen"> </label> {{end}} - <div class="eintrag-inhalt"> - <a class="eintrag-titel" href="/p/{{$slug}}/e/{{.Entry.Slug}}">{{.Entry.Title}}</a> + <div class="entry-content"> + <a class="entry-title" href="/p/{{$slug}}/e/{{.Entry.Slug}}">{{.Entry.Title}}</a> <div class="meta"> - {{if .Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}} - {{if .Stale}}<span class="marke warnung" title="Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt.">veraltet</span>{{end}} - <span class="marke">{{if eq .Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span> - {{range .Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}} - <span>{{datum .Entry.UpdatedAt}}</span> + {{if .Entry.Curated}}<span class="badge emphasis">kuratiert</span>{{end}} + {{if .Stale}}<span class="badge warning" title="Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt.">veraltet</span>{{end}} + <span class="badge">{{if eq .Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span> + {{range .Entry.Tags}}<a class="badge" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}} + <span>{{datetime .Entry.UpdatedAt}}</span> {{if .Entry.Author}}<span>· {{.Entry.Author}}</span>{{end}} </div> - {{if .Snippet}}<p class="eintrag-ausschnitt">{{.Snippet}}</p>{{end}} + {{if .Snippet}}<p class="entry-excerpt">{{.Snippet}}</p>{{end}} </div> </li> {{end}} </ul> {{if $sammelbearbeitung}} - <div class="sammelleiste"> - <span class="hilfe">Ausgewählte Einträge:</span> + <div class="bulk-bar"> + <span class="help">Ausgewählte Einträge:</span> <input type="text" name="add_tags" placeholder="Schlagworte hinzufügen, kommagetrennt" autocomplete="off"> <input type="text" name="remove_tags" placeholder="Schlagworte entfernen, kommagetrennt" autocomplete="off"> - <button type="submit" class="zweitrangig">Anwenden</button> + <button type="submit" class="secondary">Anwenden</button> {{/* formaction schickt dasselbe Formular an eine andere Adresse. Der Knopf steht bewusst hinter "Anwenden": Enter in einem Textfeld löst immer den ersten Knopf aus, und das soll nie das Löschen sein. Die Rückfrage liest static/confirm.js am Knopf. */}} - <button type="submit" class="warnung" formaction="/p/{{.Project.Slug}}/entries/bulk-delete" + <button type="submit" class="warning" formaction="/p/{{.Project.Slug}}/entries/bulk-delete" data-confirm="Die ausgewählten Einträge in den Papierkorb legen? Sie lassen sich dort {{.Data.RetentionDays}} Tage lang wiederherstellen.">In den Papierkorb</button> </div> </form> {{end}} {{else}} -<div class="leer"> +<div class="empty"> {{if .Data.Query}} <p>Nichts gefunden für „{{.Data.Query}}“.</p> - <p class="hilfe">Es wird auch nach Wortanfängen gesucht — vielleicht hilft ein anderer Begriff.</p> + <p class="help">Es wird auch nach Wortanfängen gesucht — vielleicht hilft ein anderer Begriff.</p> {{else if .Data.Tag}} <p>Keine Einträge mit dem Schlagwort „{{.Data.Tag}}“.</p> <p><a href="/p/{{.Project.Slug}}/">Alle Einträge anzeigen</a></p> - {{else if .Data.Veraltet}} + {{else if .Data.Stale}} <p>Keine veralteten Einträge.</p> <p><a href="/p/{{.Project.Slug}}/">Alle Einträge anzeigen</a></p> {{else}} <p>Noch keine Einträge.</p> - <p class="hilfe">Agenten legen sie über MCP an, Menschen über „Neuer Eintrag“.</p> + <p class="help">Agenten legen sie über MCP an, Menschen über „Neuer Eintrag“.</p> <p><a href="/hilfe/mcp-einrichten">Einen Agenten anbinden</a> · <a href="/hilfe/mit-agenten-arbeiten">So nutzen Agenten das Gedächtnis wirklich</a></p> {{end}} </div> {{end}} -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/entry-diff.html b/templates/entry-diff.html index 08c3adc..8552507 100644 --- a/templates/entry-diff.html +++ b/templates/entry-diff.html @@ -1,9 +1,9 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> - <h1>Vergleich {{template "hilfelink" "weboberflaeche#verlauf-und-vergleich"}}</h1> - <span class="hilfe"> +<div class="page-header"> + <div class="page-header-title"> + <h1>Vergleich {{template "helplink" "weboberflaeche#verlauf-und-vergleich"}}</h1> + <span class="help"> <a href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}">{{.Data.Entry.Title}}</a> · <a href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/versions">Verlauf</a> </span> @@ -12,37 +12,37 @@ die beim Anzeigen dieser Seite aktuell war — hat sich der Eintrag seitdem geändert, lehnt der Server ab, statt die neuere Änderung zu verdrängen. */}} {{if .Session.CanWrite}} - <div class="seitenkopf-aktionen"> - <form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/versions/{{.Data.OldVersion}}/restore" class="reihe" + <div class="page-header-actions"> + <form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/versions/{{.Data.OldVersion}}/restore" class="row" data-confirm="Fassung {{.Data.OldVersion}} wiederherstellen? Der aktuelle Stand bleibt im Verlauf erhalten."> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="current_version" value="{{.Data.Entry.Version}}"> - <button type="submit" class="zweitrangig">Fassung {{.Data.OldVersion}} wiederherstellen</button> + <button type="submit" class="secondary">Fassung {{.Data.OldVersion}} wiederherstellen</button> </form> </div> {{end}} </div> -<div class="diff-koepfe"> - <div class="diff-kopf diff-kopf-alt"> - <span class="marke">Fassung {{.Data.OldVersion}}</span> - <span class="hilfe">{{datum .Data.OldTime}}{{if .Data.OldAuthor}} · {{.Data.OldAuthor}}{{end}}</span> +<div class="diff-heads"> + <div class="diff-head diff-head-old"> + <span class="badge">Fassung {{.Data.OldVersion}}</span> + <span class="help">{{datetime .Data.OldTime}}{{if .Data.OldAuthor}} · {{.Data.OldAuthor}}{{end}}</span> </div> - <div class="diff-kopf diff-kopf-neu"> - <span class="marke {{if .Data.NewIsCurrent}}betont{{end}}"> + <div class="diff-head diff-head-new"> + <span class="badge {{if .Data.NewIsCurrent}}emphasis{{end}}"> Fassung {{.Data.NewVersion}}{{if .Data.NewIsCurrent}} (aktuell){{end}} </span> - <span class="hilfe">{{datum .Data.NewTime}}{{if .Data.NewAuthor}} · {{.Data.NewAuthor}}{{end}}</span> + <span class="help">{{datetime .Data.NewTime}}{{if .Data.NewAuthor}} · {{.Data.NewAuthor}}{{end}}</span> </div> </div> {{if ne .Data.OldTitle .Data.NewTitle}} -<p class="hilfe">Titel geändert: „{{.Data.OldTitle}}“ → „{{.Data.NewTitle}}“</p> +<p class="help">Titel geändert: „{{.Data.OldTitle}}“ → „{{.Data.NewTitle}}“</p> {{end}} -<div class="diff-huelle"> - <pre class="diff">{{range .Data.Lines}}<span class="diff-{{if eq .Op 1}}entfernt{{else if eq .Op 2}}hinzugefuegt{{else}}gleich{{end}}">{{if eq .Op 1}}-{{else if eq .Op 2}}+{{else}} {{end}} {{.Text}} +<div class="diff-wrap"> + <pre class="diff">{{range .Data.Lines}}<span class="diff-{{if eq .Op 1}}removed{{else if eq .Op 2}}added{{else}}same{{end}}">{{if eq .Op 1}}-{{else if eq .Op 2}}+{{else}} {{end}} {{.Text}} </span>{{end}}</pre> </div> -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/entry-versions.html b/templates/entry-versions.html index 019d89e..6a7130c 100644 --- a/templates/entry-versions.html +++ b/templates/entry-versions.html @@ -1,31 +1,31 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> - <h1>Verlauf {{template "hilfelink" "weboberflaeche#verlauf-und-vergleich"}}</h1> - <span class="hilfe"> +<div class="page-header"> + <div class="page-header-title"> + <h1>Verlauf {{template "helplink" "weboberflaeche#verlauf-und-vergleich"}}</h1> + <span class="help"> <a href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}">{{.Data.Entry.Title}}</a> · aktuell Fassung {{.Data.Entry.Version}} </span> </div> </div> -<div class="tabelle-huelle"> - <table class="tabelle"> +<div class="table-wrap"> + <table class="styled-table"> <thead> <tr> - <th class="zahl">Fassung</th> + <th class="num">Fassung</th> <th>Zugang</th> <th>Abgelöst am</th> - <th class="schmal rechts">Aktionen</th> + <th class="narrow right">Aktionen</th> </tr> </thead> <tbody> <tr> - <td class="zahl">{{.Data.Entry.Version}}</td> - <td>{{if .Data.Entry.Author}}{{.Data.Entry.Author}}{{else}}<span class="hilfe">unbekannt</span>{{end}}</td> - <td class="hilfe"><span class="marke betont">aktuell</span></td> - <td class="schmal rechts"></td> + <td class="num">{{.Data.Entry.Version}}</td> + <td>{{if .Data.Entry.Author}}{{.Data.Entry.Author}}{{else}}<span class="help">unbekannt</span>{{end}}</td> + <td class="help"><span class="badge emphasis">aktuell</span></td> + <td class="narrow right"></td> </tr> {{$slug := .Project.Slug}} {{$eslug := .Data.Entry.Slug}} @@ -36,18 +36,18 @@ {{$csrf := .CSRFToken}} {{range .Data.Versions}} <tr> - <td class="zahl">{{.Version}}</td> - <td>{{if .Author}}{{.Author}}{{else}}<span class="hilfe">unbekannt</span>{{end}}</td> - <td class="hilfe">{{datum .CreatedAt}}</td> - <td class="schmal rechts"> - <div class="reihe"> + <td class="num">{{.Version}}</td> + <td>{{if .Author}}{{.Author}}{{else}}<span class="help">unbekannt</span>{{end}}</td> + <td class="help">{{datetime .CreatedAt}}</td> + <td class="narrow right"> + <div class="row"> <a href="/p/{{$slug}}/e/{{$eslug}}/versions/{{.Version}}">Änderungen ansehen</a> {{if $darfSchreiben}} - <form method="POST" action="/p/{{$slug}}/e/{{$eslug}}/versions/{{.Version}}/restore" class="reihe" + <form method="POST" action="/p/{{$slug}}/e/{{$eslug}}/versions/{{.Version}}/restore" class="row" data-confirm="Fassung {{.Version}} wiederherstellen? Der aktuelle Stand bleibt im Verlauf erhalten."> <input type="hidden" name="csrf_token" value="{{$csrf}}"> <input type="hidden" name="current_version" value="{{$aktuell}}"> - <button type="submit" class="zweitrangig">Wiederherstellen</button> + <button type="submit" class="secondary">Wiederherstellen</button> </form> {{end}} </div> @@ -59,9 +59,9 @@ </div> {{if not .Data.Versions}} -<div class="leer"> +<div class="empty"> <p>Für diese Fassung gibt es noch keine Vorgänger.</p> </div> {{end}} -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/entry.html b/templates/entry.html index 8cf830f..cf31b88 100644 --- a/templates/entry.html +++ b/templates/entry.html @@ -1,17 +1,17 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> +<div class="page-header"> + <div class="page-header-title"> <h1>{{.Data.Entry.Title}}</h1> </div> {{if .Session.CanWrite}} - <div class="seitenkopf-aktionen"> - <a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/edit" - data-kuerzel="e" aria-keyshortcuts="e" title="Bearbeiten (Taste e)">Bearbeiten</a> - <form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/delete" class="reihe" + <div class="page-header-actions"> + <a class="button secondary" href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/edit" + data-shortcut="e" aria-keyshortcuts="e" title="Bearbeiten (Taste e)">Bearbeiten</a> + <form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/delete" class="row" data-confirm="Eintrag „{{.Data.Entry.Title}}“ in den Papierkorb legen? Er lässt sich dort wiederherstellen."> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> - <button type="submit" class="warnung">Löschen</button> + <button type="submit" class="warning">Löschen</button> </form> </div> {{end}} @@ -19,12 +19,12 @@ <div class="meta"> <code>{{.Data.Entry.Slug}}</code> - {{if .Data.Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}} - {{if .Data.Stale}}<span class="marke warnung" title="Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt.">über ein Jahr alt</span>{{end}} - <span class="marke">{{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span> + {{if .Data.Entry.Curated}}<span class="badge emphasis">kuratiert</span>{{end}} + {{if .Data.Stale}}<span class="badge warning" title="Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt.">über ein Jahr alt</span>{{end}} + <span class="badge">{{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span> {{$slug := .Project.Slug}} - {{range .Data.Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}} - <span>geändert {{datum .Data.Entry.UpdatedAt}}{{if .Data.Entry.Author}} von {{.Data.Entry.Author}}{{end}}</span> + {{range .Data.Entry.Tags}}<a class="badge" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}} + <span>geändert {{datetime .Data.Entry.UpdatedAt}}{{if .Data.Entry.Author}} von {{.Data.Entry.Author}}{{end}}</span> {{if gt .Data.Entry.Version 1}} <span>· <a href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/versions">Fassung {{.Data.Entry.Version}}, Verlauf ansehen</a></span> {{else}} @@ -34,10 +34,10 @@ {{/* .HTML ist bereits gerendertes, sicheres HTML — goldmark lässt kein Roh-HTML aus dem Markdown durch (siehe internal/entry). */}} -<article class="eintrag-text lesebreite">{{.Data.HTML}}</article> +<article class="entry-text reading-width">{{.Data.HTML}}</article> {{if .Data.Backlinks}} -<aside class="rueckverweise"> +<aside class="backlinks"> <h2>Verweist hierher</h2> <ul> {{$slug := .Project.Slug}} @@ -50,17 +50,17 @@ {{/* Zweite Reihe im Verweisgraphen: Einträge, die über einen Zwischeneintrag mit diesem verknüpft sind. Findet Zusammenhänge, die kein Suchwort teilt. */}} -{{if .Data.Verwandt}} -<aside class="rueckverweise"> - <h2>Auch verwandt {{template "hilfelink" "weboberflaeche#verwandte-eintraege"}}</h2> +{{if .Data.Related}} +<aside class="backlinks"> + <h2>Auch verwandt {{template "helplink" "weboberflaeche#verwandte-eintraege"}}</h2> <ul> {{$slug := .Project.Slug}} - {{range .Data.Verwandt}} + {{range .Data.Related}} <li><a href="/p/{{$slug}}/e/{{.Slug}}">{{.Title}}</a> - <span class="hilfe">über <a href="/p/{{$slug}}/e/{{.Via}}">{{.Via}}</a></span></li> + <span class="help">über <a href="/p/{{$slug}}/e/{{.Via}}">{{.Via}}</a></span></li> {{end}} </ul> </aside> {{end}} -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/graph.html b/templates/graph.html index 6ad5381..debcbfa 100644 --- a/templates/graph.html +++ b/templates/graph.html @@ -1,24 +1,24 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> +<div class="page-header"> + <div class="page-header-title"> <h1>Graph</h1> - <span class="hilfe">Verweise zwischen den Einträgen dieses Projekts. Gestrichelte Knoten sind + <span class="help">Verweise zwischen den Einträgen dieses Projekts. Gestrichelte Knoten sind Ziele eines <code>[[…]]</code>-Verweises, zu denen es noch keinen Eintrag gibt.</span> </div> - <div class="seitenkopf-aktionen"> - <button type="button" class="zweitrangig" data-graph-einpassen>Ganzen Graphen zeigen</button> + <div class="page-header-actions"> + <button type="button" class="secondary" data-graph-fit>Ganzen Graphen zeigen</button> </div> </div> -<p class="hilfe" data-graph-hinweis hidden></p> +<p class="help" data-graph-hint hidden></p> -<div class="graph-huelle"> +<div class="graph-wrap"> <svg data-graph="/p/{{.Project.Slug}}/graph/data" data-graph-base="/p/{{.Project.Slug}}/e/" role="img" aria-label="Verweisgraph"></svg> </div> -<p class="hilfe">Ziehen zum Verschieben, Mausrad zum Zoomen — beim Hineinzoomen erscheinen weitere Namen. +<p class="help">Ziehen zum Verschieben, Mausrad zum Zoomen — beim Hineinzoomen erscheinen weitere Namen. Zeigt die Maus auf einen Knoten, treten er und seine Nachbarn hervor. Ein Klick öffnet den Eintrag.</p> -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/help.html b/templates/help.html new file mode 100644 index 0000000..c9cb7dc --- /dev/null +++ b/templates/help.html @@ -0,0 +1,25 @@ +{{template "header" .}} + +<div class="page-header"> + <div class="page-header-title"> + <h1>{{.Data.Page.Title}}</h1> + <span class="help">{{.Data.Page.Summary}}</span> + </div> +</div> + +{{/* Die Hilfeseiten stehen als Reiterleiste über dem Text statt in der + Seitenleiste. Die Seitenleiste bleibt so dieselbe wie auf allen anderen + Seiten, und wer die Hilfe liest, sieht trotzdem auf einen Blick, welche + Themen es gibt. */}} +<nav class="help-nav" aria-label="Hilfethemen"> + {{$aktuell := .Data.Page.Slug}} + {{range .Data.Pages}} + <a href="/hilfe/{{.Slug}}" {{if eq $aktuell .Slug}}class="active" aria-current="page"{{end}}>{{.Title}}</a> + {{end}} +</nav> + +<article class="entry-text help-text"> + {{.Data.HTML}} +</article> + +{{template "footer" .}} diff --git a/templates/hilfe.html b/templates/hilfe.html deleted file mode 100644 index 6841641..0000000 --- a/templates/hilfe.html +++ /dev/null @@ -1,25 +0,0 @@ -{{template "kopf" .}} - -<div class="seitenkopf"> - <div class="seitenkopf-titel"> - <h1>{{.Data.Seite.Titel}}</h1> - <span class="hilfe">{{.Data.Seite.Kurz}}</span> - </div> -</div> - -{{/* Die Hilfeseiten stehen als Reiterleiste über dem Text statt in der - Seitenleiste. Die Seitenleiste bleibt so dieselbe wie auf allen anderen - Seiten, und wer die Hilfe liest, sieht trotzdem auf einen Blick, welche - Themen es gibt. */}} -<nav class="hilfe-navigation" aria-label="Hilfethemen"> - {{$aktuell := .Data.Seite.Slug}} - {{range .Data.Seiten}} - <a href="/hilfe/{{.Slug}}" {{if eq $aktuell .Slug}}class="aktiv" aria-current="page"{{end}}>{{.Titel}}</a> - {{end}} -</nav> - -<article class="eintrag-text hilfe-text"> - {{.Data.HTML}} -</article> - -{{template "fuss" .}} diff --git a/templates/import.html b/templates/import.html index 31127a1..0e733a1 100644 --- a/templates/import.html +++ b/templates/import.html @@ -1,58 +1,58 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> - <h1>Import {{template "hilfelink" "weboberflaeche#import"}}</h1> - <span class="hilfe">Markdown-Dateien als ZIP-Archiv hochladen: einen Export aus Munin +<div class="page-header"> + <div class="page-header-title"> + <h1>Import {{template "helplink" "weboberflaeche#import"}}</h1> + <span class="help">Markdown-Dateien als ZIP-Archiv hochladen: einen Export aus Munin oder vorhandene Dokumentation. Jede Datei wird ein Eintrag.</span> </div> </div> -<section class="abschnitt"> - <div class="karte"> +<section class="section"> + <div class="card"> {{/* enctype="multipart/form-data" ist nötig, damit der Browser die Datei selbst mitschickt und nicht nur ihren Namen. */}} - <form method="POST" action="/p/{{.Project.Slug}}/import" enctype="multipart/form-data" class="formular"> + <form method="POST" action="/p/{{.Project.Slug}}/import" enctype="multipart/form-data" class="form"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> - <div class="feld mittel"> + <div class="field medium"> <label for="archiv">ZIP-Archiv</label> - <span class="hilfe">Dateien auf <code>.md</code>, auch in Unterordnern, höchstens - {{.Data.MaxDateien}} Stück. Alles andere wird übergangen.</span> + <span class="help">Dateien auf <code>.md</code>, auch in Unterordnern, höchstens + {{.Data.MaxFiles}} Stück. Alles andere wird übergangen.</span> <input type="file" id="archiv" name="archiv" accept=".zip,application/zip" required> </div> - <div class="feld lang"> + <div class="field long"> <label for="modus">Wenn es den Bezeichner schon gibt</label> <select id="modus" name="modus"> - <option value="skip" {{if eq .Data.Modus "skip"}}selected{{end}}>überspringen — Vorhandenes bleibt, wie es ist</option> - <option value="overwrite" {{if eq .Data.Modus "overwrite"}}selected{{end}}>überschreiben — alter Stand wandert in den Verlauf</option> + <option value="skip" {{if eq .Data.Mode "skip"}}selected{{end}}>überspringen — Vorhandenes bleibt, wie es ist</option> + <option value="overwrite" {{if eq .Data.Mode "overwrite"}}selected{{end}}>überschreiben — alter Stand wandert in den Verlauf</option> </select> </div> - <div class="feld ankreuz"> + <div class="field checkbox"> {{/* Beim ersten Aufruf ist die Vorschau angehakt. Wer eine Vorschau gesehen hat, bekommt das Formular ohne Haken zurück: Der nächste Schritt ist dann das Übernehmen. */}} - <input type="checkbox" id="vorschau" name="vorschau" value="1" {{if not .Data.Bericht}}checked{{end}}> + <input type="checkbox" id="vorschau" name="vorschau" value="1" {{if not .Data.Report}}checked{{end}}> <label for="vorschau"> Nur Vorschau - <span class="hilfe">Zeigt, was passieren würde, ohne etwas zu schreiben.</span> + <span class="help">Zeigt, was passieren würde, ohne etwas zu schreiben.</span> </label> </div> - <div class="formular-aktionen"> + <div class="form-actions"> <button type="submit">Hochladen</button> </div> </form> </div> </section> -{{with .Data.Bericht}} -<section class="abschnitt"> - <h2>{{if $.Data.Vorschau}}Vorschau{{else}}Ergebnis{{end}}</h2> - <div class="tabelle-huelle"> - <table class="tabelle"> +{{with .Data.Report}} +<section class="section"> + <h2>{{if $.Data.Preview}}Vorschau{{else}}Ergebnis{{end}}</h2> + <div class="table-wrap"> + <table class="styled-table"> <thead> <tr> <th>Datei</th> @@ -62,14 +62,14 @@ </tr> </thead> <tbody> - {{range .Zeilen}} + {{range .Rows}} <tr> - <td><code>{{.Pfad}}</code></td> + <td><code>{{.Path}}</code></td> <td>{{if .Slug}}<code>{{.Slug}}</code>{{end}}</td> <td>{{.Title}}</td> <td> - <span class="marke{{if eq .Aktion "Fehler"}} warnung{{else if eq .Aktion "neu"}} betont{{end}}">{{.Aktion}}</span> - {{if .Grund}}<span class="hilfe">{{.Grund}}</span>{{end}} + <span class="badge{{if eq .Action "Fehler"}} warnung{{else if eq .Action "neu"}} betont{{end}}">{{.Action}}</span> + {{if .Reason}}<span class="help">{{.Reason}}</span>{{end}} </td> </tr> {{end}} @@ -79,4 +79,4 @@ </section> {{end}} -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/keys.html b/templates/keys.html index cb46212..fad7c96 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -1,33 +1,33 @@ -{{template "kopf" .}} +{{template "header" .}} -<div class="seitenkopf"> - <div class="seitenkopf-titel"> - <h1>Zugänge {{template "hilfelink" "zugaenge"}}</h1> - <span class="hilfe">Ein eigener Key je Mensch und je Agent. Nur so steht im +<div class="page-header"> + <div class="page-header-title"> + <h1>Zugänge {{template "helplink" "zugaenge"}}</h1> + <span class="help">Ein eigener Key je Mensch und je Agent. Nur so steht im Protokoll, wer etwas geschrieben hat — und ein einzelner Zugang lässt sich zurückziehen, ohne alle anderen zu stören.</span> </div> </div> {{if .Data.NewKey}} -<div class="karte key-anzeige"> +<div class="card key-display"> <strong>Key für „{{.Data.NewKeyName}}“</strong> - <code class="key-wert">{{.Data.NewKey}}</code> - <span class="hilfe">Wird nur jetzt angezeigt — gespeichert ist nur eine Prüfsumme. + <code class="key-value">{{.Data.NewKey}}</code> + <span class="help">Wird nur jetzt angezeigt — gespeichert ist nur eine Prüfsumme. Geht der Key verloren, muss ein neuer erzeugt werden.</span> {{/* Drei Clients, drei Schreibweisen für denselben Endpunkt. Der Block steht hier ausgeschrieben, weil der Klartext-Key nur auf dieser Seite ein einziges Mal zu sehen ist — wer ihn erst aus dem README zusammensetzen müsste, hätte ihn dann schon nicht mehr. */}} - <details class="key-anleitung"> + <details class="key-instructions"> <summary>MCP-Client einrichten</summary> <strong>Claude Code</strong> <pre><code>claude mcp add --transport http munin-{{.Project.Slug}} {{.Data.ServerURL}}/mcp \ --header "Authorization: Bearer {{.Data.NewKey}}"</code></pre> - <strong>Cline</strong> <span class="hilfe">(<code>cline_mcp_settings.json</code>)</span> + <strong>Cline</strong> <span class="help">(<code>cline_mcp_settings.json</code>)</span> <pre><code>{ "mcpServers": { "munin-{{.Project.Slug}}": { @@ -39,7 +39,7 @@ } }</code></pre> - <strong>OpenCode</strong> <span class="hilfe">(<code>opencode.json</code>)</span> + <strong>OpenCode</strong> <span class="help">(<code>opencode.json</code>)</span> <pre><code>{ "mcp": { "munin-{{.Project.Slug}}": { @@ -54,26 +54,26 @@ </div> {{end}} -{{if .Data.Ungenutzt}} -<div class="meldung fehler"> - {{if eq .Data.Ungenutzt 1}}Ein aktiver Zugang wurde{{else}}{{.Data.Ungenutzt}} aktive Zugänge wurden{{end}} +{{if .Data.Unused}} +<div class="message error"> + {{if eq .Data.Unused 1}}Ein aktiver Zugang wurde{{else}}{{.Data.Unused}} aktive Zugänge wurden{{end}} seit über 90 Tagen nicht benutzt. Jeder davon ist ein offener Zugang zu diesem Projekt. Wenn niemand mehr weiß, wofür ein Key da ist, ist Zurückziehen meist die richtige Wahl — ein neuer ist schnell angelegt. </div> {{end}} -{{if .Data.BaldAbgelaufen}} -<div class="meldung hinweis"> - {{if eq .Data.BaldAbgelaufen 1}}Ein Zugang läuft{{else}}{{.Data.BaldAbgelaufen}} Zugänge laufen{{end}} +{{if .Data.ExpiringSoon}} +<div class="message hint"> + {{if eq .Data.ExpiringSoon 1}}Ein Zugang läuft{{else}}{{.Data.ExpiringSoon}} Zugänge laufen{{end}} in den nächsten 14 Tagen ab. Wird er weiter gebraucht: einen neuen anlegen und in den Clients austauschen, bevor der alte nicht mehr gilt. </div> {{end}} {{if .Data.Keys}} -<div class="tabelle-huelle"> - <table class="tabelle"> +<div class="table-wrap"> + <table class="styled-table"> <thead> <tr> <th>Name</th> @@ -82,30 +82,30 @@ <th>Zuletzt benutzt</th> <th>Gültig bis</th> <th>Status</th> - <th class="schmal rechts"></th> + <th class="narrow right"></th> </tr> </thead> <tbody> - {{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}{{$jetzt := .Data.Jetzt}} + {{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}{{$jetzt := .Data.Now}} {{range .Data.Keys}} - <tr {{if not (.Valid $jetzt)}}class="gedaempft"{{end}}> + <tr {{if not (.Valid $jetzt)}}class="muted"{{end}}> <td>{{.Name}}</td> <td><code>{{.Role}}</code></td> - <td>{{datum .CreatedAt}}</td> - <td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td> - <td>{{if .ExpiresAt.IsZero}}<span class="hilfe">unbegrenzt</span>{{else}}{{bisTag .ExpiresAt}}{{end}}</td> + <td>{{datetime .CreatedAt}}</td> + <td>{{if .LastUsedAt.IsZero}}<span class="help">nie</span>{{else}}{{datetime .LastUsedAt}}{{end}}</td> + <td>{{if .ExpiresAt.IsZero}}<span class="help">unbegrenzt</span>{{else}}{{lastDay .ExpiresAt}}{{end}}</td> <td> {{if .Revoked}}zurückgezogen{{else if .Expired $jetzt}}abgelaufen{{else}}aktiv{{end}} - {{if .ExpiresSoon $jetzt}}<span class="marke warnung" title="Läuft in den nächsten 14 Tagen ab">läuft bald ab</span>{{end}} - {{if .Unused $jetzt}}<span class="marke warnung" title="Seit über 90 Tagen nicht benutzt">ungenutzt</span>{{end}} + {{if .ExpiresSoon $jetzt}}<span class="badge warning" title="Läuft in den nächsten 14 Tagen ab">läuft bald ab</span>{{end}} + {{if .Unused $jetzt}}<span class="badge warning" title="Seit über 90 Tagen nicht benutzt">ungenutzt</span>{{end}} </td> - <td class="schmal rechts"> + <td class="narrow right"> {{if .Valid $jetzt}} - <form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe" + <form method="POST" action="/p/{{$slug}}/keys/revoke" class="row" data-confirm="Key „{{.Name}}“ zurückziehen? Alle Clients, die ihn benutzen, verlieren sofort den Zugang."> <input type="hidden" name="csrf_token" value="{{$csrf}}"> <input type="hidden" name="id" value="{{.ID}}"> - <button type="submit" class="warnung">Zurückziehen</button> + <button type="submit" class="warning">Zurückziehen</button> </form> {{end}} </td> @@ -115,26 +115,26 @@ </table> </div> {{else}} -<div class="leer"> +<div class="empty"> <p>Noch kein Zugang angelegt.</p> - <p class="hilfe">Ohne Key kann sich niemand mit diesem Projekt verbinden.</p> + <p class="help">Ohne Key kann sich niemand mit diesem Projekt verbinden.</p> </div> {{end}} -<section class="abschnitt"> +<section class="section"> <h2>Neuer Zugang</h2> - <div class="karte"> - <form method="POST" action="/p/{{.Project.Slug}}/keys/create" class="formular"> + <div class="card"> + <form method="POST" action="/p/{{.Project.Slug}}/keys/create" class="form"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> - <div class="feld mittel"> + <div class="field medium"> <label for="name">Name</label> - <span class="hilfe">Wer oder was benutzt diesen Key?</span> + <span class="help">Wer oder was benutzt diesen Key?</span> <input type="text" id="name" name="name" required placeholder="z.B. tom-cline oder ci-pipeline"> </div> - <div class="feld mittel"> + <div class="field medium"> <label for="role">Rolle</label> <select id="role" name="role"> <option value="read">read — nur lesen und suchen</option> @@ -143,19 +143,19 @@ </select> </div> - <div class="feld kurz"> + <div class="field short"> <label for="expires">Gültig bis</label> - <span class="hilfe">Optional. Leer = unbegrenzt. Gilt bis einschließlich dieses Tages, + <span class="help">Optional. Leer = unbegrenzt. Gilt bis einschließlich dieses Tages, danach wie zurückgezogen. Sinnvoll für Zugänge auf Zeit, etwa für ein Projekt oder eine Person, die nur eine Weile dabei ist.</span> - <input type="date" id="expires" name="expires" min="{{.Data.Heute}}"> + <input type="date" id="expires" name="expires" min="{{.Data.Today}}"> </div> - <div class="formular-aktionen"> + <div class="form-actions"> <button type="submit">Key erzeugen</button> </div> </form> </div> </section> -{{template "fuss" .}} +{{template "footer" .}} diff --git a/templates/login.html b/templates/login.html index e218233..123e42f 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,32 +1,32 @@ {{/* Die Anmeldung bekommt einen eigenen, ruhigen Rahmen: keine Navigation, denn es gibt noch nichts zu navigieren. */}} -{{template "html-anfang" .}} +{{template "html-start" .}} -<div class="anmeldeseite"> - <div class="schale schmal"> +<div class="login-page"> + <div class="shell narrow"> {{/* Das Logo gibt es in zwei Fassungen: mit dunklem Schriftzug für helle Darstellung und mit weißem Schriftzug für dunkle Darstellung. <picture> wählt anhand der Systemeinstellung die passende aus. */}} - <picture class="anmelde-logo"> + <picture class="login-logo"> <source srcset="/static/img/munin_logo_dark.svg" media="(prefers-color-scheme: dark)"> <img src="/static/img/munin_logo_transparent.svg" alt="Munin" width="264" height="264"> </picture> - <div class="karte"> - <span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin + <div class="card"> + <span class="help">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin verwendet sein Admin-Token.</span> - {{if .Error}}<div class="meldung fehler">{{.Error}}</div>{{end}} + {{if .Error}}<div class="message error">{{.Error}}</div>{{end}} - <form method="POST" action="/login" class="formular"> + <form method="POST" action="/login" class="form"> {{/* Ohne Sitzung gibt es kein CSRF-Token, auf das man sich beziehen könnte. Geschützt ist dieses Formular über SameSite=Lax und das Rate-Limit. */}} - <div class="feld"> + <div class="field"> <label for="key">API-Key</label> <input type="password" id="key" name="key" autofocus autocomplete="current-password" placeholder="wk_…"> </div> - <div class="formular-aktionen"> + <div class="form-actions"> <button type="submit">Anmelden</button> </div> </form> @@ -34,4 +34,4 @@ </div> </div> -{{template "html-ende" .}} +{{template "html-end" .}} diff --git a/templates/overview.html b/templates/overview.html new file mode 100644 index 0000000..434f0f0 --- /dev/null +++ b/templates/overview.html @@ -0,0 +1,124 @@ +{{template "header" .}} + +{{with .Data}} +<div class="page-header"> + <div class="page-header-title"> + <h1>Überblick: {{.Workspace}} {{template "helplink" "workspace-admin#ueberblick"}}</h1> + <span class="help">Was in deinem Workspace zu tun ist und wie die Teams Munin nutzen.</span> + </div> +</div> + +<div class="metrics"> + <div class="metric"> + <span class="metric-value">{{.Total.Projects}}</span> + <span class="metric-name">Projekte</span> + </div> + <div class="metric"> + <span class="metric-value">{{.Total.Entries}}</span> + <span class="metric-name">Einträge</span> + </div> + <div class="metric"> + <span class="metric-value">{{.Total.Keys}}</span> + <span class="metric-name">gültige Keys</span> + </div> + <div class="metric"> + <span class="metric-value">{{.Writes}}</span> + <span class="metric-name">Schreibvorgänge, {{.ChartDays}} Tage</span> + </div> + <div class="metric"> + <span class="metric-value">{{.Searches}}</span> + <span class="metric-name">Suchen, {{.ChartDays}} Tage</span> + </div> +</div> + +<section class="section"> + <h2>Was zu tun ist</h2> + {{if .Groups}} + <ul class="status"> + {{range .Groups}} + <li class="{{if .Warning}}status-warning{{else}}status-hint{{end}}"> + <span class="status-kind">{{.Project}}</span> + <ul class="status-items"> + {{range .Items}} + <li{{if .Warning}} class="urgent"{{end}}>{{.Text}} + <a href="{{.Link}}" class="more-link">{{.LinkText}} →</a></li> + {{end}} + </ul> + </li> + {{end}} + </ul> + <span class="help">Rot: Im Projekt ist etwas dringend (fett). Grün: bei Gelegenheit hinsehen. Ein Punkt + verschwindet von selbst, sobald er erledigt ist.</span> + {{else}} + <div class="message hint">Alles in Ordnung — nichts zu tun.</div> + {{end}} +</section> + +<section class="section"> + <h2>Projekte</h2> + {{if .Projects}} + <div class="table-wrap"> + <table class="styled-table"> + <thead> + <tr> + <th>Projekt</th> + <th class="num">Einträge</th> + <th class="num">Schreiben Agenten</th> + <th class="num">Schreiben Menschen</th> + <th class="num">Suchen</th> + <th class="num">ohne Treffer</th> + <th>zuletzt geändert</th> + </tr> + </thead> + <tbody> + {{range .Projects}} + <tr> + <td><a href="/p/{{.Slug}}/usage">{{.Name}}</a> <span class="help">{{.Slug}}</span></td> + <td class="num">{{.Entries}}</td> + <td class="num">{{.AgentWrites}}</td> + <td class="num">{{.HumanWrites}}</td> + <td class="num">{{.Searches}}</td> + <td class="num">{{if lt .NoHitsPercent 0}}—{{else}}{{.NoHitsPercent}} %{{end}}</td> + <td>{{if .LastChanged.IsZero}}<span class="help">noch nie</span>{{else}}{{datetime .LastChanged}}{{end}}</td> + </tr> + {{end}} + </tbody> + </table> + </div> + <span class="help">Zahlen der letzten {{.ChartDays}} Tage. Der Name führt zur Nutzung des Projekts mit + allen Einzelheiten.</span> + {{else}} + <div class="empty"> + <p>Noch keine Projekte in diesem Workspace.</p> + <a href="/admin/projects" class="button">Projekt anlegen</a> + </div> + {{end}} +</section> + +{{if .Projects}} +<section class="section"> + <h2>Aktivität der letzten {{.ChartDays}} Tage</h2> + <div class="charts"> + <figure class="chart-frame"> + <figcaption> + <strong>Schreibvorgänge</strong> + <span class="legend"><span class="legend-swatch bar-agent"></span>Agenten (MCP)</span> + <span class="legend"><span class="legend-swatch bar-human"></span>Menschen (Weboberfläche)</span> + </figcaption> + {{template "chart" .WriteChart}} + </figure> + <figure class="chart-frame"> + <figcaption> + <strong>Suchen</strong> + <span class="legend"><span class="legend-swatch bar-search"></span>recall von Agenten</span> + </figcaption> + {{template "chart" .SearchChart}} + </figure> + </div> + <span class="help">Schreiben heißt angelegt, geändert oder gelöscht. Tage in UTC; mit der Maus über einen + Tag zeigt die genauen Zahlen.</span> +</section> +{{end}} +{{end}} + +{{template "footer" .}} diff --git a/templates/partials.html b/templates/partials.html index f36ea26..083113a 100644 --- a/templates/partials.html +++ b/templates/partials.html @@ -10,7 +10,7 @@ kopf / fuss Rahmen, Kopfleiste und Seitenleiste */}} -{{define "html-anfang"}}<!DOCTYPE html> +{{define "html-start"}}<!DOCTYPE html> <html lang="de"> <head> <meta charset="UTF-8"> @@ -21,20 +21,20 @@ <body> {{end}} -{{define "html-ende"}} +{{define "html-end"}} <script src="/static/confirm.js"></script> <script src="/static/preview.js"></script> <script src="/static/autocomplete.js"></script> <script src="/static/graph.js"></script> <script src="/static/tagfilter.js"></script> -<script src="/static/ungespeichert.js"></script> -<script src="/static/aehnlich.js"></script> -<script src="/static/tastenkuerzel.js"></script> +<script src="/static/unsaved.js"></script> +<script src="/static/similar.js"></script> +<script src="/static/shortcuts.js"></script> </body> </html> {{end}} -{{define "kopf"}}{{template "html-anfang" .}} +{{define "header"}}{{template "html-start" .}} {{/* Schmale Kopfleiste über die ganze Breite: Marke, Workspace-Umschalter, Hilfe und Abmelden. Alles Weitere steht in der Seitenleiste. @@ -48,54 +48,54 @@ WELCHEN Workspace er gerade bearbeitet, nicht nur auf der Auswahlseite selbst. Für einen Projekt-Zugang (immer genau ein Workspace) bleibt der Umschalter weg — WorkspaceName ist dann leer. */}} -<header class="kopfleiste"> - <div class="kopfleiste-innen"> - <div class="reihe"> - <a class="marke-wort" href="/">Munin</a> +<header class="topbar"> + <div class="topbar-inner"> + <div class="row"> + <a class="badge-word" href="/">Munin</a> {{if .Session.IsPlatformAdmin}}{{if .Session.WorkspaceName}} - <a class="arbeitsbereich" href="/admin/workspaces"> - <span class="arbeitsbereich-trenner" aria-hidden="true">/</span> - <span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span> - <span class="arbeitsbereich-pfeil" aria-hidden="true">⌄</span> + <a class="workspace" href="/admin/workspaces"> + <span class="workspace-separator" aria-hidden="true">/</span> + <span class="workspace-name">{{.Session.WorkspaceName}}</span> + <span class="workspace-arrow" aria-hidden="true">⌄</span> </a> {{end}}{{else if .Session.IsWorkspaceAdmin}} {{/* Der Workspace-Admin hat genau einen Workspace — der Name steht zur Orientierung da, ohne Umschalter. */}} - <span class="arbeitsbereich"> - <span class="arbeitsbereich-trenner" aria-hidden="true">/</span> - <span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span> + <span class="workspace"> + <span class="workspace-separator" aria-hidden="true">/</span> + <span class="workspace-name">{{.Session.WorkspaceName}}</span> </span> {{end}} </div> - <div class="reihe kopfleiste-aktionen"> - <a href="/hilfe" class="kopfleiste-link{{if eq .Bereich "hilfe"}} aktiv{{end}}" - {{if eq .Bereich "hilfe"}}aria-current="page"{{end}}>Hilfe<span class="nur-breit"> & Einstieg</span></a> - <form method="POST" action="/logout" class="reihe"> + <div class="row topbar-actions"> + <a href="/hilfe" class="topbar-link{{if eq .Section "hilfe"}} aktiv{{end}}" + {{if eq .Section "hilfe"}}aria-current="page"{{end}}>Hilfe<span class="wide-only"> & Einstieg</span></a> + <form method="POST" action="/logout" class="row"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> - <button type="submit" class="wie-link">Abmelden</button> + <button type="submit" class="link-button">Abmelden</button> </form> </div> </div> </header> -<div class="gerüst"> +<div class="layout"> {{/* ── Seitenleiste ────────────────────────────────────────────────────── Enthält drei Gruppen: die Projekte, die Bereiche des aktuellen Projekts und dessen Schlagworte. Die Schlagworte stehen hier statt als Filterzeile über der Liste — sie sind Navigation, und die Liste bekommt so mehr Platz. */}} - <aside class="seitenleiste"> + <aside class="sidebar"> {{if .Session.CanManageProjects}} - <nav class="nav-gruppe"> - <h2 class="nav-titel">Projekte</h2> - <ul class="nav-liste"> + <nav class="nav-group"> + <h2 class="nav-title">Projekte</h2> + <ul class="nav-list"> {{$aktuell := .Project.Slug}} {{range .Nav.Projects}} - <li><a href="/p/{{.Slug}}/" {{if eq $aktuell .Slug}}class="aktiv"{{end}}>{{.Name}}</a></li> + <li><a href="/p/{{.Slug}}/" {{if eq $aktuell .Slug}}class="active"{{end}}>{{.Name}}</a></li> {{end}} </ul> - <ul class="nav-liste leise"> - <li><a href="/admin/projects" {{if eq .Bereich "projekte"}}class="aktiv"{{end}}>Verwalten…</a></li> + <ul class="nav-list quiet"> + <li><a href="/admin/projects" {{if eq .Section "projekte"}}class="active"{{end}}>Verwalten…</a></li> </ul> </nav> {{end}} @@ -104,35 +104,35 @@ Projektname — für einen Projekt-Key ist das zugleich die einzige Ortsangabe, denn eine Projektliste bekommt er nicht zu sehen. */}} {{if .Project.Slug}} - <nav class="nav-gruppe"> - <h2 class="nav-titel">{{.Project.Name}}</h2> - <ul class="nav-liste"> - <li><a href="/p/{{.Project.Slug}}/" {{if eq .Bereich "eintraege"}}class="aktiv"{{end}}>Einträge</a></li> - <li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li> + <nav class="nav-group"> + <h2 class="nav-title">{{.Project.Name}}</h2> + <ul class="nav-list"> + <li><a href="/p/{{.Project.Slug}}/" {{if eq .Section "eintraege"}}class="active"{{end}}>Einträge</a></li> + <li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Section "graph"}}class="active"{{end}}>Graph</a></li> {{if .Session.CanManageKeys}} - <li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li> + <li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Section "zugaenge"}}class="active"{{end}}>Zugänge</a></li> {{end}} {{if .Session.CanImport}} - <li><a href="/p/{{.Project.Slug}}/import" {{if eq .Bereich "import"}}class="aktiv"{{end}}>Import</a></li> + <li><a href="/p/{{.Project.Slug}}/import" {{if eq .Section "import"}}class="active"{{end}}>Import</a></li> {{end}} - <li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li> - <li><a href="/p/{{.Project.Slug}}/audit" {{if eq .Bereich "protokoll"}}class="aktiv"{{end}}>Protokoll</a></li> + <li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Section "nutzung"}}class="active"{{end}}>Nutzung</a></li> + <li><a href="/p/{{.Project.Slug}}/audit" {{if eq .Section "protokoll"}}class="active"{{end}}>Protokoll</a></li> {{if .Session.CanWrite}} - <li><a href="/p/{{.Project.Slug}}/trash" {{if eq .Bereich "papierkorb"}}class="aktiv"{{end}}>Papierkorb</a></li> + <li><a href="/p/{{.Project.Slug}}/trash" {{if eq .Section "papierkorb"}}class="active"{{end}}>Papierkorb</a></li> {{end}} </ul> </nav> {{if .Nav.Tags}} - <nav class="nav-gruppe schlagworte"> - <h2 class="nav-titel">Schlagworte</h2> - <ul class="nav-liste"> - {{$slug := .Project.Slug}}{{$tag := .AktivesTag}} + <nav class="nav-group tags"> + <h2 class="nav-title">Schlagworte</h2> + <ul class="nav-list"> + {{$slug := .Project.Slug}}{{$tag := .ActiveTag}} {{range .Nav.Tags}} <li> - <a href="/p/{{$slug}}/?tag={{.Tag}}" {{if eq $tag .Tag}}class="aktiv"{{end}}> + <a href="/p/{{$slug}}/?tag={{.Tag}}" {{if eq $tag .Tag}}class="active"{{end}}> <span class="nav-text">{{.Tag}}</span> - <span class="nav-zahl">{{.Count}}</span> + <span class="nav-count">{{.Count}}</span> </a> </li> {{end}} @@ -140,11 +140,11 @@ {{/* Die Seitenleiste zeigt nur die häufigsten Schlagworte (navTagLimit in internal/web/server.go). Gibt es mehr, führt dieser Link zur vollständigen, filterbaren Liste. */}} - {{if gt .Nav.TagsGesamt (len .Nav.Tags)}} - <ul class="nav-liste leise"> - <li><a href="/p/{{.Project.Slug}}/tags" {{if eq .Bereich "schlagworte"}}class="aktiv"{{end}}> + {{if gt .Nav.TagsTotal (len .Nav.Tags)}} + <ul class="nav-list quiet"> + <li><a href="/p/{{.Project.Slug}}/tags" {{if eq .Section "schlagworte"}}class="active"{{end}}> <span class="nav-text">Alle Schlagworte…</span> - <span class="nav-zahl">{{.Nav.TagsGesamt}}</span> + <span class="nav-count">{{.Nav.TagsTotal}}</span> </a></li> </ul> {{end}} @@ -153,22 +153,22 @@ {{end}} {{if .Session.IsPlatformAdmin}} - <nav class="nav-gruppe"> - <h2 class="nav-titel">Plattform</h2> - <ul class="nav-liste"> - <li><a href="/admin/overview" {{if eq .Bereich "ueberblick"}}class="aktiv"{{end}}>Überblick (Workspace)</a></li> - <li><a href="/admin/search" {{if eq .Bereich "suche"}}class="aktiv"{{end}}>Suche (alle Projekte)</a></li> + <nav class="nav-group"> + <h2 class="nav-title">Plattform</h2> + <ul class="nav-list"> + <li><a href="/admin/overview" {{if eq .Section "ueberblick"}}class="active"{{end}}>Überblick (Workspace)</a></li> + <li><a href="/admin/search" {{if eq .Section "suche"}}class="active"{{end}}>Suche (alle Projekte)</a></li> <li><a href="/admin/audit">Protokoll (alle)</a></li> - <li><a href="/admin/workspaces" {{if eq .Bereich "workspaces"}}class="aktiv"{{end}}>Workspaces</a></li> - <li><a href="/admin/system" {{if eq .Bereich "system"}}class="aktiv"{{end}}>System</a></li> + <li><a href="/admin/workspaces" {{if eq .Section "workspaces"}}class="active"{{end}}>Workspaces</a></li> + <li><a href="/admin/system" {{if eq .Section "system"}}class="active"{{end}}>System</a></li> </ul> </nav> {{else if .Session.IsWorkspaceAdmin}} - <nav class="nav-gruppe"> - <h2 class="nav-titel">Workspace</h2> - <ul class="nav-liste"> - <li><a href="/admin/overview" {{if eq .Bereich "ueberblick"}}class="aktiv"{{end}}>Überblick</a></li> - <li><a href="/admin/search" {{if eq .Bereich "suche"}}class="aktiv"{{end}}>Suche (alle Projekte)</a></li> + <nav class="nav-group"> + <h2 class="nav-title">Workspace</h2> + <ul class="nav-list"> + <li><a href="/admin/overview" {{if eq .Section "ueberblick"}}class="active"{{end}}>Überblick</a></li> + <li><a href="/admin/search" {{if eq .Section "suche"}}class="active"{{end}}>Suche (alle Projekte)</a></li> <li><a href="/admin/audit">Protokoll (alle)</a></li> </ul> </nav> @@ -176,51 +176,51 @@ </aside> - <main class="inhalt"> - {{if .Error}}<div class="meldung fehler">{{.Error}}</div>{{end}} - {{if .Flash}}<div class="meldung hinweis">{{.Flash}}</div>{{end}} + <main class="content"> + {{if .Error}}<div class="message error">{{.Error}}</div>{{end}} + {{if .Flash}}<div class="message hint">{{.Flash}}</div>{{end}} {{end}} {{/* hilfelink setzt ein kleines "?" neben eine Überschrift oder ein Feld, das zur passenden Stelle der eingebauten Hilfe führt (AUSBAUSTUFEN.md 5.4). Aufruf mit dem Ziel unterhalb von /hilfe/, auf Wunsch mit Abschnitt: - {{template "hilfelink" "weboberflaeche#markdown-im-inhalt"}} + {{template "helplink" "weboberflaeche#markdown-im-inhalt"}} Die Abschnittsnamen entstehen aus den Überschriften der Hilfeseiten - (internal/markdown, ankerIDs). Ein Test in web_test.go prüft, dass jedes + (internal/markdown, anchorIDs). Ein Test in web_test.go prüft, dass jedes Ziel in den Templates wirklich existiert — wer eine Überschrift umbenennt, merkt es also. Die Hilfe öffnet in einem neuen Tab: Wer mitten im Bearbeiten nachschlägt, soll dabei nicht seinen ungespeicherten Text verlieren. rel="noopener" verhindert, dass der neue Tab auf das Fenster dieser Seite zugreifen kann. */}} -{{define "hilfelink"}}<a class="hilfe-link" href="/hilfe/{{.}}" target="_blank" rel="noopener" title="Hilfe zu diesem Thema (neuer Tab)" aria-label="Hilfe zu diesem Thema (öffnet in neuem Tab)">?</a>{{end}} +{{define "helplink"}}<a class="help-link" href="/hilfe/{{.}}" target="_blank" rel="noopener" title="Hilfe zu diesem Thema (neuer Tab)" aria-label="Hilfe zu diesem Thema (öffnet in neuem Tab)">?</a>{{end}} -{{define "fuss"}} +{{define "footer"}} </main> </div> -{{template "html-ende" .}} +{{template "html-end" .}} {{end}} {{/* Balkendiagramm der System- und Überblicksseite. Die Zahlen rechnet - internal/web/diagramm.go aus; hier wird nur gezeichnet. */}} -{{define "diagramm"}} -{{if .Leer}} -<p class="hilfe diagramm-leer">Keine im ganzen Zeitraum.</p> + internal/web/chart.go aus; hier wird nur gezeichnet. */}} +{{define "chart"}} +{{if .Empty}} +<p class="help chart-empty">Keine im ganzen Zeitraum.</p> {{else}} -<svg class="diagramm" viewBox="0 0 {{.Breite}} {{.Hoehe}}" role="img" aria-label="{{.Titel}}"> - {{range .Gitter}} - <line class="diagramm-gitter" x1="{{$.Links}}" x2="{{$.Rechts}}" y1="{{.Y}}" y2="{{.Y}}"/> - <text class="diagramm-text" x="{{$.Links}}" dx="-6" y="{{.Y}}" dy="4" text-anchor="end">{{.Text}}</text> +<svg class="chart" viewBox="0 0 {{.Width}} {{.Height}}" role="img" aria-label="{{.Title}}"> + {{range .Grid}} + <line class="chart-grid" x1="{{$.Links}}" x2="{{$.Right}}" y1="{{.Y}}" y2="{{.Y}}"/> + <text class="chart-text" x="{{$.Links}}" dx="-6" y="{{.Y}}" dy="4" text-anchor="end">{{.Text}}</text> {{end}} - {{range .Balken}} + {{range .Bars}} <g> - <title>{{.Titel}} - - {{range .Teile}}{{end}} + {{.Title}} + + {{range .Parts}}{{end}} {{end}} - {{range .Achse}} - {{.Text}} + {{range .Axis}} + {{.Text}} {{end}} {{end}} diff --git a/templates/project-edit.html b/templates/project-edit.html index af65729..22e00cd 100644 --- a/templates/project-edit.html +++ b/templates/project-edit.html @@ -1,34 +1,34 @@ -{{template "kopf" .}} +{{template "header" .}} -
-
+