Bugfixes, Security und Umbau auf einheitliche Namen

This commit is contained in:
Thomas Adamek 2026-10-19 10:13:56 +02:00
parent 1ce9be1e2e
commit 6fa86b5941
167 changed files with 9606 additions and 9361 deletions

View file

@ -12,7 +12,7 @@
SESSION_SECRET= SESSION_SECRET=
# Zugang des Plattform-Admins: legt Projekte an und löscht sie, kommt an jedes # 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 # openssl rand -hex 24
ADMIN_TOKEN= ADMIN_TOKEN=

View file

@ -4,7 +4,7 @@ Wie man KI-Werkzeuge so einrichtet und anspricht, dass sie den Munin
tatsächlich benutzen. tatsächlich benutzen.
> Dieselben Inhalte stehen für Nutzer auch in der Weboberfläche unter > 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. > etwas ändert, sollte dort nachziehen.
--- ---

View file

@ -156,7 +156,7 @@ sucht.
### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt ### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt
`tokenize` bildet `ä→a`, `ö→o`, `ü→u` und `ß→ss` ab, beim Indizieren wie beim `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 ✓ prüfung ↔ prufung ✓
@ -241,7 +241,7 @@ Problem.
### 2.4 Wortvarianten, Stoppwörter, Tippfehler — ✅ umgesetzt (2026-10-14) ### 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): mehrere Varianten, jede schwächer gewichtet als ein exakter Treffer (1):
| Variante | Beispiel | Gewicht | ab Länge | | 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) ### 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 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 Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden
genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich), 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 Richtungen, samt Wortanfang, Endung und Teilwort). Die Zeile
„Auch gesucht als: …" zählt zum Titel — so findet „Release-Ablauf" auch den „Auch gesucht als: …" zählt zum Titel — so findet „Release-Ablauf" auch den
„Deployment-Prozess", wenn dort „release" steht. „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 gemeldet: „Munin – Architektur“ gegen „Munin – Überblick“ ergibt ein
gemeinsames Wort von zweien, also 50 %, genau die Schwelle. Jetzt fällt ein gemeinsames Wort von zweien, also 50 %, genau die Schwelle. Jetzt fällt ein
Präfix vor einem Trenner (` – `, ` - `, `: `, ` | `) aus dem Vergleich, wenn Präfix vor einem Trenner (` – `, ` - `, `: `, ` | `) aus dem Vergleich, wenn
**beide** Titel dasselbe haben (`ohneGemeinsamesPraefix` in **beide** Titel dasselbe haben (`withoutCommonPrefix` in
`internal/dubletten`). Verschiedene Präfixe bleiben im `internal/duplicates`). Verschiedene Präfixe bleiben im
Vergleich, denn dann können sie etwas unterscheiden. Ein Bindestrich ohne Vergleich, denn dann können sie etwas unterscheiden. Ein Bindestrich ohne
Leerzeichen („Deployment-Ablauf“) ist kein Trenner. Eine echte Dublette unter Leerzeichen („Deployment-Ablauf“) ist kein Trenner. Eine echte Dublette unter
gleichem Präfix fällt weiter auf. Tests: `TestOhneGemeinsamesPraefix`, gleichem Präfix fällt weiter auf. Tests: `TestWithoutCommonPrefix`,
`TestRememberGemeinsamesPraefixKeineDublette`. `TestRememberCommonPrefixNoDuplicate`.
Verworfen: Wörter nach ihrer Häufigkeit im Projekt gewichten. In einem jungen 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 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 Suchbegriff `postgres-migration`. „postgres“ ist der Wortanfang von
„postgresql“. **Behoben:** Ein Suchbegriff aus „Auch gesucht als“ zählt „postgresql“. **Behoben:** Ein Suchbegriff aus „Auch gesucht als“ zählt
jetzt nur wörtlich, Titelwörter weiter mit allen Varianten 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. gegen den Suchbegriff `release` bleibt erkannt.
2. „Munin – Entscheidungen zur PostgreSQL-Anbindung“ gegen „Munin – Betrieb mit 2. „Munin – Entscheidungen zur PostgreSQL-Anbindung“ gegen „Munin – Betrieb mit
PostgreSQL“: Beide Titel enthalten „PostgreSQL“. **Bleibt** gemeldet — 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 allgemeines Wort, sieht der Agent sofort, dass `force` richtig ist, ohne den
Kandidaten erst zu laden. Kandidaten erst zu laden.
Tests: `TestUeberdeckungMitSuchbegriffen`, `TestRememberSuchbegriffNurWoertlich`, Tests: `TestCoverageWithSearchTerms`, `TestRememberSearchTermOnlyLiteral`,
`matched_words` in `TestRememberMeldetDublette`. `matched_words` in `TestRememberReportsDuplicate`.
### 3.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14) ### 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 Wiederherstellung holte den Haken nicht zurück (sie lässt ihn bewusst, wie er
aktuell ist). 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 - `remember` auf einen kuratierten Eintrag schreibt nicht, sondern meldet
`judgment_required` mit Handlungsanweisung. `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. Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg.
**Umgesetzt:** Tabelle `entry_versions` **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 Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht
beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen
Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird. Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird.
@ -627,17 +627,17 @@ WP12).
**✅ Umgesetzt 2026-09-24:** **✅ 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. `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 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 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 `PurgeFromTrash` und `PurgeTrash`, und beide fassen nur an, was schon im
Papierkorb liegt. 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 Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex
aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen. 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`). dasselbe aus Sicht eines Agenten (`get`, `recall`, `list`).
- **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag - **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag
zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf 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 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 gründlich aufgeräumt“. `confirm.js` liest die Rückfrage jetzt auch am
gedrückten Knopf (`event.submitter`). 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 `main.go` läuft im selben täglichen Takt wie das Protokoll-Aufräumen und
schreibt je Eintrag eine Protokollzeile mit Herkunft `system`. schreibt je Eintrag eine Protokollzeile mit Herkunft `system`.
Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie 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 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 auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand
angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des 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. den die Anmelde-Middleware befüllt.
### 4.2 Tests bei jeder Änderung — ✅ umgesetzt (manuell) ### 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 (nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace
auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die
Workspace-Umschalter von Slack/Linear/Notion (`templates/partials.html`, 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 9. ✅ `scripts/backup.sh`: sichert Kontroll-DB und jede Workspace-Datei
einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner, einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner,
optionale Aufräumfrist über `RETENTION_DAYS`. README verlinkt es 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. `pruneAuditRegularly` und `exportRegularly` nur den ältesten Workspace.
Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1) Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1)
griff dort nicht. Jetzt gehen beide in jedem Durchgang über 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 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 dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber
gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`. gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`.
@ -840,8 +840,8 @@ und am selben Tag behoben):
`EXPORT_DIR/<workspace-id>/<projekt>/`, dazu `_workspace.md` mit dem Namen. `EXPORT_DIR/<workspace-id>/<projekt>/`, dazu `_workspace.md` mit dem Namen.
Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner
erzeugt. `./munin -export` exportiert alle Workspaces und endet mit erzeugt. `./munin -export` exportiert alle Workspaces und endet mit
Fehlercode, wenn einer scheitert. Tests: `TestExportTrenntWorkspaces`, Fehlercode, wenn einer scheitert. Tests: `TestExportSeparatesWorkspaces`,
`TestExportKnopfImZweitenWorkspace`, `main_test.go`. `TestExportButtonInSecondWorkspace`, `main_test.go`.
Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr 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. an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden.
@ -900,10 +900,10 @@ unterscheiden.
**✅ Umgesetzt 2026-09-24:** **✅ 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 Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle
sieht. 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 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 Bestand aus `entries`, für ein Projekt oder alle, in zwei Abfragen. Es gibt
keine eigene Zähltabelle, die auseinanderlaufen könnte. 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.** **✅ 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 `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 `revoked_at`. Vorhandene Keys behalten ihr Verhalten, die Migration fasst
keine Daten an. 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. - **Abgelaufene Keys sind nie „ungenutzt“** — sie öffnen ohnehin nichts mehr.
- **Nicht dabei:** Ablaufdatum für Workspace-Admin-Tokens (`wa_…`). Bei Bedarf - **Nicht dabei:** Ablaufdatum für Workspace-Admin-Tokens (`wa_…`). Bei Bedarf
derselbe Weg in der Kontroll-Datenbank. derselbe Weg in der Kontroll-Datenbank.
- **Tests:** `TestKeyAblauf` (Store, auch gegen PostgreSQL), - **Tests:** `TestKeyExpiry` (Store, auch gegen PostgreSQL),
`TestKeyMitAblaufdatum` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung). `TestKeyWithExpiryDate` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung).
### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30) ### 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 | | 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. | | `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. |
| `dialekt` | `dialekt.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. | | `dialect` | `dialect.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. | | `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 ```go
type dialekt interface { type dialekt interface {
rebind(abfrage string) string // ? → $1, $2 … (SQLite: unverändert) 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. deshalb, wo sie ist.
**Zwei Abfragen mussten umgeschrieben werden:** `CuratedOverrides` **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 Spalten ohne Aggregat. Das kann nur SQLite, PostgreSQL lehnt es ab. Beide
nutzen jetzt `ROW_NUMBER() OVER (…)`, das können beide Datenbanken (SQLite ab 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 #### Entscheidung: bewusst schlichtes SQL
@ -1281,8 +1281,8 @@ Später denkbar, erst bei konkretem Bedarf: `TIMESTAMPTZ` statt Text,
#### Entscheidung: Migrationen je Datenbank getrennt #### Entscheidung: Migrationen je Datenbank getrennt
``` ```
internal/store/migrations/sqlite/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_suchprotokoll.sql internal/store/migrations/postgres/001_init.sql … 006_search_log.sql
internal/store/migrations_control/sqlite/… internal/store/migrations_control/sqlite/…
internal/store/migrations_control/postgres/… internal/store/migrations_control/postgres/…
``` ```
@ -1415,7 +1415,7 @@ Leseverbindung.
`ab`). Deshalb die Datenbank mit `--locale=C` anlegen, dann verhält sie sich `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. wie SQLite. Die Startprüfung (siehe oben) kann das gleich mit prüfen.
- **`LIKE`** unterscheidet in PostgreSQL Groß- und Kleinschreibung, in SQLite - **`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. vergleichen feste, kleingeschriebene Texte. Bei neuen Abfragen beachten.
- **`NULL` beim Sortieren:** PostgreSQL sortiert `NULL` bei `DESC` nach vorn, - **`NULL` beim Sortieren:** PostgreSQL sortiert `NULL` bei `DESC` nach vorn,
SQLite nach hinten. Betrifft heute nichts (nur `deleted_at` ist nullbar). SQLite nach hinten. Betrifft heute nichts (nur `deleted_at` ist nullbar).
@ -1510,7 +1510,7 @@ diesem Ablauf:
| Wann | Was | | Wann | Was |
|---|---| |---|---|
| Bei jeder Änderung | `go test ./...` (nur SQLite) | | 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 | | 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 | | 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 - Die ganze Store-Testsuite lief ohne einen Fehler gegen PostgreSQL. Gefunden
hat sie nur einen Fehler in einer Test-Hilfsfunktion (aufräumen nach hat sie nur einen Fehler in einer Test-Hilfsfunktion (aufräumen nach
gelöschtem Workspace). 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 - Abfragepläne mit 5.000 Einträgen, Schlagworten, Verweisen und Protokoll
(PostgreSQL 18 im Container, MacBook): (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 | | `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 | | 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 | | 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 | | Schlagwortmenü | ganze Tabelle `entry_tags` | 1,3 ms |
| Suchen ohne Treffer | ganze Tabelle `search_log`, Fensterfunktion | 1,4 ms | | Suchen ohne Treffer | ganze Tabelle `search_log`, Fensterfunktion | 1,4 ms |
| Suchindex aufbauen | ganze Tabelle `entries` (braucht alle Zeilen) | 0,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 eigener Dienst, der wegfallen kann, und die Probe der Sicherung bestand aus
vielen Handgriffen. vielen Handgriffen.
**`/live` und `/ready`** (`internal/web/handlers_bereitschaft.go`): **`/live` und `/ready`** (`internal/web/handlers_ready.go`):
| Pfad | Prüft | Daran hängt | | Pfad | Prüft | Daran hängt |
|---|---|---| |---|---|---|
@ -1600,7 +1600,7 @@ vielen Handgriffen.
Log. Dort steht nur der **Wechsel** (`Bereitschaft: Datenbank antwortet Log. Dort steht nur der **Wechsel** (`Bereitschaft: Datenbank antwortet
nicht …` / `… wieder`), sonst flutete eine Abfrage alle paar Sekunden das Log. nicht …` / `… wieder`), sonst flutete eine Abfrage alle paar Sekunden das Log.
- Alle drei Pfade erscheinen nicht im Zugriffsprotokoll. - 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). `/live` weiter 200, keine Einzelheiten in der Antwort).
**`scripts/sicherung-pruefen.sh`**: spielt eine Sicherung zurück (SQLite mit **`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. 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 - 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. derselben Logik; zwei Fassungen liefen mit der Zeit auseinander.
- `GET /p/{projekt}/entries/similar?title=…&slug=…` (Schreibrecht) liefert die - `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?“. 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 - **Nur ein Hinweis, keine Sperre:** Ein Mensch sieht die Liste und
entscheidet; speichern geht immer. Die Links öffnen einen neuen Tab, damit entscheidet; speichern geht immer. Die Links öffnen einen neuen Tab, damit
der angefangene Text bleibt. der angefangene Text bleibt.
- Nur beim Anlegen, nicht beim Bearbeiten. Ist der Bezeichner schon vergeben, - Nur beim Anlegen, nicht beim Bearbeiten. Ist der Bezeichner schon vergeben,
ist es eine Aktualisierung — dann gibt es keinen Hinweis. 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) ### 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 Bearbeitungsformular): Verlässt jemand die Seite mit Änderungen, fragt der
Browser nach (`beforeunload`). „Geändert“ heißt: anders als beim Laden — wer 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. 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 verwandt*: die **zweite Reihe** aus `store.RelatedEntries` (dieselbe Funktion
wie MCP `related`), jeweils mit dem Zwischeneintrag. Die erste Reihe fehlt wie MCP `related`), jeweils mit dem Zwischeneintrag. Die erste Reihe fehlt
dort absichtlich: Eigene Verweise stehen im Text, eingehende unter *Verweist 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) ### 5.10 Veraltete Einträge sichtbar machen — ✅ umgesetzt (2026-10-16)
@ -1695,8 +1695,8 @@ Einzelansicht. Jetzt:
`?veraltet=1` zeigt genau diese. `?veraltet=1` zeigt genau diese.
- Kennzahl auf der Nutzungsseite, verlinkt auf die gefilterte Liste. - Kennzahl auf der Nutzungsseite, verlinkt auf die gefilterte Liste.
- Store: `EntryFilter.ChangedBefore`, `CountStaleEntries`, `StaleCutoff`. - Store: `EntryFilter.ChangedBefore`, `CountStaleEntries`, `StaleCutoff`.
Tests: `TestVeralteteEintraegeFilternUndZaehlen` (auch PostgreSQL), Tests: `TestStaleEntriesFilterAndCount` (auch PostgreSQL),
`TestVeralteteEintraege`. `TestStaleEntries`.
### 5.11 Protokoll filtern — ✅ umgesetzt (2026-10-16) ### 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 gefilterte Ansicht als Adresse weitergeben lässt. Kaputte Werte in der Adresse
ergeben das ungefilterte Protokoll statt einer Fehlerseite. Höchstens 200 ergeben das ungefilterte Protokoll statt einer Fehlerseite. Höchstens 200
Vorgänge wie bisher. Tests: `TestAuditFilter` (auch PostgreSQL), Vorgänge wie bisher. Tests: `TestAuditFilter` (auch PostgreSQL),
`TestProtokollFiltern`. `TestAuditFilterForm`.
### 5.12 Systemseite — ✅ umgesetzt (2026-10-16) ### 5.12 Systemseite — ✅ umgesetzt (2026-10-16)
`/admin/system`, nur Plattform-Admin: `/admin/system`, nur Plattform-Admin:
- Datenbank (`Speicher.Beschreibung`, ohne Passwort) und Bereitschaft wie - Datenbank (`Backend.Description`, ohne Passwort) und Bereitschaft wie
`/ready`. `/ready`.
- Offene Verbindungen gegen das Budget: mit PostgreSQL `max_connections` und - Offene Verbindungen gegen das Budget: mit PostgreSQL `max_connections` und
die daraus folgende Höchstzahl an Workspaces (`Speicher.Kapazitaet`, die daraus folgende Höchstzahl an Workspaces (`Backend.Capacity`,
`Kapazitaet.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in `Capacity.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in
Benutzung / lesen-schreiben / gewartet (`sql.DBStats` über Benutzung / lesen-schreiben / gewartet (`sql.DBStats` über
`Store.Verbindungen`). `Store.PoolStats`).
- Fassung aus den Build-Informationen von Go (`vcs.revision`, `vcs.time`, - Fassung aus den Build-Informationen von Go (`vcs.revision`, `vcs.time`,
`vcs.modified`), Go-Fassung, Start und Laufzeit. `vcs.modified`), Go-Fassung, Start und Laufzeit.
Test: `TestSystemseite`. Test: `TestSystemPage`.
### 5.13 Suche über alle Projekte — ✅ umgesetzt (2026-10-16) ### 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. nicht vergleichbar.
Bewusst getrennt von 4.9: Am Zugriff der Keys ändert sich nichts, ein Key und 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). (auch: ein Projektzugang kommt nicht dran).
### 5.14 Tastenkürzel — ✅ umgesetzt (2026-10-16) ### 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 `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 bleiben so an einer Stelle. Buchstaben gelten nur außerhalb von
Eingabefeldern, mit Strg/Alt/Cmd bleiben sie dem Browser. 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) ### 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 Anmelden, was sie dürfen und was nicht, ein Projekt einrichten, Überblick
behalten (Nutzung, Protokoll, Suche über alle Projekte), regelmäßige Pflege, behalten (Nutzung, Protokoll, Suche über alle Projekte), regelmäßige Pflege,
Projekt löschen. Sichtbar für Workspace- und Plattform-Admin. Dafür ersetzt 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 bisherige `NurAdmin`. Tests: Sichtbarkeit je Lesergruppe, keine Links auf
Seiten, die der Leser nicht sieht, und neu: **jeder `#Abschnitt`-Link Seiten, die der Leser nicht sieht, und neu: **jeder `#Abschnitt`-Link
innerhalb der Hilfe trifft eine vorhandene Überschrift** innerhalb der Hilfe trifft eine vorhandene Überschrift**
(`TestAnkerInLinksGibtEs`). (`TestLinkAnchorsExist`).
- **„Hilfe & Einstieg“ steht in der Kopfleiste** links neben „Abmelden“ statt - **„Hilfe & Einstieg“ steht in der Kopfleiste** links neben „Abmelden“ statt
unten in der Seitenleiste, wo sie bei vielen Projekten und Schlagworten aus unten in der Seitenleiste, wo sie bei vielen Projekten und Schlagworten aus
dem Blick rutschte. Auf schmalen Bildschirmen nur „Hilfe“. 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 nur das Eingabefeld, nicht mehr Beschriftung und Hinweis — lange Hinweise
brachen vorher in eine Spalte von knapp 290 Pixeln um. Mehr Abstand zwischen brachen vorher in eine Spalte von knapp 290 Pixeln um. Mehr Abstand zwischen
Feldern, Hinweise mit Zeilenabstand 1,5 und bis zur Lesebreite. Zahlen-, 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 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 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; sortiert nicht nach `C`); ein Workspace ließ sich nicht auswerten;
WAL-Datei über 64 MB (der Checkpoint kommt nicht durch); ein Workspace auf WAL-Datei über 64 MB (der Checkpoint kommt nicht durch); ein Workspace auf
einem anderen Schema-Stand als das Programm; der letzte Export einem anderen Schema-Stand als das Programm; der letzte Export
@ -1804,20 +1804,20 @@ kommen drei Abschnitte:
**Umsetzung:** **Umsetzung:**
- `internal/store/systemstand.go`: `Store.Bestand`, `Store.Belegung` (über - `internal/store/sysinfo.go`: `Store.Inventory`, `Store.DiskUsage` (über
eine neue Dialekt-Methode `belegung`), `Store.Aktivitaet`, eine neue Dialekt-Methode `diskUsage`), `Store.Activity`,
`Store.Schemastand`. Keys werden in Go gezählt, mit denselben Regeln wie `Store.SchemaVersion`. Keys werden in Go gezählt, mit denselben Regeln wie
die Seite „Zugänge“ (`APIKey.Unused`, `ExpiresSoon`). die Seite „Zugänge“ (`APIKey.Unused`, `ExpiresSoon`).
- `internal/statistik`: Der `Sammler` fragt alle Workspaces ab und merkt sich - `internal/stats`: Der `Collector` fragt alle Workspaces ab und merkt sich
das Ergebnis 5 Minuten (`MindestAbstand`). Gerechnet wird beim Aufruf der das Ergebnis 5 Minuten (`MinInterval`). Gerechnet wird beim Aufruf der
Seite, ohne Hintergrundlauf. Er öffnet dafür alle Workspaces — das tun das Seite, ohne Hintergrundlauf. Er öffnet dafür alle Workspaces — das tun das
Aufräumen und der Export beim Start ohnehin. 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 ausgerechnet. Kein JavaScript, keine Bibliothek, keine `style`-Attribute
(CSP); Farben über Klassen aus `style.css`, damit das dunkle Schema passt. (CSP); Farben über Klassen aus `style.css`, damit das dunkle Schema passt.
Tooltip je Tag über `<title>`. Tooltip je Tag über `<title>`.
- `export.Exporter` merkt sich den letzten Lauf über alle Workspaces - `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`. `main.go`.
**Nicht umgesetzt** (siehe 4.5): Laufzeitwerte wie Aufrufe je Werkzeug und **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 Platz auf der Platte fehlt ebenfalls: Er braucht Systemaufrufe je
Betriebssystem und ist ein klassischer Wert für das Monitoring. Betriebssystem und ist ein klassischer Wert für das Monitoring.
Tests: `TestBestand`, `TestBelegung`, `TestAktivitaet`, `TestSchemastand` Tests: `TestInventory`, `TestDiskUsage`, `TestActivity`, `TestSchemaVersion`
(auch gegen PostgreSQL), `TestErhebung`, `TestMindestAbstand`, (auch gegen PostgreSQL), `TestCollect`, `TestMinInterval`,
`TestDiagramm`, `TestRunde125`, `TestGroesse`, `TestSystemseiteZustand`. `TestChart`, `TestRound125`, `TestByteSize`, `TestSystemPageState`.
Geprüft mit Screenshots (1280 und 500 Pixel) mit Testdaten über 30 Tage. Geprüft mit Screenshots (1280 und 500 Pixel) mit Testdaten über 30 Tage.
### 5.17 Überblick für Workspace-Admins — ✅ umgesetzt (2026-10-16) ### 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 - **Aktivität:** dieselben Diagramme wie auf der Systemseite, nur für diesen
Workspace. Workspace.
Umsetzung: `internal/web/handlers_ueberblick.go`; Umsetzung: `internal/web/handlers_overview.go`;
`statistik.Sammler.FuerWorkspace` (ohne Zwischenspeicher — nach einem neuen `stats.Collector.ForWorkspace` (ohne Zwischenspeicher — nach einem neuen
Key soll der Hinweis sofort verschwinden); das Diagramm-Template liegt jetzt Key soll der Hinweis sofort verschwinden); das Diagramm-Template liegt jetzt
in `partials.html`. Die Startseite des Workspace-Admins bleibt die 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). Hinweisen, keine Technik auf der Seite, ein Projektzugang kommt nicht dran).
### 5.6 Verweise in Code ignorieren — ✅ umgesetzt (2026-10-16) ### 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. Verweis wäre schlimmer als ein überflüssiger.
- Bestehende Einträge behalten ihre alten Verweise in `entry_links`, bis sie - Bestehende Einträge behalten ihre alten Verweise in `entry_links`, bis sie
das nächste Mal geschrieben werden. 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) ### 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`. `keys.html` zeigt in den Konfigurationsblöcken noch `https://DEIN-SERVER/mcp`.
Die Hilfe setzt die Adresse schon ein (`serverURL` in 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. verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen.
**✅ Umgesetzt 2026-09-24:** `keyListData.ServerURL`, gesetzt in **✅ 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 ### 5.3 Mehr Markdown in Einträgen — ✅ umgesetzt
`entry.RenderHTML` nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in `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, Tabelle. **Lösung:** `extension.GFM` einschalten (Tabellen, Durchstreichen,
Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die
Sicherheitsannahme in `RenderHTML` ändert sich nicht. Danach den Hinweis auf 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 **✅ Umgesetzt 2026-09-24:** Neues Paket `internal/markdown` — ein
goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und
`hilfe.Render` rufen nur noch dort hinein. Einziger Unterschied: `help.Render` rufen nur noch dort hinein. Einziger Unterschied:
`RenderMitAnkern` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht — `RenderWithAnchors` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht —
Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche" Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche"
soll nicht mit einer id der Seite selbst zusammenstoßen können. 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 `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. ungespeicherter Text verloren geht.
Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute 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 `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 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 Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so
im Test, welcher Link dadurch ins Leere zeigen würde. 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 gibt, der aktuell gültigen. Der Zeilenvergleich selbst
(`internal/entry/diff.go`, `DiffLines`) ist eine klassische (`internal/entry/diff.go`, `DiffLines`) ist eine klassische
LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für 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. Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern.
**✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte **✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte

View file

@ -102,7 +102,7 @@ Load-Balancer oder Monitoring gehört an `/ready`.
| Meldung | Ursache | Abhilfe | | 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 | | `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 | | `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 | | `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 | | `address already in use` | Ein anderer Prozess belegt den Port | anderen Prozess beenden oder `PORT` ändern |

View file

@ -188,15 +188,15 @@ reproduzierbar.
jedem Schreiben den aktuellen Stand aus der Datenbank nach. Der letzte Aufruf jedem Schreiben den aktuellen Stand aus der Datenbank nach. Der letzte Aufruf
liest damit immer den neuesten Stand, egal in welcher Reihenfolge die Aufrufe liest damit immer den neuesten Stand, egal in welcher Reihenfolge die Aufrufe
ankommen. Abgesichert durch `internal/search/registry_test.go` und 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 **Aufbau des Suchindex blockierte den ganzen Workspace (behoben).** Die
Registry hatte nur eine Sperre für alle Projekte eines Workspace und hielt sie 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 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 einem Neustart musste so jedes Projekt warten, bis das größte fertig
eingelesen war. Seit 2026-10-06 hat jedes Projekt seine eigene Sperre. eingelesen war. Seit 2026-10-06 hat jedes Projekt seine eigene Sperre.
Abgesichert durch `TestLangsamerAufbauBremstAndereProjekteNicht` und Abgesichert durch `TestSlowBuildDoesNotBlockOtherProjects` und
`TestAenderungWaehrendDesAufbausGehtNichtVerloren`: Ein Schreibvorgang `TestChangeDuringBuildIsNotLost`: Ein Schreibvorgang
während des Aufbaus wartet, bis der Aufbau fertig ist, und trägt seinen Stand während des Aufbaus wartet, bis der Aufbau fertig ist, und trägt seinen Stand
danach nach. danach nach.

View file

@ -339,8 +339,8 @@ und entscheidet:
Im zweiten Fall fällt der Haken „kuratiert" weg, das Protokoll vermerkt die 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 Begründung, und die Nutzungsseite zeigt den Eintrag unter *Zur Prüfung*, bis ein
Mensch ihn wieder kuratiert. Einzelheiten: `internal/dubletten` und Mensch ihn wieder kuratiert. Einzelheiten: `internal/duplicates` und
`internal/mcpserver/kuratiert.go`. Jeder Kandidat nennt die gemeinsamen Wörter `internal/mcpserver/curated.go`. Jeder Kandidat nennt die gemeinsamen Wörter
(`matched_words`). (`matched_words`).
### Gleichzeitiges Schreiben ### Gleichzeitiges Schreiben
@ -416,7 +416,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
einmal hinzufügen oder entfernen. einmal hinzufügen oder entfernen.
- **Gibt es schon?** Beim Anlegen zeigt Munin unter dem Titel vorhandene - **Gibt es schon?** Beim Anlegen zeigt Munin unter dem Titel vorhandene
Einträge mit ähnlichem Titel und die gemeinsamen Wörter — dieselbe Prüfung 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 - **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. Seite verlässt, ohne zu speichern, bekommt eine Rückfrage des Browsers.
- **Auch verwandt:** Unter einem Eintrag stehen neben den Rückverweisen die - **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 | | Variable | Standard | Bedeutung |
|---|---|---| |---|---|---|
| `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. | | `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) | | `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 | | `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` | | `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). Eine Übersicht über jede einzelne Datei steht in [REPOMAP.md](REPOMAP.md).
**Hilfeseiten pflegen:** Die Hilfe unter `/hilfe` sind Markdown-Dateien in **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 Funktion, gehört die Anpassung der Hilfe in denselben Commit. Eine neue Seite
braucht eine Datei *und* einen Eintrag in der Liste `seiten` in braucht eine Datei *und* einen Eintrag in der Liste `pages` in
`internal/hilfe/hilfe.go`. Ein Test prüft, dass beides zusammenpasst und dass `internal/help/help.go`. Ein Test prüft, dass beides zusammenpasst und dass
alle Links auf `/hilfe/…` zu existierenden Seiten führen. Seiten mit alle Links auf `/hilfe/…` zu existierenden Seiten führen. Seiten mit
`NurAdmin: true` sieht nur der Plattform-Admin; allgemeine Seiten dürfen `NurAdmin: true` sieht nur der Plattform-Admin; allgemeine Seiten dürfen
nicht auf sie verlinken (auch das prüft ein Test). In den Seiten 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 Welche Datenbank, entscheidet `DB_DRIVER`. Alle Abfragen sind für beide
dieselben: Sie werden mit `?` geschrieben und laufen durch einen kleinen dieselben: Sie werden mit `?` geschrieben und laufen durch einen kleinen
Dialekt, der für PostgreSQL `$1, $2 …` daraus macht (`internal/store/dialekt.go`, Dialekt, der für PostgreSQL `$1, $2 …` daraus macht (`internal/store/dialect.go`,
`verbindung.go`). Das SQL vermeidet deshalb alles, was nur eine der beiden `pool.go`). Das SQL vermeidet deshalb alles, was nur eine der beiden
kann. Die Regeln stehen als Kommentar in kann. Die Regeln stehen als Kommentar in
`internal/store/migrations/sqlite/001_init.sql`. `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 Start, für einen Workspace beim ersten Öffnen. Bestehende Migrationen werden
nie verändert. nie verändert.
Zwei Tests passen auf: `TestMigrationsordnerGleich` (beide Ordner haben Zwei Tests passen auf: `TestMigrationDirsMatch` (beide Ordner haben
dieselben Dateien, läuft immer) und `TestSchemaGleich` (beide ergeben dasselbe dieselben Dateien, läuft immer) und `TestSchemaMatches` (beide ergeben dasselbe
Schema, braucht PostgreSQL): Schema, braucht PostgreSQL):
```bash ```bash

View file

@ -2,6 +2,13 @@
Übersicht aller Dateien und Ordner mit ihrer Aufgabe. Ü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/ munin/
├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die ├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die
@ -9,12 +16,12 @@ munin/
│ Mindestlänge), öffnet Kontroll-Datenbank und │ Mindestlänge), öffnet Kontroll-Datenbank und
│ Workspace-Verwaltung (legt beim ersten Start den │ Workspace-Verwaltung (legt beim ersten Start den
│ Workspace "Intern" an), setzt die │ Workspace "Intern" an), setzt die
│ Bestandteile zusammen (baueHandler, auch vom │ Bestandteile zusammen (buildHandler, auch vom
│ Lasttest genutzt) und startet den Server. │ Lasttest genutzt) und startet den Server.
│ Enthält außerdem die Server-Timeouts (gegen │ Enthält außerdem die Server-Timeouts (gegen
│ Slowloris), die Body-Größenlimits und die │ Slowloris), die Body-Größenlimits und die
│ täglichen Aufgaben (Protokoll aufräumen, │ täglichen Aufgaben (Protokoll aufräumen,
│ Export), die über fuerJedenWorkspace alle │ Export), die über forEachWorkspace alle
│ Workspaces erreichen. Tests: main_test.go, │ Workspaces erreichen. Tests: main_test.go,
│ Lasttest: last_test.go (nur mit -tags last). │ Lasttest: last_test.go (nur mit -tags last).
│ │
@ -56,18 +63,18 @@ munin/
│ │ │ der Treiber modernc.org/sqlite ist: Er ist reines │ │ │ der Treiber modernc.org/sqlite ist: Er ist reines
│ │ │ Go und erlaubt damit ein statisches Binary. │ │ │ 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 │ │ │ main.go anhand von DB_DRIVER gewählt
│ │ │ (AUSBAUSTUFEN.md 4.10). │ │ │ (AUSBAUSTUFEN.md 4.10).
│ │ ├── speicher_sqlite.go ← Eine Datei je Workspace plus control.db (Standard). │ │ ├── backend_sqlite.go ← Eine Datei je Workspace plus control.db (Standard).
│ │ ├── speicher_postgres.go← Ein Schema je Workspace (munin_ws_<id>) plus │ │ ├── backend_postgres.go ← Ein Schema je Workspace (munin_ws_<id>) plus
│ │ │ munin_control. Feste Poolgrößen, Prüfung gegen │ │ │ munin_control. Feste Poolgrößen, Prüfung gegen
│ │ │ max_connections, Wiederholung beim Start, │ │ │ max_connections, Wiederholung beim Start,
│ │ │ Workspace-Name als Schema-Kommentar. │ │ │ 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 │ │ │ unterscheidet: Platzhalter (? → $1) und
│ │ │ Migrationsordner. Bewusst klein. │ │ │ 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. │ │ │ den Dialekt — vorbei kommt keine.
│ │ │ │ │ │
│ │ ├── control.go ← Die zentrale Kontroll-Datenbank (OpenControl): │ │ ├── control.go ← Die zentrale Kontroll-Datenbank (OpenControl):
@ -110,28 +117,28 @@ munin/
│ │ │ fremden Projekten │ │ │ fremden Projekten
│ │ │ · LinkGraph liefert Knoten und Kanten für die │ │ │ · LinkGraph liefert Knoten und Kanten für die
│ │ │ Graph-Ansicht │ │ │ Graph-Ansicht
│ │ │ Jede lesende Abfrage trägt nichtImPapierkorb │ │ │ Jede lesende Abfrage trägt notInTrash
│ │ │ (deleted_at IS NULL) — außer PutEntry, das │ │ │ (deleted_at IS NULL) — außer PutEntry, das
│ │ │ einen Eintrag im Papierkorb bewusst sieht und │ │ │ einen Eintrag im Papierkorb bewusst sieht und
│ │ │ ihn beim Überschreiben zurückholt. │ │ │ 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 │ │ │ (Schreibvorgänge der letzten 30 Tage aus dem
│ │ │ Protokoll, Bestand aus entries — für ein oder │ │ │ Protokoll, Bestand aus entries — für ein oder
│ │ │ alle Projekte in zwei Abfragen), dazu │ │ │ alle Projekte in zwei Abfragen), dazu
│ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys. │ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys.
│ │ │ Keine eigene Zähltabelle. │ │ │ 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- │ │ │ Systemseite: Bestand, Belegung (Dialekt-
│ │ │ Methode belegung), Aktivität je Tag, │ │ │ Methode belegung), Aktivität je Tag,
│ │ │ Schemastand. │ │ │ Schemastand.
│ │ ├── suchprotokoll.go ← Was Agenten mit recall suchen (search_log): │ │ ├── searchlog.go ← Was Agenten mit recall suchen (search_log):
│ │ │ LogSearch, SearchStatsFor, EmptySearches, │ │ │ LogSearch, SearchStatsFor, EmptySearches,
│ │ │ PruneSearchLog. Eigene Tabelle statt Protokoll, │ │ │ PruneSearchLog. Eigene Tabelle statt Protokoll,
│ │ │ 30 Tage, verschwindet mit dem Projekt. │ │ │ 30 Tage, verschwindet mit dem Projekt.
│ │ │ │ │ │
│ │ ├── papierkorb.go ← Papierkorb: MoveEntryToTrash (statt DELETE), │ │ ├── trash.go ← Papierkorb: MoveEntryToTrash (statt DELETE),
│ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash │ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash
│ │ │ (nur was schon im Papierkorb liegt) und │ │ │ (nur was schon im Papierkorb liegt) und
│ │ │ PurgeTrash für die tägliche Aufgabe. Die Frist │ │ │ PurgeTrash für die tägliche Aufgabe. Die Frist
@ -167,7 +174,7 @@ munin/
│ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln │ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln
│ │ │ für portables SQL, an die sich alle weiteren │ │ │ für portables SQL, an die sich alle weiteren
│ │ │ Migrationen halten sollen. │ │ │ Migrationen halten sollen.
│ │ ├── 002_projekt_in_nebentabellen.sql │ │ ├── 002_project_in_child_tables.sql
│ │ ← Führt project_id auch in entry_tags und │ │ ← Führt project_id auch in entry_tags und
│ │ entry_links. Eine bewusste Redundanz gegen zwei │ │ entry_links. Eine bewusste Redundanz gegen zwei
│ │ gemessene Bremsen: das Schlagwortmenü lief über │ │ gemessene Bremsen: das Schlagwortmenü lief über
@ -175,34 +182,34 @@ munin/
│ │ und bei den Rückverweisen kippte durch ein │ │ und bei den Rückverweisen kippte durch ein
│ │ ORDER BY die Verknüpfungsreihenfolge │ │ ORDER BY die Verknüpfungsreihenfolge
│ │ (3,1 ms statt 9 µs). │ │ (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 │ │ ← Spalte write_limit_hourly an projects. Vorgabe 0
│ │ heißt "keine eigene Angabe" — dann gilt der │ │ heißt "keine eigene Angabe" — dann gilt der
│ │ Standard aus WRITE_LIMIT_HOURLY. So muss die │ │ Standard aus WRITE_LIMIT_HOURLY. So muss die
│ │ Migration keine Daten anfassen. │ │ Migration keine Daten anfassen.
│ │ ├── 004_aenderungshistorie.sql │ │ ├── 004_change_history.sql
│ │ ← Tabelle entry_versions für abgelöste Fassungen. │ │ ← Tabelle entry_versions für abgelöste Fassungen.
│ │ Wird mit dem Eintrag gelöscht (ON DELETE CASCADE). │ │ Wird mit dem Eintrag gelöscht (ON DELETE CASCADE).
│ │ ├── 005_papierkorb.sql │ │ ├── 005_trash.sql
│ │ ← deleted_at/deleted_by an entries und ein │ │ ← deleted_at/deleted_by an entries und ein
│ │ Teilindex nur über den Papierkorb. Löschen setzt │ │ Teilindex nur über den Papierkorb. Löschen setzt
│ │ nur noch die Markierung; CASCADE greift erst │ │ nur noch die Markierung; CASCADE greift erst
│ │ beim endgültigen Löschen. │ │ beim endgültigen Löschen.
│ │ ├── 006_suchprotokoll.sql │ │ ├── 006_search_log.sql
│ │ ← Tabelle search_log (recall-Anfragen, 30 Tage). │ │ ← 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; │ │ ← expires_at an api_keys. Leer = läuft nie ab;
│ │ abgelaufen gilt wie zurückgezogen. │ │ abgelaufen gilt wie zurückgezogen.
│ │ │ │
│ ├── statistik/ ── Kennzahlen aller Workspaces ─────────────────── │ ├── 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 │ │ Schema-Stand und Aktivität der letzten 30 Tage
│ │ über alle Workspaces. Rechnet beim Aufruf der │ │ über alle Workspaces. Rechnet beim Aufruf der
│ │ Seite, höchstens alle 5 Minuten neu. │ │ Seite, höchstens alle 5 Minuten neu.
│ │ │ │
│ ├── workspace/ ── Welche Datei gehört zu welcher Abteilung? ────── │ ├── workspace/ ── Welche Datei gehört zu welcher Abteilung? ──────
│ │ └── workspace.go ← Manager öffnet Workspaces über den │ │ └── 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 │ │ jeweils mit eigenem Suchindex. Datenbank und
│ │ Index gehören zusammen, damit kein Index │ │ Index gehören zusammen, damit kein Index
│ │ versehentlich an der falschen Datei hängt. │ │ versehentlich an der falschen Datei hängt.
@ -257,7 +264,7 @@ munin/
│ │ └── diff.go ← Zeilenvergleich zweier Fassungen (DiffLines) als │ │ └── diff.go ← Zeilenvergleich zweier Fassungen (DiffLines) als
│ │ LCS ohne externe Abhängigkeit. Bei sehr langen │ │ LCS ohne externe Abhängigkeit. Bei sehr langen
│ │ Einträgen weicht er auf einen groben Vergleich │ │ Einträgen weicht er auf einen groben Vergleich
│ │ aus (grobDiff), damit Speicher und Rechenzeit │ │ aus (coarseDiff), damit Speicher und Rechenzeit
│ │ begrenzt bleiben. │ │ begrenzt bleiben.
│ │ │ │
│ ├── markdown/ ── Markdown nach HTML, eine Regel für alles ─────── │ ├── markdown/ ── Markdown nach HTML, eine Regel für alles ───────
@ -265,7 +272,7 @@ munin/
│ │ Hilfe: GFM (Tabellen, Checklisten, │ │ Hilfe: GFM (Tabellen, Checklisten,
│ │ Durchstreichen, automatische Links), Roh-HTML │ │ Durchstreichen, automatische Links), Roh-HTML
│ │ gesperrt. Nur die Hilfe bekommt Überschrift- │ │ gesperrt. Nur die Hilfe bekommt Überschrift-
│ │ Anker (RenderMitAnkern) — mit gefalteten │ │ Anker (RenderWithAnchors) — mit gefalteten
│ │ Umlauten über store.Slugify, damit die │ │ Umlauten über store.Slugify, damit die
│ │ "?"-Links der Oberfläche darauf zeigen können. │ │ "?"-Links der Oberfläche darauf zeigen können.
│ │ │ │
@ -287,7 +294,7 @@ munin/
│ │ Fehler ein halbes Archiv statt einer Fehlerseite. │ │ Fehler ein halbes Archiv statt einer Fehlerseite.
│ │ │ │
│ ├── importer/ ── Gegenstück zum Export ────────────────────────── │ ├── 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. │ │ │ Grenzen für Anzahl und ausgepackte Größe.
│ │ │ Übergeht _index.md, versteckte Ordner, __MACOSX. │ │ │ Übergeht _index.md, versteckte Ordner, __MACOSX.
│ │ ├── parse.go ← Kleiner Frontmatter-Leser (kein YAML-Paket): │ │ ├── parse.go ← Kleiner Frontmatter-Leser (kein YAML-Paket):
@ -305,7 +312,7 @@ munin/
│ │ │ Agenten, SearchFuzzy (mit Tippfehlern) für die │ │ │ Agenten, SearchFuzzy (mit Tippfehlern) für die
│ │ │ Weboberfläche. │ │ │ Weboberfläche.
│ │ │ │ │ │
│ │ ├── varianten.go ← Wortvarianten je Suchwort, schwächer gewichtet │ │ ├── variants.go ← Wortvarianten je Suchwort, schwächer gewichtet
│ │ │ als ein exakter Treffer: Wortanfang ("deploy" → │ │ │ als ein exakter Treffer: Wortanfang ("deploy" →
│ │ │ Deployment), Endung ("deployen"), Teilwort │ │ │ Deployment), Endung ("deployen"), Teilwort
│ │ │ ("prozess" → Releaseprozess), Tippfehler. │ │ │ ("prozess" → Releaseprozess), Tippfehler.
@ -322,7 +329,7 @@ munin/
│ │ Aufbau nicht den ganzen Workspace aufhält. │ │ Aufbau nicht den ganzen Workspace aufhält.
│ │ │ │
│ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ─────────────────── │ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ───────────────────
│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel, │ │ ├── help.go ← Liste der Hilfeseiten (Reihenfolge, Titel,
│ │ │ Zielgruppe: alle / Admins / Plattform-Admin) und │ │ │ Zielgruppe: alle / Admins / Plattform-Admin) und
│ │ │ das Rendern mit Überschrift-Ankern. Erklärt, │ │ │ das Rendern mit Überschrift-Ankern. Erklärt,
│ │ │ warum die Hilfe im Binary steckt statt als │ │ │ warum die Hilfe im Binary steckt statt als
@ -367,25 +374,25 @@ munin/
│ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die │ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die
│ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit │ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit
│ │ │ in server.go). │ │ │ 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 │ │ │ für jede Rolle. Die Admin-Projektliste nutzt
│ │ │ dieselben Zahlen (projectListItems). │ │ │ dieselben Zahlen (projectListItems).
│ │ ├── handlers_system.go ← /admin/system (Plattform-Admin): Kasten │ │ ├── handlers_system.go ← /admin/system (Plattform-Admin): Kasten
│ │ │ "Zustand" (systemHinweise: Warnungen und │ │ │ "Zustand" (systemHints: Warnungen und
│ │ │ Hinweise), Aktivität, Bestand, Speicher, │ │ │ Hinweise), Aktivität, Bestand, Speicher,
│ │ │ Bereitschaft, offene Verbindungen je Workspace │ │ │ Bereitschaft, offene Verbindungen je Workspace
│ │ │ gegen das Budget, Fassung aus den Build-Infos. │ │ │ gegen das Budget, Fassung aus den Build-Infos.
│ │ ├── handlers_ueberblick.go │ │ ├── handlers_overview.go
│ │ │ ← /admin/overview: Überblick des Workspace für │ │ │ ← /admin/overview: Überblick des Workspace für
│ │ │ Workspace-Admins — Befunde je Projekt mit Link │ │ │ Workspace-Admins — Befunde je Projekt mit Link
│ │ │ (projektHinweise), Projekttabelle, Aktivität. │ │ │ (projectHints), Projekttabelle, Aktivität.
│ │ ├── diagramm.go ← Balkendiagramme der Systemseite als SVG, vom │ │ ├── chart.go ← Balkendiagramme der Systemseite als SVG, vom
│ │ │ Server ausgerechnet (kein JavaScript, CSP-fest). │ │ │ 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- │ │ │ Workspace, nach Projekt gruppiert (Plattform-
│ │ │ und Workspace-Admin; Keys bleiben bei einem │ │ │ und Workspace-Admin; Keys bleiben bei einem
│ │ │ Projekt). │ │ │ Projekt).
│ │ ├── handlers_bereitschaft.go │ │ ├── handlers_ready.go
│ │ │ ← /live (Prozess läuft, für den Container-Neustart) │ │ │ ← /live (Prozess läuft, für den Container-Neustart)
│ │ │ und /ready (Datenbank antwortet, sonst 503; für │ │ │ und /ready (Datenbank antwortet, sonst 503; für
│ │ │ Load-Balancer). /healthz ist Alias für /live. │ │ │ 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), │ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import),
│ │ │ nur mit Rolle admin. Läuft im Server, damit der │ │ │ nur mit Rolle admin. Läuft im Server, damit der
│ │ │ Suchindex im selben Zug nachgezogen wird. │ │ │ Suchindex im selben Zug nachgezogen wird.
│ │ ├── handlers_papierkorb.go │ │ ├── handlers_trash.go
│ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen, │ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen,
│ │ │ endgültig löschen, Filter nach löschendem Zugang. │ │ │ endgültig löschen, Filter nach löschendem Zugang.
│ │ │ Alles nur mit Schreibrecht, und bewusst nur hier │ │ │ Alles nur mit Schreibrecht, und bewusst nur hier
│ │ │ — über MCP gibt es kein Wiederherstellen. │ │ │ — ü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 │ │ │ für jede Sitzung. Setzt vorher Server-Adresse
│ │ │ und Projekt-Bezeichner in die Beispiele ein │ │ │ und Projekt-Bezeichner in die Beispiele ein
│ │ │ (serverURL, mit geprüftem Host-Header). │ │ │ (serverURL, mit geprüftem Host-Header).
@ -426,7 +433,7 @@ munin/
│ │ Speichern ohne Änderung überschreibt nichts. │ │ Speichern ohne Änderung überschreibt nichts.
│ │ │ │
│ ├── dubletten/ ── Gibt es schon? ────────────────────────────────── │ ├── 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 │ │ ähnlichem Titel? (Pruefe) Gemeinsames Präfix
│ │ zählt nicht, Suchbegriffe nur wörtlich, die │ │ zählt nicht, Suchbegriffe nur wörtlich, die
│ │ gemeinsamen Wörter kommen mit. Genutzt von │ │ gemeinsamen Wörter kommen mit. Genutzt von
@ -441,7 +448,7 @@ munin/
│ │ Qualität der Antworten, weil er bei jedem Client │ │ Qualität der Antworten, weil er bei jedem Client
│ │ im Kontext steht. │ │ 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 │ │ override_curated und Begründung; die Änderung
│ │ landet auf der Nutzungsseite zur Prüfung. │ │ landet auf der Nutzungsseite zur Prüfung.
│ │ │ │
@ -473,12 +480,12 @@ munin/
│ │ Knoten auseinander, Namen nur, wo Platz ist. │ │ Knoten auseinander, Namen nur, wo Platz ist.
│ │ Texte nur über textContent, nie innerHTML. │ │ Texte nur über textContent, nie innerHTML.
│ ├── tagfilter.js ← Filterfeld auf der Seite "Alle Schlagworte". │ ├── 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. │ │ Eintrags, fragt nach einer Tipp-Pause.
│ ├── ungespeichert.js ← Rückfrage beim Verlassen eines Formulars mit │ ├── unsaved.js ← Rückfrage beim Verlassen eines Formulars mit
│ │ ungespeicherten Änderungen (data-ungespeichert). │ │ ungespeicherten Änderungen (data-unsaved).
│ ├── tastenkuerzel.js ← "/" Suche, "n" neu, "e" bearbeiten, Strg+Enter │ ├── shortcuts.js ← "/" Suche, "n" neu, "e" bearbeiten, Strg+Enter
│ │ speichern. Ziel steht als data-kuerzel im │ │ speichern. Ziel steht als data-shortcut im
│ │ Template, die Rechte also dort. │ │ Template, die Rechte also dort.
│ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne │ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne
│ Hintergrund). Die Anmeldeseite wählt je nach │ Hintergrund). Die Anmeldeseite wählt je nach
@ -525,14 +532,14 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`.
| Datei | Schwerpunkt | | Datei | Schwerpunkt |
|---|---| |---|---|
| `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For | | `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/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/statistik/statistik_test.go` | Summen über zwei Workspaces, alle 30 Tage gefüllt, aktive und ruhige Projekte, Zwischenspeicher für 5 Minuten | | `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/diagramm_test.go` | Balken gestapelt von der Nulllinie, Skala 1-2-5, kleine Werte sichtbar, Tooltip, Bytes lesbar | | `internal/web/chart_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/usage_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/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/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/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/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/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 | | `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/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/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/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/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/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/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/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/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/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/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/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/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/dubletten/dubletten_test.go` | Wann ein gemeinsames Titel-Präfix aus dem Vergleich fällt | | `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/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/store/searchlog_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/help/help_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML |
## Datenfluss: Agent ruft Wissen ab ## Datenfluss: Agent ruft Wissen ab
@ -570,10 +577,10 @@ KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall",
→ mcpserver/tools.go (recall) → mcpserver/tools.go (recall)
→ search/registry.go Index dieses Projekts (beim ersten Mal → search/registry.go Index dieses Projekts (beim ersten Mal
aus der Datenbank aufgebaut) 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 → store/entries.go (EntryBySlug) Treffer nachladen
→ entry/entry.go (Snippet) Textausschnitt je Treffer → 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 → 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 → requireWrite() Rolle read wird hier abgewiesen
→ dubletten (Pruefe) neuer Bezeichner, ähnlicher Titel? → dubletten (Pruefe) neuer Bezeichner, ähnlicher Titel?
→ candidates zurück, nichts schreiben → 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 → judgment_required, nichts schreiben
→ store/entries.go (PutEntry) eine Transaktion: → store/entries.go (PutEntry) eine Transaktion:
· Bezeichner vorhanden? → aktualisieren · Bezeichner vorhanden? → aktualisieren

View file

@ -21,7 +21,7 @@ services:
postgres: postgres:
image: postgres:18 image: postgres:18
# Munin braucht je Workspace bis zu 3 Verbindungen (siehe # 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 command: postgres -c max_connections=200
environment: environment:
POSTGRES_USER: munin POSTGRES_USER: munin

View file

@ -15,9 +15,9 @@ var testSigner = NewSigner([]byte("ein-test-secret-das-lang-genug-ist-fuer-tests
// ── Sitzungen ──────────────────────────────────────────────────────────────── // ── Sitzungen ────────────────────────────────────────────────────────────────
func TestSessionRoundTrip(t *testing.T) { 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 { if !ok {
t.Fatal("frisch signierte Sitzung wurde als ungültig abgelehnt") 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 // TestSessionRejectsWrongSecret ist der Kern des Verfahrens: ohne das richtige
// Geheimnis darf sich niemand eine Sitzung ausstellen können. // Geheimnis darf sich niemand eine Sitzung ausstellen können.
func TestSessionRejectsWrongSecret(t *testing.T) { func TestSessionRejectsWrongSecret(t *testing.T) {
cookie := testSigner.SignSession(SubjectPlatformAdmin) cookie := testSigner.SignSession(SubjectPlatformAdmin, time.Now().Add(time.Hour))
fremder := NewSigner([]byte("ein-voellig-anderes-secret")) 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") 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" // auslesen (er ist nur base64, nicht verschlüsselt), "key:xyz" durch "platform"
// ersetzen und wieder einsetzen. // ersetzen und wieder einsetzen.
func TestSessionRejectsTampering(t *testing.T) { func TestSessionRejectsTampering(t *testing.T) {
cookie := testSigner.SignSession("key:abc123") cookie := testSigner.SignSession("key:abc123", time.Now().Add(time.Hour))
_, signature, _ := strings.Cut(cookie, ".") _, signature, _ := strings.Cut(cookie, ".")
gefaelscht := base64.RawURLEncoding.EncodeToString( forged := base64.RawURLEncoding.EncodeToString(
fmt.Appendf(nil, "%s|%d", SubjectPlatformAdmin, time.Now().Add(time.Hour).Unix()), 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") 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 // TestSessionRejectsExpired baut die Nutzlast von Hand mit einem Zeitstempel in
// der Vergangenheit und signiert sie korrekt — so wird wirklich nur die // der Vergangenheit und signiert sie korrekt — so wird wirklich nur die
// Ablaufprüfung getestet, ohne eine Stunde zu warten. // 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()) payload := fmt.Sprintf("key:abc123|%d", time.Now().Add(-time.Minute).Unix())
cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + testSigner.sign(payload) 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") t.Error("abgelaufene Sitzung wurde akzeptiert")
} }
} }
@ -78,7 +105,7 @@ func TestSessionRejectsMalformed(t *testing.T) {
} }
for _, cookie := range cases { for _, cookie := range cases {
if _, ok := testSigner.VerifySession(cookie); ok { if _, _, ok := testSigner.VerifySession(cookie); ok {
t.Errorf("kaputter Cookie wurde akzeptiert: %q", cookie) t.Errorf("kaputter Cookie wurde akzeptiert: %q", cookie)
} }
} }
@ -115,7 +142,7 @@ func TestTokenDomainSeparation(t *testing.T) {
subject := "key:abc123" subject := "key:abc123"
csrf := testSigner.CSRFToken(subject) csrf := testSigner.CSRFToken(subject)
if _, ok := testSigner.VerifySession( if _, _, ok := testSigner.VerifySession(
base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + csrf, base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + csrf,
); ok { ); ok {
t.Error("ein CSRF-Token wurde als gültige Session-Signatur akzeptiert") 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. // TestGenerateKeyIsUnique prüft, dass wirklich Zufall im Spiel ist.
func TestGenerateKeyIsUnique(t *testing.T) { func TestGenerateKeyIsUnique(t *testing.T) {
gesehen := make(map[string]bool) seen := make(map[string]bool)
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
key, err := GenerateKey() key, err := GenerateKey()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if gesehen[key] { if seen[key] {
t.Fatal("zweimal derselbe Key erzeugt") 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) { func TestLooksLikeKey(t *testing.T) {
gueltig := KeyPrefix + strings.Repeat("ab", 32) valid := KeyPrefix + strings.Repeat("ab", 32)
cases := map[string]bool{ cases := map[string]bool{
gueltig: true, valid: true,
"": false, "": false,
"wk_": false, "wk_": false,
strings.Repeat("ab", 32): false, // ohne Präfix 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 KeyPrefix + strings.Repeat("g", 64): false, // kein Hex
} }
for key, erwartet := range cases { for key, want := range cases {
if got := LooksLikeKey(key); got != erwartet { if got := LooksLikeKey(key); got != want {
t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, erwartet) t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, want)
} }
} }
} }
@ -247,10 +274,10 @@ func TestRolePermissions(t *testing.T) {
func TestCookiePolicySecure(t *testing.T) { func TestCookiePolicySecure(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
env string env string
host string host string
erwartet bool want bool
}{ }{
{"Standard, echte Domain", "", "munin.intern", true}, {"Standard, echte Domain", "", "munin.intern", true},
{"Standard, localhost", "", "localhost:8080", false}, {"Standard, localhost", "", "localhost:8080", false},
@ -265,8 +292,8 @@ func TestCookiePolicySecure(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://"+tc.host+"/", nil) r := httptest.NewRequest(http.MethodGet, "http://"+tc.host+"/", nil)
r.Host = tc.host r.Host = tc.host
if got := policy.Secure(r); got != tc.erwartet { if got := policy.Secure(r); got != tc.want {
t.Errorf("Secure = %v, erwartet %v", got, tc.erwartet) t.Errorf("Secure = %v, erwartet %v", got, tc.want)
} }
}) })
} }
@ -344,19 +371,19 @@ func TestRateLimiterResetClearsCounter(t *testing.T) {
func TestWriteBudgetAllowsUpToLimit(t *testing.T) { func TestWriteBudgetAllowsUpToLimit(t *testing.T) {
budget := NewWriteBudget(3, time.Hour) budget := NewWriteBudget(3, time.Hour)
const projekt = "p1" const project = "p1"
for i := 1; i <= 3; i++ { for i := 1; i <= 3; i++ {
ok, _, ueber := budget.Consume(projekt, 0) ok, _, over := budget.Consume(project, 0)
if !ok { if !ok {
t.Fatalf("Aufruf %d von 3 abgelehnt", i) t.Fatalf("Aufruf %d von 3 abgelehnt", i)
} }
if ueber != 0 { if over != 0 {
t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, ueber) 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 { if ok {
t.Error("der vierte Aufruf wurde angenommen") 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 // Genau 1 beim ersten abgelehnten Aufruf — daran hängt, dass die
// Überschreitung nur einmal je Fenster protokolliert wird. // Überschreitung nur einmal je Fenster protokolliert wird.
if ueber != 1 { if over != 1 {
t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", ueber) t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", over)
} }
if _, _, ueber := budget.Consume(projekt, 0); ueber != 2 { if _, _, over := budget.Consume(project, 0); over != 2 {
t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", ueber) 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) { func TestWriteBudgetRemainingCountsDown(t *testing.T) {
budget := NewWriteBudget(5, time.Hour) budget := NewWriteBudget(5, time.Hour)
if frei, grenze := budget.Remaining("p1", 0); frei != 5 || grenze != 5 { if free, limit := budget.Remaining("p1", 0); free != 5 || limit != 5 {
t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", frei, grenze) t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", free, limit)
} }
budget.Consume("p1", 0) budget.Consume("p1", 0)
budget.Consume("p1", 0) budget.Consume("p1", 0)
if frei, _ := budget.Remaining("p1", 0); frei != 3 { if free, _ := budget.Remaining("p1", 0); free != 3 {
t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", frei) t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", free)
} }
// Remaining selbst darf nichts verbrauchen. // Remaining selbst darf nichts verbrauchen.
if frei, _ := budget.Remaining("p1", 0); frei != 3 { if free, _ := budget.Remaining("p1", 0); free != 3 {
t.Errorf("frei = %d — Remaining hat mitgezählt", frei) t.Errorf("frei = %d — Remaining hat mitgezählt", free)
} }
} }
@ -511,16 +538,16 @@ func TestSetTrustedProxiesRejectsGarbage(t *testing.T) {
SetTrustedProxies("") SetTrustedProxies("")
} }
func TestPlatformSubjectHinUndZurueck(t *testing.T) { func TestPlatformSubjectRoundTrip(t *testing.T) {
faelle := []struct{ fingerabdruck, workspaceID string }{ cases := []struct{ fingerprint, workspaceID string }{
{"abc123", ""}, {"abc123", ""},
{"abc123", "ws-1"}, {"abc123", "ws-1"},
} }
for _, f := range faelle { for _, f := range cases {
subject := PlatformSubject(f.fingerabdruck, f.workspaceID) subject := PlatformSubject(f.fingerprint, f.workspaceID)
fp, ws, ok := ParsePlatformSubject(subject) fp, ws, ok := ParsePlatformSubject(subject)
if !ok || fp != f.fingerabdruck || ws != 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.fingerabdruck, 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") a := NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert")
b := NewSigner([]byte("geheimnis-zwei-geheimnis-zwei-geheimnis")).Fingerprint("wert") b := NewSigner([]byte("geheimnis-zwei-geheimnis-zwei-geheimnis")).Fingerprint("wert")
if a == b { if a == b {

View file

@ -41,6 +41,23 @@ func (s *Signer) ValidCSRFToken(token, subject string) bool {
} }
// CSRFTokenFromRequest holt das Token aus dem Formular oder dem Header. // 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 { func CSRFTokenFromRequest(r *http.Request) string {
if token := r.Header.Get(CSRFHeaderName); token != "" { if token := r.Header.Get(CSRFHeaderName); token != "" {
return token return token

View file

@ -14,98 +14,98 @@ import (
// ── Der gemeinsame Unterbau ────────────────────────────────────────────────── // ── Der gemeinsame Unterbau ──────────────────────────────────────────────────
// fensterStand ist der Zählerstand eines Schlüssels im laufenden Fenster. // windowState ist der Zählerstand eines Schlüssels im laufenden Fenster.
type fensterStand struct { type windowState struct {
anzahl int count int
fensterBis time.Time 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 // 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 // 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 // Zähler ist beides nur eine Zeichenkette — deshalb lässt sich dieselbe
// Mechanik für beides verwenden. // Mechanik für beides verwenden.
type fensterZaehler struct { type windowCounter struct {
mu sync.Mutex mu sync.Mutex
fenster time.Duration window time.Duration
staende map[string]*fensterStand 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. // abgelaufene Einträge regelmäßig entfernt.
// //
// Ohne das Aufräumen würde die Map unbegrenzt wachsen: Jede IP, die einmal // 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 // vorbeigeschaut hat, bliebe für immer darin stehen — bei Scannern und Bots sind
// das schnell sehr viele. // das schnell sehr viele.
func neuerZaehler(fenster time.Duration) *fensterZaehler { func newCounter(window time.Duration) *windowCounter {
z := &fensterZaehler{ z := &windowCounter{
fenster: fenster, window: window,
staende: make(map[string]*fensterStand), states: make(map[string]*windowState),
} }
go func() { go func() {
ticker := time.NewTicker(10 * time.Minute) ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
z.aufraeumen() z.cleanup()
} }
}() }()
return z return z
} }
// lies gibt den aktuellen Stand zurück, ohne zu zählen. // peek gibt den aktuellen Stand zurück, ohne zu zählen.
// restlaufzeit ist die verbleibende Dauer des Fensters. // remaining ist die verbleibende Dauer des Fensters.
func (z *fensterZaehler) lies(schluessel string) (anzahl int, restlaufzeit time.Duration) { func (z *windowCounter) peek(key string) (count int, remaining time.Duration) {
z.mu.Lock() z.mu.Lock()
defer z.mu.Unlock() defer z.mu.Unlock()
stand, vorhanden := z.staende[schluessel] state, existing := z.states[key]
now := time.Now() now := time.Now()
// Kein Eintrag oder Fenster abgelaufen: der Schlüssel steht bei null. // 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 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. // count 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) { func (z *windowCounter) count(key string) (count int, remaining time.Duration) {
z.mu.Lock() z.mu.Lock()
defer z.mu.Unlock() defer z.mu.Unlock()
now := time.Now() now := time.Now()
stand, vorhanden := z.staende[schluessel] state, existing := z.states[key]
// Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein // Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein
// frisches ersetzt — sonst würde der alte Stand weitergezählt. // frisches ersetzt — sonst würde der alte Stand weitergezählt.
if !vorhanden || now.After(stand.fensterBis) { if !existing || now.After(state.windowEnd) {
stand = &fensterStand{fensterBis: now.Add(z.fenster)} state = &windowState{windowEnd: now.Add(z.window)}
z.staende[schluessel] = stand z.states[key] = state
} }
stand.anzahl++ state.count++
return stand.anzahl, stand.fensterBis.Sub(now) return state.count, state.windowEnd.Sub(now)
} }
// vergiss löscht den Zähler eines Schlüssels. // forget löscht den Zähler eines Schlüssels.
func (z *fensterZaehler) vergiss(schluessel string) { func (z *windowCounter) forget(key string) {
z.mu.Lock() z.mu.Lock()
defer z.mu.Unlock() defer z.mu.Unlock()
delete(z.staende, schluessel) delete(z.states, key)
} }
// aufraeumen entfernt abgelaufene Einträge aus der Map. // cleanup entfernt abgelaufene Einträge aus der Map.
func (z *fensterZaehler) aufraeumen() { func (z *windowCounter) cleanup() {
z.mu.Lock() z.mu.Lock()
defer z.mu.Unlock() defer z.mu.Unlock()
now := time.Now() now := time.Now()
for schluessel, stand := range z.staende { for key, state := range z.states {
if now.After(stand.fensterBis) { if now.After(state.windowEnd) {
delete(z.staende, schluessel) delete(z.states, key)
} }
} }
} }
@ -121,32 +121,32 @@ const (
// ohne das könnte ein Angreifer beliebig oft Passwörter und API-Keys // ohne das könnte ein Angreifer beliebig oft Passwörter und API-Keys
// durchprobieren. // durchprobieren.
type RateLimiter struct { type RateLimiter struct {
zaehler *fensterZaehler counter *windowCounter
} }
// NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche. // NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche.
func NewRateLimiter() *RateLimiter { 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. // 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. // retryAfter gibt an wie lange bei einer Sperre noch gewartet werden muss.
func (l *RateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) { func (l *RateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) {
anzahl, restlaufzeit := l.zaehler.lies(ip) count, remaining := l.counter.peek(ip)
if anzahl >= rateLimitMax { if count >= rateLimitMax {
return false, restlaufzeit return false, remaining
} }
return true, 0 return true, 0
} }
// RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP. // RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP.
func (l *RateLimiter) RecordFailure(ip string) { 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. // Reset löscht den Zähler einer IP nach einem erfolgreichen Zugriff.
func (l *RateLimiter) Reset(ip string) { func (l *RateLimiter) Reset(ip string) {
l.zaehler.vergiss(ip) l.counter.forget(ip)
} }
// ── Schreibbudget je Projekt ───────────────────────────────────────────────── // ── 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 — // 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. // siehe AUSBAUSTUFEN.md. Solange genau ein Prozess läuft, reicht das hier.
type WriteBudget struct { type WriteBudget struct {
zaehler *fensterZaehler counter *windowCounter
standard int standard int
} }
@ -183,9 +183,9 @@ type WriteBudget struct {
// //
// standard gilt für alle Projekte, die keine eigene Obergrenze gesetzt haben. // standard gilt für alle Projekte, die keine eigene Obergrenze gesetzt haben.
// Ein Wert von 0 oder kleiner schaltet die Begrenzung vollständig ab. // 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{ return &WriteBudget{
zaehler: neuerZaehler(fenster), counter: newCounter(window),
standard: standard, 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 // Consume verbucht einen Schreibvorgang für ein Projekt und sagt, ob er noch ins
// Budget passte. // 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 // aus NewWriteBudget. Die Obergrenze wird bei jedem Aufruf mitgegeben statt im
// Budget gespeichert — so wirkt eine Änderung in der Verwaltung sofort, ohne // Budget gespeichert — so wirkt eine Änderung in der Verwaltung sofort, ohne
// dass hier irgendein zwischengespeicherter Wert veralten könnte. // 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 // weiterversucht, das Fenster nie volllaufen lassen — und der Zählerstand wäre
// keine ehrliche Auskunft darüber, wie viel gerade los ist. // 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. // alles im Rahmen ist, 1 beim allerersten abgelehnten Aufruf, dann aufsteigend.
// //
// Dieser dritte Rückgabewert sieht nach Ballast aus, hat aber einen konkreten // Dieser dritte Rückgabewert sieht nach Ballast aus, hat aber einen konkreten
// Zweck: Der Aufrufer soll die Überschreitung protokollieren können, ohne bei // Zweck: Der Aufrufer soll die Überschreitung protokollieren können, ohne bei
// JEDEM abgelehnten Aufruf eine Zeile zu schreiben. Ein Agent in einer Schleife // JEDEM abgelehnten Aufruf eine Zeile zu schreiben. Ein Agent in einer Schleife
// erzeugt sonst genau die Datenflut, die das Budget verhindern soll — nur eben // 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. // entsteht je Fenster genau ein Vermerk.
func (b *WriteBudget) Consume(projektID string, projektLimit int) (ok bool, retryAfter time.Duration, ueberschreitung int) { func (b *WriteBudget) Consume(projectID string, projectLimit int) (ok bool, retryAfter time.Duration, excess int) {
grenze := b.grenzeFuer(projektLimit) limit := b.limitFor(projectLimit)
if grenze <= 0 { if limit <= 0 {
return true, 0, 0 // Begrenzung abgeschaltet return true, 0, 0 // Begrenzung abgeschaltet
} }
anzahl, restlaufzeit := b.zaehler.zaehle(projektID) count, remaining := b.counter.count(projectID)
if anzahl > grenze { if count > limit {
return false, restlaufzeit, anzahl - grenze return false, remaining, count - limit
} }
return true, 0, 0 return true, 0, 0
} }
// Remaining sagt, wie viele Schreibvorgänge im laufenden Fenster noch frei sind, // Remaining sagt, wie viele Schreibvorgänge im laufenden Fenster noch frei sind,
// ohne etwas zu verbrauchen. Für die Anzeige in der Verwaltung. // ohne etwas zu verbrauchen. Für die Anzeige in der Verwaltung.
func (b *WriteBudget) Remaining(projektID string, projektLimit int) (frei int, grenze int) { func (b *WriteBudget) Remaining(projectID string, projectLimit int) (free int, limit int) {
grenze = b.grenzeFuer(projektLimit) limit = b.limitFor(projectLimit)
if grenze <= 0 { if limit <= 0 {
return 0, 0 return 0, 0
} }
anzahl, _ := b.zaehler.lies(projektID) count, _ := b.counter.peek(projectID)
frei = grenze - anzahl free = limit - count
if frei < 0 { if free < 0 {
frei = 0 free = 0
} }
return frei, grenze return free, limit
} }
// grenzeFuer löst die geltende Obergrenze auf: erst das Projekt, dann der Standard. // limitFor löst die geltende Obergrenze auf: erst das Projekt, dann der Standard.
func (b *WriteBudget) grenzeFuer(projektLimit int) int { func (b *WriteBudget) limitFor(projectLimit int) int {
if projektLimit > 0 { if projectLimit > 0 {
return projektLimit return projectLimit
} }
return b.standard return b.standard
} }

View file

@ -20,8 +20,29 @@ import (
const SessionCookieName = "munin_session" const SessionCookieName = "munin_session"
// SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt. // 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 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 // SubjectPlatformAdmin ist der Anfang des Session-Subjects des Plattform-Admins
// (ADMIN_TOKEN). Das vollständige Subject baut PlatformSubject. // (ADMIN_TOKEN). Das vollständige Subject baut PlatformSubject.
const SubjectPlatformAdmin = "platform" 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 // 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 // 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). // bei jedem Request neu in der Datenbank (siehe internal/web).
func (s *Signer) SignSession(subject string) string { //
expiry := time.Now().Add(SessionTTL).Unix() // expiry ist der Ablaufzeitpunkt. Bei einer Anmeldung ist das jetzt plus
payload := fmt.Sprintf("%s|%d", subject, expiry) // 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)) encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
return encoded + "." + s.sign(payload) return encoded + "." + s.sign(payload)
} }
// VerifySession prüft Signatur und Ablaufdatum und gibt das Subject zurück. // VerifySession prüft Signatur und Ablaufdatum und gibt Subject und
func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) { // Ablaufzeitpunkt zurück.
func (s *Signer) VerifySession(cookieValue string) (subject string, expiry time.Time, ok bool) {
encoded, signature, found := strings.Cut(cookieValue, ".") encoded, signature, found := strings.Cut(cookieValue, ".")
if !found { if !found {
return "", false return "", time.Time{}, false
} }
payloadBytes, err := base64.RawURLEncoding.DecodeString(encoded) payloadBytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil { if err != nil {
return "", false return "", time.Time{}, false
} }
payload := string(payloadBytes) payload := string(payloadBytes)
// Reihenfolge ist wichtig: erst die Signatur prüfen, dann den Inhalt auswerten. // Reihenfolge ist wichtig: erst die Signatur prüfen, dann den Inhalt auswerten.
// Alles vor dieser Zeile stammt aus einer nicht vertrauenswürdigen Quelle. // Alles vor dieser Zeile stammt aus einer nicht vertrauenswürdigen Quelle.
if subtle.ConstantTimeCompare([]byte(signature), []byte(s.sign(payload))) != 1 { if subtle.ConstantTimeCompare([]byte(signature), []byte(s.sign(payload))) != 1 {
return "", false return "", time.Time{}, false
} }
subject, expiryStr, found := strings.Cut(payload, "|") subject, expiryStr, found := strings.Cut(payload, "|")
if !found { if !found {
return "", false return "", time.Time{}, false
} }
expiry, err := strconv.ParseInt(expiryStr, 10, 64) unix, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil || time.Now().Unix() > expiry { if err != nil {
return "", false 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 // Fingerprint leitet aus einem Wert einen kurzen, nicht umkehrbaren

View file

@ -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). // (AUSBAUSTUFEN.md 3.3).
// //
// Eigenes Paket, weil zwei Wege es brauchen: "remember" über MCP (fragt // Eigenes Paket, weil zwei Wege es brauchen: "remember" über MCP (fragt
// zurück, statt anzulegen) und das Formular "Neuer Eintrag" der Weboberfläche // zurück, statt anzulegen) und das Formular "Neuer Eintrag" der Weboberfläche
// (zeigt beim Tippen „Gibt es schon?"). Beide sollen dieselbe Prüfung haben — // (zeigt beim Tippen „Gibt es schon?"). Beide sollen dieselbe Prüfung haben —
// zwei getrennte würden mit der Zeit unterschiedlich urteilen. // zwei getrennte würden mit der Zeit unterschiedlich urteilen.
package dubletten package duplicates
// Dubletten über Ähnlichkeit erkennen. // Dubletten über Ähnlichkeit erkennen.
// //
@ -41,8 +41,8 @@ import (
) )
const ( const (
// Schwelle ist der Mindestanteil gemeinsamer Titelwörter, ab dem // Threshold ist der Mindestanteil gemeinsamer Titelwörter, ab dem
// ein vorhandener Eintrag als Kandidat gilt (siehe search.Ueberdeckung). // ein vorhandener Eintrag als Kandidat gilt (siehe search.Coverage).
// //
// 0,5 heißt: Die Hälfte der Wörter stimmt überein. „Deployment-Ablauf" // 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 // 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 // „Datenbank-Zugang". Ein solcher Fehlalarm kostet den Agenten einen
// zweiten Aufruf mit force; eine übersehene Dublette kostet auf Dauer // zweiten Aufruf mit force; eine übersehene Dublette kostet auf Dauer
// mehr. // 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. // Die Suche findet Kandidaten, die Überdeckung entscheidet.
suchtiefe = 10 searchDepth = 10
// maxKandidaten: So viele Kandidaten gehen höchstens zurück. // maxCandidates: So viele Kandidaten gehen höchstens zurück.
maxKandidaten = 5 maxCandidates = 5
) )
// Kandidat ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt. // Candidate ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt.
type Kandidat struct { type Candidate struct {
Slug string Slug string
Title string Title string
Overlap int // Übereinstimmung in Prozent Overlap int // Übereinstimmung in Prozent
@ -71,7 +71,7 @@ type Kandidat struct {
MatchedWords []string 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 // 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 // zu prüfen, und das Ergebnis ist leer. Ein leerer slug (das Formular, bevor
// jemand einen Bezeichner eingetragen hat) überspringt diese Prüfung. // 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 // Richtungen. So findet ein neuer „Release-Ablauf" den vorhandenen
// „Deployment-Prozess", wenn dort „release" als Suchbegriff steht. Ein // „Deployment-Prozess", wenn dort „release" als Suchbegriff steht. Ein
// Suchbegriff zählt dabei nur wörtlich, ein Titelwort auch mit Varianten // Suchbegriff zählt dabei nur wörtlich, ein Titelwort auch mit Varianten
// (siehe search.UeberdeckungMitSuchbegriffen). // (siehe search.CoverageWithSearchTerms).
func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project, func Check(ctx context.Context, ws *workspace.Workspace, project store.Project,
slug, titel, inhalt string) ([]Kandidat, error) { slug, title, content string) ([]Candidate, error) {
if strings.TrimSpace(titel) == "" { if strings.TrimSpace(title) == "" {
return nil, nil return nil, nil
} }
if slug != "" { if slug != "" {
@ -102,10 +102,10 @@ func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project,
return nil, err return nil, err
} }
neuerName := titel + " " + entry.AuchGesuchtAls(inhalt) newName := title + " " + entry.AlsoSearchedAs(content)
var kandidaten []Kandidat var candidates []Candidate
for _, t := range idx.Search(neuerName, suchtiefe) { for _, t := range idx.Search(newName, searchDepth) {
e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID) e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
continue // Index kennt einen Eintrag, den es nicht mehr gibt 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, // Ein gemeinsames Präfix wie „Munin – " sagt nichts über das Thema,
// siehe ohneGemeinsamesPraefix. // siehe withoutCommonPrefix.
themaNeu, themaVorhanden := ohneGemeinsamesPraefix(titel, e.Title) topicNew, topicExisting := withoutCommonPrefix(title, e.Title)
// In beide Richtungen, der höhere Wert zählt: „Deployment" (neu) ist // In beide Richtungen, der höhere Wert zählt: „Deployment" (neu) ist
// vollständig in „Deployment-Prozess" enthalten, umgekehrt nur zur // vollständig in „Deployment-Prozess" enthalten, umgekehrt nur zur
// Hälfte. Beides ist ein Hinweis auf dasselbe Thema. // Hälfte. Beides ist ein Hinweis auf dasselbe Thema.
anteil, gemeinsam := search.UeberdeckungMitSuchbegriffen( share, shared := search.CoverageWithSearchTerms(
themaNeu, themaVorhanden, entry.AuchGesuchtAls(e.Content)) topicNew, topicExisting, entry.AlsoSearchedAs(e.Content))
rueckwaerts, gemeinsamRueckwaerts := search.UeberdeckungMitSuchbegriffen( backwards, sharedBackwards := search.CoverageWithSearchTerms(
themaVorhanden, themaNeu, entry.AuchGesuchtAls(inhalt)) topicExisting, topicNew, entry.AlsoSearchedAs(content))
if rueckwaerts > anteil { if backwards > share {
anteil, gemeinsam = rueckwaerts, gemeinsamRueckwaerts share, shared = backwards, sharedBackwards
} }
if anteil < Schwelle { if share < Threshold {
continue continue
} }
kandidaten = append(kandidaten, Kandidat{ candidates = append(candidates, Candidate{
Slug: e.Slug, Slug: e.Slug,
Title: e.Title, Title: e.Title,
Overlap: int(math.Round(anteil * 100)), Overlap: int(math.Round(share * 100)),
MatchedWords: gemeinsam, MatchedWords: shared,
}) })
} }
// Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge // Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge
// der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall. // der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall.
sort.SliceStable(kandidaten, func(i, j int) bool { sort.SliceStable(candidates, func(i, j int) bool {
return kandidaten[i].Overlap > kandidaten[j].Overlap return candidates[i].Overlap > candidates[j].Overlap
}) })
if len(kandidaten) > maxKandidaten { if len(candidates) > maxCandidates {
kandidaten = kandidaten[:maxKandidaten] 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", „Munin - Architektur",
// „Munin: Architektur", „Munin | Architektur". // „Munin: Architektur", „Munin | Architektur".
// //
// Ein Bindestrich zählt nur mit Leerzeichen auf beiden Seiten. Ohne // Ein Bindestrich zählt nur mit Leerzeichen auf beiden Seiten. Ohne
// Leerzeichen ist er Teil eines Worts — „Deployment-Ablauf" hat kein Präfix. // 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. // haben, und gibt die beiden Themen zurück.
// //
// # Warum? // # Warum?
@ -175,28 +175,28 @@ var titelTrenner = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`)
// verschiedene („Munin – Deployment" und „Hugin – Deployment") oder nur einer // verschiedene („Munin – Deployment" und „Hugin – Deployment") oder nur einer
// eines, bleibt der volle Titel im Vergleich — dann kann das Präfix etwas // eines, bleibt der volle Titel im Vergleich — dann kann das Präfix etwas
// unterscheiden. Bleibt nach dem Präfix nichts übrig, ebenfalls. // unterscheiden. Bleibt nach dem Präfix nichts übrig, ebenfalls.
func ohneGemeinsamesPraefix(neu, vorhanden string) (string, string) { func withoutCommonPrefix(newer, existing string) (string, string) {
praefixNeu, themaNeu, okNeu := titelTeilen(neu) prefixNew, topicNew, okNew := titleParts(newer)
praefixVorhanden, themaVorhanden, okVorhanden := titelTeilen(vorhanden) prefixExisting, topicExisting, okExisting := titleParts(existing)
if !okNeu || !okVorhanden || praefixNeu != praefixVorhanden { if !okNew || !okExisting || prefixNew != prefixExisting {
return neu, vorhanden 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 // Präfix kommt vereinheitlicht zurück (klein, einfache Leerzeichen), damit
// „Munin" und „munin" als dasselbe gelten. ok ist false, wenn es keinen // „Munin" und „munin" als dasselbe gelten. ok ist false, wenn es keinen
// Trenner gibt oder eine Seite leer ist. // Trenner gibt oder eine Seite leer ist.
func titelTeilen(titel string) (praefix, thema string, ok bool) { func titleParts(title string) (prefix, topic string, ok bool) {
pos := titelTrenner.FindStringIndex(titel) pos := titleSeparator.FindStringIndex(title)
if pos == nil { if pos == nil {
return "", titel, false return "", title, false
} }
praefix = strings.Join(strings.Fields(strings.ToLower(titel[:pos[0]])), " ") prefix = strings.Join(strings.Fields(strings.ToLower(title[:pos[0]])), " ")
thema = strings.TrimSpace(titel[pos[1]:]) topic = strings.TrimSpace(title[pos[1]:])
if praefix == "" || thema == "" { if prefix == "" || topic == "" {
return "", titel, false return "", title, false
} }
return praefix, thema, true return prefix, topic, true
} }

View file

@ -1,14 +1,14 @@
package dubletten package duplicates
import "testing" 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. // Dublettenvergleich herausfällt — nur wenn beide Titel dasselbe haben.
func TestOhneGemeinsamesPraefix(t *testing.T) { func TestWithoutCommonPrefix(t *testing.T) {
faelle := []struct { cases := []struct {
name string name string
neu, vorhanden string newer, existing string
themaNeu, themaVorhand string topicNew, topicExisting string
}{ }{
{"gleiches Präfix mit Halbgeviertstrich", {"gleiches Präfix mit Halbgeviertstrich",
"Munin – Architektur", "Munin – Überblick", "Architektur", "Überblick"}, "Munin – Architektur", "Munin – Überblick", "Architektur", "Überblick"},
@ -36,12 +36,12 @@ func TestOhneGemeinsamesPraefix(t *testing.T) {
"Munin – ", "Munin – Überblick", "Munin – ", "Munin – Überblick"}, "Munin – ", "Munin – Überblick", "Munin – ", "Munin – Überblick"},
} }
for _, f := range faelle { for _, f := range cases {
t.Run(f.name, func(t *testing.T) { t.Run(f.name, func(t *testing.T) {
neu, vorhanden := ohneGemeinsamesPraefix(f.neu, f.vorhanden) newer, existing := withoutCommonPrefix(f.newer, f.existing)
if neu != f.themaNeu || vorhanden != f.themaVorhand { if newer != f.topicNew || existing != f.topicExisting {
t.Errorf("ohneGemeinsamesPraefix(%q, %q) = (%q, %q), erwartet (%q, %q)", 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)
} }
}) })
} }

View file

@ -33,14 +33,14 @@ const maxDiffCells = 4_000_000
// verschobener, aber unveränderter Absatz wird dabei nicht zwingend als // verschobener, aber unveränderter Absatz wird dabei nicht zwingend als
// "gleich" erkannt, wenn sich in seiner Umgebung etwas geändert hat — für die // "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. // Frage "was hat sich bei diesem Schreibvorgang geändert" reicht das.
func DiffLines(alt, neu string) []DiffLine { func DiffLines(old, newer string) []DiffLine {
altZeilen := splitLines(alt) oldLines := splitLines(old)
neuZeilen := splitLines(neu) newLines := splitLines(newer)
if len(altZeilen)*len(neuZeilen) > maxDiffCells { if len(oldLines)*len(newLines) > maxDiffCells {
return grobDiff(altZeilen, neuZeilen) return coarseDiff(oldLines, newLines)
} }
return lcsDiff(altZeilen, neuZeilen) return lcsDiff(oldLines, newLines)
} }
func splitLines(s string) []string { func splitLines(s string) []string {
@ -50,15 +50,15 @@ func splitLines(s string) []string {
return strings.Split(s, "\n") 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 // Zeilenzuordnung, nur der komplette alte Text als entfernt und der
// komplette neue als hinzugefügt. // komplette neue als hinzugefügt.
func grobDiff(altZeilen, neuZeilen []string) []DiffLine { func coarseDiff(oldLines, newLines []string) []DiffLine {
lines := make([]DiffLine, 0, len(altZeilen)+len(neuZeilen)) lines := make([]DiffLine, 0, len(oldLines)+len(newLines))
for _, z := range altZeilen { for _, z := range oldLines {
lines = append(lines, DiffLine{Op: DiffRemove, Text: z}) lines = append(lines, DiffLine{Op: DiffRemove, Text: z})
} }
for _, z := range neuZeilen { for _, z := range newLines {
lines = append(lines, DiffLine{Op: DiffAdd, Text: z}) lines = append(lines, DiffLine{Op: DiffAdd, Text: z})
} }
return lines return lines

View file

@ -30,13 +30,13 @@ func TestDiffLinesIdentical(t *testing.T) {
} }
func TestDiffLinesChangeInMiddle(t *testing.T) { func TestDiffLinesChangeInMiddle(t *testing.T) {
alt := "Zeile 1\nZeile 2\nZeile 3" old := "Zeile 1\nZeile 2\nZeile 3"
neu := "Zeile 1\nGeändert\nZeile 3" newer := "Zeile 1\nGeändert\nZeile 3"
got := formatDiff(DiffLines(alt, neu)) got := formatDiff(DiffLines(old, newer))
erwartet := " Zeile 1\n- Zeile 2\n+ Geändert\n Zeile 3\n" want := " Zeile 1\n- Zeile 2\n+ Geändert\n Zeile 3\n"
if got != erwartet { if got != want {
t.Errorf("DiffLines =\n%s\nerwartet:\n%s", got, erwartet) t.Errorf("DiffLines =\n%s\nerwartet:\n%s", got, want)
} }
} }
@ -57,14 +57,14 @@ func TestDiffLinesEmptyOldContent(t *testing.T) {
func TestDiffLinesFallsBackForHugeInput(t *testing.T) { func TestDiffLinesFallsBackForHugeInput(t *testing.T) {
// Erzwingt den grobDiff-Pfad ohne wirklich riesige Strings zu bauen: die // Erzwingt den grobDiff-Pfad ohne wirklich riesige Strings zu bauen: die
// Grenze fällt auf Zeilenanzahl, nicht Textlänge. // Grenze fällt auf Zeilenanzahl, nicht Textlänge.
altGross := make([]byte, 0, 2200*2) oldUpper := make([]byte, 0, 2200*2)
neuGross := make([]byte, 0, 2200*2) newUpper := make([]byte, 0, 2200*2)
for i := 0; i < 2200; i++ { for i := 0; i < 2200; i++ {
altGross = append(altGross, 'a', '\n') oldUpper = append(oldUpper, 'a', '\n')
neuGross = append(neuGross, 'b', '\n') newUpper = append(newUpper, 'b', '\n')
} }
lines := DiffLines(string(altGross), string(neuGross)) lines := DiffLines(string(oldUpper), string(newUpper))
for _, l := range lines { for _, l := range lines {
if l.Op == DiffEqual { if l.Op == DiffEqual {
t.Fatal("grobDiff sollte keine DiffEqual-Zeilen liefern") t.Fatal("grobDiff sollte keine DiffEqual-Zeilen liefern")

View file

@ -27,18 +27,18 @@ import (
// Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf // Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf
// verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres // verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres
// Escaping einsetzt. // 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 // 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. // 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 // Name des importierten Pakets ist — sonst wäre das Paket in dieser
// Funktion verdeckt.) // 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 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 // 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 { func Snippet(content, query string) string {
text := RenderPlain(content) text := RenderPlain(content)
nadel := trefferwort(text, query) needle := hitWord(text, query)
if nadel == "" { if needle == "" {
// Kein Wort der Anfrage steht wörtlich im Text (z.B. weil der Fund über // 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 // eine Endung oder ein Synonym kam) — dann der Anfang des Texts als
// Vorschau. // 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) 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 { if start > 0 {
ausschnitt = "…" + ausschnitt excerpt = "…" + excerpt
} }
if ende < len(text) { if end < len(text) {
ausschnitt += "…" 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 // dem sich der Ausschnitt ausrichten darf. Kürzere Wörter („wie", „an") stehen
// in fast jedem Text und sagen nichts über den Treffer. // 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. // der Anfrage, das im Text vorkommt. "" heißt: nichts davon kommt vor.
// //
// # Warum nicht einfach die ganze Anfrage? // # 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 // genau eine Wortfolge), danach die einzelnen Wörter, längste zuerst. Die
// Länge ist ein einfacher Ersatz für „aussagekräftig": „Zertifikatswechsel" // Länge ist ein einfacher Ersatz für „aussagekräftig": „Zertifikatswechsel"
// verrät mehr über die Fundstelle als „Dienst". // verrät mehr über die Fundstelle als „Dienst".
func trefferwort(text, query string) string { func hitWord(text, query string) string {
query = strings.TrimSpace(query) query = strings.TrimSpace(query)
if query == "" { if query == "" {
return "" return ""
} }
klein := strings.ToLower(text) lower := strings.ToLower(text)
if strings.Contains(klein, strings.ToLower(query)) { if strings.Contains(lower, strings.ToLower(query)) {
return query return query
} }
// FieldsFunc zerlegt an jedem Zeichen, für das die Funktion true liefert — // FieldsFunc zerlegt an jedem Zeichen, für das die Funktion true liefert —
// hier an allem, was kein Buchstabe und keine Ziffer ist. // 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) return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}) })
// Längste zuerst. SliceStable lässt gleich lange Wörter in der Reihenfolge // Längste zuerst. SliceStable lässt gleich lange Wörter in der Reihenfolge
// der Anfrage stehen — so ist das Ergebnis immer dasselbe. // der Anfrage stehen — so ist das Ergebnis immer dasselbe.
sort.SliceStable(woerter, func(i, j int) bool { sort.SliceStable(words, func(i, j int) bool {
return utf8.RuneCountInString(woerter[i]) > utf8.RuneCountInString(woerter[j]) return utf8.RuneCountInString(words[i]) > utf8.RuneCountInString(words[j])
}) })
for _, w := range woerter { for _, w := range words {
if utf8.RuneCountInString(w) < minTrefferwort { if utf8.RuneCountInString(w) < minHitWord {
break // sortiert: alle folgenden sind noch kürzer 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 w
} }
} }
return "" return ""
} }
// kuerzen schneidet einen Text auf höchstens n Zeichen. // truncate schneidet einen Text auf höchstens n Zeichen.
func kuerzen(text string, n int) string { func truncate(text string, n int) string {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if len(text) <= n { if len(text) <= n {
return text return text
@ -148,34 +148,34 @@ func kuerzen(text string, n int) string {
// Wichtig: Erst wird der Text escaped, dann werden die <mark>-Auszeichnungen // Wichtig: Erst wird der Text escaped, dann werden die <mark>-Auszeichnungen
// eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen. // eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen.
func HighlightSnippet(content, query string) string { func HighlightSnippet(content, query string) string {
ausschnitt := Snippet(content, query) excerpt := Snippet(content, query)
sicher := html.EscapeString(ausschnitt) safe := html.EscapeString(excerpt)
if query == "" { if query == "" {
return sicher return safe
} }
// Hervorgehoben wird dasselbe Stück der Anfrage, an dem sich der // Hervorgehoben wird dasselbe Stück der Anfrage, an dem sich der
// Ausschnitt ausgerichtet hat. Gesucht wird im escapten Text nach dem // Ausschnitt ausgerichtet hat. Gesucht wird im escapten Text nach dem
// ebenfalls escapten Stück, damit die Positionen zusammenpassen. // ebenfalls escapten Stück, damit die Positionen zusammenpassen.
treffer := trefferwort(ausschnitt, query) hits := hitWord(excerpt, query)
if treffer == "" { if hits == "" {
return sicher return safe
} }
nadel := html.EscapeString(treffer) needle := html.EscapeString(hits)
idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel)) idx := strings.Index(strings.ToLower(safe), strings.ToLower(needle))
if idx == -1 { 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). // 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: "". // Eintrags zurück, so wie sie dort stehen. Ohne eine solche Zeile: "".
// //
// Agenten schreiben die Zeile nicht immer gleich. Erkannt werden deshalb auch // 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 // Gibt es mehrere solcher Zeilen, zählt die letzte — die Anleitung bittet
// darum, die Zeile ans Ende zu stellen. // darum, die Zeile ans Ende zu stellen.
func AuchGesuchtAls(content string) string { func AlsoSearchedAs(content string) string {
ergebnis := "" result := ""
for _, zeile := range strings.Split(content, "\n") { for _, line := range strings.Split(content, "\n") {
// Aufzählungszeichen, Zitatzeichen und Hervorhebungen am Anfang weg. // Aufzählungszeichen, Zitatzeichen und Hervorhebungen am Anfang weg.
zeile = strings.TrimLeft(strings.TrimSpace(zeile), "-*_> ") line = strings.TrimLeft(strings.TrimSpace(line), "-*_> ")
if !strings.HasPrefix(strings.ToLower(zeile), auchGesuchtAlsPraefix) { if !strings.HasPrefix(strings.ToLower(line), alsoSearchedAsPrefix) {
continue continue
} }
rest := zeile[len(auchGesuchtAlsPraefix):] rest := line[len(alsoSearchedAsPrefix):]
rest = strings.TrimLeft(rest, ":*_ ") 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 // TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat. // die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
func TitleFromContent(content string) string { func TitleFromContent(content string) string {
for _, zeile := range strings.Split(content, "\n") { for _, line := range strings.Split(content, "\n") {
zeile = strings.TrimSpace(zeile) line = strings.TrimSpace(line)
if zeile == "" { if line == "" {
continue continue
} }
zeile = strings.TrimLeft(zeile, "#") line = strings.TrimLeft(line, "#")
zeile = strings.TrimSpace(zeile) line = strings.TrimSpace(line)
if zeile != "" { if line != "" {
return kuerzen(zeile, 120) return truncate(line, 120)
} }
} }
return "Ohne Titel" return "Ohne Titel"

View file

@ -12,19 +12,19 @@ import (
// einschleust, hängt damit allein daran, dass goldmark kein Roh-HTML durchreicht. // einschleust, hängt damit allein daran, dass goldmark kein Roh-HTML durchreicht.
// Wird diese Einstellung je gelockert, schlägt dieser Test an. // Wird diese Einstellung je gelockert, schlägt dieser Test an.
func TestRenderHTMLEscapesRawHTML(t *testing.T) { func TestRenderHTMLEscapesRawHTML(t *testing.T) {
boesartig := []string{ malicious := []string{
"<script>alert(1)</script>", "<script>alert(1)</script>",
"<img src=x onerror=alert(1)>", "<img src=x onerror=alert(1)>",
"<iframe src=\"https://fremd.example\"></iframe>", "<iframe src=\"https://fremd.example\"></iframe>",
} }
for _, eingabe := range boesartig { for _, input := range malicious {
html, err := RenderHTML(eingabe, "/p/test/e/") html, err := RenderHTML(input, "/p/test/e/")
if err != nil { 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") { 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) t.Fatal(err)
} }
for _, erwartet := range []string{"<h1", "<ul>", "<li>", "<code>"} { for _, want := range []string{"<h1", "<ul>", "<li>", "<code>"} {
if !strings.Contains(html, erwartet) { if !strings.Contains(html, want) {
t.Errorf("%s fehlt im Ergebnis: %s", erwartet, html) 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 // eine ganze Frage, die so nie im Text steht. Der Ausschnitt muss trotzdem an
// der Fundstelle ansetzen, nicht am Textanfang. // 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) text := strings.Repeat("Vorlauf. ", 40) + "Der Zertifikatswechsel ist im März. " + strings.Repeat("Nachlauf. ", 40)
got := Snippet(text, "wie läuft der Zertifikatswechsel") 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. // 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." text := "Dienst. " + strings.Repeat("Füllwort. ", 60) + "Kubernetes-Cluster hier."
got := Snippet(text, "dienst kubernetes") 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." text := "Wir deployen über GitHub Actions."
faelle := map[string]string{ cases := map[string]string{
"GitHub Actions": "GitHub Actions", // ganze Anfrage kommt vor "GitHub Actions": "GitHub Actions", // ganze Anfrage kommt vor
"wie deployen wir": "deployen", "wie deployen wir": "deployen",
"wie wir": "", // nur kurze Wörter "wie wir": "", // nur kurze Wörter
@ -134,16 +134,16 @@ func TestTrefferwort(t *testing.T) {
"actions, github?": "actions", // gleich lang: Reihenfolge der Anfrage "actions, github?": "actions", // gleich lang: Reihenfolge der Anfrage
} }
for anfrage, erwartet := range faelle { for query, want := range cases {
if got := trefferwort(text, anfrage); got != erwartet { if got := hitWord(text, query); got != want {
t.Errorf("trefferwort(%q) = %q, erwartet %q", anfrage, got, erwartet) 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. // 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") got := HighlightSnippet("Wir deployen über GitHub Actions.", "wie deployen wir")
if !strings.Contains(got, "<mark>deployen</mark>") { if !strings.Contains(got, "<mark>deployen</mark>") {
@ -200,16 +200,16 @@ func TestTitleFromContent(t *testing.T) {
" \n \n": "Ohne Titel", " \n \n": "Ohne Titel",
} }
for eingabe, erwartet := range cases { for input, want := range cases {
if got := TitleFromContent(eingabe); got != erwartet { if got := TitleFromContent(input); got != want {
t.Errorf("TitleFromContent(%q) = %q, erwartet %q", eingabe, got, erwartet) t.Errorf("TitleFromContent(%q) = %q, erwartet %q", input, got, want)
} }
} }
} }
func TestTitleFromContentIsShortened(t *testing.T) { func TestTitleFromContentIsShortened(t *testing.T) {
lang := strings.Repeat("Wort ", 100) long := strings.Repeat("Wort ", 100)
got := TitleFromContent(lang) got := TitleFromContent(long)
if len(got) > 130 { if len(got) > 130 {
t.Errorf("der Titel ist %d Zeichen lang — er wurde nicht gekürzt", len(got)) 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 ───────────────────────────────────────────────────────── // ── Auch gesucht als ─────────────────────────────────────────────────────────
func TestAuchGesuchtAls(t *testing.T) { func TestAlsoSearchedAs(t *testing.T) {
faelle := map[string]string{ cases := map[string]string{
"Text.\n\nAuch gesucht als: release, rollout": "release, rollout", "Text.\n\nAuch gesucht als: release, rollout": "release, rollout",
"Text.\n*Auch gesucht als:* release": "release", "Text.\n*Auch gesucht als:* release": "release",
"- **Auch gesucht als:** release, auslieferung": "release, auslieferung", "- **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 "Wir haben auch gesucht als Team.": "", // nicht am Zeilenanfang
} }
for inhalt, erwartet := range faelle { for content, want := range cases {
if got := AuchGesuchtAls(inhalt); got != erwartet { if got := AlsoSearchedAs(content); got != want {
t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", inhalt, got, erwartet) t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", content, got, want)
} }
} }
} }

View file

@ -67,38 +67,38 @@ type Exporter struct {
// letzter ist der zuletzt abgeschlossene Lauf über alle Workspaces — // letzter ist der zuletzt abgeschlossene Lauf über alle Workspaces —
// für die Systemseite. Der Mutex schützt ihn, weil der tägliche Lauf in // für die Systemseite. Der Mutex schützt ihn, weil der tägliche Lauf in
// einer eigenen Goroutine schreibt, während die Seite liest. // einer eigenen Goroutine schreibt, während die Seite liest.
mu sync.Mutex mu sync.Mutex
letzter Lauf 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 // main.go). Die Systemseite meldet einen Export als überfällig, wenn der
// letzte Lauf deutlich länger zurückliegt. // letzte Lauf deutlich länger zurückliegt.
const Intervall = 24 * time.Hour const Interval = 24 * time.Hour
// Lauf beschreibt einen Export über alle Workspaces. // Run beschreibt einen Export über alle Workspaces.
type Lauf struct { type Run struct {
Ende time.Time // Nullzeitpunkt = seit dem Start noch keiner fertig End time.Time // Nullzeitpunkt = seit dem Start noch keiner fertig
Dauer time.Duration Duration time.Duration
Stats Stats Stats Stats
Fehler string // leer = vollständig 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 // Knopf "Exportieren" für einen einzelnen Workspace merkt sich der Exporter
// nicht: Er sagt nichts darüber, ob die übrigen gesichert sind. // 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() e.mu.Lock()
defer e.mu.Unlock() 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). // fertig ist).
func (e *Exporter) LetzterLauf() Lauf { func (e *Exporter) LastRun() Run {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
return e.letzter return e.last
} }
// New erstellt einen Exporter, der unter root schreibt. // 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 var stats Stats
// Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim // 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 // nur aus Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese
// Prüfung. // Prüfung.
if !store.ValidSlug(project.WorkspaceID) { if !store.ValidSlug(project.WorkspaceID) {
return stats, fmt.Errorf("ungültige Workspace-ID %q", 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 { if err != nil {
return stats, err return stats, err
} }
ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug) target := filepath.Join(e.root, project.WorkspaceID, project.Slug)
if err := os.MkdirAll(ziel, 0750); err != nil { if err := os.MkdirAll(target, 0750); err != nil {
return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
} }
// Namen merken, um danach zu wissen, was übrig bleibt. // Namen merken, um danach zu wissen, was übrig bleibt.
erwartet := make(map[string]bool, len(dateien)) want := make(map[string]bool, len(files))
for _, d := range dateien { for _, d := range files {
// Im Ordner des Projekts zählt nur der Dateiname, ohne den // Im Ordner des Projekts zählt nur der Dateiname, ohne den
// Projektordner davor. // Projektordner davor.
name := filepath.Base(d.Pfad) name := filepath.Base(d.Path)
erwartet[name] = true 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 return stats, err
} }
stats.Written++ stats.Written++
} }
entfernt, err := raeumeAuf(ziel, erwartet) removed, err := cleanup(target, want)
if err != nil { if err != nil {
return stats, err return stats, err
} }
stats.Removed = entfernt stats.Removed = removed
return stats, nil 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 // ExportWorkspace exportiert alle Projekte eines Workspace. s muss die
// Workspace-Datei von ws sein. // Workspace-Datei von ws sein.
func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store.Workspace) (Stats, error) { 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) { 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) dir := filepath.Join(e.root, ws.ID)
if err := os.MkdirAll(ordner, 0750); err != nil { if err := os.MkdirAll(dir, 0750); err != nil {
return gesamt, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err) 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 // Ohne diese Datei wäre der Ordner nur eine ID — niemand wüsste beim
// Wiederherstellen, welche Abteilung darin steckt. // Wiederherstellen, welche Abteilung darin steckt.
if err := schreibeDatei(filepath.Join(ordner, workspaceDateiname), rendereWorkspace(ws)); err != nil { if err := writeFile(filepath.Join(dir, workspaceFilename), renderWorkspace(ws)); err != nil {
return gesamt, err return total, err
} }
gesamt.Written++ total.Written++
projekte, err := s.ListProjects(ctx, ws.ID) projects, err := s.ListProjects(ctx, ws.ID)
if err != nil { if err != nil {
return gesamt, err return total, err
} }
for _, p := range projekte { for _, p := range projects {
stats, err := e.ExportProject(ctx, s, p) stats, err := e.ExportProject(ctx, s, p)
if err != nil { 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 total.Written += stats.Written
gesamt.Removed += stats.Removed 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 // schreiben: je Eintrag eine Datei und die Übersicht _index.md, alle unter
// "<projekt-slug>/". // "<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 // 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 // ein heruntergeladenes ZIP passt genauso in den Import wie ein Ordner aus
// EXPORT_DIR. // 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 // Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe
// store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier // store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier
// noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts // 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) 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 { if err != nil {
return nil, err return nil, err
} }
dateien := make([]Datei, 0, len(eintraege)+1) files := make([]File, 0, len(entries)+1)
for _, eintrag := range eintraege { for _, entry := range entries {
if !store.ValidSlug(eintrag.Slug) { if !store.ValidSlug(entry.Slug) {
return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug) return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", entry.Slug, project.Slug)
} }
dateien = append(dateien, Datei{ files = append(files, File{
Pfad: project.Slug + "/" + eintrag.Slug + ".md", Path: project.Slug + "/" + entry.Slug + ".md",
Inhalt: rendere(eintrag), Content: render(entry),
Geaendert: eintrag.UpdatedAt, Modified: entry.UpdatedAt,
}) })
} }
// Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist. // Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist.
dateien = append(dateien, Datei{ files = append(files, File{
Pfad: project.Slug + "/" + indexDateiname, Path: project.Slug + "/" + indexFilename,
Inhalt: rendereIndex(project, eintraege), Content: renderIndex(project, entries),
}) })
return dateien, nil return files, nil
} }
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird. // indexFilename ist die Übersicht, die je Projekt mit erzeugt wird.
const indexDateiname = "_index.md" const indexFilename = "_index.md"
// workspaceDateiname nennt den Workspace, zu dem ein ID-Ordner gehört. // workspaceFilename nennt den Workspace, zu dem ein ID-Ordner gehört.
const workspaceDateiname = "_workspace.md" 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. // 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 var b strings.Builder
b.WriteString("---\n") b.WriteString("---\n")
schreibeFeld(&b, "id", ws.ID) writeField(&b, "id", ws.ID)
schreibeFeld(&b, "name", ws.Name) writeField(&b, "name", ws.Name)
b.WriteString("---\n\n") b.WriteString("---\n\n")
b.WriteString("# Workspace " + ws.Name + "\n") b.WriteString("# Workspace " + ws.Name + "\n")
return b.String() 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? // # Warum der Vergleich?
// //
@ -270,69 +270,69 @@ func rendereWorkspace(ws store.Workspace) string {
// geschrieben, änderte sich zwar der Inhalt nicht, wohl aber die Änderungszeit — // geschrieben, änderte sich zwar der Inhalt nicht, wohl aber die Änderungszeit —
// und Werkzeuge, die darauf schauen, meldeten ständig Bewegung, wo keine ist. // und Werkzeuge, die darauf schauen, meldeten ständig Bewegung, wo keine ist.
// Git selbst stört das nicht, aber Sicherungsprogramme und Dateiüberwachungen schon. // Git selbst stört das nicht, aber Sicherungsprogramme und Dateiüberwachungen schon.
func schreibeDatei(pfad, inhalt string) error { func writeFile(filePath, content string) error {
if vorhanden, err := os.ReadFile(pfad); err == nil && string(vorhanden) == inhalt { if existing, err := os.ReadFile(filePath); err == nil && string(existing) == content {
return nil return nil
} }
if err := os.WriteFile(pfad, []byte(inhalt), 0640); err != nil { if err := os.WriteFile(filePath, []byte(content), 0640); err != nil {
return fmt.Errorf("%s konnte nicht geschrieben werden: %w", pfad, err) return fmt.Errorf("%s konnte nicht geschrieben werden: %w", filePath, err)
} }
return nil 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 // Es werden ausschließlich .md-Dateien angefasst und keine Unterordner — falls
// jemand eigene Notizen daneben legt, bleiben sie unberührt. // jemand eigene Notizen daneben legt, bleiben sie unberührt.
func raeumeAuf(ordner string, erwartet map[string]bool) (int, error) { func cleanup(dir string, want map[string]bool) (int, error) {
eintraege, err := os.ReadDir(ordner) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return 0, fmt.Errorf("Exportordner konnte nicht gelesen werden: %w", err) return 0, fmt.Errorf("Exportordner konnte nicht gelesen werden: %w", err)
} }
var entfernt int var removed int
for _, e := range eintraege { for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || erwartet[e.Name()] { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || want[e.Name()] {
continue continue
} }
if err := os.Remove(filepath.Join(ordner, e.Name())); err != nil { if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
return entfernt, fmt.Errorf("%s konnte nicht entfernt werden: %w", e.Name(), err) 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 // Der Kopf enthält alle Felder, damit sich der Bestand daraus auch wieder
// einlesen ließe — nicht nur Titel und Text. // einlesen ließe — nicht nur Titel und Text.
func rendere(e store.Entry) string { func render(e store.Entry) string {
var b strings.Builder var b strings.Builder
b.WriteString("---\n") b.WriteString("---\n")
schreibeFeld(&b, "slug", e.Slug) writeField(&b, "slug", e.Slug)
schreibeFeld(&b, "title", e.Title) writeField(&b, "title", e.Title)
if len(e.Tags) > 0 { if len(e.Tags) > 0 {
b.WriteString("tags: [") b.WriteString("tags: [")
for i, t := range e.Tags { for i, t := range e.Tags {
if i > 0 { if i > 0 {
b.WriteString(", ") b.WriteString(", ")
} }
b.WriteString(yamlWert(t)) b.WriteString(yamlValue(t))
} }
b.WriteString("]\n") b.WriteString("]\n")
} }
schreibeFeld(&b, "source", e.Source) writeField(&b, "source", e.Source)
if e.Author != "" { if e.Author != "" {
schreibeFeld(&b, "author", e.Author) writeField(&b, "author", e.Author)
} }
b.WriteString("curated: " + strconv.FormatBool(e.Curated) + "\n") b.WriteString("curated: " + strconv.FormatBool(e.Curated) + "\n")
b.WriteString("version: " + strconv.Itoa(e.Version) + "\n") b.WriteString("version: " + strconv.Itoa(e.Version) + "\n")
schreibeFeld(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339)) writeField(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339))
schreibeFeld(&b, "updated_at", e.UpdatedAt.UTC().Format(time.RFC3339)) writeField(&b, "updated_at", e.UpdatedAt.UTC().Format(time.RFC3339))
b.WriteString("---\n\n") b.WriteString("---\n\n")
b.WriteString("# " + e.Title + "\n\n") b.WriteString("# " + e.Title + "\n\n")
@ -342,17 +342,17 @@ func rendere(e store.Entry) string {
return b.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 // 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 // damit auch dann einen Git-Diff erzeugen, wenn sich inhaltlich nichts getan
// hat. Wann exportiert wurde, sagen der Git-Commit und die Änderungszeit der // hat. Wann exportiert wurde, sagen der Git-Commit und die Änderungszeit der
// Datei ohnehin — nur eben nur dann, wenn es etwas zu sagen gab. // 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 var b strings.Builder
b.WriteString("---\n") b.WriteString("---\n")
schreibeFeld(&b, "title", project.Name+" — Übersicht") writeField(&b, "title", project.Name+" — Übersicht")
b.WriteString("---\n\n") b.WriteString("---\n\n")
b.WriteString("# " + project.Name + "\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(project.Description + "\n\n")
} }
b.WriteString(fmt.Sprintf("%d Einträge. Automatisch erzeugt — Änderungen hier wirken nicht zurück.\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. // Nach erstem Schlagwort gruppieren; Einträge ohne Schlagwort ans Ende.
const ohne = "Ohne Schlagwort" const without = "Ohne Schlagwort"
gruppen := make(map[string][]store.Entry) groups := make(map[string][]store.Entry)
for _, e := range eintraege { for _, e := range entries {
kategorie := ohne category := without
if len(e.Tags) > 0 { 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)) categories := make([]string, 0, len(groups))
for k := range gruppen { for k := range groups {
kategorien = append(kategorien, k) categories = append(categories, k)
} }
sort.Slice(kategorien, func(i, j int) bool { sort.Slice(categories, func(i, j int) bool {
if kategorien[i] == ohne { if categories[i] == without {
return false return false
} }
if kategorien[j] == ohne { if categories[j] == without {
return true return true
} }
return kategorien[i] < kategorien[j] return categories[i] < categories[j]
}) })
for _, kategorie := range kategorien { for _, category := range categories {
b.WriteString("## " + kategorie + "\n\n") b.WriteString("## " + category + "\n\n")
liste := gruppen[kategorie] list := groups[category]
sort.Slice(liste, func(i, j int) bool { return liste[i].Title < liste[j].Title }) 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)) b.WriteString(fmt.Sprintf("- [%s](%s.md)", e.Title, e.Slug))
if e.Curated { if e.Curated {
b.WriteString(" ✓") b.WriteString(" ✓")
@ -409,19 +409,19 @@ func rendereIndex(project store.Project, eintraege []store.Entry) string {
return b.String() return b.String()
} }
// schreibeFeld hängt ein YAML-Feld an. // writeField hängt ein YAML-Feld an.
func schreibeFeld(b *strings.Builder, name, wert string) { func writeField(b *strings.Builder, name, value string) {
b.WriteString(name + ": " + yamlWert(wert) + "\n") 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 // Immer in doppelte Anführungszeichen und die enthaltenen maskieren: Damit ist
// egal, ob der Wert einen Doppelpunkt, ein Rautezeichen oder einen Zeilenumbruch // egal, ob der Wert einen Doppelpunkt, ein Rautezeichen oder einen Zeilenumbruch
// enthält. Ohne diese Vorsicht würde ein Titel wie "Fehler: Zugriff verweigert" // enthält. Ohne diese Vorsicht würde ein Titel wie "Fehler: Zugriff verweigert"
// die Datei unlesbar machen. // die Datei unlesbar machen.
func yamlWert(s string) string { func yamlValue(s string) string {
ersetzt := strings.NewReplacer( replaced := strings.NewReplacer(
`\`, `\\`, `\`, `\\`,
`"`, `\"`, `"`, `\"`,
"\n", `\n`, "\n", `\n`,
@ -429,5 +429,5 @@ func yamlWert(s string) string {
"\t", `\t`, "\t", `\t`,
).Replace(s) ).Replace(s)
return `"` + ersetzt + `"` return `"` + replaced + `"`
} }

View file

@ -10,8 +10,8 @@ import (
"munin/internal/store" "munin/internal/store"
) )
// aufbau legt Datenbank, Projekt und Exporter an. // setup legt Datenbank, Projekt und Exporter an.
func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) { func setup(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
t.Helper() t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) 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 // der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für
// den Test reicht eine beliebige ID. // den Test reicht eine beliebige ID.
workspaceID := store.NewID() 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 { if err != nil {
t.Fatal(err) 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 // gleich der Workspace-Ordner, damit die Tests Pfade wie
// ziel/plattform/eintrag.md prüfen können, ohne die ID zu kennen. // ziel/plattform/eintrag.md prüfen können, ohne die ID zu kennen.
root := t.TempDir() 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. // readFile gibt den Inhalt einer exportierten Datei zurück.
func lies(t *testing.T, teile ...string) string { func readFile(t *testing.T, parts ...string) string {
t.Helper() t.Helper()
inhalt, err := os.ReadFile(filepath.Join(teile...)) content, err := os.ReadFile(filepath.Join(parts...))
if err != nil { if err != nil {
t.Fatalf("Datei nicht lesbar: %v", err) t.Fatalf("Datei nicht lesbar: %v", err)
} }
return string(inhalt) return string(content)
} }
func TestExportWritesEntries(t *testing.T) { func TestExportWritesEntries(t *testing.T) {
e, db, projekt, ziel := aufbau(t) e, db, project, target := setup(t)
ctx := context.Background() ctx := context.Background()
if _, err := db.PutEntry(ctx, store.EntryWrite{ 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"}, Content: "Wir deployen über GitHub Actions.", Tags: []string{"ops", "deployment"},
Source: store.SourceAgent, Author: "claude", Curated: true, Source: store.SourceAgent, Author: "claude", Curated: true,
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
stats, err := e.ExportProject(ctx, db, projekt) stats, err := e.ExportProject(ctx, db, project)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@ -69,11 +69,11 @@ func TestExportWritesEntries(t *testing.T) {
t.Errorf("%d Dateien geschrieben, erwartet 2", stats.Written) 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 // Der Kopf muss alle Felder tragen, damit sich der Bestand daraus auch
// wieder einlesen ließe. // wieder einlesen ließe.
for _, erwartet := range []string{ for _, want := range []string{
`slug: "deployment"`, `slug: "deployment"`,
`title: "Deployment-Prozess"`, `title: "Deployment-Prozess"`,
`tags: ["deployment", "ops"]`, `tags: ["deployment", "ops"]`,
@ -84,12 +84,12 @@ func TestExportWritesEntries(t *testing.T) {
"created_at:", "created_at:",
"updated_at:", "updated_at:",
} { } {
if !strings.Contains(inhalt, erwartet) { if !strings.Contains(content, want) {
t.Errorf("%q fehlt im Dateikopf:\n%s", erwartet, inhalt) 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") 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 // Ein Titel wie "Fehler: Zugriff verweigert" enthält einen Doppelpunkt und würde
// ohne Anführungszeichen die YAML-Struktur zerstören. // ohne Anführungszeichen die YAML-Struktur zerstören.
func TestExportEscapesYAML(t *testing.T) { func TestExportEscapesYAML(t *testing.T) {
e, db, projekt, ziel := aufbau(t) e, db, project, target := setup(t)
ctx := context.Background() ctx := context.Background()
heikel := `Fehler: "Zugriff verweigert" — siehe #42` sensitive := `Fehler: "Zugriff verweigert" — siehe #42`
if _, err := db.PutEntry(ctx, store.EntryWrite{ 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, Content: "Text", Source: store.SourceHuman,
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := e.ExportProject(ctx, db, projekt); err != nil { if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err) 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. // Der Wert muss in Anführungszeichen stehen und die enthaltenen maskiert sein.
if !strings.Contains(inhalt, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) { if !strings.Contains(content, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) {
t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", inhalt) t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", content)
} }
// Der Kopf muss weiterhin genau zwei Trennlinien haben. // Der Kopf muss weiterhin genau zwei Trennlinien haben.
if anzahl := strings.Count(inhalt, "\n---\n"); anzahl != 1 { if count := strings.Count(content, "\n---\n"); count != 1 {
kopfEnde := strings.Index(inhalt[4:], "---") headEnd := strings.Index(content[4:], "---")
if kopfEnde == -1 { if headEnd == -1 {
t.Error("der Dateikopf wurde nicht sauber abgeschlossen") 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 // TestExportRemovesOrphans prüft, dass gelöschte Einträge auch aus dem Export
// verschwinden — sonst wäre die Sicherung nach kurzer Zeit voller Karteileichen. // verschwinden — sonst wäre die Sicherung nach kurzer Zeit voller Karteileichen.
func TestExportRemovesOrphans(t *testing.T) { func TestExportRemovesOrphans(t *testing.T) {
e, db, projekt, ziel := aufbau(t) e, db, project, target := setup(t)
ctx := context.Background() 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}) 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}) 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) 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") t.Fatal("die Datei wurde gar nicht erst angelegt")
} }
// Ein Eintrag im Papierkorb gehört nicht mehr in den Export. // 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) t.Fatal(err)
} }
stats, err := e.ExportProject(ctx, db, projekt) stats, err := e.ExportProject(ctx, db, project)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if stats.Removed != 1 { if stats.Removed != 1 {
t.Errorf("%d Dateien entfernt, erwartet 1", stats.Removed) 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") 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") 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 // TestExportLeavesForeignFilesAlone prüft, dass eigene Dateien im Zielordner
// nicht angetastet werden. // nicht angetastet werden.
func TestExportLeavesForeignFilesAlone(t *testing.T) { func TestExportLeavesForeignFilesAlone(t *testing.T) {
e, db, projekt, ziel := aufbau(t) e, db, project, target := setup(t)
ctx := context.Background() ctx := context.Background()
ordner := filepath.Join(ziel, "plattform") dir := filepath.Join(target, "plattform")
if err := os.MkdirAll(ordner, 0750); err != nil { if err := os.MkdirAll(dir, 0750); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Eine Nicht-Markdown-Datei und ein Unterordner müssen überleben. // Eine Nicht-Markdown-Datei und ein Unterordner müssen überleben.
os.WriteFile(filepath.Join(ordner, "notizen.txt"), []byte("privat"), 0640) os.WriteFile(filepath.Join(dir, "notizen.txt"), []byte("privat"), 0640)
os.MkdirAll(filepath.Join(ordner, "anhaenge"), 0750) 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) 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") 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") 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 // TestExportIsStable prüft, dass ein zweiter Lauf ohne Änderungen die Dateien
// nicht anfasst — sonst meldeten Sicherungswerkzeuge ständig Bewegung. // nicht anfasst — sonst meldeten Sicherungswerkzeuge ständig Bewegung.
func TestExportIsStable(t *testing.T) { func TestExportIsStable(t *testing.T) {
e, db, projekt, ziel := aufbau(t) e, db, project, target := setup(t)
ctx := context.Background() 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}) 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) t.Fatal(err)
} }
pfad := filepath.Join(ziel, "plattform", "eintrag.md") filePath := filepath.Join(target, "plattform", "eintrag.md")
vorher, err := os.Stat(pfad) before, err := os.Stat(filePath)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := e.ExportProject(ctx, db, projekt); err != nil { if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err) t.Fatal(err)
} }
nachher, err := os.Stat(pfad) after, err := os.Stat(filePath)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !vorher.ModTime().Equal(nachher.ModTime()) { if !before.ModTime().Equal(after.ModTime()) {
t.Error("die unveränderte Datei wurde neu geschrieben") 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 // Für eine Git-Sicherung ist das der entscheidende Punkt: Ein Lauf ohne
// Änderungen darf keinen Diff erzeugen. // Änderungen darf keinen Diff erzeugen.
func TestExportProducesNoDiffWhenUnchanged(t *testing.T) { func TestExportProducesNoDiffWhenUnchanged(t *testing.T) {
e, db, projekt, ziel := aufbau(t) e, db, project, target := setup(t)
ctx := context.Background() 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}) 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) t.Fatal(err)
} }
// Inhalt aller Dateien merken. // Inhalt aller Dateien merken.
ordner := filepath.Join(ziel, "plattform") dir := filepath.Join(target, "plattform")
vorher := make(map[string]string) before := make(map[string]string)
dateien, _ := os.ReadDir(ordner) files, _ := os.ReadDir(dir)
for _, d := range dateien { for _, d := range files {
vorher[d.Name()] = lies(t, ordner, d.Name()) 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) t.Fatal(err)
} }
for name, alt := range vorher { for name, old := range before {
if neu := lies(t, ordner, name); neu != alt { 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", 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. // TestExportIndex prüft die Übersicht.
func TestExportIndex(t *testing.T) { func TestExportIndex(t *testing.T) {
e, db, projekt, ziel := aufbau(t) e, db, project, target := setup(t)
ctx := context.Background() 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}) 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}) 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) t.Fatal(err)
} }
index := lies(t, ziel, "plattform", "_index.md") index := readFile(t, target, "plattform", "_index.md")
// Kein Zeitstempel im Kopf — siehe TestExportProducesNoDiffWhenUnchanged. // Kein Zeitstempel im Kopf — siehe TestExportProducesNoDiffWhenUnchanged.
if strings.Contains(index, "generated_at") { if strings.Contains(index, "generated_at") {
t.Error("die Übersicht trägt einen Zeitstempel und erzeugt damit bei jedem Lauf einen Diff") 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", "# Plattform-Team",
"Wissen des Teams", "Wissen des Teams",
"## ops", "## ops",
@ -291,8 +291,8 @@ func TestExportIndex(t *testing.T) {
"[Alpha](a.md)", "[Alpha](a.md)",
"[Beta](b.md)", "[Beta](b.md)",
} { } {
if !strings.Contains(index, erwartet) { if !strings.Contains(index, want) {
t.Errorf("%q fehlt in der Übersicht:\n%s", erwartet, index) 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 — // TestExportAllProjects prüft, dass jedes Projekt seinen eigenen Ordner bekommt —
// die Abschottung gilt auch im Export. // die Abschottung gilt auch im Export.
func TestExportAllProjects(t *testing.T) { func TestExportAllProjects(t *testing.T) {
e, db, projektA, ziel := aufbau(t) e, db, projectA, target := setup(t)
ctx := context.Background() ctx := context.Background()
// Beide Projekte liegen in derselben Workspace-Datei — dieselbe // Beide Projekte liegen in derselben Workspace-Datei — dieselbe
// workspace_id wie projektA reicht, eine eigene Workspace ist nicht nötig. // workspace_id wie projectA reicht, eine eigene Workspace ist nicht nötig.
projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "anderes", "Anderes Team", "") projectB, err := db.CreateProject(ctx, projectA.WorkspaceID, "anderes", "Anderes Team", "")
if err != nil { if err != nil {
t.Fatal(err) 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}) 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}) 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 { if _, err := e.ExportWorkspace(ctx, db, ws); err != nil {
t.Fatal(err) 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") 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") t.Error("Projekt B wurde nicht korrekt exportiert")
} }
// Kein Eintrag darf im Ordner des anderen Projekts liegen. // 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") t.Error("ein Eintrag aus Projekt B landete im Ordner von Projekt A")
} }
} }
func TestExportEmptyProject(t *testing.T) { 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 { if err != nil {
t.Fatalf("ein leeres Projekt führte zu einem Fehler: %v", err) 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 { if stats.Written != 1 {
t.Errorf("%d Dateien, erwartet 1", stats.Written) 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") 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 // Bezeichners dürfen sich im Export weder überschreiben noch gegenseitig
// Dateien wegräumen. Vor der Ebene je Workspace passierte genau das. // Dateien wegräumen. Vor der Ebene je Workspace passierte genau das.
func TestExportTrenntWorkspaces(t *testing.T) { func TestExportSeparatesWorkspaces(t *testing.T) {
ctx := context.Background() ctx := context.Background()
root := t.TempDir() root := t.TempDir()
e := New(root) e := New(root)
@ -362,7 +362,7 @@ func TestExportTrenntWorkspaces(t *testing.T) {
ws store.Workspace ws store.Workspace
db *store.Store db *store.Store
} }
var beide []workspace var both []workspace
for _, name := range []string{"Vertrieb", "Technik"} { for _, name := range []string{"Vertrieb", "Technik"} {
db, err := store.Open(filepath.Join(t.TempDir(), name+".db")) db, err := store.Open(filepath.Join(t.TempDir(), name+".db"))
if err != nil { if err != nil {
@ -371,33 +371,33 @@ func TestExportTrenntWorkspaces(t *testing.T) {
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
ws := store.Workspace{ID: store.NewID(), Name: name} 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 { if err != nil {
t.Fatal(err) 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 { Title: name, Content: "gehört zu " + name, Source: store.SourceHuman}); err != nil {
t.Fatal(err) 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 // Zweimal hintereinander: Beim zweiten Lauf würde das Aufräumen die
// Dateien des anderen Workspace löschen, wenn sie im selben Ordner lägen. // Dateien des anderen Workspace löschen, wenn sie im selben Ordner lägen.
for lauf := 0; lauf < 2; lauf++ { for run := 0; run < 2; run++ {
for _, w := range beide { for _, w := range both {
if _, err := e.ExportWorkspace(ctx, w.db, w.ws); err != nil { if _, err := e.ExportWorkspace(ctx, w.db, w.ws); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
} }
for _, w := range beide { for _, w := range both {
datei := "nur-" + strings.ToLower(w.ws.Name) + ".md" file := "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) { 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) 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) t.Errorf("_workspace.md nennt %s nicht", w.ws.Name)
} }
} }

View file

@ -10,20 +10,20 @@ import (
"munin/internal/store" "munin/internal/store"
) )
// Datei ist eine Datei des Exports, noch nicht geschrieben. // File ist eine Datei des Exports, noch nicht geschrieben.
type Datei struct { type File struct {
// Pfad ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter // Path ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter
// Windows — so verlangt es das ZIP-Format. // Windows — so verlangt es das ZIP-Format.
Pfad string Path string
Inhalt 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 // ihre letzte Änderung, damit ein entpacktes Archiv in jedem Dateimanager
// die richtige Reihenfolge zeigt. Leer bedeutet: Zeitpunkt des Downloads. // 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>/ // <projekt>/
// _index.md // _index.md
@ -31,71 +31,71 @@ type Datei struct {
// //
// Das ist genau das, was der Import eines Projekts erwartet (Unterordner // Das ist genau das, was der Import eines Projekts erwartet (Unterordner
// erlaubt, Dateien mit "_" am Anfang werden übergangen). // erlaubt, Dateien mit "_" am Anfang werden übergangen).
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) {
return projektDateien(ctx, s, project) 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: // im selben Aufbau wie ein Workspace-Ordner unter EXPORT_DIR:
// //
// _workspace.md // _workspace.md
// <projekt-a>/… // <projekt-a>/…
// <projekt-b>/… // <projekt-b>/…
func WorkspaceDateien(ctx context.Context, s *store.Store, ws store.Workspace) ([]Datei, error) { func WorkspaceFiles(ctx context.Context, s *store.Store, ws store.Workspace) ([]File, error) {
dateien := []Datei{{Pfad: workspaceDateiname, Inhalt: rendereWorkspace(ws)}} files := []File{{Path: workspaceFilename, Content: renderWorkspace(ws)}}
projekte, err := s.ListProjects(ctx, ws.ID) projects, err := s.ListProjects(ctx, ws.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, p := range projekte { for _, p := range projects {
projekt, err := projektDateien(ctx, s, p) project, err := projectFiles(ctx, s, p)
if err != nil { if err != nil {
return nil, fmt.Errorf("Projekt %q: %w", p.Slug, err) 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? // # Warum erst sammeln, dann packen?
// //
// Beim Download schreibt w direkt in die Antwort an den Browser. Ist der // 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 // 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 // als Fehlerseite melden — der Browser bekäme nur ein kaputtes ZIP. Deshalb
// liest der Aufrufer zuerst alles aus der Datenbank (ProjektDateien, // liest der Aufrufer zuerst alles aus der Datenbank (ProjectFiles,
// WorkspaceDateien). Was danach noch schiefgehen kann, ist nur noch die // WorkspaceFiles). Was danach noch schiefgehen kann, ist nur noch die
// Verbindung selbst. // Verbindung selbst.
func SchreibeZIP(w io.Writer, dateien []Datei) error { func WriteZIP(w io.Writer, files []File) error {
jetzt := time.Now() now := time.Now()
archiv := zip.NewWriter(w) archive := zip.NewWriter(w)
for _, d := range dateien { for _, d := range files {
geaendert := d.Geaendert changed := d.Modified
if geaendert.IsZero() { if changed.IsZero() {
geaendert = jetzt changed = now
} }
// CreateHeader statt Create, um Komprimierung und Zeitstempel // CreateHeader statt Create, um Komprimierung und Zeitstempel
// selbst festzulegen. Deflate ist das Verfahren, das jedes // selbst festzulegen. Deflate ist das Verfahren, das jedes
// Entpackprogramm kennt. // Entpackprogramm kennt.
f, err := archiv.CreateHeader(&zip.FileHeader{ f, err := archive.CreateHeader(&zip.FileHeader{
Name: d.Pfad, Name: d.Path,
Method: zip.Deflate, Method: zip.Deflate,
Modified: geaendert, Modified: changed,
}) })
if err != nil { if err != nil {
return err return err
} }
if _, err := io.WriteString(f, d.Inhalt); err != nil { if _, err := io.WriteString(f, d.Content); err != nil {
return err return err
} }
} }
// Close schreibt das Inhaltsverzeichnis ans Ende des Archivs. Ohne diesen // Close schreibt das Inhaltsverzeichnis ans Ende des Archivs. Ohne diesen
// Schritt ist das ZIP unbrauchbar, auch wenn alle Dateien darin stehen. // Schritt ist das ZIP unbrauchbar, auch wenn alle Dateien darin stehen.
return archiv.Close() return archive.Close()
} }

141
internal/help/help.go Normal file
View file

@ -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)
}

188
internal/help/help_test.go Normal file
View file

@ -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)
}
}
}
}

View file

@ -12,6 +12,11 @@
weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe
(`{{SERVER}}/hilfe`) reicht als Einstieg. (`{{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 Soll eine Abteilung ihre Projekte selbst anlegen, reicht es, ihr einen
[Workspace-Admin](#workspace-admins) einzurichten. Die Schritte 2 und 3 [Workspace-Admin](#workspace-admins) einzurichten. Die Schritte 2 und 3
übernimmt dann sie. übernimmt dann sie.

View file

@ -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)
}

View file

@ -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)
}
}
}
}

View file

@ -17,25 +17,25 @@ import (
// tatsächlich ausgepackt wird, nicht was das Archiv über sich behauptet: Die // tatsächlich ausgepackt wird, nicht was das Archiv über sich behauptet: Die
// Größenangaben im Inneren eines ZIP-Archivs kann jeder frei hineinschreiben. // Größenangaben im Inneren eines ZIP-Archivs kann jeder frei hineinschreiben.
const ( const (
// MaxDateien ist die Höchstzahl an Markdown-Dateien je Import. // MaxFiles ist die Höchstzahl an Markdown-Dateien je Import.
MaxDateien = 5000 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 // Ein Eintrag, der sich nicht im Browser anlegen ließe, soll auch über
// den Import nicht hineinkommen. // den Import nicht hineinkommen.
MaxDateiBytes = 1 << 20 // 1 MB MaxFileBytes = 1 << 20 // 1 MB
// MaxGesamtBytes begrenzt den ausgepackten Inhalt insgesamt. // MaxTotalBytes begrenzt den ausgepackten Inhalt insgesamt.
MaxGesamtBytes = 100 << 20 // 100 MB MaxTotalBytes = 100 << 20 // 100 MB
) )
// Datei ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet. // File ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet.
type Datei struct { type File struct {
Pfad string Path string
Inhalt []byte Content []byte
} }
// LeseZip liest alle Markdown-Dateien aus einem ZIP-Archiv. // ReadZip liest alle Markdown-Dateien aus einem ZIP-Archiv.
// //
// Übergangen werden: // Übergangen werden:
// - alles, was nicht auf .md oder .markdown endet (Bilder, PDFs, …) // - 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 // Unterordner sind erlaubt. So lässt sich ein ganzer Doku-Ordner auf einmal
// hochladen. Die Einträge landen trotzdem alle nebeneinander im Projekt, // hochladen. Die Einträge landen trotzdem alle nebeneinander im Projekt,
// denn Munin kennt keine Ordner. // denn Munin kennt keine Ordner.
func LeseZip(daten []byte) ([]Datei, error) { func ReadZip(data []byte) ([]File, error) {
archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten))) archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil { if err != nil {
return nil, errors.New("die Datei ist kein lesbares ZIP-Archiv") return nil, errors.New("die Datei ist kein lesbares ZIP-Archiv")
} }
var dateien []Datei var files []File
var gesamt int64 var total int64
for _, f := range archiv.File { for _, f := range archive.File {
if f.FileInfo().IsDir() || !istEintragsdatei(f.Name) { if f.FileInfo().IsDir() || !isEntryFile(f.Name) {
continue continue
} }
if len(dateien) >= MaxDateien { if len(files) >= MaxFiles {
return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxDateien) 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 { if err != nil {
return nil, fmt.Errorf("%s: %w", f.Name, err) return nil, fmt.Errorf("%s: %w", f.Name, err)
} }
gesamt += int64(len(inhalt)) total += int64(len(content))
if gesamt > MaxGesamtBytes { if total > MaxTotalBytes {
return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxGesamtBytes>>20) 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 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 // isEntryFile entscheidet anhand des Pfads, ob eine Datei ein Eintrag
// werden soll (siehe LeseZip). // werden soll (siehe ReadZip).
func istEintragsdatei(pfad string) bool { func isEntryFile(filePath string) bool {
for _, teil := range strings.Split(pfad, "/") { for _, part := range strings.Split(filePath, "/") {
if strings.HasPrefix(teil, ".") || teil == "__MACOSX" { if strings.HasPrefix(part, ".") || part == "__MACOSX" {
return false return false
} }
} }
name := path.Base(pfad) name := path.Base(filePath)
if strings.HasPrefix(name, "_") { if strings.HasPrefix(name, "_") {
return false return false
} }
endung := strings.ToLower(path.Ext(name)) suffix := strings.ToLower(path.Ext(name))
return endung == ".md" || endung == ".markdown" return suffix == ".md" || suffix == ".markdown"
} }
// leseBegrenzt packt eine Datei aus und bricht ab, sobald sie größer als // readLimited 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 // limit wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die
// Datei zu groß. // 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() r, err := f.Open()
if err != nil { if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err) return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
} }
defer r.Close() defer r.Close()
inhalt, err := io.ReadAll(io.LimitReader(r, grenze+1)) content, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil { if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err) return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
} }
if int64(len(inhalt)) > grenze { if int64(len(content)) > limit {
return nil, fmt.Errorf("größer als %d KB", grenze>>10) return nil, fmt.Errorf("größer als %d KB", limit>>10)
} }
return inhalt, nil return content, nil
} }

View file

@ -35,150 +35,150 @@ import (
"munin/internal/store" "munin/internal/store"
) )
// Modus legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist. // Mode legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist.
type Modus string type Mode string
const ( const (
ModusUeberspringen = Modus("skip") ModeSkip = Mode("skip")
ModusUeberschreiben = Modus("overwrite") ModeOverwrite = Mode("overwrite")
) )
// Aktion ist das Ergebnis für eine einzelne Datei. // Action ist das Ergebnis für eine einzelne Datei.
type Aktion string type Action string
const ( const (
AktionNeu = Aktion("neu") ActionCreated = Action("neu")
AktionUeberschrieben = Aktion("überschrieben") ActionOverwritten = Action("überschrieben")
AktionUnveraendert = Aktion("unverändert") ActionUnchanged = Action("unverändert")
AktionUebersprungen = Aktion("übersprungen") ActionSkipped = Action("übersprungen")
AktionFehler = Aktion("Fehler") ActionFailed = Action("Fehler")
) )
// Optionen steuern einen Import. // Options steuern einen Import.
type Optionen struct { type Options struct {
Modus Modus Mode Mode
// Vorschau: nur ausrechnen, was passieren würde, nichts schreiben. // Preview: nur ausrechnen, was passieren würde, nichts schreiben.
Vorschau bool 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 // vorhandener Dokumentation. Üblich ist der Name des importierenden
// Zugangs. // Zugangs.
Autor string Author string
} }
// Zeile ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt. // Row ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt.
type Zeile struct { type Row struct {
Pfad string Path string
Slug string Slug string
Title string Title string
Aktion Aktion Action Action
Grund string // Erläuterung, etwa bei Fehler oder Übersprungen Reason string // Erläuterung, etwa bei Fehler oder Übersprungen
} }
// Bericht fasst einen Import zusammen. // Report fasst einen Import zusammen.
type Bericht struct { type Report struct {
Zeilen []Zeile Rows []Row
Neu int Created int
Ueberschrieben int Overwritten int
Unveraendert int Unchanged int
Uebersprungen int Skipped int
Fehler 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. // 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. // Summary ist eine Zeile für Meldung und Protokoll.
func (b Bericht) Zusammenfassung() string { func (b Report) Summary() string {
return fmt.Sprintf("%d neu, %d überschrieben, %d unverändert, %d übersprungen, %d Fehler", 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 // Import liest die Dateien in das Projekt ein, oder rechnet bei
// opt.Vorschau nur aus, was passieren würde. // opt.Preview nur aus, was passieren würde.
// //
// Ein Fehler in einer einzelnen Datei bricht den Import nicht ab. Er steht im // 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 // 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 // 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. // Transaktion je Eintrag), aber der Import als Ganzes ist keine Transaktion.
// Ihn danach im Modus „Überspringen“ zu wiederholen, macht den Rest. // 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) { func Import(ctx context.Context, s *store.Store, projectID string, files []File, opt Options) (Report, error) {
var b Bericht var b Report
if opt.Modus != ModusUeberspringen && opt.Modus != ModusUeberschreiben { if opt.Mode != ModeSkip && opt.Mode != ModeOverwrite {
return b, fmt.Errorf("unbekannter Modus %q", opt.Modus) return b, fmt.Errorf("unbekannter Modus %q", opt.Mode)
} }
// Den Bestand einmal vorab laden statt je Datei nachzufragen: Bei einigen // Den Bestand einmal vorab laden statt je Datei nachzufragen: Bei einigen
// tausend Dateien sind das zwei Abfragen statt einiger tausend. // tausend Dateien sind das zwei Abfragen statt einiger tausend.
vorhanden, err := s.AllEntries(ctx, projectID) existing, err := s.AllEntries(ctx, projectID)
if err != nil { if err != nil {
return b, err return b, err
} }
bestand := make(map[string]store.Entry, len(vorhanden)) inventory := make(map[string]store.Entry, len(existing))
for _, e := range vorhanden { for _, e := range existing {
bestand[e.Slug] = e inventory[e.Slug] = e
} }
// Einträge im Papierkorb belegen ihren Bezeichner weiter (siehe // Einträge im Papierkorb belegen ihren Bezeichner weiter (siehe
// store.PutEntry). Sie müssen eigens erkannt werden: Sonst holte ein // store.PutEntry). Sie müssen eigens erkannt werden: Sonst holte ein
// Import im Modus „Überspringen“ gelöschte Einträge stillschweigend // Import im Modus „Überspringen“ gelöschte Einträge stillschweigend
// zurück, weil sie für AllEntries nicht existieren. // zurück, weil sie für AllEntries nicht existieren.
geloescht, err := s.ListTrash(ctx, projectID, "") deleted, err := s.ListTrash(ctx, projectID, "")
if err != nil { if err != nil {
return b, err return b, err
} }
imPapierkorb := make(map[string]bool, len(geloescht)) inTrash := make(map[string]bool, len(deleted))
for _, e := range geloescht { for _, e := range deleted {
imPapierkorb[e.Slug] = true inTrash[e.Slug] = true
} }
// Welche Datei hat welchen Bezeichner schon belegt? Zwei Dateien mit // Welche Datei hat welchen Bezeichner schon belegt? Zwei Dateien mit
// demselben Bezeichner in einem Archiv (etwa "ops/deploy.md" und // demselben Bezeichner in einem Archiv (etwa "ops/deploy.md" und
// "alt/deploy.md") würden sich sonst gegenseitig überschreiben. // "alt/deploy.md") würden sich sonst gegenseitig überschreiben.
belegtDurch := make(map[string]string) usedBy := make(map[string]string)
for _, d := range dateien { for _, d := range files {
e, err := Parse(d.Pfad, d.Inhalt) e, err := Parse(d.Path, d.Content)
zeile := Zeile{Pfad: d.Pfad, Slug: e.Slug, Title: e.Title} line := Row{Path: d.Path, Slug: e.Slug, Title: e.Title}
switch { switch {
case err != nil: case err != nil:
zeile.Aktion, zeile.Grund = AktionFehler, err.Error() line.Action, line.Reason = ActionFailed, err.Error()
case belegtDurch[e.Slug] != "": case usedBy[e.Slug] != "":
zeile.Aktion = AktionFehler line.Action = ActionFailed
zeile.Grund = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, belegtDurch[e.Slug]) line.Reason = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, usedBy[e.Slug])
default: default:
belegtDurch[e.Slug] = d.Pfad usedBy[e.Slug] = d.Path
alt, gibtEs := bestand[e.Slug] old, exists := inventory[e.Slug]
switch { switch {
case gibtEs && gleicherInhalt(alt, e): case exists && sameContent(old, e):
zeile.Aktion = AktionUnveraendert line.Action = ActionUnchanged
case (gibtEs || imPapierkorb[e.Slug]) && opt.Modus == ModusUeberspringen: case (exists || inTrash[e.Slug]) && opt.Mode == ModeSkip:
zeile.Aktion = AktionUebersprungen line.Action = ActionSkipped
zeile.Grund = "Bezeichner schon vorhanden" line.Reason = "Bezeichner schon vorhanden"
if imPapierkorb[e.Slug] { if inTrash[e.Slug] {
zeile.Grund = "Bezeichner gehört einem Eintrag im Papierkorb" line.Reason = "Bezeichner gehört einem Eintrag im Papierkorb"
} }
case gibtEs || imPapierkorb[e.Slug]: case exists || inTrash[e.Slug]:
zeile.Aktion = AktionUeberschrieben line.Action = ActionOverwritten
zeile.Grund = "bisheriger Stand steht im Verlauf" line.Reason = "bisheriger Stand steht im Verlauf"
if imPapierkorb[e.Slug] { if inTrash[e.Slug] {
zeile.Grund = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf" line.Reason = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf"
} }
default: default:
zeile.Aktion = AktionNeu line.Action = ActionCreated
} }
if !opt.Vorschau && (zeile.Aktion == AktionNeu || zeile.Aktion == AktionUeberschrieben) { if !opt.Preview && (line.Action == ActionCreated || line.Action == ActionOverwritten) {
geschrieben, err := s.PutEntry(ctx, schreibauftrag(projectID, e, opt.Autor, zeile.Aktion == AktionNeu)) written, err := s.PutEntry(ctx, writeRequest(projectID, e, opt.Author, line.Action == ActionCreated))
if err != nil { if err != nil {
// Ungültige Angaben (etwa ein zu langer Titel) meldet // Ungültige Angaben (etwa ein zu langer Titel) meldet
// PutEntry vor jedem Schreibzugriff. Sie betreffen nur // 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 { if ctx.Err() != nil {
return b, ctx.Err() return b, ctx.Err()
} }
zeile.Aktion, zeile.Grund = AktionFehler, err.Error() line.Action, line.Reason = ActionFailed, err.Error()
} else { } else {
b.Geschrieben = append(b.Geschrieben, geschrieben) b.Written = append(b.Written, written)
} }
} }
} }
b.Zeilen = append(b.Zeilen, zeile) b.Rows = append(b.Rows, line)
switch zeile.Aktion { switch line.Action {
case AktionNeu: case ActionCreated:
b.Neu++ b.Created++
case AktionUeberschrieben: case ActionOverwritten:
b.Ueberschrieben++ b.Overwritten++
case AktionUnveraendert: case ActionUnchanged:
b.Unveraendert++ b.Unchanged++
case AktionUebersprungen: case ActionSkipped:
b.Uebersprungen++ b.Skipped++
case AktionFehler: case ActionFailed:
b.Fehler++ b.Failed++
} }
} }
return b, nil 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 // Der Autor hängt davon ab, was passiert. Beim Anlegen bleibt der aus der
// Datei, denn eine Wiederherstellung soll zeigen, wer den Eintrag // Datei, denn eine Wiederherstellung soll zeigen, wer den Eintrag
// ursprünglich geschrieben hat. Beim Überschreiben steht der importierende // ursprünglich geschrieben hat. Beim Überschreiben steht der importierende
// Zugang da: Diese Fassung hat er erzeugt, und so steht es auch im Verlauf. // 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{ w := store.EntryWrite{
ProjectID: projectID, ProjectID: projectID,
Slug: e.Slug, Slug: e.Slug,
@ -236,19 +236,19 @@ func schreibauftrag(projectID string, e Eintrag, autor string, neu bool) store.E
if w.Source == "" { if w.Source == "" {
w.Source = store.SourceHuman w.Source = store.SourceHuman
} }
if w.Author == "" || !neu { if w.Author == "" || !isNew {
w.Author = autor w.Author = author
} }
return w 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, // würde. Verglichen wird, was ein Mensch als Inhalt ansieht: Titel, Text,
// Schlagworte, Prüfvermerk. Autor und Zeitstempel zählen nicht. Sonst wäre // Schlagworte, Prüfvermerk. Autor und Zeitstempel zählen nicht. Sonst wäre
// nach einem Export und Re-Import nie etwas „unverändert“. // nach einem Export und Re-Import nie etwas „unverändert“.
func gleicherInhalt(alt store.Entry, neu Eintrag) bool { func sameContent(old store.Entry, newer Entry) bool {
return alt.Title == neu.Title && return old.Title == newer.Title &&
alt.Content == neu.Content && old.Content == newer.Content &&
alt.Curated == neu.Curated && old.Curated == newer.Curated &&
slices.Equal(alt.Tags, store.NormalizeTags(neu.Tags)) slices.Equal(old.Tags, store.NormalizeTags(newer.Tags))
} }

View file

@ -16,7 +16,7 @@ import (
) )
// testStore öffnet eine frische Datenbank mit einem Projekt. // 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() t.Helper()
s, err := store.Open(filepath.Join(t.TempDir(), "test.db")) s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil { if err != nil {
@ -24,48 +24,48 @@ func testStore(t *testing.T, projektSlug string) (*store.Store, store.Project) {
} }
t.Cleanup(func() { s.Close() }) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return s, p return s, p
} }
// packe baut ein ZIP-Archiv aus Pfad → Inhalt. // pack baut ein ZIP-Archiv aus Pfad → Inhalt.
func packe(t *testing.T, dateien map[string]string) []byte { func pack(t *testing.T, files map[string]string) []byte {
t.Helper() t.Helper()
var puffer bytes.Buffer var buf bytes.Buffer
w := zip.NewWriter(&puffer) w := zip.NewWriter(&buf)
for pfad, inhalt := range dateien { for filePath, content := range files {
f, err := w.Create(pfad) f, err := w.Create(filePath)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
f.Write([]byte(inhalt)) f.Write([]byte(content))
} }
if err := w.Close(); err != nil { if err := w.Close(); err != nil {
t.Fatal(err) 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. // Exportordner eines Projekts zippen würde.
func packeOrdner(t *testing.T, ordner string) []byte { func packDir(t *testing.T, dir string) []byte {
t.Helper() t.Helper()
dateien := map[string]string{} files := map[string]string{}
eintraege, err := os.ReadDir(ordner) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, e := range eintraege { for _, e := range entries {
inhalt, err := os.ReadFile(filepath.Join(ordner, e.Name())) content, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil { if err != nil {
t.Fatal(err) 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, // 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. // Autor, Prüfvermerk und Änderungsdatum.
func TestRoundTripFromExport(t *testing.T) { func TestRoundTripFromExport(t *testing.T) {
ctx := context.Background() 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{ original := []store.EntryWrite{
{Slug: "deployment", Title: "Deployment: so geht's", Content: "Schritt 1\n\n# Keine Titelzeile\n\nsiehe [[rollback]]", {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, 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", {Slug: "rollback", Title: `Rollback mit "Anführungszeichen"`, Content: "Zurück.\nZweite Zeile",
Source: store.SourceHuman, Author: "anna"}, Source: store.SourceHuman, Author: "anna"},
} }
for _, w := range original { for _, w := range original {
w.ProjectID = qp.ID w.ProjectID = qp.ID
if _, err := quelle.PutEntry(ctx, w); err != nil { if _, err := source.PutEntry(ctx, w); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
exportOrdner := t.TempDir() exportDir := t.TempDir()
qp.WorkspaceID = "ws-test" 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) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(dateien) != 2 { if len(files) != 2 {
t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(dateien)) t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(files))
} }
ziel, zp := testStore(t, "ziel") target, zp := testStore(t, "ziel")
bericht, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "importeur"}) report, err := Import(ctx, target, zp.ID, files, Options{Mode: ModeSkip, Author: "importeur"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if bericht.Neu != 2 || bericht.Fehler != 0 { if report.Created != 2 || report.Failed != 0 {
t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen) t.Fatalf("Bericht: %s — %+v", report.Summary(), report.Rows)
} }
for _, w := range original { for _, w := range original {
a, _ := quelle.EntryBySlug(ctx, qp.ID, w.Slug) a, _ := source.EntryBySlug(ctx, qp.ID, w.Slug)
b, err := ziel.EntryBySlug(ctx, zp.ID, w.Slug) b, err := target.EntryBySlug(ctx, zp.ID, w.Slug)
if err != nil { if err != nil {
t.Fatalf("%s fehlt nach dem Import: %v", w.Slug, err) 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. // Die Verweise sind wieder da, rollback wird von deployment verlinkt.
rueck, _ := ziel.Backlinks(ctx, zp.ID, "rollback") back, _ := target.Backlinks(ctx, zp.ID, "rollback")
if len(rueck) != 1 || rueck[0].Slug != "deployment" { if len(back) != 1 || back[0].Slug != "deployment" {
t.Errorf("Rückverweise nach dem Import: %+v", rueck) t.Errorf("Rückverweise nach dem Import: %+v", back)
} }
// Ein zweiter Lauf ändert nichts und erzeugt keine neuen Fassungen. // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if nochmal.Unveraendert != 2 { if again.Unchanged != 2 {
t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", nochmal.Zusammenfassung()) 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. // muss ohne Umweg wieder in den Import passen — genau dafür ist es da.
func TestRoundTripFromDownload(t *testing.T) { func TestRoundTripFromDownload(t *testing.T) {
ctx := context.Background() ctx := context.Background()
quelle, qp := testStore(t, "quelle") source, qp := testStore(t, "quelle")
for _, w := range []store.EntryWrite{ for _, w := range []store.EntryWrite{
{Slug: "deployment", Title: "Deployment", Content: "siehe [[rollback]]", Tags: []string{"ops"}, Source: store.SourceAgent, Author: "agent-1"}, {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"}, {Slug: "rollback", Title: "Rollback", Content: "Zurück.", Source: store.SourceHuman, Author: "anna"},
} { } {
w.ProjectID = qp.ID w.ProjectID = qp.ID
if _, err := quelle.PutEntry(ctx, w); err != nil { if _, err := source.PutEntry(ctx, w); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
dateien, err := export.ProjektDateien(ctx, quelle, qp) files, err := export.ProjectFiles(ctx, source, qp)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var zip bytes.Buffer var zip bytes.Buffer
if err := export.SchreibeZIP(&zip, dateien); err != nil { if err := export.WriteZIP(&zip, files); err != nil {
t.Fatal(err) t.Fatal(err)
} }
gelesen, err := LeseZip(zip.Bytes()) read, err := ReadZip(zip.Bytes())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
ziel, zp := testStore(t, "ziel") target, zp := testStore(t, "ziel")
bericht, err := Importiere(ctx, ziel, zp.ID, gelesen, Optionen{Modus: ModusUeberspringen, Autor: "importeur"}) report, err := Import(ctx, target, zp.ID, read, Options{Mode: ModeSkip, Author: "importeur"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if bericht.Neu != 2 || bericht.Fehler != 0 { if report.Created != 2 || report.Failed != 0 {
t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen) t.Fatalf("Bericht: %s — %+v", report.Summary(), report.Rows)
} }
for _, slug := range []string{"deployment", "rollback"} { for _, slug := range []string{"deployment", "rollback"} {
a, _ := quelle.EntryBySlug(ctx, qp.ID, slug) a, _ := source.EntryBySlug(ctx, qp.ID, slug)
b, err := ziel.EntryBySlug(ctx, zp.ID, slug) b, err := target.EntryBySlug(ctx, zp.ID, slug)
if err != nil { if err != nil {
t.Fatalf("%s fehlt nach dem Import: %v", slug, err) t.Fatalf("%s fehlt nach dem Import: %v", slug, err)
} }
@ -205,36 +205,36 @@ func TestConflictModes(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
dateien := []Datei{ files := []File{
{Pfad: "vorhanden.md", Inhalt: []byte("# Vorhanden\n\nneu")}, {Path: "vorhanden.md", Content: []byte("# Vorhanden\n\nneu")},
{Pfad: "frisch.md", Inhalt: []byte("# Frisch\n\nText")}, {Path: "frisch.md", Content: []byte("# Frisch\n\nText")},
} }
// Vorschau schreibt nichts. // Preview schreibt nichts.
vorschau, err := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Vorschau: true, Autor: "imp"}) preview, err := Import(ctx, s, p.ID, files, Options{Mode: ModeOverwrite, Preview: true, Author: "imp"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if vorschau.Neu != 1 || vorschau.Ueberschrieben != 1 || len(vorschau.Geschrieben) != 0 { if preview.Created != 1 || preview.Overwritten != 1 || len(preview.Written) != 0 {
t.Errorf("Vorschau: %s", vorschau.Zusammenfassung()) t.Errorf("Vorschau: %s", preview.Summary())
} }
if _, err := s.EntryBySlug(ctx, p.ID, "frisch"); err == nil { if _, err := s.EntryBySlug(ctx, p.ID, "frisch"); err == nil {
t.Error("die Vorschau hat geschrieben") t.Error("die Vorschau hat geschrieben")
} }
// Überspringen lässt Vorhandenes in Ruhe. // Überspringen lässt Vorhandenes in Ruhe.
ueber, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "imp"}) over, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip, Author: "imp"})
if ueber.Neu != 1 || ueber.Uebersprungen != 1 { if over.Created != 1 || over.Skipped != 1 {
t.Errorf("Überspringen: %s", ueber.Zusammenfassung()) t.Errorf("Überspringen: %s", over.Summary())
} }
if e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden"); e.Content != "alt" { if e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden"); e.Content != "alt" {
t.Error("Überspringen hat überschrieben") t.Error("Überspringen hat überschrieben")
} }
// Überschreiben legt eine neue Fassung an, die alte steht im Verlauf. // Überschreiben legt eine neue Fassung an, die alte steht im Verlauf.
neu, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "imp"}) newer, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeOverwrite, Author: "imp"})
if neu.Ueberschrieben != 1 || neu.Unveraendert != 1 { if newer.Overwritten != 1 || newer.Unchanged != 1 {
t.Errorf("Überschreiben: %s", neu.Zusammenfassung()) t.Errorf("Überschreiben: %s", newer.Summary())
} }
e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden") e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden")
if e.Content != "neu" || e.Version != 2 || e.Author != "imp" { if e.Content != "neu" || e.Version != 2 || e.Author != "imp" {
@ -253,10 +253,10 @@ func TestTrashIsNotRevivedWhenSkipping(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
dateien := []Datei{{Pfad: "weg.md", Inhalt: []byte("# Weg\n\nwieder da")}} files := []File{{Path: "weg.md", Content: []byte("# Weg\n\nwieder da")}}
b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen}) b, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip})
if b.Uebersprungen != 1 || !strings.Contains(b.Zeilen[0].Grund, "Papierkorb") { if b.Skipped != 1 || !strings.Contains(b.Rows[0].Reason, "Papierkorb") {
t.Errorf("Bericht: %s, Grund %q", b.Zusammenfassung(), b.Zeilen[0].Grund) t.Errorf("Bericht: %s, Grund %q", b.Summary(), b.Rows[0].Reason)
} }
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); err == nil { if _, err := s.EntryBySlug(ctx, p.ID, "weg"); err == nil {
t.Error("der gelöschte Eintrag wurde zurückgeholt") t.Error("der gelöschte Eintrag wurde zurückgeholt")
@ -267,13 +267,13 @@ func TestDuplicateSlugInArchive(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s, p := testStore(t, "test") s, p := testStore(t, "test")
dateien := []Datei{ files := []File{
{Pfad: "ops/deploy.md", Inhalt: []byte("eins")}, {Path: "ops/deploy.md", Content: []byte("eins")},
{Pfad: "alt/deploy.md", Inhalt: []byte("zwei")}, {Path: "alt/deploy.md", Content: []byte("zwei")},
} }
b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen}) b, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip})
if b.Neu != 1 || b.Fehler != 1 || !strings.Contains(b.Zeilen[1].Grund, "ops/deploy.md") { if b.Created != 1 || b.Failed != 1 || !strings.Contains(b.Rows[1].Reason, "ops/deploy.md") {
t.Errorf("Bericht: %s — %+v", b.Zusammenfassung(), b.Zeilen) t.Errorf("Bericht: %s — %+v", b.Summary(), b.Rows)
} }
} }
@ -286,17 +286,17 @@ func TestParsePlainMarkdown(t *testing.T) {
t.Errorf("gelesen: %+v", e) t.Errorf("gelesen: %+v", e)
} }
ohneUeberschrift, err := Parse("notiz.md", []byte("Nur Text")) noHeading, err := Parse("notiz.md", []byte("Nur Text"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if ohneUeberschrift.Title != "notiz" { if noHeading.Title != "notiz" {
t.Errorf("Titel ohne Überschrift: %q", ohneUeberschrift.Title) t.Errorf("Titel ohne Überschrift: %q", noHeading.Title)
} }
} }
func TestParseForeignFrontmatter(t *testing.T) { func TestParseForeignFrontmatter(t *testing.T) {
datei := `--- file := `---
title: 'It''s a title' title: 'It''s a title'
date: 2024-01-01 date: 2024-01-01
tags: tags:
@ -306,7 +306,7 @@ draft: false
--- ---
Inhalt` Inhalt`
e, err := Parse("post.md", []byte(datei)) e, err := Parse("post.md", []byte(file))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -326,44 +326,44 @@ func TestParseInlineListWithCommaInQuotes(t *testing.T) {
} }
func TestParseRejects(t *testing.T) { func TestParseRejects(t *testing.T) {
faelle := map[string]string{ cases := map[string]string{
"offener Kopf": "---\ntitle: x\nText ohne Ende", "offener Kopf": "---\ntitle: x\nText ohne Ende",
"leer": "---\ntitle: x\n---\n\n", "leer": "---\ntitle: x\n---\n\n",
"ungültiger slug": "---\nslug: \"Mit Leerzeichen\"\n---\nText", "ungültiger slug": "---\nslug: \"Mit Leerzeichen\"\n---\nText",
"nur eine Überschrift": "# Titel\n", "nur eine Überschrift": "# Titel\n",
} }
for name, inhalt := range faelle { for name, content := range cases {
if _, err := Parse("x.md", []byte(inhalt)); err == nil { if _, err := Parse("x.md", []byte(content)); err == nil {
t.Errorf("%s: kein Fehler", name) t.Errorf("%s: kein Fehler", name)
} }
} }
} }
func TestLeseZipSkipsAndLimits(t *testing.T) { func TestReadZipSkipsAndLimits(t *testing.T) {
archiv := packe(t, map[string]string{ archive := pack(t, map[string]string{
"projekt/_index.md": "Übersicht", "projekt/_index.md": "Übersicht",
"projekt/eintrag.md": "Text", "projekt/eintrag.md": "Text",
"projekt/bild.png": "PNG", "projekt/bild.png": "PNG",
"__MACOSX/projekt/._eintrag.md": "Müll", "__MACOSX/projekt/._eintrag.md": "Müll",
"projekt/.obsidian/workspace.md": "Einstellungen", "projekt/.obsidian/workspace.md": "Einstellungen",
}) })
dateien, err := LeseZip(archiv) files, err := ReadZip(archive)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(dateien) != 1 || dateien[0].Pfad != "projekt/eintrag.md" { if len(files) != 1 || files[0].Path != "projekt/eintrag.md" {
t.Errorf("gelesen: %+v", dateien) t.Errorf("gelesen: %+v", files)
} }
zuGross := packe(t, map[string]string{"gross.md": strings.Repeat("x", MaxDateiBytes+1)}) tooLarge := pack(t, map[string]string{"gross.md": strings.Repeat("x", MaxFileBytes+1)})
if _, err := LeseZip(zuGross); err == nil { if _, err := ReadZip(tooLarge); err == nil {
t.Error("eine zu große Datei wurde angenommen") 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") 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") t.Error("ein Archiv ohne Markdown wurde angenommen")
} }
} }

View file

@ -10,9 +10,9 @@ import (
"munin/internal/store" "munin/internal/store"
) )
// Eintrag ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben. // Entry ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben.
type Eintrag struct { type Entry struct {
Pfad string // Pfad innerhalb des Archivs, für die Anzeige Path string // Pfad innerhalb des Archivs, für die Anzeige
Slug string Slug string
Title string Title string
Content string Content string
@ -42,44 +42,44 @@ type Eintrag struct {
// //
// Felder, die Munin nicht kennt, werden ignoriert. So scheitert der Import // Felder, die Munin nicht kennt, werden ignoriert. So scheitert der Import
// nicht an einem Kopf aus Hugo, Obsidian oder Jekyll. // nicht an einem Kopf aus Hugo, Obsidian oder Jekyll.
func Parse(pfad string, inhalt []byte) (Eintrag, error) { func Parse(filePath string, content []byte) (Entry, error) {
e := Eintrag{Pfad: pfad} e := Entry{Path: filePath}
// Windows-Zeilenenden und ein Byte-Order-Mark am Anfang würden sonst den // Windows-Zeilenenden und ein Byte-Order-Mark am Anfang würden sonst den
// Kopf unkenntlich machen. Beides kommt bei Dateien aus Editoren unter // Kopf unkenntlich machen. Beides kommt bei Dateien aus Editoren unter
// Windows häufig vor. // 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") text = strings.TrimPrefix(text, "\uFEFF")
kopf, rumpf, err := trenneKopf(text) head, body, err := splitFrontmatter(text)
if err != nil { if err != nil {
return e, err return e, err
} }
for name, wert := range kopf { for name, value := range head {
switch name { switch name {
case "slug": case "slug":
e.Slug = wert.text e.Slug = value.text
case "title": case "title":
e.Title = wert.text e.Title = value.text
case "tags": case "tags":
e.Tags = wert.liste e.Tags = value.list
if len(e.Tags) == 0 && wert.text != "" { if len(e.Tags) == 0 && value.text != "" {
// "tags: ops" ohne Klammern: ein einzelnes Schlagwort. // "tags: ops" ohne Klammern: ein einzelnes Schlagwort.
e.Tags = []string{wert.text} e.Tags = []string{value.text}
} }
case "source": case "source":
if wert.text == store.SourceHuman || wert.text == store.SourceAgent { if value.text == store.SourceHuman || value.text == store.SourceAgent {
e.Source = wert.text e.Source = value.text
} }
case "author": case "author":
e.Author = wert.text e.Author = value.text
case "curated": case "curated":
e.Curated = wert.text == "true" e.Curated = value.text == "true"
case "created_at": case "created_at":
e.CreatedAt = leseZeit(wert.text) e.CreatedAt = parseTimestamp(value.text)
case "updated_at": 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 // wieder weg, sonst stünde sie nach jedem Export und Import einmal mehr
// da. Eine Überschrift, die NICHT dem Titel entspricht, bleibt stehen: // da. Eine Überschrift, die NICHT dem Titel entspricht, bleibt stehen:
// Dann ist sie Teil des Textes. // Dann ist sie Teil des Textes.
rumpf = strings.TrimLeft(rumpf, "\n") body = strings.TrimLeft(body, "\n")
if ueberschrift, rest, ok := ersteUeberschrift(rumpf); ok { if heading, rest, ok := firstHeading(body); ok {
switch { switch {
case e.Title == "": case e.Title == "":
e.Title = ueberschrift e.Title = heading
rumpf = rest body = rest
case ueberschrift == e.Title: case heading == e.Title:
rumpf = rest 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 == "" { if e.Title == "" {
e.Title = name e.Title = name
} }
@ -117,20 +117,20 @@ func Parse(pfad string, inhalt []byte) (Eintrag, error) {
return e, nil 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. // ersten Zeile und gibt sie samt dem Rest des Textes zurück.
func ersteUeberschrift(text string) (ueberschrift, rest string, ok bool) { func firstHeading(text string) (heading, rest string, ok bool) {
zeile, rest, _ := strings.Cut(text, "\n") line, rest, _ := strings.Cut(text, "\n")
if !strings.HasPrefix(zeile, "# ") { if !strings.HasPrefix(line, "# ") {
return "", text, false 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. // Datum. Alles andere ergibt den Nullzeitpunkt, also „jetzt“ beim Anlegen.
// Ein unlesbares Datum ist kein Grund, eine ganze Datei abzulehnen. // 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"} { for _, format := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(format, s); err == nil { if t, err := time.Parse(format, s); err == nil {
return t return t
@ -141,14 +141,14 @@ func leseZeit(s string) time.Time {
// ── Kopf (Frontmatter) ─────────────────────────────────────────────────────── // ── 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. // Liste.
type kopfWert struct { type headValue struct {
text string text string
liste []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. // Ohne Kopf ist die ganze Datei der Rumpf.
// //
// # Warum kein YAML-Paket? // # Warum kein YAML-Paket?
@ -168,59 +168,59 @@ type kopfWert struct {
// tags: // tags:
// - a // - a
// - b // - b
func trenneKopf(text string) (map[string]kopfWert, string, error) { func splitFrontmatter(text string) (map[string]headValue, string, error) {
if !strings.HasPrefix(text, "---\n") { if !strings.HasPrefix(text, "---\n") {
return nil, text, nil return nil, text, nil
} }
zeilen := strings.Split(text, "\n") lines := strings.Split(text, "\n")
kopf := make(map[string]kopfWert) head := make(map[string]headValue)
var offeneListe string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt var openList string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt
for i := 1; i < len(zeilen); i++ { for i := 1; i < len(lines); i++ {
zeile := zeilen[i] line := lines[i]
if strings.TrimSpace(zeile) == "---" { if strings.TrimSpace(line) == "---" {
return kopf, strings.Join(zeilen[i+1:], "\n"), nil return head, strings.Join(lines[i+1:], "\n"), nil
} }
// Listenpunkt einer Blockliste, etwa " - ops" unter "tags:". // Listenpunkt einer Blockliste, etwa " - ops" unter "tags:".
if eingerueckt := strings.TrimSpace(zeile); offeneListe != "" && strings.HasPrefix(eingerueckt, "- ") { if indented := strings.TrimSpace(line); openList != "" && strings.HasPrefix(indented, "- ") {
w := kopf[offeneListe] w := head[openList]
w.liste = append(w.liste, einzelwert(strings.TrimSpace(eingerueckt[2:]))) w.list = append(w.list, singleValue(strings.TrimSpace(indented[2:])))
kopf[offeneListe] = w head[openList] = w
continue continue
} }
offeneListe = "" openList = ""
if strings.TrimSpace(zeile) == "" || strings.HasPrefix(strings.TrimSpace(zeile), "#") { if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue continue
} }
name, wert, ok := strings.Cut(zeile, ":") name, value, ok := strings.Cut(line, ":")
if !ok { if !ok {
continue // keine Zuweisung, etwas, das dieser kleine Leser nicht kennt continue // keine Zuweisung, etwas, das dieser kleine Leser nicht kennt
} }
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
wert = strings.TrimSpace(wert) value = strings.TrimSpace(value)
switch { switch {
case wert == "": case value == "":
offeneListe = name openList = name
kopf[name] = kopfWert{} head[name] = headValue{}
case strings.HasPrefix(wert, "["): case strings.HasPrefix(value, "["):
kopf[name] = kopfWert{liste: inlineListe(wert)} head[name] = headValue{list: inlineList(value)}
default: 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") 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). // der Export schreibt (siehe export.yamlWert).
func einzelwert(s string) string { func singleValue(s string) string {
switch { switch {
case len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"': case len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"':
return strings.NewReplacer(`\\`, `\`, `\"`, `"`, `\n`, "\n", `\t`, "\t").Replace(s[1 : 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 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. // von Anführungszeichen trennen nicht.
func inlineListe(s string) []string { func inlineList(s string) []string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[") s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]") s = strings.TrimSuffix(s, "]")
var teile []string var parts []string
var aktuell strings.Builder var current strings.Builder
var inZeichen byte // 0 = außerhalb, sonst das öffnende Anführungszeichen var inChars byte // 0 = außerhalb, sonst das öffnende Anführungszeichen
fertig := func() { done := func() {
if t := strings.TrimSpace(aktuell.String()); t != "" { if t := strings.TrimSpace(current.String()); t != "" {
teile = append(teile, einzelwert(t)) parts = append(parts, singleValue(t))
} }
aktuell.Reset() current.Reset()
} }
for i := 0; i < len(s); i++ { for i := 0; i < len(s); i++ {
c := s[i] c := s[i]
switch { switch {
case inZeichen == '"' && c == '\\' && i+1 < len(s): case inChars == '"' && c == '\\' && i+1 < len(s):
// Maskiertes Zeichen: beide Bytes übernehmen, einzelwert löst es auf. // Maskiertes Zeichen: beide Bytes übernehmen, singleValue löst es auf.
aktuell.WriteByte(c) current.WriteByte(c)
i++ i++
aktuell.WriteByte(s[i]) current.WriteByte(s[i])
case inZeichen != 0 && c == inZeichen: case inChars != 0 && c == inChars:
inZeichen = 0 inChars = 0
aktuell.WriteByte(c) current.WriteByte(c)
case inZeichen == 0 && (c == '"' || c == '\''): case inChars == 0 && (c == '"' || c == '\''):
inZeichen = c inChars = c
aktuell.WriteByte(c) current.WriteByte(c)
case inZeichen == 0 && c == ',': case inChars == 0 && c == ',':
fertig() done()
default: default:
aktuell.WriteByte(c) current.WriteByte(c)
} }
} }
fertig() done()
return teile return parts
} }

View file

@ -10,7 +10,7 @@ import (
"munin/internal/store" "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 // wird als ZIP exportiert (derselbe Weg wie „als ZIP herunterladen“) und in
// einen zweiten, leeren Workspace importiert. Danach müssen dieselben Einträge // einen zweiten, leeren Workspace importiert. Danach müssen dieselben Einträge
// mit demselben Inhalt dastehen. // mit demselben Inhalt dastehen.
@ -18,7 +18,7 @@ import (
// Läuft nur mit MUNIN_TEST_PG (siehe internal/store/postgres_test.go). Export // 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 // und Import lesen und schreiben ausschließlich über den Store, sind also
// nicht an SQLite gebunden — dieser Test belegt das. // nicht an SQLite gebunden — dieser Test belegt das.
func TestRundreisePostgres(t *testing.T) { func TestRoundTripPostgres(t *testing.T) {
url := os.Getenv("MUNIN_TEST_PG") url := os.Getenv("MUNIN_TEST_PG")
if url == "" { if url == "" {
t.Skip("MUNIN_TEST_PG ist nicht gesetzt — PostgreSQL-Test übersprungen") 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() }) t.Cleanup(func() { sp.Close() })
// Zwei frische Workspace-Schemas, nach dem Test wieder weg. // Zwei frische Workspace-Schemas, nach dem Test wieder weg.
oeffne := func() *store.Store { open := func() *store.Store {
id := store.NewID() id := store.NewID()
s, err := sp.OpenWorkspace(id) s, err := sp.OpenWorkspace(id)
if err != nil { if err != nil {
@ -44,11 +44,11 @@ func TestRundreisePostgres(t *testing.T) {
}) })
return s return s
} }
quelle, ziel := oeffne(), oeffne() source, target := open(), open()
// Quelle: ein Projekt mit Schlagworten, Verweis, Umlauten und einem // Quelle: ein Projekt mit Schlagworten, Verweis, Umlauten und einem
// kuratierten Eintrag. // kuratierten Eintrag.
projekt, err := quelle.CreateProject(ctx, "ws", "plattform", "Plattform", "") project, err := source.CreateProject(ctx, "ws", "plattform", "Plattform", "")
if err != nil { if err != nil {
t.Fatal(err) 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.", {Slug: "vpn", Title: "VPN-Zugang", Content: "Zugang über vpn.intern — Änderungen bei der IT.",
Tags: []string{"ops", "zugang"}, Source: store.SourceAgent}, Tags: []string{"ops", "zugang"}, Source: store.SourceAgent},
} { } {
e.ProjectID = projekt.ID e.ProjectID = project.ID
if _, err := quelle.PutEntry(ctx, e); err != nil { if _, err := source.PutEntry(ctx, e); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
// Export als ZIP und wieder auspacken — wie Download und Upload. // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var zip bytes.Buffer var zip bytes.Buffer
if err := export.SchreibeZIP(&zip, dateien); err != nil { if err := export.WriteZIP(&zip, files); err != nil {
t.Fatal(err) t.Fatal(err)
} }
gelesen, err := LeseZip(zip.Bytes()) read, err := ReadZip(zip.Bytes())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
zielProjekt, err := ziel.CreateProject(ctx, "ws", "plattform", "Plattform", "") targetProject, err := target.CreateProject(ctx, "ws", "plattform", "Plattform", "")
if err != nil { if err != nil {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if bericht.Neu != 2 || bericht.Fehler != 0 { if report.Created != 2 || report.Failed != 0 {
t.Fatalf("Import: %d neu, %d Fehler, erwartet 2 neu und 0 Fehler: %+v", bericht.Neu, bericht.Fehler, bericht.Zeilen) 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"} { 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
nachher, err := ziel.EntryBySlug(ctx, zielProjekt.ID, slug) after, err := target.EntryBySlug(ctx, targetProject.ID, slug)
if err != nil { if err != nil {
t.Fatalf("%s fehlt nach dem Import: %v", slug, err) t.Fatalf("%s fehlt nach dem Import: %v", slug, err)
} }
if nachher.Title != vorher.Title || nachher.Content != vorher.Content || if after.Title != before.Title || after.Content != before.Content ||
nachher.Curated != vorher.Curated || len(nachher.Tags) != len(vorher.Tags) { after.Curated != before.Curated || len(after.Tags) != len(before.Tags) {
t.Errorf("%s unterscheidet sich:\nvorher: %+v\nnachher: %+v", slug, vorher, nachher) t.Errorf("%s unterscheidet sich:\nvorher: %+v\nnachher: %+v", slug, before, after)
} }
} }
} }

View file

@ -1,6 +1,6 @@
// Package markdown ist die eine Stelle, an der Munin Markdown in HTML wandelt — // 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 // für Einträge (internal/entry) genauso wie für die eingebaute Hilfe
// (internal/hilfe). // (internal/help).
// //
// # Warum ein eigenes Paket? // # Warum ein eigenes Paket?
// //
@ -34,14 +34,14 @@ import (
"munin/internal/store" "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 // gelten. Zusätzliche Parser-Optionen (etwa Überschrift-Anker) kommen als
// Parameter dazu. // Parameter dazu.
// //
// Das "..." vor dem Typ heißt in Go "beliebig viele Werte dieses Typs" // 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. // Zusatzoptionen aufrufen.
func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown { func newRenderer(extra ...parser.Option) goldmark.Markdown {
return goldmark.New( return goldmark.New(
// GFM ("GitHub Flavored Markdown") bündelt vier Erweiterungen: // GFM ("GitHub Flavored Markdown") bündelt vier Erweiterungen:
// Tabellen, ~~Durchstreichen~~, Checklisten (- [ ] / - [x]) und // 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 // Das ist, was die meisten aus GitHub oder GitLab gewohnt sind — und was
// Agenten ohnehin schreiben. // Agenten ohnehin schreiben.
goldmark.WithExtensions(extension.GFM), 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 // Renderer hat keinen Zustand, der sich zwischen zwei Aufrufen ändert, und ist
// damit auch bei gleichzeitigen Anfragen sicher. // damit auch bei gleichzeitigen Anfragen sicher.
var ( 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 // id-Attribute: Eintragstexte kommen von Menschen und Agenten, und eine
// Überschrift wie "## suche" ergäbe id="suche" — die könnte mit einer id // Ü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. // 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 // Text ("## Markdown im Inhalt" → id="markdown-im-inhalt"), damit Links
// aus der Oberfläche direkt auf einen Abschnitt zeigen können // aus der Oberfläche direkt auf einen Abschnitt zeigen können
// (AUSBAUSTUFEN.md 5.4). Die Hilfeseiten liegen im Repository, ihre // (AUSBAUSTUFEN.md 5.4). Die Hilfeseiten liegen im Repository, ihre
// Überschriften sind also bekannt und unbedenklich. // Ü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. // Render wandelt Markdown in HTML, ohne Überschrift-Anker. Für Einträge.
func Render(quelle string) (string, error) { func Render(source string) (string, error) {
return umwandeln(ohneAnker, quelle) 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. // 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 // Jede Seite bekommt ihren eigenen Anker-Generator. Er merkt sich, welche
// ids schon vergeben sind — und das darf nicht in die nächste Seite // ids schon vergeben sind — und das darf nicht in die nächste Seite
// hinüberwirken (und bei gleichzeitigen Anfragen schon gar nicht). // hinüberwirken (und bei gleichzeitigen Anfragen schon gar nicht).
kontext := parser.NewContext(parser.WithIDs(&ankerIDs{vergeben: map[string]bool{}})) context := parser.NewContext(parser.WithIDs(&anchorIDs{assigned: map[string]bool{}}))
return umwandeln(mitAnker, quelle, parser.WithContext(kontext)) return convert(withAnchors, source, parser.WithContext(context))
} }
// umwandeln ist der gemeinsame Teil beider Funktionen. // convert ist der gemeinsame Teil beider Funktionen.
func umwandeln(renderer goldmark.Markdown, quelle string, optionen ...parser.ParseOption) (string, error) { func convert(renderer goldmark.Markdown, source string, options ...parser.ParseOption) (string, error) {
// bytes.Buffer ist ein wachsender Speicher, in den goldmark das Ergebnis // bytes.Buffer ist ein wachsender Speicher, in den goldmark das Ergebnis
// schreibt. Das & übergibt einen Zeiger darauf, damit goldmark genau diesen // schreibt. Das & übergibt einen Zeiger darauf, damit goldmark genau diesen
// Puffer füllt und nicht eine Kopie davon. // Puffer füllt und nicht eine Kopie davon.
var puffer bytes.Buffer var buf bytes.Buffer
if err := renderer.Convert([]byte(quelle), &puffer, optionen...); err != nil { if err := renderer.Convert([]byte(source), &buf, options...); err != nil {
return "", fmt.Errorf("Markdown konnte nicht dargestellt werden: %w", err) 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? // # 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: // goldmark erwartet hier ein Interface (parser.IDs) mit zwei Methoden:
// Generate und Put. In Go muss man ein Interface nicht ausdrücklich // Generate und Put. In Go muss man ein Interface nicht ausdrücklich
// "implementieren" — es reicht, dass der Typ Methoden mit genau diesen Namen // "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. // an parser.WithIDs übergeben wird.
type ankerIDs struct { type anchorIDs struct {
vergeben map[string]bool assigned map[string]bool
} }
// Generate bildet aus dem Text einer Überschrift eine id. Kommt dieselbe // Generate bildet aus dem Text einer Überschrift eine id. Kommt dieselbe
// Überschrift zweimal vor, bekommt die zweite ein "-1" angehängt, die dritte // Überschrift zweimal vor, bekommt die zweite ein "-1" angehängt, die dritte
// ein "-2" — ids müssen auf einer Seite eindeutig sein. // ein "-2" — ids müssen auf einer Seite eindeutig sein.
func (a *ankerIDs) Generate(text []byte, _ ast.NodeKind) []byte { func (a *anchorIDs) Generate(text []byte, _ ast.NodeKind) []byte {
basis := store.Slugify(string(text)) base := store.Slugify(string(text))
if basis == "" { if base == "" {
// Eine Überschrift nur aus Sonderzeichen ("## ???") — dann eben // Eine Überschrift nur aus Sonderzeichen ("## ???") — dann eben
// ein neutraler Name, eindeutig gemacht wie jeder andere. // ein neutraler Name, eindeutig gemacht wie jeder andere.
basis = "abschnitt" base = "abschnitt"
} }
id := basis id := base
for nummer := 1; a.vergeben[id]; nummer++ { for number := 1; a.assigned[id]; number++ {
id = fmt.Sprintf("%s-%d", basis, nummer) id = fmt.Sprintf("%s-%d", base, number)
} }
a.vergeben[id] = true a.assigned[id] = true
return []byte(id) return []byte(id)
} }
// Put merkt sich eine von Hand vergebene id, damit Generate sie nicht noch // Put merkt sich eine von Hand vergebene id, damit Generate sie nicht noch
// einmal erzeugt. (Von Hand vergebene ids nutzen die Hilfeseiten derzeit // einmal erzeugt. (Von Hand vergebene ids nutzen die Hilfeseiten derzeit
// nicht, goldmark verlangt die Methode aber.) // nicht, goldmark verlangt die Methode aber.)
func (a *ankerIDs) Put(id []byte) { func (a *anchorIDs) Put(id []byte) {
a.vergeben[string(id)] = true a.assigned[string(id)] = true
} }

View file

@ -5,11 +5,11 @@ import (
"testing" "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). // Links funktionieren — in Einträgen genauso wie in der Hilfe (AUSBAUSTUFEN.md 5.3).
func TestGFMErweiterungen(t *testing.T) { func TestGFMExtensions(t *testing.T) {
faelle := []struct { cases := []struct {
name, quelle, erwartet string name, source, want string
}{ }{
{"Tabelle", "| a | b |\n|---|---|\n| 1 | 2 |", "<table>"}, {"Tabelle", "| a | b |\n|---|---|\n| 1 | 2 |", "<table>"},
{"Durchstreichen", "~~veraltet~~", "<del>veraltet</del>"}, {"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. // Beide Renderer prüfen: Sie sollen sich nur in den Ankern unterscheiden.
renderer := map[string]func(string) (string, error){ renderer := map[string]func(string) (string, error){
"Render": Render, "Render": Render,
"RenderMitAnkern": RenderMitAnkern, "RenderMitAnkern": RenderWithAnchors,
} }
for rName, render := range renderer { for rName, render := range renderer {
for _, f := range faelle { for _, f := range cases {
html, err := render(f.quelle) html, err := render(f.source)
if err != nil { if err != nil {
t.Fatalf("%s/%s: %v", rName, f.name, err) t.Fatalf("%s/%s: %v", rName, f.name, err)
} }
if !strings.Contains(html, f.erwartet) { if !strings.Contains(html, f.want) {
t.Errorf("%s/%s: %q fehlt in %q", rName, f.name, f.erwartet, html) 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 // 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 // weder Skripte noch gefährliche Link-Ziele durchkommen — auch nicht mit den
// GFM-Erweiterungen. // GFM-Erweiterungen.
func TestKeinRohHTML(t *testing.T) { func TestNoRawHTML(t *testing.T) {
boese := []string{ evil := []string{
"<script>alert(1)</script>", "<script>alert(1)</script>",
`<img src=x onerror="alert(1)">`, `<img src=x onerror="alert(1)">`,
"[klick](javascript:alert(1))", "[klick](javascript:alert(1))",
"| a |\n|---|\n| <script>alert(1)</script> |", "| a |\n|---|\n| <script>alert(1)</script> |",
"- [ ] <script>alert(1)</script>", "- [ ] <script>alert(1)</script>",
} }
for _, quelle := range boese { for _, source := range evil {
for _, render := range []func(string) (string, error){Render, RenderMitAnkern} { for _, render := range []func(string) (string, error){Render, RenderWithAnchors} {
html, _ := render(quelle) html, _ := render(source)
if strings.Contains(html, "<script") || strings.Contains(html, "onerror") || strings.Contains(html, "javascript:") { 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. // Umlauten und eindeutig je Seite.
func TestAnker(t *testing.T) { func TestAnchor(t *testing.T) {
quelle := "## Zugänge verwalten\n\n## Protokoll\n\n## Protokoll\n\n## ???" source := "## Zugänge verwalten\n\n## Protokoll\n\n## Protokoll\n\n## ???"
html, err := RenderMitAnkern(quelle) html, err := RenderWithAnchors(source)
if err != nil { if err != nil {
t.Fatal(err) 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 // Ein zweiter Aufruf fängt wieder von vorn an — die vergebenen ids der
// ersten Seite dürfen nicht hinüberwirken. // ersten Seite dürfen nicht hinüberwirken.
nochmal, _ := RenderMitAnkern("## Protokoll") again, _ := RenderWithAnchors("## Protokoll")
if !strings.Contains(nochmal, `id="protokoll"`) { if !strings.Contains(again, `id="protokoll"`) {
t.Errorf("zweite Seite bekommt nicht die einfache id: %q", nochmal) t.Errorf("zweite Seite bekommt nicht die einfache id: %q", again)
} }
// Einträge: keine ids, damit Eintragstext nicht mit ids der Seite // Einträge: keine ids, damit Eintragstext nicht mit ids der Seite
// kollidieren kann. // kollidieren kann.
eintrag, _ := Render(quelle) entry, _ := Render(source)
if strings.Contains(eintrag, "id=") { if strings.Contains(entry, "id=") {
t.Errorf("Eintrag hat Überschrift-ids bekommen: %q", eintrag) t.Errorf("Eintrag hat Überschrift-ids bekommen: %q", entry)
} }
} }

View file

@ -14,24 +14,24 @@ import (
"munin/internal/workspace" "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 // darf nur aus der Goroutine des Tests selbst kommen, nicht aus den
// nebenläufigen Agenten unten. // 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{ sess := web.Session{
Subject: "key:" + ws.ID + ":" + name, Subject: "key:" + ws.ID + ":" + name,
WorkspaceID: ws.ID, WorkspaceID: ws.ID,
Key: store.APIKey{ID: name, Name: name, ProjectID: projekt.ID, Role: string(auth.RoleWrite)}, Key: store.APIKey{ID: name, Name: name, ProjectID: project.ID, Role: string(auth.RoleWrite)},
Project: projekt, Project: project,
Role: auth.RoleWrite, Role: auth.RoleWrite,
} }
ctx := web.WithSessionForTest(context.Background(), sess) ctx := web.WithSessionForTest(context.Background(), sess)
ctx = web.WithWorkspaceForTest(ctx, ws) ctx = web.WithWorkspaceForTest(ctx, ws)
clientTransport, serverTransport := mcp.NewInMemoryTransports() 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -41,14 +41,14 @@ func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name s
serverSession.Close() serverSession.Close()
return nil, nil, err return nil, nil, err
} }
schliessen := func() { closeIt := func() {
clientSession.Close() clientSession.Close()
serverSession.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 // nach gleichzeitigen Schreibvorgängen auf denselben Eintrag den Stand der
// Datenbank kennt. // 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 // Jeder Schreibvorgang bekommt deshalb ein eigenes, eindeutiges Wort. Am Ende
// muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden // muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden
// sein. // sein.
func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) { func TestConcurrentWritesKeepSearchIndexCurrent(t *testing.T) {
h, ws, projekt := aufbauMitBudget(t, 100000) h, ws, project := setupWithBudget(t, 100000)
ctx := context.Background() ctx := context.Background()
// Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der // Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der
// Test würde nichts prüfen (siehe Registry.Update). // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
const runden, agenten, jeAgent = 20, 8, 10 const rounds, agents, perAgent = 20, 8, 10
for runde := 0; runde < runden; runde++ { for round := 0; round < rounds; round++ {
slug := fmt.Sprintf("umkaempft-%02d", runde) slug := fmt.Sprintf("umkaempft-%02d", round)
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex 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) wg.Add(1)
go func(a int) { go func(a int) {
defer wg.Done() defer wg.Done()
melde := func(err error) { report := func(err error) {
mu.Lock() mu.Lock()
fehler = append(fehler, err) errs = append(errs, err)
mu.Unlock() 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 { if err != nil {
melde(err) report(err)
return 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. // Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt.
wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende") word := fmt.Sprintf("marke%sx%s", letters(round*1000+a*100+i), "ende")
erg, err := client.CallTool(ctx, &mcp.CallToolParams{ res, err := client.CallTool(ctx, &mcp.CallToolParams{
Name: "remember", Name: "remember",
// force: Jede Runde legt unter neuem Bezeichner mit // force: Jede Runde legt unter neuem Bezeichner mit
// demselben Titel an. Die Dublettenprüfung würde das // demselben Titel an. Die Dublettenprüfung würde das
// zu Recht anhalten — hier geht es aber ums // zu Recht anhalten — hier geht es aber ums
// gleichzeitige Schreiben, nicht um Dubletten. // gleichzeitige Schreiben, nicht um Dubletten.
Arguments: map[string]any{ 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 { if err != nil {
melde(err) report(err)
return return
} }
if erg.IsError { if res.IsError {
melde(fmt.Errorf("remember: %s", text(erg))) report(fmt.Errorf("remember: %s", text(res)))
return return
} }
} }
@ -123,37 +123,37 @@ func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) {
} }
wg.Wait() wg.Wait()
if len(fehler) > 0 { if len(errs) > 0 {
t.Fatalf("Runde %d: %d Fehler, erster: %v", runde, len(fehler), fehler[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 { if err != nil {
t.Fatal(err) 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", 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. // Das Wort der letzten Fassung steht nach "Stand " im Inhalt.
letztesWort := e.Content[len("Stand "):] lastWord := e.Content[len("Stand "):]
gefunden := false found := false
for _, r := range idx.Search(letztesWort, 5) { for _, r := range idx.Search(lastWord, 5) {
if r.DocID == slug { 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 — "+ 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. // 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) s := fmt.Sprintf("%06d", n)
b := []byte(s) b := []byte(s)
for i := range b { for i := range b {

View file

@ -8,7 +8,7 @@ package mcpserver
// still: Der Agent erfuhr nicht, dass er geprüftes Wissen ersetzt hatte, und // still: Der Agent erfuhr nicht, dass er geprüftes Wissen ersetzt hatte, und
// kein Mensch bekam es mit. // 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 // 1. remember auf einen kuratierten Eintrag schreibt NICHT, sondern meldet
// judgment_required mit einer Erklärung. // judgment_required mit einer Erklärung.
@ -37,9 +37,9 @@ import (
"munin/internal/workspace" "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. // 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) e, err := ws.Store.EntryBySlug(ctx, project.ID, slug)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
return false, nil return false, nil
@ -50,20 +50,20 @@ func istKuratiert(ctx context.Context, ws *workspace.Workspace, project store.Pr
return e.Curated, nil 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 // Eintrag ohne override_curated ändern will. Sie sagt ausdrücklich, was er
// tun soll — wie alle Meldungen an Modelle. // 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 " + "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 " + "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. " + "\"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. " + "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." "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 // Begründung trägt. Ohne sie wüsste der prüfende Mensch nicht, warum der
// geprüfte Stand ersetzt wurde. // geprüfte Stand ersetzt wurde.
func begruendungPruefen(in rememberInput) error { func checkJustification(in rememberInput) error {
if in.OverrideCurated && strings.TrimSpace(in.Reason) == "" { if in.OverrideCurated && strings.TrimSpace(in.Reason) == "" {
return errors.New("override_curated braucht eine Begründung unter \"reason\": " + return errors.New("override_curated braucht eine Begründung unter \"reason\": " +
"Warum stimmt der kuratierte Stand nicht mehr?") "Warum stimmt der kuratierte Stand nicht mehr?")

File diff suppressed because it is too large Load diff

View file

@ -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."` 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 // duplicateCandidate ist ein vorhandener Eintrag, der einem neu anzulegenden
// ähnelt (siehe internal/dubletten) — so, wie MCP ihn ausliefert. // ähnelt (siehe internal/duplicates) — so, wie MCP ihn ausliefert.
type dublettenKandidat struct { type duplicateCandidate struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Title string `json:"title"` Title string `json:"title"`
Overlap int `json:"overlap" jsonschema:"Übereinstimmung der Titel in Prozent"` Overlap int `json:"overlap" jsonschema:"Übereinstimmung der Titel in Prozent"`
@ -250,8 +250,8 @@ type rememberOutput struct {
Message string `json:"message"` Message string `json:"message"`
// Nur gesetzt, wenn NICHT geschrieben wurde, weil es ähnliche Einträge gibt. // 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"` 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"` Candidates []duplicateCandidate `json:"candidates,omitempty" jsonschema:"Vorhandene Einträge, die dem neuen ähneln"`
} }
type forgetInput struct { type forgetInput struct {

View file

@ -11,7 +11,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth" "munin/internal/auth"
"munin/internal/dubletten" "munin/internal/duplicates"
"munin/internal/entry" "munin/internal/entry"
"munin/internal/store" "munin/internal/store"
"munin/internal/web" "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 // Abfrage: web.APIAuth hat die Projektzeile für DIESEN Request gerade eben
// frisch aus der Datenbank geholt. Der Wert ist damit aktuell, und der // frisch aus der Datenbank geholt. Der Wert ist damit aktuell, und der
// Schreibpfad kostet keine zusätzliche Abfrage. // Schreibpfad kostet keine zusätzliche Abfrage.
erlaubt, retryAfter, ueberschreitung := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly) allowed, retryAfter, excess := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly)
if erlaubt { if allowed {
return nil return nil
} }
// Nur die erste Ablehnung je Fenster vermerken — sonst schriebe eine // Nur die erste Ablehnung je Fenster vermerken — sonst schriebe eine
// Schleife im Agenten das Protokoll voll, statt die Einträge. // 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", 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) 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 // Etwas großzügiger suchen als am Ende ausgegeben wird: Die
// Schlagwortfilterung unten entfernt womöglich noch Treffer. // Schlagwortfilterung unten entfernt womöglich noch Treffer.
synonyme := begrenzeSynonyme(in.Synonyms) synonyms := limitSynonyms(in.Synonyms)
treffer := idx.Search(mitSynonymen(in.Query, synonyme), limit*3) hits := idx.Search(withSynonyms(in.Query, synonyms), limit*3)
gesucht := store.NormalizeTags(in.Tags) wanted := store.NormalizeTags(in.Tags)
jetzt := time.Now() now := time.Now()
veraltet := 0 stale := 0
ergebnisse := make([]recallMatch, 0, limit) results := make([]recallMatch, 0, limit)
for _, t := range treffer { for _, t := range hits {
if len(ergebnisse) >= limit { if len(results) >= limit {
break break
} }
@ -195,24 +195,24 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
return nil, recallOutput{}, err return nil, recallOutput{}, err
} }
if !hatAlleTags(e.Tags, gesucht) { if !hasAllTags(e.Tags, wanted) {
continue continue
} }
alt := e.Stale(jetzt) old := e.Stale(now)
if alt { if old {
veraltet++ stale++
} }
ergebnisse = append(ergebnisse, recallMatch{ results = append(results, recallMatch{
Slug: e.Slug, Slug: e.Slug,
Title: e.Title, Title: e.Title,
Snippet: entry.Snippet(e.Content, mitSynonymen(in.Query, synonyme)), Snippet: entry.Snippet(e.Content, withSynonyms(in.Query, synonyms)),
Tags: e.Tags, Tags: e.Tags,
Source: e.Source, Source: e.Source,
Curated: e.Curated, Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"), 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) sess, _ := web.SessionFromContext(ctx)
ws.Store.LogSearch(context.WithoutCancel(ctx), store.SearchLogEntry{ ws.Store.LogSearch(context.WithoutCancel(ctx), store.SearchLogEntry{
ProjectID: project.ID, APIKeyID: sess.Key.ID, 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)} out := recallOutput{Results: results, Total: len(results)}
if len(ergebnisse) == 0 { if len(results) == 0 {
out.Hint = "Nichts gefunden. Erst mit anderen Begriffen erneut suchen (Synonyme, Übersetzung, " + out.Hint = "Nichts gefunden. Erst mit anderen Begriffen erneut suchen (Synonyme, Übersetzung, " +
"Abkürzungen unter \"synonyms\") oder mit \"list\" einen Überblick verschaffen. " + "Abkürzungen unter \"synonyms\") oder mit \"list\" einen Überblick verschaffen. " +
"Erst wenn es wirklich nichts gibt und etwas Neues festgehalten werden soll, " + "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 // nicht nach hinten: Ein alter Eintrag kann der einzige zum
// Thema sein. Der Hinweis sorgt dafür, dass das Modell das // Thema sein. Der Hinweis sorgt dafür, dass das Modell das
// Feld "stale" nicht übersieht. // 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) "+ 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 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. // Suche mitlaufen.
// //
// # Warum so einfach? // # 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 // 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. // gewollt — eine eigene Gewichtung für Synonyme bräuchte es dafür nicht.
// //
// Die Synonyme sollten vorher durch begrenzeSynonyme gegangen sein. // Die Synonyme sollten vorher durch limitSynonyms gegangen sein.
func mitSynonymen(query string, synonyme []string) string { func withSynonyms(query string, synonyms []string) string {
if len(synonyme) == 0 { if len(synonyms) == 0 {
return query 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. // recallMaxSynonyms.
// //
// Die Obergrenze verhindert, dass ein übereifriges Modell dreißig Begriffe // Die Obergrenze verhindert, dass ein übereifriges Modell dreißig Begriffe
// mitschickt: Dann passt am Ende fast jeder Eintrag auf irgendeinen davon. // mitschickt: Dann passt am Ende fast jeder Eintrag auf irgendeinen davon.
func begrenzeSynonyme(synonyme []string) []string { func limitSynonyms(synonyms []string) []string {
var sauber []string var clean []string
for _, s := range synonyme { for _, s := range synonyms {
if s = strings.TrimSpace(s); s != "" { if s = strings.TrimSpace(s); s != "" {
sauber = append(sauber, s) clean = append(clean, s)
} }
if len(sauber) == recallMaxSynonyms { if len(clean) == recallMaxSynonyms {
break break
} }
} }
return sauber return clean
} }
// hatAlleTags prüft, ob alle gesuchten Schlagworte vorhanden sind. // hasAllTags prüft, ob alle gesuchten Schlagworte vorhanden sind.
func hatAlleTags(vorhanden, gesucht []string) bool { func hasAllTags(existing, wanted []string) bool {
if len(gesucht) == 0 { if len(wanted) == 0 {
return true return true
} }
menge := make(map[string]bool, len(vorhanden)) set := make(map[string]bool, len(existing))
for _, t := range vorhanden { for _, t := range existing {
menge[t] = true set[t] = true
} }
for _, g := range gesucht { for _, g := range wanted {
if !menge[g] { if !set[g] {
return false return false
} }
} }
@ -323,13 +323,13 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
return nil, getOutput{}, err 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 { if err != nil {
return nil, getOutput{}, err return nil, getOutput{}, err
} }
linkedBy := make([]string, 0, len(rueck)) linkedBy := make([]string, 0, len(back))
for _, b := range rueck { for _, b := range back {
linkedBy = append(linkedBy, b.Slug) linkedBy = append(linkedBy, b.Slug)
} }
@ -378,7 +378,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
limit = listMaxLimit 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, Tag: in.Tag,
Curated: in.Curated, Curated: in.Curated,
Limit: limit, Limit: limit,
@ -387,7 +387,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
return nil, listOutput{}, err return nil, listOutput{}, err
} }
gesamt, err := ws.Store.CountEntries(ctx, project.ID) total, err := ws.Store.CountEntries(ctx, project.ID)
if err != nil { if err != nil {
return nil, listOutput{}, err return nil, listOutput{}, err
} }
@ -397,9 +397,9 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
return nil, listOutput{}, err return nil, listOutput{}, err
} }
jetzt := time.Now() now := time.Now()
items := make([]listItem, 0, len(eintraege)) items := make([]listItem, 0, len(entries))
for _, e := range eintraege { for _, e := range entries {
items = append(items, listItem{ items = append(items, listItem{
Slug: e.Slug, Slug: e.Slug,
Title: e.Title, Title: e.Title,
@ -407,16 +407,16 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
Source: e.Source, Source: e.Source,
Curated: e.Curated, Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"), 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 { 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) == "" { if strings.TrimSpace(in.Content) == "" {
return nil, rememberOutput{}, errors.New("content darf nicht leer sein") 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 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") return nil, rememberOutput{}, errors.New("slug fehlt und ließ sich nicht aus dem Titel ableiten")
} }
titel := strings.TrimSpace(in.Title) title := strings.TrimSpace(in.Title)
if titel == "" { if title == "" {
titel = entry.TitleFromContent(in.Content) title = entry.TitleFromContent(in.Content)
} }
// Gibt es schon einen ähnlichen Eintrag unter anderem Bezeichner? // Gibt es schon einen ähnlichen Eintrag unter anderem Bezeichner?
// Dann nicht anlegen, sondern die Kandidaten zurückgeben (siehe // 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. // Daten lesen und entscheiden, nicht einen Fehlschlag behandeln.
if !in.Force { 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 { if err != nil {
return nil, rememberOutput{}, err return nil, rememberOutput{}, err
} }
kandidaten := make([]dublettenKandidat, 0, len(gefunden)) candidates := make([]duplicateCandidate, 0, len(found))
for _, k := range gefunden { for _, k := range found {
kandidaten = append(kandidaten, dublettenKandidat{ candidates = append(candidates, duplicateCandidate{
Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, MatchedWords: k.MatchedWords, Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, MatchedWords: k.MatchedWords,
}) })
} }
if len(kandidaten) > 0 { if len(candidates) > 0 {
return nil, rememberOutput{ return nil, rememberOutput{
Slug: slug, Slug: slug,
JudgmentRequired: true, JudgmentRequired: true,
Candidates: kandidaten, Candidates: candidates,
Message: "Nichts geschrieben: Es gibt bereits ähnliche Einträge (candidates). " + Message: "Nichts geschrieben: Es gibt bereits ähnliche Einträge (candidates). " +
"\"matched_words\" nennt die gemeinsamen Wörter. " + "\"matched_words\" nennt die gemeinsamen Wörter. " +
"Passt einer davon, ihn mit \"get\" laden, den neuen Inhalt einarbeiten und " + "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 // 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. // 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 { if err != nil {
return nil, rememberOutput{}, err return nil, rememberOutput{}, err
} }
if warKuratiert && !in.OverrideCurated { if wasCurated && !in.OverrideCurated {
return nil, rememberOutput{ return nil, rememberOutput{
Slug: slug, Slug: slug,
JudgmentRequired: true, JudgmentRequired: true,
Message: kuratiertHinweis, Message: curatedHint,
}, nil }, nil
} }
geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{ written, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, ProjectID: project.ID,
Slug: slug, Slug: slug,
Title: titel, Title: title,
Content: in.Content, Content: in.Content,
Tags: in.Tags, Tags: in.Tags,
Source: store.SourceAgent, Source: store.SourceAgent,
@ -529,13 +529,13 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
return nil, rememberOutput{}, err 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 // Hat der Agent einen kuratierten Eintrag geändert, steht das samt
// Begründung im Protokoll. Daraus baut die Nutzungsseite die Liste // Begründung im Protokoll. Daraus baut die Nutzungsseite die Liste
// „bitte prüfen" (store.CuratedOverrides). // „bitte prüfen" (store.CuratedOverrides).
detail := store.EntryWriteDetail(geschrieben) detail := store.EntryWriteDetail(written)
if warKuratiert { if wasCurated {
detail = store.CuratedOverrideDetail(in.Reason) 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 // (PutEntry vergibt beim Anlegen immer 1) — eine Nachfrage vorher
// könnte bei zwei gleichzeitigen Schreibern danebenliegen, und sie // könnte bei zwei gleichzeitigen Schreibern danebenliegen, und sie
// sähe einen Eintrag im Papierkorb nicht. // 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 { switch {
case warKuratiert: case wasCurated:
meldung = fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d). Er war kuratiert; die Markierung "+ 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.", "ist aufgehoben, und ein Mensch prüft die Änderung. Der geprüfte Stand steht im Verlauf.",
slug, geschrieben.Version) slug, written.Version)
case istNeu: case isNew:
meldung = fmt.Sprintf("Eintrag %q angelegt.", slug) message = fmt.Sprintf("Eintrag %q angelegt.", slug)
case geschrieben.RestoredFromTrash: case written.RestoredFromTrash:
// Der Bezeichner gehörte einem gelöschten Eintrag. Das Modell // Der Bezeichner gehörte einem gelöschten Eintrag. Das Modell
// konnte ihn vorher mit recall/get nicht sehen — es soll aber // konnte ihn vorher mit recall/get nicht sehen — es soll aber
// wissen, dass es gerade etwas Gelöschtes wiederbelebt hat. // 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. "+ "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{ ws.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx), ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceMCP, 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{ return nil, rememberOutput{
Slug: slug, Slug: slug,
Version: geschrieben.Version, Version: written.Version,
Created: istNeu, Created: isNew,
Message: meldung, Message: message,
}, nil }, nil
}, },
) )
@ -652,12 +652,12 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
return nil, relatedOutput{}, err return nil, relatedOutput{}, err
} }
abstand := in.Distance distance := in.Distance
if abstand <= 0 { if distance <= 0 {
abstand = relatedDefaultDistance distance = relatedDefaultDistance
} }
if abstand > relatedMaxDistance { if distance > relatedMaxDistance {
abstand = relatedMaxDistance distance = relatedMaxDistance
} }
limit := in.Limit limit := in.Limit
@ -668,7 +668,7 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
limit = relatedMaxLimit 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) { 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) 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 return nil, relatedOutput{}, err
} }
jetzt := time.Now() now := time.Now()
items := make([]relatedItem, 0, len(verwandte)) items := make([]relatedItem, 0, len(related))
for _, v := range verwandte { for _, v := range related {
items = append(items, relatedItem{ items = append(items, relatedItem{
Slug: v.Slug, Slug: v.Slug,
Title: v.Title, Title: v.Title,
@ -686,7 +686,7 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
Via: v.Via, Via: v.Via,
Relation: v.Relation, Relation: v.Relation,
Updated: v.UpdatedAt.Format("2006-01-02 15:04"), Updated: v.UpdatedAt.Format("2006-01-02 15:04"),
Stale: store.StaleSince(v.UpdatedAt, jetzt), Stale: store.StaleSince(v.UpdatedAt, now),
}) })
} }

View file

@ -7,27 +7,27 @@ import (
"testing" "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. // 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, // 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. // wie viele verschiedene Wörter der Index durchgehen muss, nicht was sie heißen.
// Der feste Startwert macht jeden Lauf gleich. // Der feste Startwert macht jeden Lauf gleich.
func baueGrossenIndex() *Index { func buildLargeIndex() *Index {
const ( const (
eintraege = 10_000 entries = 10_000
woerter = 150 words = 150
wortschatz = 20_000 vocabulary = 20_000
) )
zufall := rand.New(rand.NewSource(1)) rng := rand.New(rand.NewSource(1))
idx := NewIndex() idx := NewIndex()
var text strings.Builder var text strings.Builder
for i := 0; i < eintraege; i++ { for i := 0; i < entries; i++ {
text.Reset() text.Reset()
for j := 0; j < woerter; j++ { for j := 0; j < words; j++ {
fmt.Fprintf(&text, "wort%d ", zufall.Intn(wortschatz)) fmt.Fprintf(&text, "wort%d ", rng.Intn(vocabulary))
} }
idx.Index(fmt.Sprintf("eintrag-%d", i), "Titel", text.String(), nil) idx.Index(fmt.Sprintf("eintrag-%d", i), "Titel", text.String(), nil)
} }
@ -39,7 +39,7 @@ func baueGrossenIndex() *Index {
// //
// go test ./internal/search/ -bench . -run '^$' // go test ./internal/search/ -bench . -run '^$'
func BenchmarkSearch(b *testing.B) { func BenchmarkSearch(b *testing.B) {
idx := baueGrossenIndex() idx := buildLargeIndex()
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {

View file

@ -225,10 +225,10 @@ func (s *Index) removeUnsafe(docID string) {
// limit begrenzt die Anzahl der zurückgegebenen Ergebnisse. // limit begrenzt die Anzahl der zurückgegebenen Ergebnisse.
// //
// Neben exakten Treffern findet Search auch Wortanfänge, Wörter mit anderer // 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. // gleicht Search nicht aus — dafür gibt es SearchFuzzy.
func (s *Index) Search(queryStr string, limit int) []Result { 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 // 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 // Tippfehler; bei ihnen brächte die Toleranz vor allem zusätzliche, unsichere
// Treffer. Deshalb nutzt das MCP-Werkzeug recall weiterhin Search. // Treffer. Deshalb nutzt das MCP-Werkzeug recall weiterhin Search.
func (s *Index) SearchFuzzy(queryStr string, limit int) []Result { 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. // search ist die gemeinsame Umsetzung von Search und SearchFuzzy.
func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result { func (s *Index) search(queryStr string, limit int, typo bool) []Result {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
queryTokens := ohneStoppwoerter(tokenize(queryStr)) queryTokens := withoutStopwords(tokenize(queryStr))
if len(queryTokens) == 0 || len(s.docs) == 0 { if len(queryTokens) == 0 || len(s.docs) == 0 {
return nil 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 — // Je Dokument zählt für dieses Suchwort nur die beste Variante —
// ein exakter Treffer wird also nicht durch denselben Treffer als // ein exakter Treffer wird also nicht durch denselben Treffer als
// Wortanfang noch einmal aufgewertet. // 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): // IDF (Inverse Document Frequency):
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der"). // Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1) // 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) tf := float64(p.freq)
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen)) tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
wert := v.gewicht * idf * tfNorm value := v.weight * idf * tfNorm
if wert > beste[p.docID] { if value > best[p.docID] {
beste[p.docID] = wert best[p.docID] = value
} }
} }
} }
for docID, wert := range beste { for docID, value := range best {
scores[docID] += wert 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. // Es werden nur Buchstaben und Ziffern behalten, alles andere gilt als Trennzeichen.
// Einzeichen-Tokens werden ignoriert (kaum aussagekräftig). // 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 { func tokenize(text string) []string {
text = strings.ToLower(text) text = strings.ToLower(text)
var tokens []string var tokens []string
@ -333,7 +333,7 @@ func tokenize(text string) []string {
for _, r := range text { for _, r := range text {
if unicode.IsLetter(r) || unicode.IsDigit(r) { if unicode.IsLetter(r) || unicode.IsDigit(r) {
current.WriteString(faltUmlaut(r)) current.WriteString(foldUmlauts(r))
} else { } else {
if current.Len() > 1 { if current.Len() > 1 {
tokens = append(tokens, current.String()) tokens = append(tokens, current.String())
@ -349,7 +349,7 @@ func tokenize(text string) []string {
return tokens return tokens
} }
// faltUmlaut bildet Umlaute und ß auf ihre Grundform ab. // foldUmlauts bildet Umlaute und ß auf ihre Grundform ab.
// //
// # Wozu? // # Wozu?
// //
@ -375,7 +375,7 @@ func tokenize(text string) []string {
// //
// Alle anderen Zeichen bleiben unverändert; für lateinische Buchstaben und // Alle anderen Zeichen bleiben unverändert; für lateinische Buchstaben und
// Ziffern kostet die Funktion nur einen Vergleich. // Ziffern kostet die Funktion nur einen Vergleich.
func faltUmlaut(r rune) string { func foldUmlauts(r rune) string {
switch r { switch r {
case 'ä': case 'ä':
return "a" return "a"

View file

@ -28,16 +28,16 @@ type Registry struct {
// mu schützt nur die Zuordnung Projekt → Fach und wird immer nur kurz // mu schützt nur die Zuordnung Projekt → Fach und wird immer nur kurz
// gehalten. Die eigentliche Arbeit (Aufbau, Aktualisierung) läuft unter // gehalten. Die eigentliche Arbeit (Aufbau, Aktualisierung) läuft unter
// der Sperre des jeweiligen Fachs. // der Sperre des jeweiligen Fachs.
mu sync.Mutex mu sync.Mutex
faecher map[string]*fach // Schlüssel: Projekt-ID 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 // dem Lesen der Einträge und dem Befüllen des Index. So lässt sich ein
// langsamer Aufbau nachstellen. // 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? // # Warum eine Sperre je Projekt?
// //
@ -48,7 +48,7 @@ type Registry struct {
// //
// Mit einer Sperre je Projekt wartet nur, wer auf genau dieses Projekt // 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. // zugreift. Das muss er auch: Ohne fertigen Index gibt es nichts zu suchen.
type fach struct { type slot struct {
mu sync.Mutex mu sync.Mutex
idx *Index // nil, solange der Index noch nicht (oder nicht mehr) aufgebaut ist 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. // genau passenden ungeprüften nicht verdrängen.
const curatedBoost = 1.2 const curatedBoost = 1.2
// boostFuer gibt den Suchfaktor eines Eintrags zurück. // boostFor gibt den Suchfaktor eines Eintrags zurück.
func boostFuer(e store.Entry) float64 { func boostFor(e store.Entry) float64 {
if e.Curated { if e.Curated {
return curatedBoost return curatedBoost
} }
@ -74,31 +74,31 @@ func boostFuer(e store.Entry) float64 {
// Zugriff auf ein Projekt (siehe For). // Zugriff auf ein Projekt (siehe For).
func NewRegistry(s *store.Store) *Registry { func NewRegistry(s *store.Store) *Registry {
return &Registry{ return &Registry{
store: s, store: s,
faecher: make(map[string]*fach), 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. // 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() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
f, ok := r.faecher[projectID] f, ok := r.slots[projectID]
if !ok { if !ok {
f = &fach{} f = &slot{}
r.faecher[projectID] = f r.slots[projectID] = f
} }
return f return f
} }
// vorhandenesFach gibt das Fach eines Projekts zurück, ohne eines anzulegen. // existingSlot gibt das Fach eines Projekts zurück, ohne eines anzulegen.
func (r *Registry) vorhandenesFach(projectID string) (*fach, bool) { func (r *Registry) existingSlot(projectID string) (*slot, bool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
f, ok := r.faecher[projectID] f, ok := r.slots[projectID]
return f, ok 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 // erste auf. Die anderen warten an der Sperre des Fachs und bekommen danach
// den fertigen Index. // den fertigen Index.
func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) { func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) {
f := r.fachFuer(projectID) f := r.slotFor(projectID)
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() 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. // 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 { 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 { if err != nil {
return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err) return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err)
} }
if r.nachDemLesen != nil { if r.afterRead != nil {
r.nachDemLesen(projectID) r.afterRead(projectID)
} }
for _, e := range eintraege { for _, e := range entries {
idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFuer(e)) idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFor(e))
} }
return nil 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 // Reihenfolge die Aufrufe ankommen. Das kostet eine kleine Leseabfrage je
// Schreibvorgang. // Schreibvorgang.
func (r *Registry) Refresh(ctx context.Context, projectID, slug string) { func (r *Registry) Refresh(ctx context.Context, projectID, slug string) {
f, ok := r.vorhandenesFach(projectID) f, ok := r.existingSlot(projectID)
if !ok { if !ok {
return 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) log.Printf("Suchindex: %q in Projekt %s nicht lesbar, Index wird neu aufgebaut: %v", slug, projectID, err)
f.idx = nil f.idx = nil
default: 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) { func (r *Registry) Forget(projectID string) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
delete(r.faecher, projectID) delete(r.slots, projectID)
} }

View file

@ -9,9 +9,9 @@ import (
"munin/internal/store" "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. // 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() t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) 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() }) t.Cleanup(func() { db.Close() })
ctx := context.Background() ctx := context.Background()
projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") project, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
r := NewRegistry(db) r := NewRegistry(db)
// Den Index vorher anlegen — sonst sind Aktualisierungen ein No-op. // 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 { if err != nil {
t.Fatal(err) 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() t.Helper()
e, err := db.PutEntry(context.Background(), store.EntryWrite{ 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -46,8 +46,8 @@ func schreibe(t *testing.T, db *store.Store, projekt store.Project, slug, inhalt
return e return e
} }
func findet(idx *Index, wort, slug string) bool { func finds(idx *Index, word, slug string) bool {
for _, r := range idx.Search(wort, 10) { for _, r := range idx.Search(word, 10) {
if r.DocID == slug { if r.DocID == slug {
return true return true
} }
@ -55,54 +55,54 @@ func findet(idx *Index, wort, slug string) bool {
return false 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 // 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. // 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 // 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. // wird die Reihenfolge deshalb von Hand vertauscht.
func TestUeberholteAktualisierungHaeltIndexAktuell(t *testing.T) { func TestStaleUpdateKeepsIndexCurrent(t *testing.T) {
r, db, projekt, idx := aufbauRegistry(t) r, db, project, idx := setupRegistry(t)
schreibe(t, db, projekt, "umkaempft", "Stand wortzwei") write(t, db, project, "umkaempft", "Stand wortzwei")
schreibe(t, db, projekt, "umkaempft", "Stand wortdrei") write(t, db, project, "umkaempft", "Stand wortdrei")
r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 3 r.Refresh(context.Background(), project.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 2, verspätet
if !findet(idx, "wortdrei", "umkaempft") { if !finds(idx, "wortdrei", "umkaempft") {
t.Error("der Index kennt den aktuellen Stand (Fassung 3) nicht") 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)") 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 // 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. // nach der des Löschens beim Index an, darf der Eintrag nicht wieder auftauchen.
func TestVerspaeteteAktualisierungNachLoeschenBleibtWeg(t *testing.T) { func TestLateUpdateAfterDeleteStaysGone(t *testing.T) {
r, db, projekt, idx := aufbauRegistry(t) r, db, project, idx := setupRegistry(t)
ctx := context.Background() ctx := context.Background()
schreibe(t, db, projekt, "kurzlebig", "Stand wortgeloescht") write(t, db, project, "kurzlebig", "Stand wortgeloescht")
if err := db.MoveEntryToTrash(ctx, projekt.ID, "kurzlebig", "test"); err != nil { if err := db.MoveEntryToTrash(ctx, project.ID, "kurzlebig", "test"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
r.Refresh(ctx, projekt.ID, "kurzlebig") // Löschender r.Refresh(ctx, project.ID, "kurzlebig") // Löschender
r.Refresh(ctx, projekt.ID, "kurzlebig") // Schreiber, verspätet 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") 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 // noch aufgebaut wird, muss Projekt B ohne Wartezeit durchkommen. Früher hielt
// der Aufbau eine Sperre für den ganzen Workspace. // 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")) db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -110,40 +110,40 @@ func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) {
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
ctx := context.Background() ctx := context.Background()
gross, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "") big, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
klein, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "") small, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
r := NewRegistry(db) r := NewRegistry(db)
imAufbau := make(chan struct{}) building := make(chan struct{})
weiter := make(chan struct{}) release := make(chan struct{})
r.nachDemLesen = func(projectID string) { r.afterRead = func(projectID string) {
if projectID == gross.ID { if projectID == big.ID {
close(imAufbau) close(building)
<-weiter // Aufbau hängt, bis der Test ihn freigibt <-release // Aufbau hängt, bis der Test ihn freigibt
} }
} }
fertig := make(chan error) done := make(chan error)
go func() { go func() {
_, err := r.For(ctx, gross.ID) _, err := r.For(ctx, big.ID)
fertig <- err done <- err
}() }()
<-imAufbau <-building
// Projekt B, während A noch aufgebaut wird. // Projekt B, während A noch aufgebaut wird.
klar := make(chan error) smallDone := make(chan error)
go func() { go func() {
_, err := r.For(ctx, klein.ID) _, err := r.For(ctx, small.ID)
klar <- err smallDone <- err
}() }()
select { select {
case err := <-klar: case err := <-smallDone:
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -151,17 +151,17 @@ func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) {
t.Fatal("Projekt B wartet auf den Aufbau von Projekt A") t.Fatal("Projekt B wartet auf den Aufbau von Projekt A")
} }
close(weiter) close(release)
if err := <-fertig; err != nil { if err := <-done; err != nil {
t.Fatal(err) 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 // Einträge schon gelesen, da wird einer davon geändert. Das Refresh des
// Schreibers muss warten und danach den neuen Stand nachtragen — sonst hielte // Schreibers muss warten und danach den neuen Stand nachtragen — sonst hielte
// der fertige Index den Stand von vor der Änderung. // 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")) db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -169,66 +169,66 @@ func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) {
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
ctx := context.Background() ctx := context.Background()
projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "") project, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
schreibe(t, db, projekt, "eintrag", "Stand wortalt") write(t, db, project, "eintrag", "Stand wortalt")
r := NewRegistry(db) r := NewRegistry(db)
refreshFertig := make(chan struct{}) refreshDone := make(chan struct{})
r.nachDemLesen = func(string) { r.afterRead = func(string) {
// Die Einträge sind gelesen, der Index noch nicht befüllt. Jetzt // Die Einträge sind gelesen, der Index noch nicht befüllt. Jetzt
// schreibt jemand und meldet es, wie es die Handler tun. // 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() { go func() {
r.Refresh(ctx, projekt.ID, "eintrag") r.Refresh(ctx, project.ID, "eintrag")
close(refreshFertig) close(refreshDone)
}() }()
// Refresh darf nicht durchlaufen, solange der Aufbau läuft. // Refresh darf nicht durchlaufen, solange der Aufbau läuft.
select { select {
case <-refreshFertig: case <-refreshDone:
t.Error("Refresh lief während des Aufbaus durch") t.Error("Refresh lief während des Aufbaus durch")
case <-time.After(50 * time.Millisecond): case <-time.After(50 * time.Millisecond):
} }
} }
idx, err := r.For(ctx, projekt.ID) idx, err := r.For(ctx, project.ID)
if err != nil { if err != nil {
t.Fatal(err) 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") 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") 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. // Einträgen den Suchfaktor mitgibt — beim Aufbau wie beim Nachziehen.
func TestRegistryBevorzugtKuratierte(t *testing.T) { func TestRegistryPrefersCurated(t *testing.T) {
r, db, projekt, idx := aufbauRegistry(t) r, db, project, idx := setupRegistry(t)
ctx := context.Background() ctx := context.Background()
for _, kuratiert := range []bool{false, true} { for _, curated := range []bool{false, true} {
slug := "ungeprueft" slug := "ungeprueft"
if kuratiert { if curated {
slug = "geprueft" slug = "geprueft"
} }
if _, err := db.PutEntry(ctx, store.EntryWrite{ if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: slug, Title: "Deployment", Content: "Ablauf.", ProjectID: project.ID, Slug: slug, Title: "Deployment", Content: "Ablauf.",
Source: store.SourceHuman, Curated: kuratiert, Source: store.SourceHuman, Curated: curated,
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
r.Refresh(ctx, projekt.ID, slug) r.Refresh(ctx, project.ID, slug)
} }
treffer := idx.Search("deployment", 10) hits := idx.Search("deployment", 10)
if len(treffer) != 2 || treffer[0].DocID != "geprueft" { if len(hits) != 2 || hits[0].DocID != "geprueft" {
t.Errorf("Treffer = %v, erwartet den kuratierten zuerst", ergebnisIDs(treffer)) t.Errorf("Treffer = %v, erwartet den kuratierten zuerst", resultIDs(hits))
} }
} }

View file

@ -5,8 +5,8 @@ import (
"testing" "testing"
) )
// baueIndex legt einen Index mit ein paar Dokumenten an. // buildIndex legt einen Index mit ein paar Dokumenten an.
func baueIndex() *Index { func buildIndex() *Index {
idx := NewIndex() idx := NewIndex()
idx.Index("deployment-prozess", "Deployment-Prozess", idx.Index("deployment-prozess", "Deployment-Prozess",
"Wir deployen über GitHub Actions auf den Produktivserver.", []string{"ops", "deployment"}) "Wir deployen über GitHub Actions auf den Produktivserver.", []string{"ops", "deployment"})
@ -17,20 +17,20 @@ func baueIndex() *Index {
return idx return idx
} }
// ergebnisIDs gibt die Kennungen der Treffer in Reihenfolge zurück. // resultIDs gibt die Kennungen der Treffer in Reihenfolge zurück.
func ergebnisIDs(treffer []Result) []string { func resultIDs(hits []Result) []string {
ids := make([]string, len(treffer)) ids := make([]string, len(hits))
for i, t := range treffer { for i, t := range hits {
ids[i] = t.DocID ids[i] = t.DocID
} }
return ids return ids
} }
func TestSearchFindsExactTerm(t *testing.T) { func TestSearchFindsExactTerm(t *testing.T) {
treffer := baueIndex().Search("bastion", 10) hits := buildIndex().Search("bastion", 10)
if len(treffer) != 1 || treffer[0].DocID != "server-zugang" { if len(hits) != 1 || hits[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v, erwartet nur server-zugang", ergebnisIDs(treffer)) 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-titel", "Deployment", "Ein Text über etwas ganz anderes.", nil)
idx.Index("im-text", "Irgendwas", "Hier kommt das Wort Deployment einmal vor.", 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 { if len(hits) != 2 {
t.Fatalf("%d Treffer, erwartet 2", len(treffer)) t.Fatalf("%d Treffer, erwartet 2", len(hits))
} }
if treffer[0].DocID != "im-titel" { if hits[0].DocID != "im-titel" {
t.Errorf("bester Treffer = %q, erwartet den Titel-Treffer", treffer[0].DocID) 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 // bei einer KI-Anfrage ein stiller Fehltreffer: Das Modell erfährt schlicht
// nicht, dass es den Eintrag gäbe. // nicht, dass es den Eintrag gäbe.
func TestSearchFindsByPrefix(t *testing.T) { 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") t.Fatal("die Präfix-Suche fand nichts")
} }
if treffer[0].DocID != "deployment-prozess" { if hits[0].DocID != "deployment-prozess" {
t.Errorf("bester Treffer = %q, erwartet deployment-prozess", treffer[0].DocID) t.Errorf("bester Treffer = %q, erwartet deployment-prozess", hits[0].DocID)
} }
} }
// TestSearchPrefixNeedsThreeChars prüft, dass sehr kurze Bruchstücke nicht // TestSearchPrefixNeedsThreeChars prüft, dass sehr kurze Bruchstücke nicht
// greifen — "de" würde sonst auf halbe Wörterbücher passen. // greifen — "de" würde sonst auf halbe Wörterbücher passen.
func TestSearchPrefixNeedsThreeChars(t *testing.T) { func TestSearchPrefixNeedsThreeChars(t *testing.T) {
if treffer := baueIndex().Search("de", 10); len(treffer) > 0 { if hits := buildIndex().Search("de", 10); len(hits) > 0 {
t.Errorf("zweibuchstabiges Präfix lieferte %d Treffer", len(treffer)) 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("exakt", "Test", "Das Wort ssh kommt hier vor.", nil)
idx.Index("praefix", "Test", "Hier steht sshd, also nur ein Wortanfang.", 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") t.Fatal("keine Treffer")
} }
if treffer[0].DocID != "exakt" { if hits[0].DocID != "exakt" {
t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID) t.Errorf("bester Treffer = %q, erwartet den exakten", hits[0].DocID)
} }
} }
func TestSearchTagsAreIndexed(t *testing.T) { func TestSearchTagsAreIndexed(t *testing.T) {
treffer := baueIndex().Search("sicherheit", 10) hits := buildIndex().Search("sicherheit", 10)
if len(treffer) != 1 || treffer[0].DocID != "server-zugang" { if len(hits) != 1 || hits[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", ergebnisIDs(treffer)) t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", resultIDs(hits))
} }
} }
func TestSearchIsCaseInsensitive(t *testing.T) { func TestSearchIsCaseInsensitive(t *testing.T) {
idx := baueIndex() idx := buildIndex()
klein := idx.Search("bastion", 10) lower := idx.Search("bastion", 10)
gross := idx.Search("BASTION", 10) upper := idx.Search("BASTION", 10)
if len(klein) != len(gross) || len(klein) == 0 { if len(lower) != len(upper) || len(lower) == 0 {
t.Errorf("Groß-/Kleinschreibung macht einen Unterschied: %d vs. %d", len(klein), len(gross)) 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) idx.Index(strings.Repeat("x", i+1), "Titel", "gemeinsames Stichwort", nil)
} }
if treffer := idx.Search("gemeinsames", 5); len(treffer) != 5 { if hits := idx.Search("gemeinsames", 5); len(hits) != 5 {
t.Errorf("%d Treffer, erwartet 5", len(treffer)) t.Errorf("%d Treffer, erwartet 5", len(hits))
} }
} }
func TestSearchEmptyQuery(t *testing.T) { func TestSearchEmptyQuery(t *testing.T) {
if treffer := baueIndex().Search("", 10); len(treffer) != 0 { if hits := buildIndex().Search("", 10); len(hits) != 0 {
t.Errorf("eine leere Anfrage lieferte %d Treffer", len(treffer)) 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", "Alt", "Das alte Stichwort lautet Apfelbaum.", nil)
idx.Index("eintrag", "Neu", "Das neue Stichwort lautet Birnbaum.", 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") 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") t.Error("der neue Inhalt fehlt im Index")
} }
} }
func TestIndexRemove(t *testing.T) { func TestIndexRemove(t *testing.T) {
idx := baueIndex() idx := buildIndex()
idx.Remove("server-zugang") 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") t.Error("der entfernte Eintrag ist noch auffindbar")
} }
// Die übrigen Einträge müssen unberührt bleiben. // 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") t.Error("beim Entfernen wurde zu viel gelöscht")
} }
} }
func TestRemoveUnknownIsHarmless(t *testing.T) { func TestRemoveUnknownIsHarmless(t *testing.T) {
idx := baueIndex() idx := buildIndex()
idx.Remove("gibtesnicht") 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") 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) { func TestTokenizeSplitsOnPunctuation(t *testing.T) {
got := tokenize("GitHub-Actions, SSH/SCP; done.") 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 { for _, token := range got {
if !erwartet[token] { if !want[token] {
t.Errorf("unerwartetes Token %q in %v", token, got) t.Errorf("unerwartetes Token %q in %v", token, got)
} }
} }
if len(got) != len(erwartet) { if len(got) != len(want) {
t.Errorf("tokenize = %v, erwartet %d Token", got, len(erwartet)) t.Errorf("tokenize = %v, erwartet %d Token", got, len(want))
} }
} }
// ── Umlaute ────────────────────────────────────────────────────────────────── // ── 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 // 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 // zwei verschiedene Wörter. Genau so tippt aber, wer auf einer fremden Tastatur
// sitzt. // sitzt.
func TestUmlautfaltung(t *testing.T) { func TestUmlautFolding(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("bericht", "Prüfung der Größe", 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"}) "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. // Jeweils: mit Umlaut geschrieben und ohne — beide müssen treffen.
paare := [][2]string{ pairs := [][2]string{
{"prüfung", "prufung"}, {"prüfung", "prufung"},
{"größe", "grosse"}, {"größe", "grosse"},
{"störung", "storung"}, {"störung", "storung"},
@ -215,28 +215,28 @@ func TestUmlautfaltung(t *testing.T) {
{"läuft", "lauft"}, {"läuft", "lauft"},
} }
for _, paar := range paare { for _, pair := range pairs {
for _, anfrage := range paar { for _, query := range pair {
if treffer := idx.Search(anfrage, 10); len(treffer) == 0 { if hits := idx.Search(query, 10); len(hits) == 0 {
t.Errorf("Suche nach %q fand nichts", anfrage) t.Errorf("Suche nach %q fand nichts", query)
} }
} }
} }
} }
// TestUmlautfaltungImSchlagwort prüft, dass auch Schlagworte gefaltet werden. // TestUmlautFoldingInTag prüft, dass auch Schlagworte gefaltet werden.
func TestUmlautfaltungImSchlagwort(t *testing.T) { func TestUmlautFoldingInTag(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("eintrag", "Titel", "Inhalt", []string{"prüfung"}) 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") 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. // anfasst als nötig — „neue" darf nicht zu „nu" werden.
func TestFaltungVerschontNormaleWoerter(t *testing.T) { func TestFoldingSparesNormalWords(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"neue": "neue", "neue": "neue",
"steuer": "steuer", "steuer": "steuer",
@ -246,64 +246,64 @@ func TestFaltungVerschontNormaleWoerter(t *testing.T) {
"tür": "tur", "tür": "tur",
} }
for eingabe, erwartet := range cases { for input, want := range cases {
got := tokenize(eingabe) got := tokenize(input)
if len(got) != 1 || got[0] != erwartet { if len(got) != 1 || got[0] != want {
t.Errorf("tokenize(%q) = %v, erwartet [%s]", eingabe, got, erwartet) t.Errorf("tokenize(%q) = %v, erwartet [%s]", input, got, want)
} }
} }
} }
// TestPraefixMitUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche. // TestPrefixWithUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche.
func TestPraefixMitUmlaut(t *testing.T) { func TestPrefixWithUmlaut(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("eintrag", "Überwachung", "Die Überwachung meldet Störungen.", nil) idx.Index("eintrag", "Überwachung", "Die Überwachung meldet Störungen.", nil)
// "uberwach" ist Präfix von "uberwachung" — beides gefaltet. // "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") t.Error("Präfix ohne Umlaut fand den Eintrag nicht")
} }
} }
// ── Bevorzugung (Boost) ────────────────────────────────────────────────────── // ── Bevorzugung (Boost) ──────────────────────────────────────────────────────
// TestBoostBevorzugtBeiGleicherRelevanz prüft, dass der Faktor bei sonst // TestBoostPrefersOnEqualRelevance prüft, dass der Faktor bei sonst
// gleichen Dokumenten den Ausschlag gibt. // gleichen Dokumenten den Ausschlag gibt.
func TestBoostBevorzugtBeiGleicherRelevanz(t *testing.T) { func TestBoostPrefersOnEqualRelevance(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("normal", "Deployment", "Ablauf.", nil) idx.Index("normal", "Deployment", "Ablauf.", nil)
idx.IndexWithBoost("bevorzugt", "Deployment", "Ablauf.", nil, 1.2) 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" { if len(hits) != 2 || hits[0].DocID != "bevorzugt" {
t.Errorf("Treffer = %v, erwartet bevorzugt zuerst", ergebnisIDs(treffer)) 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. // klar besser passenden Eintrag nicht überholt.
func TestBoostVerdraengtKeinenGenauenTreffer(t *testing.T) { func TestBoostDoesNotDisplaceExactHit(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("genau", "Zertifikatswechsel", "Der Zertifikatswechsel im März.", nil) idx.Index("genau", "Zertifikatswechsel", "Der Zertifikatswechsel im März.", nil)
idx.IndexWithBoost("rand", "Wartung", "Allgemeines, unter anderem ein Zertifikatswechsel.", nil, 1.2) 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" { if len(hits) != 2 || hits[0].DocID != "genau" {
t.Errorf("Treffer = %v, erwartet genau zuerst", ergebnisIDs(treffer)) 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. // unauffindbar macht.
func TestBoostOhneWertIstNeutral(t *testing.T) { func TestBoostWithoutValueIsNeutral(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.IndexWithBoost("null", "Deployment", "Ablauf.", nil, 0) 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 { if len(hits) != 1 || hits[0].Score <= 0 {
t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", treffer) t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", hits)
} }
} }

View file

@ -26,39 +26,39 @@ import (
// Gewichte der Varianten — ein exakter Treffer zählt 1. // Gewichte der Varianten — ein exakter Treffer zählt 1.
const ( 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". // 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 // „prozess" → „releaseprozess". Niedriger als der Wortanfang, weil
// Teilwörter öfter zufällig passen. // 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". // unterscheiden: „deploymnet" → „deployment".
gewichtTippfehler = 0.3 weightTypo = 0.3
) )
// Mindestlängen der Suchwörter für die einzelnen Varianten. Kürzere Wörter // 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" // passen auf zu vieles: „de" ist der Anfang halber Wörterbücher, und „port"
// steckt in „Reporting", „Export" und „Support". // steckt in „Reporting", „Export" und „Support".
const ( const (
minWortanfang = 3 minWordPrefix = 3
minTeilwort = 5 minSubword = 5
minStamm = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt minStem = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt
minTippfehler = 4 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. // Ähnlichkeit gefunden wurden, samt ihrem Gewicht.
type variante struct { type variant struct {
postings []posting postings []posting
gewicht float64 weight float64
} }
// variantenFuer sucht alle Fundstellen eines Suchworts: exakt, über den // variantsFor sucht alle Fundstellen eines Suchworts: exakt, über den
// Wortanfang, als Teilwort und — nur wenn tippfehler gesetzt ist und sonst // Wortanfang, als Teilwort und — nur wenn typo gesetzt ist und sonst
// gar nichts passt — mit Tippfehlern. // gar nichts passt — mit Tippfehlern.
// //
// Muss unter der Lesesperre des Index aufgerufen werden. // 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 // geht die Funktion einmal den ganzen Wortschatz des Projekts durch. Gemessen
// (siehe bench_test.go) sind das bei 20.000 verschiedenen Wörtern einige // (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. // Zehntel Millisekunden je Anfrage — für Agenten wie Menschen nicht spürbar.
func (s *Index) variantenFuer(term string, tippfehler bool) []variante { func (s *Index) variantsFor(term string, typo bool) []variant {
var ergebnis []variante var result []variant
if exakt := s.invertedIndex[term]; len(exakt) > 0 { if exact := s.invertedIndex[term]; len(exact) > 0 {
ergebnis = append(ergebnis, variante{postings: exakt, gewicht: 1}) result = append(result, variant{postings: exact, weight: 1})
} }
stamm := kuerzeEndung(term) stem := trimSuffix(term)
sucheWortanfang := len(term) >= minWortanfang searchWordPrefix := len(term) >= minWordPrefix
sucheTeilwort := len(term) >= minTeilwort searchSubword := len(term) >= minSubword
// Je Variante: Dokument → höchste Häufigkeit eines passenden Worts. // Je Variante: Dokument → höchste Häufigkeit eines passenden Worts.
// Höchste statt Summe aus demselben Grund wie oben: „deploy", „deployment" // Höchste statt Summe aus demselben Grund wie oben: „deploy", „deployment"
// und „deployt" im selben Eintrag sollen nicht dreifach zählen. // und „deployt" im selben Eintrag sollen nicht dreifach zählen.
wortanfang := make(map[string]int) wordPrefix := make(map[string]int)
teilwort := make(map[string]int) subword := make(map[string]int)
for wort, postings := range s.invertedIndex { for word, postings := range s.invertedIndex {
switch { switch {
case sucheWortanfang && strings.HasPrefix(wort, term): case searchWordPrefix && strings.HasPrefix(word, term):
merkeHoechste(wortanfang, postings) keepHighest(wordPrefix, postings)
case stamm != "" && strings.HasPrefix(wort, stamm): case stem != "" && strings.HasPrefix(word, stem):
merkeHoechste(wortanfang, postings) keepHighest(wordPrefix, postings)
case sucheTeilwort && strings.Contains(wort, term): case searchSubword && strings.Contains(word, term):
merkeHoechste(teilwort, postings) keepHighest(subword, postings)
} }
} }
if len(wortanfang) > 0 { if len(wordPrefix) > 0 {
ergebnis = append(ergebnis, variante{postings: alsPostings(wortanfang), gewicht: gewichtWortanfang}) result = append(result, variant{postings: asPostings(wordPrefix), weight: weightWordPrefix})
} }
if len(teilwort) > 0 { if len(subword) > 0 {
ergebnis = append(ergebnis, variante{postings: alsPostings(teilwort), gewicht: gewichtTeilwort}) result = append(result, variant{postings: asPostings(subword), weight: weightSubword})
} }
// Tippfehler nur als letzte Rückfallebene: Sie sind die unsicherste // Tippfehler nur als letzte Rückfallebene: Sie sind die unsicherste
// Variante und kosten den meisten Rechenaufwand. // Variante und kosten den meisten Rechenaufwand.
if tippfehler && len(ergebnis) == 0 { if typo && len(result) == 0 {
if treffer := s.tippfehlerPostings(term); len(treffer) > 0 { if hits := s.typoPostings(term); len(hits) > 0 {
ergebnis = append(ergebnis, variante{postings: treffer, gewicht: gewichtTippfehler}) 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. // Suchwort nur um wenige Buchstaben unterscheiden.
func (s *Index) tippfehlerPostings(term string) []posting { func (s *Index) typoPostings(term string) []posting {
suchwort := []rune(term) searchWord := []rune(term)
erlaubt := erlaubteTippfehler(len(suchwort)) allowed := allowedTypos(len(searchWord))
if erlaubt == 0 { if allowed == 0 {
return nil return nil
} }
beste := make(map[string]int) best := make(map[string]int)
for wort, postings := range s.invertedIndex { for word, postings := range s.invertedIndex {
// Schneller Vorfilter: Unterscheiden sich die Längen schon stärker als // Schneller Vorfilter: Unterscheiden sich die Längen schon stärker als
// erlaubt, kann der Abstand nicht kleiner sein. Das erspart die // erlaubt, kann der Abstand nicht kleiner sein. Das erspart die
// eigentliche Rechnung für fast alle Wörter. // eigentliche Rechnung für fast alle Wörter.
laenge := utf8.RuneCountInString(wort) length := utf8.RuneCountInString(word)
if abs(laenge-len(suchwort)) > erlaubt { if abs(length-len(searchWord)) > allowed {
continue continue
} }
if levenshtein(suchwort, []rune(wort)) <= erlaubt { if levenshtein(searchWord, []rune(word)) <= allowed {
merkeHoechste(beste, postings) 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: // Länge abweichen dürfen. Bei kurzen Wörtern wäre schon ein Fehler zu viel:
// „host" und „post" sind verschiedene Dinge. // „host" und „post" sind verschiedene Dinge.
func erlaubteTippfehler(laenge int) int { func allowedTypos(length int) int {
switch { switch {
case laenge < minTippfehler: case length < minTypo:
return 0 return 0
case laenge < 8: case length < 8:
return 1 return 1
default: default:
return 2 return 2
@ -165,33 +165,33 @@ func erlaubteTippfehler(laenge int) int {
// Feldern. Jede Zeile braucht aber nur die Zeile davor — deshalb genügen hier // Feldern. Jede Zeile braucht aber nur die Zeile davor — deshalb genügen hier
// zwei Zeilen, die abwechselnd überschrieben werden. // zwei Zeilen, die abwechselnd überschrieben werden.
func levenshtein(a, b []rune) int { func levenshtein(a, b []rune) int {
vorher := make([]int, len(b)+1) before := make([]int, len(b)+1)
aktuell := make([]int, len(b)+1) current := make([]int, len(b)+1)
// Erste Zeile: aus dem leeren Wort wird b durch j-maliges Einfügen. // Erste Zeile: aus dem leeren Wort wird b durch j-maliges Einfügen.
for j := range vorher { for j := range before {
vorher[j] = j before[j] = j
} }
for i := 1; i <= len(a); i++ { 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++ { for j := 1; j <= len(b); j++ {
kosten := 1 cost := 1
if a[i-1] == b[j-1] { if a[i-1] == b[j-1] {
kosten = 0 cost = 0
} }
aktuell[j] = min( current[j] = min(
vorher[j]+1, // Buchstabe aus a löschen before[j]+1, // Buchstabe aus a löschen
aktuell[j-1]+1, // Buchstabe aus b einfügen current[j-1]+1, // Buchstabe aus b einfügen
vorher[j-1]+kosten, // Buchstabe ersetzen (oder gleich lassen) 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. // häufigsten deutschen Plural- und Beugungsendungen, dazu das englische -s.
// Längere Endungen stehen vorn, damit „prozesses" zu „prozess" wird und nicht // Längere Endungen stehen vorn, damit „prozesses" zu „prozess" wird und nicht
// zu „prozesse". // 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 // Bewusst nicht dabei ist „-er": Im Deutschen bildet es meist ein eigenes Wort
// („Rechner", „Benutzer"), und aus „Server" würde „Serv", das dann auch // („Rechner", „Benutzer"), und aus „Server" würde „Serv", das dann auch
// „Service" fände. // „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. // oder "", wenn es nichts zu kürzen gibt.
// //
// deployen → deploy (findet dann „deployment") // 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 // 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 // Suchwort mit Endung auch das Wort ohne Endung findet. Da der Stamm nur als
// Wortanfang gesucht wird, findet umgekehrt „zertifikat" auch „zertifikate". // Wortanfang gesucht wird, findet umgekehrt „zertifikat" auch „zertifikate".
func kuerzeEndung(term string) string { func trimSuffix(term string) string {
for _, endung := range endungen { for _, suffix := range suffixes {
if strings.HasSuffix(term, endung) && len(term)-len(endung) >= minStamm { if strings.HasSuffix(term, suffix) && len(term)-len(suffix) >= minStem {
return term[:len(term)-len(endung)] return term[:len(term)-len(suffix)]
} }
} }
return "" 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. // das Thema verraten.
// //
// # Warum nur beim Suchen? // # Warum nur beim Suchen?
// //
// Die Liste wird nur auf die Anfrage angewendet, nicht beim Indizieren. Der // 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 // Index bleibt also vollständig, und wer gezielt nach „was" sucht, findet es
// weiterhin (siehe ohneStoppwoerter). // weiterhin (siehe withoutStopwords).
// //
// # Warum überhaupt? // # 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" // Nicht dabei sind Wörter, die in der IT etwas bedeuten können, etwa „it"
// oder „go". // oder „go".
var stoppwoerter = map[string]bool{ var stopwords = map[string]bool{
// Deutsch // Deutsch
"der": true, "die": true, "das": true, "den": true, "dem": true, "des": true, "der": true, "die": true, "das": true, "den": true, "dem": true, "des": true,
"ein": true, "eine": true, "einen": true, "einem": true, "einer": true, "eines": 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, "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 // 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 // meint jemand offenbar genau diese Wörter, und eine leere Anfrage hülfe
// niemandem. // niemandem.
func ohneStoppwoerter(tokens []string) []string { func withoutStopwords(tokens []string) []string {
rest := make([]string, 0, len(tokens)) rest := make([]string, 0, len(tokens))
for _, t := range tokens { for _, t := range tokens {
if !stoppwoerter[t] { if !stopwords[t] {
rest = append(rest, t) rest = append(rest, t)
} }
} }
@ -287,22 +287,22 @@ func ohneStoppwoerter(tokens []string) []string {
return rest return rest
} }
// merkeHoechste trägt für jedes Dokument die höchste Häufigkeit ein. // keepHighest trägt für jedes Dokument die höchste Häufigkeit ein.
func merkeHoechste(beste map[string]int, postings []posting) { func keepHighest(best map[string]int, postings []posting) {
for _, p := range postings { for _, p := range postings {
if p.freq > beste[p.docID] { if p.freq > best[p.docID] {
beste[p.docID] = p.freq best[p.docID] = p.freq
} }
} }
} }
// alsPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um. // asPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um.
func alsPostings(beste map[string]int) []posting { func asPostings(best map[string]int) []posting {
ergebnis := make([]posting, 0, len(beste)) result := make([]posting, 0, len(best))
for docID, freq := range beste { for docID, freq := range best {
ergebnis = append(ergebnis, posting{docID: docID, freq: freq}) result = append(result, posting{docID: docID, freq: freq})
} }
return ergebnis return result
} }
// abs gibt den Betrag einer ganzen Zahl zurück. // abs gibt den Betrag einer ganzen Zahl zurück.
@ -313,7 +313,7 @@ func abs(n int) int {
return n 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. // 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 // 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 // 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 // müsste je Projekt neu eingestellt werden. Der Anteil gemeinsamer Wörter ist
// dagegen in jedem Projekt gleich zu lesen. // dagegen in jedem Projekt gleich zu lesen.
func Ueberdeckung(anfrage, text string) float64 { func Coverage(query, text string) float64 {
anteil, _ := UeberdeckungMitSuchbegriffen(anfrage, text, "") share, _ := CoverageWithSearchTerms(query, text, "")
return anteil 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 // 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 // (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? // # Warum zwei Teile?
// //
@ -353,65 +353,65 @@ func Ueberdeckung(anfrage, text string) float64 {
// //
// „Release-Ablauf" vs. Suchbegriff „release" → zählt (wörtlich) // „Release-Ablauf" vs. Suchbegriff „release" → zählt (wörtlich)
// „Betrieb mit PostgreSQL" vs. Suchbegriff „postgres" → zählt nicht // „Betrieb mit PostgreSQL" vs. Suchbegriff „postgres" → zählt nicht
func UeberdeckungMitSuchbegriffen(anfrage, titel, suchbegriffe string) (float64, []string) { func CoverageWithSearchTerms(query, title, searchTerms string) (float64, []string) {
woerter := ohneStoppwoerter(tokenize(anfrage)) words := withoutStopwords(tokenize(query))
if len(woerter) == 0 { if len(words) == 0 {
return 0, nil return 0, nil
} }
imTitel := tokenize(titel) inTitle := tokenize(title)
wortgenau := make(map[string]bool) wordExact := make(map[string]bool)
for _, w := range tokenize(suchbegriffe) { for _, w := range tokenize(searchTerms) {
wortgenau[w] = true wordExact[w] = true
} }
// Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht // Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht
// zu zwei Dritteln „Server". // zu zwei Dritteln „Server".
gesehen := make(map[string]bool, len(woerter)) seen := make(map[string]bool, len(words))
gesamt := 0 total := 0
var gefunden []string var found []string
for _, w := range woerter { for _, w := range words {
if gesehen[w] { if seen[w] {
continue continue
} }
gesehen[w] = true seen[w] = true
gesamt++ total++
if wortgenau[w] { if wordExact[w] {
gefunden = append(gefunden, w) found = append(found, w)
continue continue
} }
for _, v := range imTitel { for _, v := range inTitle {
if passtZu(w, v) { if matches(w, v) {
gefunden = append(gefunden, w) found = append(found, w)
break 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. // matches prüft, ob zwei einzelne Wörter als dasselbe gelten.
func passtZu(a, b string) bool { func matches(a, b string) bool {
if a == b { if a == b {
return true return true
} }
// Wortanfang, in beide Richtungen: „deploy" ↔ „deployment", // Wortanfang, in beide Richtungen: „deploy" ↔ „deployment",
// „releaseprozess" ↔ „release". Beide müssen lang genug sein, sonst passt // „releaseprozess" ↔ „release". Beide müssen lang genug sein, sonst passt
// „de" zu allem. // „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)) { (strings.HasPrefix(a, b) || strings.HasPrefix(b, a)) {
return true return true
} }
// Endung: „zertifikate" ↔ „zertifikat". // Endung: „zertifikate" ↔ „zertifikat".
if stamm := kuerzeEndung(a); stamm != "" && strings.HasPrefix(b, stamm) { if stem := trimSuffix(a); stem != "" && strings.HasPrefix(b, stem) {
return true return true
} }
if stamm := kuerzeEndung(b); stamm != "" && strings.HasPrefix(a, stamm) { if stem := trimSuffix(b); stem != "" && strings.HasPrefix(a, stem) {
return true return true
} }
// Teil eines zusammengesetzten Worts: „prozess" ↔ „releaseprozess". // 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 true
} }
return len(b) >= minTeilwort && strings.Contains(a, b) return len(b) >= minSubword && strings.Contains(a, b)
} }

View file

@ -6,124 +6,124 @@ import (
"testing" "testing"
) )
// enthaelt prüft, ob ein Eintrag unter den Treffern ist. // contains prüft, ob ein Eintrag unter den Treffern ist.
func enthaelt(treffer []Result, docID string) bool { func contains(hits []Result, docID string) bool {
return slices.Contains(ergebnisIDs(treffer), docID) return slices.Contains(resultIDs(hits), docID)
} }
// ── Wortanfang ─────────────────────────────────────────────────────────────── // ── 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 // 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" // exakten Treffer gab. Stand „deploy" in irgendeinem Eintrag, fand „deploy"
// den „Deployment-Prozess" nicht mehr — ein stiller Fehltreffer, der umso // den „Deployment-Prozess" nicht mehr — ein stiller Fehltreffer, der umso
// häufiger wird, je größer ein Projekt ist. // häufiger wird, je größer ein Projekt ist.
func TestWortanfangNebenExaktemTreffer(t *testing.T) { func TestWordPrefixBesideExactHit(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("skript", "Skript", "Das Skript heißt deploy und liegt im Repository.", nil) 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) 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") { if !contains(hits, "prozess") {
t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", ergebnisIDs(treffer)) t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", resultIDs(hits))
} }
if treffer[0].DocID != "skript" { if hits[0].DocID != "skript" {
t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID) 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. // desselben Anfangs nicht vor einem exakten Treffer landet.
func TestWortanfangZaehltNichtDoppelt(t *testing.T) { func TestWordPrefixNotCountedTwice(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("exakt", "Hinweis", "Hier steht config.", nil) idx.Index("exakt", "Hinweis", "Hier steht config.", nil)
idx.Index("viele", "Hinweis", "configuration configure configured configs configurable", 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" { if len(hits) != 2 || hits[0].DocID != "exakt" {
t.Errorf("Treffer = %v, erwartet exakt vor viele", ergebnisIDs(treffer)) t.Errorf("Treffer = %v, erwartet exakt vor viele", resultIDs(hits))
} }
} }
// ── Zusammengesetzte Wörter ────────────────────────────────────────────────── // ── 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; // Bindestrich („Release-Prozess") genügte schon immer die Zerlegung in Wörter;
// zusammengeschrieben ist „prozess" aber weder ein eigenes Wort noch ein // zusammengeschrieben ist „prozess" aber weder ein eigenes Wort noch ein
// Wortanfang. // Wortanfang.
func TestTeilwortFindetKomposita(t *testing.T) { func TestSubwordFindsCompounds(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("release", "Releaseprozess", "Ablauf für neue Fassungen.", nil) 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("zertifikat", "Wartung", "Der jährliche SSL-Zertifikatswechsel steht im Kalender.", nil)
idx.Index("datenbank", "Datenbankzugang", "Zugriff nur über das VPN.", nil) idx.Index("datenbank", "Datenbankzugang", "Zugriff nur über das VPN.", nil)
faelle := map[string]string{ cases := map[string]string{
"prozess": "release", "prozess": "release",
"wechsel": "zertifikat", "wechsel": "zertifikat",
"zugang": "datenbank", "zugang": "datenbank",
"datenbank": "datenbank", // Wortanfang, kein Teilwort — darf trotzdem nicht verloren gehen "datenbank": "datenbank", // Wortanfang, kein Teilwort — darf trotzdem nicht verloren gehen
} }
for anfrage, erwartet := range faelle { for query, want := range cases {
if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) { if hits := idx.Search(query, 10); !contains(hits, want) {
t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) 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. // zufällig in zu vielen anderen.
func TestTeilwortBrauchtFuenfZeichen(t *testing.T) { func TestSubwordNeedsFiveChars(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("bericht", "Reporting", "Monatliche Auswertung.", nil) idx.Index("bericht", "Reporting", "Monatliche Auswertung.", nil)
if treffer := idx.Search("port", 10); len(treffer) > 0 { if hits := idx.Search("port", 10); len(hits) > 0 {
t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", ergebnisIDs(treffer)) 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. // dem Wort, in dem es nur steckt.
func TestExaktVorTeilwort(t *testing.T) { func TestExactBeforeSubword(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("kompositum", "Releaseprozess", "Ablauf.", nil) idx.Index("kompositum", "Releaseprozess", "Ablauf.", nil)
idx.Index("eigenstaendig", "Prozess", "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" { if len(hits) != 2 || hits[0].DocID != "eigenstaendig" {
t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", ergebnisIDs(treffer)) t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", resultIDs(hits))
} }
} }
// ── Endungen ───────────────────────────────────────────────────────────────── // ── 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" // Wort im Eintrag. Die Suche nach dem Wortanfang hilft hier nicht: „deployen"
// ist kein Anfang von „deployment". // ist kein Anfang von „deployment".
func TestEndungenWerdenAusgeglichen(t *testing.T) { func TestSuffixesAreNormalized(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("deployment", "Deployment", "Ablauf.", nil) idx.Index("deployment", "Deployment", "Ablauf.", nil)
idx.Index("zertifikat", "Zertifikat", "Erneuerung.", nil) idx.Index("zertifikat", "Zertifikat", "Erneuerung.", nil)
idx.Index("datenbank", "Datenbank", "Sicherung.", nil) idx.Index("datenbank", "Datenbank", "Sicherung.", nil)
faelle := map[string]string{ cases := map[string]string{
"deployen": "deployment", "deployen": "deployment",
"zertifikate": "zertifikat", "zertifikate": "zertifikat",
"datenbanken": "datenbank", "datenbanken": "datenbank",
} }
for anfrage, erwartet := range faelle { for query, want := range cases {
if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) { if hits := idx.Search(query, 10); !contains(hits, want) {
t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) t.Errorf("Search(%q) = %v, erwartet %s", query, resultIDs(hits), want)
} }
} }
} }
func TestKuerzeEndung(t *testing.T) { func TestTrimSuffix(t *testing.T) {
faelle := map[string]string{ cases := map[string]string{
"deployen": "deploy", "deployen": "deploy",
"zertifikate": "zertifikat", "zertifikate": "zertifikat",
"datenbanken": "datenbank", "datenbanken": "datenbank",
@ -134,96 +134,96 @@ func TestKuerzeEndung(t *testing.T) {
"server": "", // „-er" wird bewusst nicht gekürzt "server": "", // „-er" wird bewusst nicht gekürzt
} }
for eingabe, erwartet := range faelle { for input, want := range cases {
if got := kuerzeEndung(eingabe); got != erwartet { if got := trimSuffix(input); got != want {
t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", eingabe, got, erwartet) t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", input, got, want)
} }
} }
} }
// ── Stoppwörter ────────────────────────────────────────────────────────────── // ── 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". // 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 := NewIndex()
idx.Index("deployment", "Deployment-Prozess", "So kommt eine Fassung in Produktion.", nil) 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) 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") { if !contains(hits, "deployment") {
t.Errorf("Treffer = %v, erwartet deployment", ergebnisIDs(treffer)) t.Errorf("Treffer = %v, erwartet deployment", resultIDs(hits))
} }
if enthaelt(treffer, "kuehlung") { if contains(hits, "kuehlung") {
t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", ergebnisIDs(treffer)) 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. // aus Stoppwörtern, wird trotzdem gesucht.
func TestNurStoppwoerterBleibtSuchbar(t *testing.T) { func TestOnlyStopwordsStaysSearchable(t *testing.T) {
idx := NewIndex() idx := NewIndex()
idx.Index("anleitung", "Wie man testet", "Schritt für Schritt.", nil) 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") 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", // in der Liste steht, die tokenize erzeugt. Stünde dort „für" statt „fur",
// würde es nie greifen. // würde es nie greifen.
func TestStoppwoerterSindGefaltet(t *testing.T) { func TestStopwordsAreFolded(t *testing.T) {
for wort := range stoppwoerter { for word := range stopwords {
if got := tokenize(wort); len(got) != 1 || got[0] != wort { 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", wort, got) t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", word, got)
} }
} }
} }
// ── Tippfehler ─────────────────────────────────────────────────────────────── // ── 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. // 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 := NewIndex()
idx.Index("deployment", "Deployment", "Ablauf.", nil) idx.Index("deployment", "Deployment", "Ablauf.", nil)
idx.Index("server", "Server", "Wartung.", nil) idx.Index("server", "Server", "Wartung.", nil)
faelle := map[string]string{ cases := map[string]string{
"deploymnet": "deployment", // zwei vertauschte Buchstaben "deploymnet": "deployment", // zwei vertauschte Buchstaben
"sever": "server", // ein Buchstabe fehlt "sever": "server", // ein Buchstabe fehlt
} }
for anfrage, erwartet := range faelle { for query, want := range cases {
if treffer := idx.SearchFuzzy(anfrage, 10); !enthaelt(treffer, erwartet) { if hits := idx.SearchFuzzy(query, 10); !contains(hits, want) {
t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet) t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", query, resultIDs(hits), want)
} }
if treffer := idx.Search(anfrage, 10); len(treffer) > 0 { if hits := idx.Search(query, 10); len(hits) > 0 {
t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", anfrage, ergebnisIDs(treffer)) 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". // 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 := NewIndex()
idx.Index("host", "Host", "Rechner im Netz.", nil) idx.Index("host", "Host", "Rechner im Netz.", nil)
idx.Index("cost", "Cost", "Kostenstelle.", 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" { if len(hits) != 1 || hits[0].DocID != "host" {
t.Errorf("Treffer = %v, erwartet nur host", ergebnisIDs(treffer)) t.Errorf("Treffer = %v, erwartet nur host", resultIDs(hits))
} }
} }
func TestLevenshtein(t *testing.T) { func TestLevenshtein(t *testing.T) {
faelle := []struct { cases := []struct {
a, b string a, b string
abstand int distance int
}{ }{
{"server", "server", 0}, {"server", "server", 0},
{"sever", "server", 1}, // einfügen {"sever", "server", 1}, // einfügen
@ -234,33 +234,33 @@ func TestLevenshtein(t *testing.T) {
{"prüfung", "prufung", 1}, // Runen, nicht Bytes {"prüfung", "prufung", 1}, // Runen, nicht Bytes
} }
for _, f := range faelle { for _, f := range cases {
if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.abstand { 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.abstand) t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.distance)
} }
} }
} }
func TestErlaubteTippfehler(t *testing.T) { func TestAllowedTypos(t *testing.T) {
faelle := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2} cases := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2}
for laenge, erwartet := range faelle { for length, want := range cases {
if got := erlaubteTippfehler(laenge); got != erwartet { if got := allowedTypos(length); got != want {
t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", laenge, got, erwartet) t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", length, got, want)
} }
} }
} }
// ── Überdeckung (Dubletten) ────────────────────────────────────────────────── // ── Ü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. // Suchbegriff nur wörtlich. Dazu kommen die gefundenen Wörter zurück.
func TestUeberdeckungMitSuchbegriffen(t *testing.T) { func TestCoverageWithSearchTerms(t *testing.T) {
faelle := []struct { cases := []struct {
name string name string
anfrage, titel, suchbegriffe string query, title, searchTerms string
anteil float64 share float64
gemeinsam string shared string
}{ }{
{"Suchbegriff wörtlich", "Release-Ablauf", "Deployment-Prozess", "release, rollout", 0.5, "release"}, {"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, ""}, {"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"}, {"Umlaute im Suchbegriff", "Übergabe", "Anderes", "uebergabe, übergabe", 1, "ubergabe"},
{"nichts", "VPN-Zugang", "Drucker", "", 0, ""}, {"nichts", "VPN-Zugang", "Drucker", "", 0, ""},
} }
for _, f := range faelle { for _, f := range cases {
t.Run(f.name, func(t *testing.T) { t.Run(f.name, func(t *testing.T) {
anteil, gemeinsam := UeberdeckungMitSuchbegriffen(f.anfrage, f.titel, f.suchbegriffe) share, shared := CoverageWithSearchTerms(f.query, f.title, f.searchTerms)
if anteil != f.anteil || strings.Join(gemeinsam, " ") != f.gemeinsam { if share != f.share || strings.Join(shared, " ") != f.shared {
t.Errorf("= %v %q, erwartet %v %q", anteil, gemeinsam, f.anteil, f.gemeinsam) t.Errorf("= %v %q, erwartet %v %q", share, shared, f.share, f.shared)
} }
}) })
} }
} }
func TestUeberdeckung(t *testing.T) { func TestCoverage(t *testing.T) {
faelle := []struct { cases := []struct {
anfrage, text string query, text string
erwartet float64 want float64
}{ }{
{"Deployment-Ablauf", "Deployment-Prozess", 0.5}, {"Deployment-Ablauf", "Deployment-Prozess", 0.5},
{"Releaseprozess", "Release-Prozess", 1}, {"Releaseprozess", "Release-Prozess", 1},
@ -296,9 +296,9 @@ func TestUeberdeckung(t *testing.T) {
{"Port", "Reporting", 0}, // zu kurz für ein Teilwort {"Port", "Reporting", 0}, // zu kurz für ein Teilwort
} }
for _, f := range faelle { for _, f := range cases {
if got := Ueberdeckung(f.anfrage, f.text); got != f.erwartet { if got := Coverage(f.query, f.text); got != f.want {
t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.anfrage, f.text, got, f.erwartet) t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.query, f.text, got, f.want)
} }
} }
} }

View file

@ -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
}

View file

@ -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)
}
}

290
internal/stats/stats.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -66,14 +66,14 @@ const (
// festen Werte oben, etwa mit `detail LIKE 'wiederhergestellt%'`. Eine // festen Werte oben, etwa mit `detail LIKE 'wiederhergestellt%'`. Eine
// Wiederherstellung ist fachlich immer eine Aktualisierung: Der Eintrag // Wiederherstellung ist fachlich immer eine Aktualisierung: Der Eintrag
// existiert ja schon, sonst gäbe es keine alte Fassung. // 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. // 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 // DetailCuratedOverridden leitet im Protokoll einen Schreibvorgang ein, mit dem
// ein Agent einen kuratierten Eintrag geändert hat — und damit die Markierung // 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. // Begründung des Agenten.
// //
// Fachlich ist das eine Aktualisierung und zählt in der Nutzungsübersicht auch // 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. // auffindbar: CuratedOverrides sucht mit LIKE danach.
const DetailCuratedOverridden = "kuratiert aufgehoben" 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. // nicht den Eintrag noch einmal enthalten.
const maxBegruendung = 300 const maxJustification = 300
// CuratedOverrideDetail baut den Protokolltext für DetailCuratedOverridden. // CuratedOverrideDetail baut den Protokolltext für DetailCuratedOverridden.
func CuratedOverrideDetail(begruendung string) string { func CuratedOverrideDetail(justification string) string {
begruendung = strings.TrimSpace(begruendung) justification = strings.TrimSpace(justification)
// In Runen zählen, nicht in Bytes — sonst könnte der Schnitt mitten in // In Runen zählen, nicht in Bytes — sonst könnte der Schnitt mitten in
// einem Umlaut landen. // einem Umlaut landen.
if r := []rune(begruendung); len(r) > maxBegruendung { if r := []rune(justification); len(r) > maxJustification {
begruendung = string(r[:maxBegruendung]) + "…" justification = string(r[:maxJustification]) + "…"
} }
return DetailCuratedOverridden + ": " + begruendung return DetailCuratedOverridden + ": " + justification
} }
// EntryWriteDetail sagt anhand des gespeicherten Eintrags, ob der // EntryWriteDetail sagt anhand des gespeicherten Eintrags, ob der
@ -129,7 +129,7 @@ func (s *Store) LogAudit(ctx context.Context, e AuditEvent) {
e.ID = NewID() 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) `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, NowString(), e.ProjectID, e.APIKeyID, e.Actor, e.Action, e.EntrySlug, e.Source, e.IP, e.Detail, 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 // Die Bedingungen werden gesammelt und mit AND verbunden. Werte gehen
// immer als Platzhalter in die Abfrage, nie als Text. // immer als Platzhalter in die Abfrage, nie als Text.
var bedingungen []string var conditions []string
werte := []any{} values := []any{}
if f.ProjectID != "" { if f.ProjectID != "" {
bedingungen = append(bedingungen, `project_id = ?`) conditions = append(conditions, `project_id = ?`)
werte = append(werte, f.ProjectID) values = append(values, f.ProjectID)
} }
if f.Action != "" { if f.Action != "" {
bedingungen = append(bedingungen, `action = ?`) conditions = append(conditions, `action = ?`)
werte = append(werte, f.Action) values = append(values, f.Action)
} }
if f.Actor != "" { if f.Actor != "" {
bedingungen = append(bedingungen, `actor = ?`) conditions = append(conditions, `actor = ?`)
werte = append(werte, f.Actor) values = append(values, f.Actor)
} }
if !f.Since.IsZero() { if !f.Since.IsZero() {
bedingungen = append(bedingungen, `ts >= ?`) conditions = append(conditions, `ts >= ?`)
werte = append(werte, formatTime(f.Since)) values = append(values, formatTime(f.Since))
} }
if !f.Until.IsZero() { if !f.Until.IsZero() {
bedingungen = append(bedingungen, `ts < ?`) conditions = append(conditions, `ts < ?`)
werte = append(werte, formatTime(f.Until)) 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` FROM audit_events`
if len(bedingungen) > 0 { if len(conditions) > 0 {
abfrage += ` WHERE ` + strings.Join(bedingungen, ` AND `) query += ` WHERE ` + strings.Join(conditions, ` AND `)
} }
abfrage += ` ORDER BY ts DESC LIMIT ?` query += ` ORDER BY ts DESC LIMIT ?`
werte = append(werte, limit) values = append(values, limit)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil { if err != nil {
return nil, fmt.Errorf("Audit-Log konnte nicht gelesen werden: %w", err) return nil, fmt.Errorf("Audit-Log konnte nicht gelesen werden: %w", err)
} }
defer rows.Close() defer rows.Close()
var ereignisse []AuditEvent var events []AuditEvent
for rows.Next() { for rows.Next() {
var e AuditEvent var e AuditEvent
var ts string var ts string
@ -231,39 +231,39 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, err
return nil, err return nil, err
} }
e.Timestamp = parseTime(ts) 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 — // AuditActors nennt alle Zugangsnamen, die im Protokoll vorkommen, sortiert —
// für die Auswahlliste der Protokollseite. Mit projectID nur die eines // für die Auswahlliste der Protokollseite. Mit projectID nur die eines
// Projekts. Höchstens auditActorLimit, damit die Liste bedienbar bleibt. // Projekts. Höchstens auditActorLimit, damit die Liste bedienbar bleibt.
func (s *Store) AuditActors(ctx context.Context, projectID string) ([]string, error) { func (s *Store) AuditActors(ctx context.Context, projectID string) ([]string, error) {
abfrage := `SELECT DISTINCT actor FROM audit_events WHERE actor <> ''` query := `SELECT DISTINCT actor FROM audit_events WHERE actor <> ''`
werte := []any{} values := []any{}
if projectID != "" { if projectID != "" {
abfrage += ` AND project_id = ?` query += ` AND project_id = ?`
werte = append(werte, projectID) values = append(values, projectID)
} }
abfrage += ` ORDER BY actor LIMIT ?` query += ` ORDER BY actor LIMIT ?`
werte = append(werte, auditActorLimit) values = append(values, auditActorLimit)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil { if err != nil {
return nil, fmt.Errorf("Zugänge im Protokoll konnten nicht gelesen werden: %w", err) return nil, fmt.Errorf("Zugänge im Protokoll konnten nicht gelesen werden: %w", err)
} }
defer rows.Close() defer rows.Close()
var namen []string var names []string
for rows.Next() { for rows.Next() {
var n string var n string
if err := rows.Scan(&n); err != nil { if err := rows.Scan(&n); err != nil {
return nil, err 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. // 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. // 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. // Übergeben wird die Grenze als Zeitstempel im Datenbankformat; alles davor fällt weg.
func (s *Store) PruneAudit(ctx context.Context, aelterAls string) (int64, error) { func (s *Store) PruneAudit(ctx context.Context, olderThan string) (int64, error) {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM audit_events WHERE ts < ?`, aelterAls) res, err := s.write.ExecContext(ctx, `DELETE FROM audit_events WHERE ts < ?`, olderThan)
if err != nil { if err != nil {
return 0, fmt.Errorf("Audit-Log konnte nicht aufgeräumt werden: %w", err) 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. // ProjectNames gibt eine Zuordnung von Projekt-ID zu Anzeigename zurück.
// Wird in der Audit-Ansicht gebraucht, damit dort nicht rohe IDs stehen. // 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) { 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 { if err != nil {
return nil, err return nil, err
} }
namen := make(map[string]string, len(projekte)) names := make(map[string]string, len(projects))
for _, p := range projekte { for _, p := range projects {
namen[p.ID] = p.Name 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 // 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 return map[string]string{}, nil
} }
platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(projectIDs)), ",") placeholder := strings.TrimSuffix(strings.Repeat("?,", len(projectIDs)), ",")
werte := make([]any, len(projectIDs)) values := make([]any, len(projectIDs))
for i, id := range projectIDs { for i, id := range projectIDs {
werte[i] = id values[i] = id
} }
rows, err := s.lesen.QueryContext(ctx, rows, err := s.read.QueryContext(ctx,
`SELECT id, name FROM api_keys WHERE project_id IN (`+platzhalter+`)`, werte...) `SELECT id, name FROM api_keys WHERE project_id IN (`+placeholder+`)`, values...)
if err != nil { if err != nil {
return nil, fmt.Errorf("Key-Namen konnten nicht gelesen werden: %w", err) return nil, fmt.Errorf("Key-Namen konnten nicht gelesen werden: %w", err)
} }
defer rows.Close() defer rows.Close()
namen := make(map[string]string) names := make(map[string]string)
for rows.Next() { for rows.Next() {
var id, name string var id, name string
if err := rows.Scan(&id, &name); err != nil { if err := rows.Scan(&id, &name); err != nil {
return nil, err return nil, err
} }
namen[id] = name names[id] = name
} }
return namen, rows.Err() return names, rows.Err()
} }

View file

@ -17,26 +17,26 @@ import "context"
// //
// In beiden Fällen bekommt jeder Workspace seinen eigenen, abgeschotteten // In beiden Fällen bekommt jeder Workspace seinen eigenen, abgeschotteten
// Bereich mit denselben Tabellen. // Bereich mit denselben Tabellen.
// Kapazitaet beschreibt, wie viele Datenbankverbindungen Munin belegen darf. // Capacity beschreibt, wie viele Datenbankverbindungen Munin belegen darf.
// Die Rechnung dahinter steht bei PruefeKapazitaet (speicher_postgres.go). // Die Rechnung dahinter steht bei CheckCapacity (backend_postgres.go).
type Kapazitaet struct { type Capacity struct {
Begrenzt bool // false: keine gemeinsame Grenze (SQLite) Limited bool // false: keine gemeinsame Grenze (SQLite)
Erlaubt int // max_connections des Servers Allowed int // max_connections des Servers
Reserve int // bleibt frei für psql, pg_dump, Superuser Reserve int // bleibt frei für psql, pg_dump, Superuser
JeWorkspace int // so viele belegt ein geöffneter Workspace höchstens PerWorkspace int // so viele belegt ein geöffneter Workspace höchstens
Fest int // Kontroll-Datenbank und Verwaltungsverbindung Fixed int // Kontroll-Datenbank und Verwaltungsverbindung
} }
// MaxWorkspaces ist die Zahl der Workspaces, die mit dieser Grenze höchstens // MaxWorkspaces ist die Zahl der Workspaces, die mit dieser Grenze höchstens
// gleichzeitig geöffnet sein können. // gleichzeitig geöffnet sein können.
func (k Kapazitaet) MaxWorkspaces() int { func (k Capacity) MaxWorkspaces() int {
if !k.Begrenzt || k.JeWorkspace == 0 { if !k.Limited || k.PerWorkspace == 0 {
return 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 // OpenControl öffnet die Kontroll-Datenbank und bringt ihr Schema auf den
// aktuellen Stand. // aktuellen Stand.
OpenControl() (*ControlStore, error) OpenControl() (*ControlStore, error)
@ -49,29 +49,29 @@ type Speicher interface {
// Der Store dazu muss vorher geschlossen sein (siehe workspace.Manager). // Der Store dazu muss vorher geschlossen sein (siehe workspace.Manager).
DeleteWorkspace(workspaceID string) error 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 // Verbindungen bräuchten, als die Datenbank erlaubt. Bei SQLite gibt es
// diese Grenze nicht. // 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). // 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 // (PostgreSQL) bzw. die Datei (SQLite). Für die Workspace-Übersicht des
// Plattform-Admins, damit sich ein Workspace in der Datenbank wiederfinden // Plattform-Admins, damit sich ein Workspace in der Datenbank wiederfinden
// lässt. // 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, // verhindern, aber behoben werden sollten — für die Systemseite. Leer,
// wenn alles in Ordnung ist. // 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. // Passwort.
Beschreibung() string Description() string
// Close gibt alles frei, was der Speicher selbst offen hält (nicht die // Close gibt alles frei, was der Speicher selbst offen hält (nicht die
// geöffneten Stores — die schließt ihr jeweiliger Besitzer). // geöffneten Stores — die schließt ihr jeweiliger Besitzer).

View file

@ -21,7 +21,7 @@ import (
// (max_connections, Standard 100). Würde Munin mehr öffnen, lehnte PostgreSQL // (max_connections, Standard 100). Würde Munin mehr öffnen, lehnte PostgreSQL
// neue Verbindungen ab und Anfragen schlügen fehl. Deshalb gelten hier feste, // 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 // 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“. // AUSBAUSTUFEN.md 4.10, „Verbindungen fest begrenzt“.
// //
// Kleine Pools reichen, weil eine Verbindung nur für die Dauer einer Abfrage // 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 // Ist ein Pool voll, wartet die Anfrage in Go auf eine freie Verbindung (bis
// zum Ende ihres Kontexts), statt in PostgreSQL zu scheitern. // zum Ende ihres Kontexts), statt in PostgreSQL zu scheitern.
const ( const (
// pgLeseverbindungen je Workspace (und für die Kontroll-Datenbank). // pgReadConns je Workspace (und für die Kontroll-Datenbank).
pgLeseverbindungen = 2 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. // wie unter SQLite: Schreibvorgänge eines Workspace laufen nacheinander.
// Mehr erst, wenn die schreibenden Methoden gegen gleichzeitiges Schreiben // Mehr erst, wenn die schreibenden Methoden gegen gleichzeitiges Schreiben
// abgesichert sind (AUSBAUSTUFEN.md 4.10, Schritt 4) — sonst kann z.B. // abgesichert sind (AUSBAUSTUFEN.md 4.10, Schritt 4) — sonst kann z.B.
// PutEntry unter PostgreSQL Änderungen verlieren. // 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. // 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. // 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 // pgReserve bleibt frei — für psql, Backups (pg_dump) und die
// Verbindungen, die PostgreSQL selbst für Superuser zurückhält. // Verbindungen, die PostgreSQL selbst für Superuser zurückhält.
pgReserve = 10 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. // 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 // Beim Start ist PostgreSQL vielleicht noch nicht bereit (der Container
// fährt gerade hoch). Munin versucht es so oft, mit dieser Pause dazwischen. // fährt gerade hoch). Munin versucht es so oft, mit dieser Pause dazwischen.
pgStartVersuche = 15 pgStartAttempts = 15
pgStartPause = 2 * time.Second pgStartPause = 2 * time.Second
) )
@ -68,14 +68,14 @@ const (
// Anwendungen liegen. Begründung: AUSBAUSTUFEN.md 4.10, „Schemanamen aus der // Anwendungen liegen. Begründung: AUSBAUSTUFEN.md 4.10, „Schemanamen aus der
// Workspace-ID“. // Workspace-ID“.
const ( const (
ControlSchema = "munin_control" ControlSchema = "munin_control"
workspaceSchemaPraefix = "munin_ws_" 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 // Ziffern, Bindestrich). Alles andere wird abgelehnt, bevor es in einen
// Schemanamen wandert. // Schemanamen wandert.
var erlaubteWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`) var validWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`)
// WorkspaceSchema liefert den Schemanamen eines Workspace, z.B. // WorkspaceSchema liefert den Schemanamen eines Workspace, z.B.
// "munin_ws_mue9bh3x_1c0e1120". // "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. // 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 // "-" wird zu "_", weil ein Bindestrich in PostgreSQL überall Anführungszeichen
// erzwingen würde. Den lesbaren Namen trägt ein Kommentar am Schema (siehe // erzwingen würde. Den lesbaren Namen trägt ein Kommentar am Schema (siehe
// nachDemBenennen). // afterRename).
func WorkspaceSchema(workspaceID string) (string, error) { 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) 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 // PostgreSQL kürzt längere Namen stillschweigend auf 63 Byte. Zwei lange
// IDs könnten dann im selben Schema landen — lieber gleich ablehnen. // IDs könnten dann im selben Schema landen — lieber gleich ablehnen.
@ -100,7 +100,7 @@ func WorkspaceSchema(workspaceID string) (string, error) {
// ── Speicher ───────────────────────────────────────────────────────────────── // ── 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. // Schema in einer PostgreSQL-Datenbank ab.
// //
// Jedes Schema bekommt eigene Pools, deren Verbindungen mit // 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 // SQLite unverändert — "SELECT … FROM entries" trifft automatisch die Tabelle
// im richtigen Schema, und ein Workspace kann die Tabellen eines anderen gar // im richtigen Schema, und ein Workspace kann die Tabellen eines anderen gar
// nicht erst sehen. // nicht erst sehen.
type PostgresSpeicher struct { type PostgresBackend struct {
basis *pgx.ConnConfig // aus DATABASE_URL; je Schema kopiert und ergänzt 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 admin *sql.DB // eine Verbindung ohne search_path, für CREATE/DROP SCHEMA
// controlSchema ist im Betrieb immer ControlSchema. Nur Tests setzen ein // controlSchema ist im Betrieb immer ControlSchema. Nur Tests setzen ein
@ -117,58 +117,58 @@ type PostgresSpeicher struct {
// anfassen (siehe postgres_test.go). // anfassen (siehe postgres_test.go).
controlSchema string 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). // Systemseite gehört (siehe Hinweise).
hinweise []string hints []string
} }
// NewPostgres verbindet sich mit der Datenbank aus url, wartet nötigenfalls, // NewPostgres verbindet sich mit der Datenbank aus url, wartet nötigenfalls,
// bis sie bereit ist, und legt das Schema der Kontroll-Datenbank an. // bis sie bereit ist, und legt das Schema der Kontroll-Datenbank an.
func NewPostgres(ctx context.Context, url string) (*PostgresSpeicher, error) { func NewPostgres(ctx context.Context, url string) (*PostgresBackend, error) {
basis, err := pgx.ParseConfig(url) base, err := pgx.ParseConfig(url)
if err != nil { if err != nil {
// pgx ersetzt das Passwort in der Fehlermeldung durch "xxxxx". // pgx ersetzt das Passwort in der Fehlermeldung durch "xxxxx".
return nil, fmt.Errorf("DATABASE_URL ist ungültig: %w", err) return nil, fmt.Errorf("DATABASE_URL ist ungültig: %w", err)
} }
admin := stdlib.OpenDB(*basis) admin := stdlib.OpenDB(*base)
admin.SetMaxOpenConns(1) 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() admin.Close()
return nil, err 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() admin.Close()
return nil, err return nil, err
} }
return p, nil 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 // 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 // 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 // Sekunden später bereit als Munin. Ohne Wiederholung bräche Munin dann sofort
// ab. // ab.
func (p *PostgresSpeicher) warteAufDatenbank(ctx context.Context) error { func (p *PostgresBackend) waitForDatabase(ctx context.Context) error {
var letzterFehler error var lastErr error
for versuch := 1; versuch <= pgStartVersuche; versuch++ { for attempt := 1; attempt <= pgStartAttempts; attempt++ {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
letzterFehler = p.admin.PingContext(pingCtx) lastErr = p.admin.PingContext(pingCtx)
cancel() cancel()
if letzterFehler == nil { if lastErr == nil {
return nil return nil
} }
if versuch < pgStartVersuche { if attempt < pgStartAttempts {
log.Printf("PostgreSQL ist noch nicht erreichbar (Versuch %d von %d): %v", versuch, pgStartVersuche, letzterFehler) log.Printf("PostgreSQL ist noch nicht erreichbar (Versuch %d von %d): %v", attempt, pgStartAttempts, lastErr)
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() 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. // angelegt wurde.
// //
// SQLite sortiert Text Byte für Byte. PostgreSQL sortiert nach der // 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 // 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 // sortiert als unter SQLite. Kein Grund, den Start zu verweigern, aber einer
// für einen deutlichen Hinweis. // für einen deutlichen Hinweis.
func (p *PostgresSpeicher) pruefeSortierung(ctx context.Context) { func (p *PostgresBackend) checkCollation(ctx context.Context) {
var sortierung string var sortOrder string
err := p.admin.QueryRowContext(ctx, err := p.admin.QueryRowContext(ctx,
`SELECT datcollate FROM pg_database WHERE datname = current_database()`, `SELECT datcollate FROM pg_database WHERE datname = current_database()`,
).Scan(&sortierung) ).Scan(&sortOrder)
if err != nil { if err != nil {
log.Printf("Hinweis: Sortierung der Datenbank konnte nicht geprüft werden: %v", err) log.Printf("Hinweis: Sortierung der Datenbank konnte nicht geprüft werden: %v", err)
return return
} }
if sortierung != "C" && sortierung != "POSIX" { if sortOrder != "C" && sortOrder != "POSIX" {
hinweis := fmt.Sprintf("Die Datenbank sortiert nach %q statt \"C\". Listen sind dann anders sortiert als unter SQLite. "+ 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).", sortierung) "Abhilfe: Datenbank mit --locale=C anlegen (siehe AUSBAUSTUFEN.md 4.10).", sortOrder)
log.Printf("Warnung: %s", hinweis) log.Printf("Warnung: %s", hint)
p.hinweise = append(p.hinweise, hinweis) 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 // Liste entsteht einmal in NewPostgres: Die Sortierung einer Datenbank lässt
// sich nachträglich nicht ändern, ein erneutes Nachfragen brächte nichts. // 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. // OpenControl öffnet die Kontroll-Datenbank im Schema munin_control.
// //
// Der ControlStore bekommt den Speicher als Haken mit: Er prüft vor jedem neuen // 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 // Workspace die Verbindungen und legt danach dessen Schema samt lesbarem Namen
// an (siehe vorDemAnlegen, nachDemBenennen). // an (siehe beforeCreate, afterRename).
func (p *PostgresSpeicher) OpenControl() (*ControlStore, error) { func (p *PostgresBackend) OpenControl() (*ControlStore, error) {
lesen, schreiben, err := p.oeffnePools(p.controlSchema, controlMigrationsFS, "migrations_control") read, write, err := p.openPools(p.controlSchema, controlMigrationsFS, "migrations_control")
if err != nil { if err != nil {
return nil, err 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 // OpenWorkspace öffnet das Schema eines Workspace und legt es an, falls es
// noch fehlt. // noch fehlt.
func (p *PostgresSpeicher) OpenWorkspace(workspaceID string) (*Store, error) { func (p *PostgresBackend) OpenWorkspace(workspaceID string) (*Store, error) {
schema, err := WorkspaceSchema(workspaceID) schema, err := WorkspaceSchema(workspaceID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := p.legeSchemaAn(context.Background(), schema); err != nil { if err := p.createSchema(context.Background(), schema); err != nil {
return nil, err return nil, err
} }
lesen, schreiben, err := p.oeffnePools(schema, migrationsFS, "migrations") read, write, err := p.openPools(schema, migrationsFS, "migrations")
if err != nil { if err != nil {
return nil, err 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 // DeleteWorkspace löscht das Schema eines Workspace samt aller Tabellen und
// Daten darin. // Daten darin.
func (p *PostgresSpeicher) DeleteWorkspace(workspaceID string) error { func (p *PostgresBackend) DeleteWorkspace(workspaceID string) error {
schema, err := WorkspaceSchema(workspaceID) schema, err := WorkspaceSchema(workspaceID)
if err != nil { if err != nil {
return err return err
@ -255,58 +255,58 @@ func (p *PostgresSpeicher) DeleteWorkspace(workspaceID string) error {
return nil return nil
} }
// Kapazitaet liest max_connections und nennt die Größen, mit denen // Capacity liest max_connections und nennt die Größen, mit denen
// PruefeKapazitaet rechnet. // CheckCapacity rechnet.
func (p *PostgresSpeicher) Kapazitaet(ctx context.Context) (Kapazitaet, error) { func (p *PostgresBackend) Capacity(ctx context.Context) (Capacity, error) {
erlaubt, err := p.maxConnections(ctx) allowed, err := p.maxConnections(ctx)
if err != nil { if err != nil {
return Kapazitaet{}, err return Capacity{}, err
} }
return Kapazitaet{ return Capacity{
Begrenzt: true, Erlaubt: erlaubt, Reserve: pgReserve, Limited: true, Allowed: allowed, Reserve: pgReserve,
JeWorkspace: pgVerbindungenJeWorkspace, Fest: pgFesteVerbindungen, PerWorkspace: pgConnsPerWorkspace, Fixed: pgFixedConns,
}, nil }, nil
} }
// maxConnections liest max_connections des Servers. // 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 var text string
if err := p.admin.QueryRowContext(ctx, `SHOW max_connections`).Scan(&text); err != nil { 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) 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 { if err != nil {
return 0, fmt.Errorf("max_connections hat einen unerwarteten Wert %q", text) 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 bleiben (abzüglich Reserve).
// //
// max_connections wird jedes Mal frisch gelesen: Wer es nach einer Meldung // 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 // erhöht und PostgreSQL neu startet, soll Munin dafür nicht auch neu starten
// müssen. // müssen.
func (p *PostgresSpeicher) PruefeKapazitaet(ctx context.Context, anzahl int) error { func (p *PostgresBackend) CheckCapacity(ctx context.Context, count int) error {
erlaubt, err := p.maxConnections(ctx) allowed, err := p.maxConnections(ctx)
if err != nil { if err != nil {
return err return err
} }
bedarf := anzahl*pgVerbindungenJeWorkspace + pgFesteVerbindungen demand := count*pgConnsPerWorkspace + pgFixedConns
if bedarf > erlaubt-pgReserve { if demand > allowed-pgReserve {
return fmt.Errorf("zu wenige PostgreSQL-Verbindungen: %d Workspaces brauchen bis zu %d Verbindungen "+ 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). "+ "(%d je Workspace + %d fest), max_connections ist %d (davon %d Reserve). "+
"max_connections auf mindestens %d erhöhen", "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 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 // Eine ID, die keinen gültigen Schemanamen ergibt, kann es hier nicht geben
// (OpenWorkspace hätte sie abgelehnt); falls doch, steht der Grund da. // (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) schema, err := WorkspaceSchema(workspaceID)
if err != nil { if err != nil {
return err.Error() return err.Error()
@ -314,30 +314,30 @@ func (p *PostgresSpeicher) Ort(workspaceID string) string {
return schema return schema
} }
// Beschreibung nennt Server und Datenbank, nie das Passwort. // Description nennt Server und Datenbank, nie das Passwort.
func (p *PostgresSpeicher) Beschreibung() string { func (p *PostgresBackend) Description() string {
return fmt.Sprintf("PostgreSQL — %s:%d/%s (Schemas %s, %s…)", 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. // 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 ─────────────────────────────────────────────── // ── Haken für den ControlStore ───────────────────────────────────────────────
func (p *PostgresSpeicher) vorDemAnlegen(ctx context.Context, anzahl int) error { func (p *PostgresBackend) beforeCreate(ctx context.Context, count int) error {
return p.PruefeKapazitaet(ctx, anzahl) 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 // schreibt den lesbaren Namen als Kommentar daran. In psql zeigt \dn+ ihn
// neben dem Schemanamen. // 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) schema, err := WorkspaceSchema(workspaceID)
if err != nil { if err != nil {
return err return err
} }
if err := p.legeSchemaAn(ctx, schema); err != nil { if err := p.createSchema(ctx, schema); err != nil {
return err return err
} }
@ -354,7 +354,7 @@ func (p *PostgresSpeicher) nachDemBenennen(ctx context.Context, workspaceID, nam
// ── Hilfsfunktionen ────────────────────────────────────────────────────────── // ── 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, if _, err := p.admin.ExecContext(ctx,
`CREATE SCHEMA IF NOT EXISTS `+pgx.Identifier{schema}.Sanitize(), `CREATE SCHEMA IF NOT EXISTS `+pgx.Identifier{schema}.Sanitize(),
); err != nil { ); err != nil {
@ -363,33 +363,33 @@ func (p *PostgresSpeicher) legeSchemaAn(ctx context.Context, schema string) erro
return nil 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. // 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) { func (p *PostgresBackend) openPools(schema string, fsys embed.FS, migrationsDir string) (read, write pool, err error) {
d := postgresDialekt{} d := postgresDialect{}
// Jede Verbindung dieser Pools startet mit search_path=<schema>. Kopie, // Jede Verbindung dieser Pools startet mit search_path=<schema>. Kopie,
// damit die Grundeinstellung für die anderen Schemas unverändert bleibt. // damit die Grundeinstellung für die anderen Schemas unverändert bleibt.
cfg := p.basis.Copy() cfg := p.base.Copy()
cfg.RuntimeParams["search_path"] = schema cfg.RuntimeParams["search_path"] = schema
oeffne := func(maxVerbindungen int) *sql.DB { open := func(maxConns int) *sql.DB {
db := stdlib.OpenDB(*cfg) db := stdlib.OpenDB(*cfg)
db.SetMaxOpenConns(maxVerbindungen) db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(maxVerbindungen) db.SetMaxIdleConns(maxConns)
db.SetConnMaxIdleTime(pgLeerlauf) db.SetConnMaxIdleTime(pgIdleTime)
return db return db
} }
schreibDB := oeffne(pgSchreibverbindungen) writeDB := open(pgWriteConns)
schreiben = pool{db: schreibDB, d: d} write = pool{db: writeDB, d: d}
if err := runMigrations(schreiben, fsys, migrationsDir); err != nil { if err := runMigrations(write, fsys, migrationsDir); err != nil {
schreibDB.Close() writeDB.Close()
return pool{}, pool{}, fmt.Errorf("Schema %s: %w", schema, err) return pool{}, pool{}, fmt.Errorf("Schema %s: %w", schema, err)
} }
lesen = pool{db: oeffne(pgLeseverbindungen), d: d} read = pool{db: open(pgReadConns), d: d}
return lesen, schreiben, nil return read, write, nil
} }
// textLiteral schreibt s als PostgreSQL-Textkonstante, z.B. E'Vertrieb & Co'. // textLiteral schreibt s als PostgreSQL-Textkonstante, z.B. E'Vertrieb & Co'.

View file

@ -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")
}

View file

@ -23,57 +23,57 @@ var controlMigrationsFS embed.FS
// eines zweiten Satzes Methoden auf Store, damit nie aus Versehen eine // eines zweiten Satzes Methoden auf Store, damit nie aus Versehen eine
// Workspace-Abfrage gegen die falsche Datei läuft. // Workspace-Abfrage gegen die falsche Datei läuft.
type ControlStore struct { type ControlStore struct {
lesen pool read pool
schreiben 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): // Umbenennen eines Workspace braucht. Bei SQLite gibt es keine (nil):
// Eine Workspace-Datei entsteht beim ersten Öffnen von selbst. Bei // Eine Workspace-Datei entsteht beim ersten Öffnen von selbst. Bei
// PostgreSQL wird geprüft, ob die Verbindungen reichen, und das Schema // PostgreSQL wird geprüft, ob die Verbindungen reichen, und das Schema
// samt lesbarem Namen angelegt (siehe speicher_postgres.go). // samt lesbarem Namen angelegt (siehe backend_postgres.go).
haken workspaceHaken hooks workspaceHooks
} }
// workspaceHaken wird vom Speicher gesetzt, siehe ControlStore.haken. // workspaceHooks wird vom Speicher gesetzt, siehe ControlStore.haken.
type workspaceHaken interface { type workspaceHooks interface {
// vorDemAnlegen prüft, ob ein weiterer Workspace möglich ist. anzahl ist // beforeCreate prüft, ob ein weiterer Workspace möglich ist. anzahl ist
// die Zahl der Workspaces MIT dem neuen. // 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. // afterRename läuft nach dem Anlegen und nach jedem Umbenennen.
nachDemBenennen(ctx context.Context, workspaceID, name string) error afterRename(ctx context.Context, workspaceID, name string) error
} }
// OpenControl öffnet die Kontroll-Datenbank als SQLite-Datei und bringt ihr // 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) { 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 { if err != nil {
return nil, err 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). // PoolStats liefert die Zahlen beider Pools (siehe Verbindungen).
func (c *ControlStore) Verbindungen() Verbindungen { func (c *ControlStore) PoolStats() PoolStats {
return Verbindungen{Lesen: c.lesen.db.Stats(), Schreiben: c.schreiben.db.Stats()} return PoolStats{Read: c.read.db.Stats(), Write: c.write.db.Stats()}
} }
// Close schließt beide Verbindungspools. // Close schließt beide Verbindungspools.
func (c *ControlStore) Close() error { func (c *ControlStore) Close() error {
fehlerLesen := c.lesen.Close() readErr := c.read.Close()
if err := c.schreiben.Close(); err != nil { if err := c.write.Close(); err != nil {
return err return err
} }
return fehlerLesen return readErr
} }
// Ping prüft mit einer echten Abfrage, ob die Kontroll-Datenbank antwortet. // 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, // Für /ready (siehe internal/web/server.go). Bei SQLite ist das die Datei,
// bei PostgreSQL der Datenbankserver — und damit auch der Weg dorthin. // bei PostgreSQL der Datenbankserver — und damit auch der Weg dorthin.
func (c *ControlStore) Ping(ctx context.Context) error { func (c *ControlStore) Ping(ctx context.Context) error {
var eins int var one int
return c.lesen.QueryRowContext(ctx, `SELECT 1`).Scan(&eins) return c.read.QueryRowContext(ctx, `SELECT 1`).Scan(&one)
} }
// ── Workspaces ─────────────────────────────────────────────────────────────── // ── Workspaces ───────────────────────────────────────────────────────────────
@ -90,7 +90,7 @@ func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string)
var w Workspace var w Workspace
var createdAt string 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`, `SELECT id, name, created_at FROM workspaces ORDER BY created_at LIMIT 1`,
).Scan(&w.ID, &w.Name, &createdAt) ).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. // CreateWorkspace legt einen neuen Workspace an.
func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Workspace, error) { func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Workspace, error) {
if c.haken != nil { if c.hooks != nil {
var bisher int var sofar int
if err := c.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspaces`).Scan(&bisher); err != nil { 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) 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 return Workspace{}, err
} }
} }
w := Workspace{ID: NewID(), Name: name} 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 (?, ?, ?)`, `INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)`,
w.ID, w.Name, jetzt, w.ID, w.Name, now,
); err != nil { ); err != nil {
return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err) return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err)
} }
if c.haken != nil { if c.hooks != nil {
if err := c.haken.nachDemBenennen(ctx, w.ID, w.Name); err != nil { if err := c.hooks.afterRename(ctx, w.ID, w.Name); err != nil {
return Workspace{}, err return Workspace{}, err
} }
} }
w.CreatedAt = parseTime(jetzt) w.CreatedAt = parseTime(now)
return w, nil return w, nil
} }
// ListWorkspaces gibt alle Workspaces zurück, älteste zuerst. // ListWorkspaces gibt alle Workspaces zurück, älteste zuerst.
func (c *ControlStore) ListWorkspaces(ctx context.Context) ([]Workspace, error) { 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`, `SELECT id, name, created_at FROM workspaces ORDER BY created_at`,
) )
if err != nil { if err != nil {
@ -165,7 +165,7 @@ func (c *ControlStore) WorkspaceByID(ctx context.Context, id string) (Workspace,
var w Workspace var w Workspace
var createdAt string var createdAt string
err := c.lesen.QueryRowContext(ctx, err := c.read.QueryRowContext(ctx,
`SELECT id, name, created_at FROM workspaces WHERE id = ?`, id, `SELECT id, name, created_at FROM workspaces WHERE id = ?`, id,
).Scan(&w.ID, &w.Name, &createdAt) ).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. // UpdateWorkspace benennt einen Workspace um.
func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) error { 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, `UPDATE workspaces SET name = ? WHERE id = ?`, name, id,
) )
if err != nil { if err != nil {
return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err) return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err)
} }
betroffen, err := res.RowsAffected() affected, err := res.RowsAffected()
if err != nil { if err != nil {
return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err) return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err)
} }
if betroffen == 0 { if affected == 0 {
return ErrNotFound return ErrNotFound
} }
if c.haken != nil { if c.hooks != nil {
return c.haken.nachDemBenennen(ctx, id, name) return c.hooks.afterRename(ctx, id, name)
} }
return nil 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 // wenn beides passiert ist, ist der Workspace wirklich weg (siehe
// handleWorkspaceDelete in internal/web/handlers_workspaces.go). // handleWorkspaceDelete in internal/web/handlers_workspaces.go).
func (c *ControlStore) DeleteWorkspace(ctx context.Context, id string) error { 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 { if err != nil {
return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err) return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err)
} }
betroffen, err := res.RowsAffected() affected, err := res.RowsAffected()
if err != nil { if err != nil {
return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err) return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err)
} }
if betroffen == 0 { if affected == 0 {
return ErrNotFound return ErrNotFound
} }
return nil 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 // diesen Eintrag kann der Key künftig nicht mehr seinem Workspace zugeordnet
// werden, bevor dessen Datei bekannt ist. // werden, bevor dessen Datei bekannt ist.
func (c *ControlStore) IndexKey(ctx context.Context, hash, workspaceID string) error { 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 (?, ?, ?)`, `INSERT INTO key_index (hash, workspace_id, created_at) VALUES (?, ?, ?)`,
hash, workspaceID, NowString(), 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 // RemoveKeyIndex entfernt einen Key-Hash aus dem Routing-Index. Aufzurufen im
// selben Zug wie Store.RevokeAPIKey. // selben Zug wie Store.RevokeAPIKey.
func (c *ControlStore) RemoveKeyIndex(ctx context.Context, hash string) error { 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, `DELETE FROM key_index WHERE hash = ?`, hash,
) )
if err != nil { 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 // WP4/WP5): erst hier lässt sich feststellen, welche Datei überhaupt
// angefasst werden muss, bevor Store.ResolveKeyHash dort wie gewohnt läuft. // angefasst werden muss, bevor Store.ResolveKeyHash dort wie gewohnt läuft.
func (c *ControlStore) ResolveKeyWorkspace(ctx context.Context, hash string) (workspaceID string, err error) { 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, `SELECT workspace_id FROM key_index WHERE hash = ?`, hash,
).Scan(&workspaceID) ).Scan(&workspaceID)

View file

@ -11,7 +11,7 @@ import (
// postgres_test.go). // postgres_test.go).
func testControl(t *testing.T) *ControlStore { func testControl(t *testing.T) *ControlStore {
t.Helper() t.Helper()
if pgTestAktiv() { if pgTestEnabled() {
return testPostgresControl(t) return testPostgresControl(t)
} }
@ -27,16 +27,16 @@ func TestEnsureDefaultWorkspaceIsStable(t *testing.T) {
c := testControl(t) c := testControl(t)
ctx := context.Background() ctx := context.Background()
erst, err := c.EnsureDefaultWorkspace(ctx, "Intern") first, err := c.EnsureDefaultWorkspace(ctx, "Intern")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
zweit, err := c.EnsureDefaultWorkspace(ctx, "Anderer Name") second, err := c.EnsureDefaultWorkspace(ctx, "Anderer Name")
if err != nil { if err != nil {
t.Fatal(err) 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") 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) t.Fatal(err)
} }
gefunden, err := c.ResolveKeyWorkspace(ctx, "hash-1") found, err := c.ResolveKeyWorkspace(ctx, "hash-1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if gefunden != ws.ID { if found != ws.ID {
t.Errorf("ResolveKeyWorkspace = %q, erwartet %q", gefunden, ws.ID) t.Errorf("ResolveKeyWorkspace = %q, erwartet %q", found, ws.ID)
} }
if err := c.RemoveKeyIndex(ctx, "hash-1"); err != nil { 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) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if gelesen.Name != "Team A" { if read.Name != "Team A" {
t.Errorf("WorkspaceByID.Name = %q, erwartet %q", gelesen.Name, "Team A") t.Errorf("WorkspaceByID.Name = %q, erwartet %q", read.Name, "Team A")
} }
if _, err := c.WorkspaceByID(ctx, "unbekannt"); err != ErrNotFound { if _, err := c.WorkspaceByID(ctx, "unbekannt"); err != ErrNotFound {
@ -129,9 +129,9 @@ func TestWorkspaceAdmins(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
gefunden, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1") found, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1")
if err != nil || gefunden.ID != admin.ID || gefunden.WorkspaceID != wsA.ID { if err != nil || found.ID != admin.ID || found.WorkspaceID != wsA.ID {
t.Fatalf("ResolveWorkspaceAdminHash = %+v, %v", gefunden, err) t.Fatalf("ResolveWorkspaceAdminHash = %+v, %v", found, err)
} }
if err := c.RevokeWorkspaceAdmin(ctx, wsB.ID, admin.ID); err != ErrNotFound { 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. // Die Zeile bleibt für die Verwaltung und das Protokoll erhalten.
gelesen, err := c.WorkspaceAdminByID(ctx, admin.ID) read, err := c.WorkspaceAdminByID(ctx, admin.ID)
if err != nil || !gelesen.Revoked() { if err != nil || !read.Revoked() {
t.Errorf("WorkspaceAdminByID = %+v, %v — erwartet zurückgezogen", gelesen, err) t.Errorf("WorkspaceAdminByID = %+v, %v — erwartet zurückgezogen", read, err)
} }
} }

View file

@ -6,7 +6,7 @@ import (
"strings" "strings"
) )
// dialekt beschreibt, worin sich SQLite und PostgreSQL unterscheiden. // dialect beschreibt, worin sich SQLite und PostgreSQL unterscheiden.
// //
// # Warum so klein? // # Warum so klein?
// //
@ -15,41 +15,41 @@ import (
// nur, was sich wirklich unterscheidet. Jede Methode, die hier dazukommt, muss // nur, was sich wirklich unterscheidet. Jede Methode, die hier dazukommt, muss
// für beide Datenbanken umgesetzt werden — fehlt eine, baut das Programm nicht. // für beide Datenbanken umgesetzt werden — fehlt eine, baut das Programm nicht.
// Der Compiler passt also auf, dass keine Seite vergessen wird. // 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 // rebind wandelt die Platzhalter einer Abfrage in die Schreibweise der
// Datenbank um. Geschrieben werden alle Abfragen mit "?" (SQLite); // Datenbank um. Geschrieben werden alle Abfragen mit "?" (SQLite);
// PostgreSQL erwartet "$1, $2, …". // 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. // 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 // Datei samt WAL, bei PostgreSQL die Tabellen des Schemas. Die
// Umsetzungen stehen in systemstand.go. // Umsetzungen stehen in sysinfo.go.
belegung(ctx context.Context, p pool) (Belegung, error) diskUsage(ctx context.Context, p pool) (DiskUsage, error)
} }
// ── SQLite ─────────────────────────────────────────────────────────────────── // ── SQLite ───────────────────────────────────────────────────────────────────
type sqliteDialekt struct{} type sqliteDialect struct{}
// rebind ist bei SQLite nichts zu tun: Die Abfragen sind bereits in der // rebind ist bei SQLite nichts zu tun: Die Abfragen sind bereits in der
// SQLite-Schreibweise geschrieben. // 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 ─────────────────────────────────────────────────────────────── // ── 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. // Reihenfolge des Auftretens.
// //
// Ein "?" innerhalb eines Textes in Hochkommas ('Wie geht das?'), eines // 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 // Ein verdoppeltes Hochkomma (so schreibt SQL ein Hochkomma innerhalb eines
// Textes) braucht keine Sonderbehandlung: Das erste beendet den Text, das // Textes) braucht keine Sonderbehandlung: Das erste beendet den Text, das
// zweite beginnt sofort einen neuen — das Ergebnis ist dasselbe. // 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 // Schneller Weg: Die meisten Abfragen sind kurz, aber ohne "?" gibt es
// gar nichts zu tun. // gar nichts zu tun.
if !strings.Contains(abfrage, "?") { if !strings.Contains(query, "?") {
return abfrage return query
} }
var b strings.Builder var b strings.Builder
b.Grow(len(abfrage) + 16) b.Grow(len(query) + 16)
nummer := 0 number := 0
for i := 0; i < len(abfrage); i++ { for i := 0; i < len(query); i++ {
c := abfrage[i] c := query[i]
switch { switch {
case c == '\'' || c == '"': case c == '\'' || c == '"':
// Text bzw. Bezeichner: bis zum passenden Gegenstück unverändert // Text bzw. Bezeichner: bis zum passenden Gegenstück unverändert
// übernehmen. // übernehmen.
ende := strings.IndexByte(abfrage[i+1:], c) end := strings.IndexByte(query[i+1:], c)
if ende < 0 { if end < 0 {
b.WriteString(abfrage[i:]) b.WriteString(query[i:])
return b.String() return b.String()
} }
b.WriteString(abfrage[i : i+1+ende+1]) b.WriteString(query[i : i+1+end+1])
i += ende + 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. // Kommentar bis zum Zeilenende.
ende := strings.IndexByte(abfrage[i:], '\n') end := strings.IndexByte(query[i:], '\n')
if ende < 0 { if end < 0 {
b.WriteString(abfrage[i:]) b.WriteString(query[i:])
return b.String() return b.String()
} }
b.WriteString(abfrage[i : i+ende]) b.WriteString(query[i : i+end])
i += ende - 1 i += end - 1
case c == '/' && i+1 < len(abfrage) && abfrage[i+1] == '*': case c == '/' && i+1 < len(query) && query[i+1] == '*':
// Blockkommentar bis "*/". // Blockkommentar bis "*/".
ende := strings.Index(abfrage[i+2:], "*/") end := strings.Index(query[i+2:], "*/")
if ende < 0 { if end < 0 {
b.WriteString(abfrage[i:]) b.WriteString(query[i:])
return b.String() return b.String()
} }
b.WriteString(abfrage[i : i+2+ende+2]) b.WriteString(query[i : i+2+end+2])
i += 2 + ende + 1 i += 2 + end + 1
case c == '?': case c == '?':
nummer++ number++
b.WriteByte('$') b.WriteByte('$')
b.WriteString(strconv.Itoa(nummer)) b.WriteString(strconv.Itoa(number))
default: default:
b.WriteByte(c) b.WriteByte(c)

View file

@ -9,9 +9,9 @@ import (
// Diese Tests laufen immer, auch ohne PostgreSQL. Die Tests gegen eine echte // Diese Tests laufen immer, auch ohne PostgreSQL. Die Tests gegen eine echte
// PostgreSQL-Datenbank stehen in postgres_test.go. // PostgreSQL-Datenbank stehen in postgres_test.go.
func TestPlatzhalterNummerieren(t *testing.T) { func TestNumberPlaceholders(t *testing.T) {
faelle := []struct { cases := []struct {
name, ein, aus string name, in, out string
}{ }{
{"ohne Platzhalter", `SELECT 1`, `SELECT 1`}, {"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`}, {"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?`}, {"Kommentar am Ende ohne Zeilenumbruch", `WHERE a = ? -- fertig?`, `WHERE a = $1 -- fertig?`},
{"zweistellig", `(?,?,?,?,?,?,?,?,?,?,?)`, `($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`}, {"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) { t.Run(f.name, func(t *testing.T) {
if got := platzhalterNummerieren(f.ein); got != f.aus { if got := numberPlaceholders(f.in); got != f.out {
t.Errorf("\nein: %s\nist: %s\nsoll: %s", f.ein, got, f.aus) t.Errorf("\nein: %s\nist: %s\nsoll: %s", f.in, got, f.out)
} }
}) })
} }
} }
func TestSQLiteRebindLaesstAbfrageUnveraendert(t *testing.T) { func TestSQLiteRebindLeavesQueryUnchanged(t *testing.T) {
abfrage := `SELECT a FROM t WHERE x = ?` query := `SELECT a FROM t WHERE x = ?`
if got := (sqliteDialekt{}).rebind(abfrage); got != abfrage { if got := (sqliteDialect{}).rebind(query); got != query {
t.Errorf("SQLite hat die Abfrage verändert: %s", got) 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 // Migrationen enthalten. Eine Migration, die nur in einem Ordner liegt, würde
// die beiden Datenbanken unbemerkt auseinanderlaufen lassen — siehe // die beiden Datenbanken unbemerkt auseinanderlaufen lassen — siehe
// AUSBAUSTUFEN.md 4.10. // 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). // (braucht PostgreSQL).
func TestMigrationsordnerGleich(t *testing.T) { func TestMigrationDirsMatch(t *testing.T) {
for _, quelle := range []struct { for _, source := range []struct {
fsys fs.FS fsys fs.FS
dir string dir string
}{ }{
{migrationsFS, "migrations"}, {migrationsFS, "migrations"},
{controlMigrationsFS, "migrations_control"}, {controlMigrationsFS, "migrations_control"},
} { } {
dateien := func(unterordner string) []string { files := func(subdir string) []string {
eintraege, err := fs.ReadDir(quelle.fsys, quelle.dir+"/"+unterordner) entries, err := fs.ReadDir(source.fsys, source.dir+"/"+subdir)
if err != nil { if err != nil {
t.Fatalf("%s/%s: %v", quelle.dir, unterordner, err) t.Fatalf("%s/%s: %v", source.dir, subdir, err)
} }
var namen []string var names []string
for _, e := range eintraege { for _, e := range entries {
namen = append(namen, e.Name()) names = append(names, e.Name())
} }
return namen return names
} }
sqlite := dateien((sqliteDialekt{}).migrationsUnterordner()) sqlite := files((sqliteDialect{}).migrationsSubdir())
postgres := dateien((postgresDialekt{}).migrationsUnterordner()) postgres := files((postgresDialect{}).migrationsSubdir())
if len(sqlite) == 0 { 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) { 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. // Alles, was nicht wie eine ID aussieht, darf nie in einen Schemanamen.
for _, kaputt := range []string{"", "Groß", "a b", "a;DROP", `a"b`, "../x", "ä"} { for _, broken := range []string{"", "Groß", "a b", "a;DROP", `a"b`, "../x", "ä"} {
if _, err := WorkspaceSchema(kaputt); err == nil { if _, err := WorkspaceSchema(broken); err == nil {
t.Errorf("WorkspaceSchema(%q) hätte abgelehnt werden müssen", kaputt) t.Errorf("WorkspaceSchema(%q) hätte abgelehnt werden müssen", broken)
} }
} }
// PostgreSQL würde längere Namen stillschweigend kürzen. // PostgreSQL würde längere Namen stillschweigend kürzen.
lang := "" long := ""
for len(lang) < 60 { for len(long) < 60 {
lang += "abcdefghij" long += "abcdefghij"
} }
if _, err := WorkspaceSchema(lang); err == nil { if _, err := WorkspaceSchema(long); err == nil {
t.Error("zu lange ID wurde nicht abgelehnt") t.Error("zu lange ID wurde nicht abgelehnt")
} }
} }
func TestTextLiteral(t *testing.T) { func TestTextLiteral(t *testing.T) {
faelle := map[string]string{ cases := map[string]string{
"Vertrieb": `E'Vertrieb'`, "Vertrieb": `E'Vertrieb'`,
"O'Brien": `E'O''Brien'`, "O'Brien": `E'O''Brien'`,
`C:\temp`: `E'C:\\temp'`, `C:\temp`: `E'C:\\temp'`,
@ -119,9 +119,9 @@ func TestTextLiteral(t *testing.T) {
"mit\x00Null": `E'mitNull'`, "mit\x00Null": `E'mitNull'`,
"Ärger & Öl": `E'Ärger & Öl'`, "Ärger & Öl": `E'Ärger & Öl'`,
} }
for ein, soll := range faelle { for in, expected := range cases {
if got := textLiteral(ein); got != soll { if got := textLiteral(in); got != expected {
t.Errorf("textLiteral(%q) = %s, erwartet %s", ein, got, soll) t.Errorf("textLiteral(%q) = %s, erwartet %s", in, got, expected)
} }
} }
} }

View file

@ -48,15 +48,15 @@ type EntryWrite struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
// nichtImPapierkorb gehört in JEDE lesende Abfrage auf entries (siehe // notInTrash gehört in JEDE lesende Abfrage auf entries (siehe
// migrations/005_papierkorb.sql). Als Konstante, damit sich alle Stellen mit // migrations/005_trash.sql). Als Konstante, damit sich alle Stellen mit
// einer Suche finden lassen — und damit eine neue Abfrage ohne diese Bedingung // 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. // jeden lesenden Weg.
// //
// Wo die Tabelle in einer Verknüpfung den Kurznamen "e" trägt, steht die // Wo die Tabelle in einer Verknüpfung den Kurznamen "e" trägt, steht die
// Bedingung ausgeschrieben als "e.deleted_at IS NULL". // 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. // entryColumns steht in jeder Eintrags-Abfrage.
const entryColumns = `id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at` 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 // verschiedene Schlagworte in der Datenbank — und keine Filterung würde je
// vollständig treffen. // vollständig treffen.
func NormalizeTags(tags []string) []string { func NormalizeTags(tags []string) []string {
gesehen := make(map[string]bool, len(tags)) seen := make(map[string]bool, len(tags))
var sauber []string var clean []string
for _, t := range tags { for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t)) t = strings.ToLower(strings.TrimSpace(t))
if t == "" || gesehen[t] { if t == "" || seen[t] {
continue continue
} }
gesehen[t] = true seen[t] = true
sauber = append(sauber, t) clean = append(clean, t)
} }
sort.Strings(sauber) sort.Strings(clean)
return sauber return clean
} }
// PutEntry legt einen Eintrag an oder aktualisiert ihn — der Slug entscheidet. // 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) tags := NormalizeTags(w.Tags)
links := ExtractLinks(w.Content) 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. // 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 // Bezeichner weiter. Wer ihn neu beschreibt, holt ihn zurück (siehe
// unten) — statt an einer UNIQUE-Verletzung zu scheitern oder // unten) — statt an einer UNIQUE-Verletzung zu scheitern oder
// stillschweigend einen zweiten Eintrag daneben zu legen. // 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). // "liegt im Papierkorb" (Zeitpunkt gesetzt) von "normal" (NULL).
var id, createdAt string var id, createdAt string
var version int var version int
var geloeschtAm sql.NullString var deletedAt sql.NullString
err := tx.QueryRowContext(ctx, err := tx.QueryRowContext(ctx,
`SELECT id, version, created_at, deleted_at FROM entries WHERE project_id = ? AND slug = ?`, `SELECT id, version, created_at, deleted_at FROM entries WHERE project_id = ? AND slug = ?`,
w.ProjectID, w.Slug, w.ProjectID, w.Slug,
).Scan(&id, &version, &createdAt, &geloeschtAm) ).Scan(&id, &version, &createdAt, &deletedAt)
imPapierkorb := geloeschtAm.Valid inTrash := deletedAt.Valid
switch { switch {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
@ -160,19 +160,19 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
id = NewID() id = NewID()
version = 1 version = 1
createdAt = jetzt createdAt = now
if !w.CreatedAt.IsZero() { if !w.CreatedAt.IsZero() {
createdAt = formatTime(w.CreatedAt) createdAt = formatTime(w.CreatedAt)
} }
if !w.UpdatedAt.IsZero() { if !w.UpdatedAt.IsZero() {
jetzt = formatTime(w.UpdatedAt) now = formatTime(w.UpdatedAt)
} }
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at) `INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, w.ProjectID, w.Slug, w.Title, w.Content, w.Source, w.Author, 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 { ); err != nil {
return fmt.Errorf("Eintrag konnte nicht angelegt werden: %w", err) 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 // Agent "forget" aufrief. Das ist ein Konflikt, genau wie beim
// zwischenzeitlich geänderten Eintrag: Die Löschung soll nicht // zwischenzeitlich geänderten Eintrag: Die Löschung soll nicht
// unbemerkt rückgängig gemacht werden. // 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) return fmt.Errorf("%w: der Eintrag wurde inzwischen gelöscht und liegt im Papierkorb", ErrConflict)
} }
if w.ExpectedVersion > 0 && w.ExpectedVersion != version { 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 = ?, `UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?,
deleted_at = NULL, deleted_by = '' deleted_at = NULL, deleted_by = ''
WHERE id = ?`, 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 { ); err != nil {
return fmt.Errorf("Eintrag konnte nicht geändert werden: %w", err) 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 return err
} }
ergebnis = Entry{ result = Entry{
ID: id, ID: id,
ProjectID: w.ProjectID, ProjectID: w.ProjectID,
Slug: w.Slug, Slug: w.Slug,
@ -241,9 +241,9 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
Curated: w.Curated, Curated: w.Curated,
Version: version, Version: version,
CreatedAt: parseTime(createdAt), CreatedAt: parseTime(createdAt),
UpdatedAt: parseTime(jetzt), UpdatedAt: parseTime(now),
RestoredFromTrash: imPapierkorb, RestoredFromTrash: inTrash,
} }
return nil return nil
}) })
@ -251,17 +251,17 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
if err != nil { if err != nil {
return Entry{}, err return Entry{}, err
} }
return ergebnis, nil return result, nil
} }
// snapshotEntryVersion sichert den aktuellen Stand eines Eintrags in // snapshotEntryVersion sichert den aktuellen Stand eines Eintrags in
// entry_versions, bevor er überschrieben wird — die einzige Stelle, an der // 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 // Läuft immer als Teil der Transaktion, die auch die eigentliche Änderung
// vornimmt: Entweder werden alte Fassung und neue Fassung beide geschrieben, // vornimmt: Entweder werden alte Fassung und neue Fassung beide geschrieben,
// oder keine von beiden. // 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 version int
var title, content, source, author string var title, content, source, author string
var curated int 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 // EntryVersions gibt die abgelösten Fassungen eines Eintrags zurück, neueste
// zuerst. Die aktuelle Fassung steht nicht darin — die liefert EntryBySlug. // zuerst. Die aktuelle Fassung steht nicht darin — die liefert EntryBySlug.
func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersion, error) { 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`, `SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? ORDER BY version DESC`,
entryID, entryID,
) )
@ -335,20 +335,20 @@ func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersi
} }
defer rows.Close() defer rows.Close()
var versionen []EntryVersion var versions []EntryVersion
for rows.Next() { for rows.Next() {
v, err := scanEntryVersion(rows.Scan) v, err := scanEntryVersion(rows.Scan)
if err != nil { if err != nil {
return nil, err 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. // EntryVersionByNumber liest eine einzelne abgelöste Fassung.
func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, version int) (EntryVersion, error) { 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 = ?`, `SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? AND version = ?`,
entryID, version, entryID, version,
) )
@ -365,11 +365,11 @@ func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, versio
// replaceTags ersetzt die Schlagworte eines Eintrags. // replaceTags ersetzt die Schlagworte eines Eintrags.
// //
// project_id wird mitgeschrieben, obwohl es schon am Eintrag steht — siehe die // 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 // replaceLinks sind die einzigen Schreibwege in diese Tabellen, und beide laufen
// in derselben Transaktion wie der Eintrag. Die Redundanz kann deshalb nicht // in derselben Transaktion wie der Eintrag. Die Redundanz kann deshalb nicht
// auseinanderlaufen. // 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 { 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) 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. // 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 { 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) return fmt.Errorf("Verweise konnten nicht ersetzt werden: %w", err)
} }
for _, ziel := range links { for _, target := range links {
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_links (from_entry_id, project_id, to_slug) VALUES (?, ?, ?)`, `INSERT INTO entry_links (from_entry_id, project_id, to_slug) VALUES (?, ?, ?)`,
entryID, projectID, ziel, entryID, projectID, target,
); err != nil { ); 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 return nil
@ -401,8 +401,8 @@ func replaceLinks(ctx context.Context, tx *transaktion, projectID, entryID strin
// EntryBySlug liest einen Eintrag samt Schlagworten. // EntryBySlug liest einen Eintrag samt Schlagworten.
func (s *Store) EntryBySlug(ctx context.Context, projectID, slug string) (Entry, error) { func (s *Store) EntryBySlug(ctx context.Context, projectID, slug string) (Entry, error) {
row := s.lesen.QueryRowContext(ctx, row := s.read.QueryRowContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+notInTrash,
projectID, slug, 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 // Die Abfrage wird stückweise zusammengesetzt. Wichtig: Werte gehen immer als
// Parameter (?) in die Abfrage, nie per String-Verkettung — sonst wäre das // Parameter (?) in die Abfrage, nie per String-Verkettung — sonst wäre das
// eine SQL-Injection-Lücke. // eine SQL-Injection-Lücke.
abfrage := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + nichtImPapierkorb query := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + notInTrash
werte := []any{projectID} values := []any{projectID}
if f.Tag != "" { if f.Tag != "" {
// project_id gehört mit in die Unterabfrage, damit der Index // project_id gehört mit in die Unterabfrage, damit der Index
// (project_id, tag) greift statt nur über das Schlagwort zu suchen. // (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 = ?)` query += ` AND id IN (SELECT entry_id FROM entry_tags WHERE project_id = ? AND tag = ?)`
werte = append(werte, projectID, strings.ToLower(strings.TrimSpace(f.Tag))) values = append(values, projectID, strings.ToLower(strings.TrimSpace(f.Tag)))
} }
if f.Curated { if f.Curated {
abfrage += ` AND curated = 1` query += ` AND curated = 1`
} }
if !f.ChangedBefore.IsZero() { if !f.ChangedBefore.IsZero() {
// Textvergleich auf RFC3339-Zeitstempeln, wie überall im Store (siehe // Textvergleich auf RFC3339-Zeitstempeln, wie überall im Store (siehe
// timeFormat): Das sortiert richtig und braucht keine Datumsfunktion // timeFormat): Das sortiert richtig und braucht keine Datumsfunktion
// der Datenbank. // der Datenbank.
abfrage += ` AND updated_at < ?` query += ` AND updated_at < ?`
werte = append(werte, formatTime(f.ChangedBefore)) values = append(values, formatTime(f.ChangedBefore))
} }
abfrage += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?` query += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`
werte = append(werte, limit, f.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 { if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err) return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
} }
defer rows.Close() defer rows.Close()
var eintraege []Entry var entries []Entry
for rows.Next() { for rows.Next() {
e, err := scanEntry(rows.Scan) e, err := scanEntry(rows.Scan)
if err != nil { if err != nil {
return nil, err return nil, err
} }
eintraege = append(eintraege, e) entries = append(entries, e)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err 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 // 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 // nicht dazu: Sie sollen weder gefunden werden noch im Export wieder
// auftauchen. // auftauchen.
func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, error) { func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, error) {
rows, err := s.lesen.QueryContext(ctx, rows, err := s.read.QueryContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+nichtImPapierkorb+` ORDER BY slug`, `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+notInTrash+` ORDER BY slug`,
projectID, projectID,
) )
if err != nil { if err != nil {
@ -503,26 +503,26 @@ func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, erro
} }
defer rows.Close() defer rows.Close()
var eintraege []Entry var entries []Entry
for rows.Next() { for rows.Next() {
e, err := scanEntry(rows.Scan) e, err := scanEntry(rows.Scan)
if err != nil { if err != nil {
return nil, err return nil, err
} }
eintraege = append(eintraege, e) entries = append(entries, e)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err 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). // CountEntries zählt die Einträge eines Projekts (ohne Papierkorb).
func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error) { func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error) {
var n int var n int
err := s.lesen.QueryRowContext(ctx, err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID, `SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+notInTrash, projectID,
).Scan(&n) ).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err) 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 // 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 // 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. // 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 var n int
err := s.lesen.QueryRowContext(ctx, err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND updated_at < ? AND `+nichtImPapierkorb, `SELECT COUNT(*) FROM entries WHERE project_id = ? AND updated_at < ? AND `+notInTrash,
projectID, formatTime(StaleCutoff(jetzt)), projectID, formatTime(StaleCutoff(now)),
).Scan(&n) ).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("veraltete Einträge konnten nicht gezählt werden: %w", err) 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 // Die Schlagworte gelöschter Einträge bleiben in entry_tags stehen — sie
// werden beim Wiederherstellen gebraucht. Damit sie hier nicht mitgezählt // werden beim Wiederherstellen gebraucht. Damit sie hier nicht mitgezählt
// werden, schließt die Unterabfrage die Einträge im Papierkorb aus. Die // 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. // 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 `SELECT tag, COUNT(*) AS anzahl
FROM entry_tags FROM entry_tags
WHERE project_id = ? WHERE project_id = ?
@ -592,12 +592,12 @@ type TagCount struct {
} }
// attachTagsToSlice lädt die Schlagworte für eine Ergebnisliste nach. // attachTagsToSlice lädt die Schlagworte für eine Ergebnisliste nach.
func (s *Store) attachTagsToSlice(ctx context.Context, eintraege []Entry) error { func (s *Store) attachTagsToSlice(ctx context.Context, entries []Entry) error {
zeiger := make([]*Entry, len(eintraege)) cursor := make([]*Entry, len(entries))
for i := range eintraege { for i := range entries {
zeiger[i] = &eintraege[i] 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. // 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 // 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 // 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. // Abfrage mit einer IN-Liste gestellt und das Ergebnis in Go zugeordnet.
func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error { func (s *Store) attachTags(ctx context.Context, entries []*Entry) error {
if len(eintraege) == 0 { if len(entries) == 0 {
return nil return nil
} }
// Platzhalter "?, ?, ?" in der passenden Anzahl bauen. // Platzhalter "?, ?, ?" in der passenden Anzahl bauen.
platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(eintraege)), ",") placeholder := strings.TrimSuffix(strings.Repeat("?,", len(entries)), ",")
werte := make([]any, len(eintraege)) values := make([]any, len(entries))
nachID := make(map[string]*Entry, len(eintraege)) toID := make(map[string]*Entry, len(entries))
for i, e := range eintraege { for i, e := range entries {
werte[i] = e.ID values[i] = e.ID
nachID[e.ID] = e toID[e.ID] = e
} }
rows, err := s.lesen.QueryContext(ctx, rows, err := s.read.QueryContext(ctx,
`SELECT entry_id, tag FROM entry_tags WHERE entry_id IN (`+platzhalter+`) ORDER BY tag`, `SELECT entry_id, tag FROM entry_tags WHERE entry_id IN (`+placeholder+`) ORDER BY tag`,
werte..., values...,
) )
if err != nil { if err != nil {
return fmt.Errorf("Schlagworte konnten nicht geladen werden: %w", err) 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 { if err := rows.Scan(&entryID, &tag); err != nil {
return err return err
} }
if e, ok := nachID[entryID]; ok { if e, ok := toID[entryID]; ok {
e.Tags = append(e.Tags, tag) 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) { func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, add, remove []string) ([]Entry, error) {
add = NormalizeTags(add) add = NormalizeTags(add)
remove = NormalizeTags(remove) remove = NormalizeTags(remove)
entferneMenge := make(map[string]bool, len(remove)) removeSet := make(map[string]bool, len(remove))
for _, t := range remove { for _, t := range remove {
entferneMenge[t] = true removeSet[t] = true
} }
var geaendert []Entry var changed []Entry
jetzt := NowString() now := NowString()
err := s.tx(ctx, func(tx *transaktion) error { err := s.tx(ctx, func(tx *transaction) error {
for _, id := range entryIDs { for _, id := range entryIDs {
var vorhandenID string var existingID string
err := tx.QueryRowContext(ctx, err := tx.QueryRowContext(ctx,
`SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb, id, projectID, `SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+notInTrash, id, projectID,
).Scan(&vorhandenID) ).Scan(&existingID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
continue // gehört nicht zu diesem Projekt, existiert nicht mehr oder liegt im Papierkorb 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 { if err != nil {
return fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err) return fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
} }
var bisherige []string var previous []string
for rows.Next() { for rows.Next() {
var tag string var tag string
if err := rows.Scan(&tag); err != nil { if err := rows.Scan(&tag); err != nil {
rows.Close() rows.Close()
return err return err
} }
bisherige = append(bisherige, tag) previous = append(previous, tag)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
rows.Close() rows.Close()
@ -696,18 +696,18 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs,
} }
rows.Close() rows.Close()
neu := NormalizeTags(append(bisherige, add...)) newer := NormalizeTags(append(previous, add...))
gefiltert := neu[:0] filtered := newer[:0]
for _, t := range neu { for _, t := range newer {
if !entferneMenge[t] { if !removeSet[t] {
gefiltert = append(gefiltert, t) filtered = append(filtered, t)
} }
} }
// Nur schreiben, wenn sich wirklich etwas ändert — sonst würde ein // Nur schreiben, wenn sich wirklich etwas ändert — sonst würde ein
// Sammelaufruf über eine gemischte Auswahl auch die Einträge // Sammelaufruf über eine gemischte Auswahl auch die Einträge
// "berühren", bei denen add/remove gar nichts bewirkt haben. // "berühren", bei denen add/remove gar nichts bewirkt haben.
if stringSlicesEqual(bisherige, gefiltert) { if stringSlicesEqual(previous, filtered) {
continue continue
} }
@ -718,11 +718,11 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs,
return err return err
} }
if err := replaceTags(ctx, tx, projectID, id, gefiltert); err != nil { if err := replaceTags(ctx, tx, projectID, id, filtered); err != nil {
return err return err
} }
if _, err := tx.ExecContext(ctx, 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 { ); err != nil {
return fmt.Errorf("Eintrag konnte nicht aktualisiert werden: %w", err) 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 { if err != nil {
return fmt.Errorf("Eintrag konnte nicht neu gelesen werden: %w", err) return fmt.Errorf("Eintrag konnte nicht neu gelesen werden: %w", err)
} }
e.Tags = gefiltert e.Tags = filtered
geaendert = append(geaendert, e) changed = append(changed, e)
} }
return nil return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
return geaendert, nil return changed, nil
} }
// stringSlicesEqual vergleicht zwei (bereits sortierte) Zeichenketten-Listen. // 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 // Sortiert wird deshalb unten in Go. Rückverweise sind erfahrungsgemäß eine
// Handvoll — dort kostet das Sortieren nichts. // Handvoll — dort kostet das Sortieren nichts.
rows, err := s.lesen.QueryContext(ctx, rows, err := s.read.QueryContext(ctx,
`SELECT e.slug, e.title `SELECT e.slug, e.title
FROM entry_links l FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id 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, // Graph-Ansicht sichtbar, wohin schon verwiesen wird, was aber noch fehlt,
// statt solche Verweise stillschweigend zu unterschlagen. // statt solche Verweise stillschweigend zu unterschlagen.
func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, []GraphEdge, error) { func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, []GraphEdge, error) {
entryRows, err := s.lesen.QueryContext(ctx, entryRows, err := s.read.QueryContext(ctx,
`SELECT slug, title FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID, `SELECT slug, title FROM entries WHERE project_id = ? AND `+notInTrash, projectID,
) )
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err) 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() { for entryRows.Next() {
var slug, title string var slug, title string
if err := entryRows.Scan(&slug, &title); err != nil { if err := entryRows.Scan(&slug, &title); err != nil {
entryRows.Close() entryRows.Close()
return nil, nil, err 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 { if err := entryRows.Err(); err != nil {
entryRows.Close() entryRows.Close()
@ -849,7 +849,7 @@ func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, [
} }
entryRows.Close() entryRows.Close()
linkRows, err := s.lesen.QueryContext(ctx, linkRows, err := s.read.QueryContext(ctx,
`SELECT e.slug, l.to_slug `SELECT e.slug, l.to_slug
FROM entry_links l FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id 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() defer linkRows.Close()
var kanten []GraphEdge var edges []GraphEdge
for linkRows.Next() { for linkRows.Next() {
var von, nach string var from, to string
if err := linkRows.Scan(&von, &nach); err != nil { if err := linkRows.Scan(&from, &to); err != nil {
return nil, nil, err return nil, nil, err
} }
if _, ok := knoten[nach]; !ok { if _, ok := nodes[to]; !ok {
knoten[nach] = &GraphNode{Slug: nach, Title: nach, Exists: false} 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 { if err := linkRows.Err(); err != nil {
return nil, nil, err return nil, nil, err
} }
knotenListe := make([]GraphNode, 0, len(knoten)) nodeList := make([]GraphNode, 0, len(nodes))
for _, k := range knoten { for _, k := range nodes {
knotenListe = append(knotenListe, *k) 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. // boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung.

View file

@ -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{}) 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. // (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) == "" { if strings.TrimSpace(name) == "" {
return APIKey{}, fmt.Errorf("der Key braucht einen Namen — daran erkennt man später, wer oder was ihn benutzt") 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") 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), Name: strings.TrimSpace(name),
Hash: hash, Hash: hash,
Role: role, Role: role,
ExpiresAt: ablauf, ExpiresAt: expiry,
} }
jetzt := NowString() now := NowString()
ablaufText := "" expiryText := ""
if !ablauf.IsZero() { if !expiry.IsZero() {
ablaufText = formatTime(ablauf) 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) `INSERT INTO api_keys (id, project_id, name, hash, role, created_at, last_used_at, revoked_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, '', '', ?)`, 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 { if err != nil {
return APIKey{}, fmt.Errorf("Key konnte nicht angelegt werden: %w", err) return APIKey{}, fmt.Errorf("Key konnte nicht angelegt werden: %w", err)
} }
k.CreatedAt = parseTime(jetzt) k.CreatedAt = parseTime(now)
return k, nil 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 // 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. // 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) { 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, `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 p.id, p.workspace_id, p.slug, p.name, p.description, p.created_at
FROM api_keys k 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, // APIKeyByID liest einen Key anhand seiner ID — auch einen zurückgezogenen,
// damit die Verwaltungsoberfläche ihn noch anzeigen kann. // damit die Verwaltungsoberfläche ihn noch anzeigen kann.
func (s *Store) APIKeyByID(ctx context.Context, id string) (APIKey, error) { 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) k, err := scanKey(row.Scan)
if errors.Is(err, sql.ErrNoRows) { 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. // ListAPIKeys gibt alle Keys eines Projekts zurück, neueste zuerst.
// Zurückgezogene sind dabei — sie bleiben als Beleg im Audit-Log referenziert. // Zurückgezogene sind dabei — sie bleiben als Beleg im Audit-Log referenziert.
func (s *Store) ListAPIKeys(ctx context.Context, projectID string) ([]APIKey, error) { 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`, `SELECT `+keyColumns+` FROM api_keys WHERE project_id = ? ORDER BY created_at DESC`,
projectID, 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 // Der Datensatz bleibt bestehen und wird nur markiert — sonst verlöre das
// Audit-Log die Zuordnung zu allem, was dieser Key jemals geschrieben hat. // Audit-Log die Zuordnung zu allem, was dieser Key jemals geschrieben hat.
func (s *Store) RevokeAPIKey(ctx context.Context, id string) error { 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 = ''`, `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at = ''`,
NowString(), id, NowString(), id,
) )
@ -190,6 +190,6 @@ func (s *Store) TouchAPIKey(ctx context.Context, id string, lastUsed time.Time)
if time.Since(lastUsed) < time.Hour { if time.Since(lastUsed) < time.Hour {
return nil 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 return err
} }

View file

@ -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 // Das Ergebnis ist sortiert und ohne Dubletten — ein zweiter Verweis auf dasselbe
// Ziel ist keine zusätzliche Information. // 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 { func ExtractLinks(content string) []string {
treffer := verweiseAusserhalbVonCode(content) hits := refsOutsideCode(content)
if len(treffer) == 0 { if len(hits) == 0 {
return nil return nil
} }
gesehen := make(map[string]bool, len(treffer)) seen := make(map[string]bool, len(hits))
var ziele []string var targets []string
for _, t := range treffer { for _, t := range hits {
ziel := content[t[2]:t[3]] target := content[t[2]:t[3]]
if gesehen[ziel] { if seen[target] {
continue continue
} }
gesehen[ziel] = true seen[target] = true
ziele = append(ziele, ziel) targets = append(targets, target)
} }
sort.Strings(ziele) sort.Strings(targets)
return ziele return targets
} }
// ReplaceLinks ersetzt jeden [[slug]]-Verweis durch das Ergebnis von fn. // ReplaceLinks ersetzt jeden [[slug]]-Verweis durch das Ergebnis von fn.
// //
// Wird beim Rendern verwendet, um aus den Verweisen echte Links zu machen. Die // Wird beim Rendern verwendet, um aus den Verweisen echte Links zu machen. Die
// Ersetzungsfunktion bekommt den Slug und liefert den Ersatztext. Was in Code // 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 { func ReplaceLinks(content string, fn func(slug string) string) string {
treffer := verweiseAusserhalbVonCode(content) hits := refsOutsideCode(content)
if len(treffer) == 0 { if len(hits) == 0 {
return content return content
} }
var b strings.Builder var b strings.Builder
b.Grow(len(content)) b.Grow(len(content))
bisher := 0 sofar := 0
for _, t := range treffer { for _, t := range hits {
b.WriteString(content[bisher:t[0]]) b.WriteString(content[sofar:t[0]])
b.WriteString(fn(content[t[2]:t[3]])) 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() 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 // stehen. Jeder Treffer ist wie bei regexp.FindAllStringSubmatchIndex
// aufgebaut: [Anfang, Ende, Anfang des Slugs, Ende des Slugs]. // 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 ``` // Als Code gilt, was Markdown als Code darstellt: Codeblöcke zwischen ```
// oder ~~~ und Code im Fließtext zwischen Backticks. // oder ~~~ und Code im Fließtext zwischen Backticks.
func verweiseAusserhalbVonCode(content string) [][]int { func refsOutsideCode(content string) [][]int {
alle := linkPattern.FindAllStringSubmatchIndex(content, -1) all := linkPattern.FindAllStringSubmatchIndex(content, -1)
if len(alle) == 0 { if len(all) == 0 {
return nil return nil
} }
code := codeBereiche(content) code := codeRanges(content)
if len(code) == 0 { if len(code) == 0 {
return alle return all
} }
var draussen [][]int var outside [][]int
for _, t := range alle { for _, t := range all {
if !inBereich(t[0], code) { if !inRange(t[0], code) {
draussen = append(draussen, t) outside = append(outside, t)
} }
} }
return draussen return outside
} }
// inBereich sagt, ob die Stelle pos in einem der Bereiche liegt. // inRange sagt, ob die Stelle pos in einem der Bereiche liegt.
func inBereich(pos int, bereiche [][2]int) bool { func inRange(pos int, ranges [][2]int) bool {
for _, b := range bereiche { for _, b := range ranges {
if pos >= b[0] && pos < b[1] { if pos >= b[0] && pos < b[1] {
return true return true
} }
@ -110,7 +110,7 @@ func inBereich(pos int, bereiche [][2]int) bool {
return false 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. // [Anfang, Ende) in Bytes.
// //
// Das ist bewusst kein vollständiger Markdown-Parser, sondern deckt die zwei // 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ückte Codeblöcke (vier Leerzeichen) fehlen absichtlich: Sie sind von
// eingerückten Listenzeilen nicht sicher zu unterscheiden, und ein // eingerückten Listenzeilen nicht sicher zu unterscheiden, und ein
// übersehener Verweis wäre schlimmer als ein überflüssiger. // übersehener Verweis wäre schlimmer als ein überflüssiger.
func codeBereiche(content string) [][2]int { func codeRanges(content string) [][2]int {
var bereiche [][2]int var ranges [][2]int
// ── 1. Codeblöcke, Zeile für Zeile ─────────────────────────────────── // ── 1. Codeblöcke, Zeile für Zeile ───────────────────────────────────
var fliesstext [][2]int // was außerhalb von Codeblöcken liegt var prose [][2]int // was außerhalb von Codeblöcken liegt
blockAnfang := -1 blockStart := -1
var zaun string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks var fence string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks
zeilenAnfang := 0 lineStart := 0
textAnfang := 0 textStart := 0
for zeilenAnfang < len(content) { for lineStart < len(content) {
zeilenEnde := strings.IndexByte(content[zeilenAnfang:], '\n') lineEnd := strings.IndexByte(content[lineStart:], '\n')
if zeilenEnde < 0 { if lineEnd < 0 {
zeilenEnde = len(content) lineEnd = len(content)
} else { } else {
zeilenEnde += zeilenAnfang + 1 // samt Zeilenumbruch lineEnd += lineStart + 1 // samt Zeilenumbruch
} }
zeile := content[zeilenAnfang:zeilenEnde] line := content[lineStart:lineEnd]
if blockAnfang < 0 { if blockStart < 0 {
if z := codeZaun(zeile); z != "" { if z := codeFence(line); z != "" {
fliesstext = append(fliesstext, [2]int{textAnfang, zeilenAnfang}) prose = append(prose, [2]int{textStart, lineStart})
blockAnfang, zaun = zeilenAnfang, z blockStart, fence = lineStart, z
} }
} else if z := codeZaun(zeile); z != "" && z[0] == zaun[0] && len(z) >= len(zaun) && } else if z := codeFence(line); z != "" && z[0] == fence[0] && len(z) >= len(fence) &&
strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(zeile), z[:1])) == "" { strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), z[:1])) == "" {
// Schließender Zaun: nur Zaunzeichen, nichts dahinter. // Schließender Zaun: nur Zaunzeichen, nichts dahinter.
bereiche = append(bereiche, [2]int{blockAnfang, zeilenEnde}) ranges = append(ranges, [2]int{blockStart, lineEnd})
blockAnfang = -1 blockStart = -1
textAnfang = zeilenEnde textStart = lineEnd
} }
zeilenAnfang = zeilenEnde lineStart = lineEnd
} }
if blockAnfang >= 0 { if blockStart >= 0 {
bereiche = append(bereiche, [2]int{blockAnfang, len(content)}) ranges = append(ranges, [2]int{blockStart, len(content)})
} else { } 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 ─────────────── // ── 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]] text := content[f[0]:f[1]]
for i := 0; i < len(text); { for i := 0; i < len(text); {
if text[i] != '`' { if text[i] != '`' {
i++ i++
continue continue
} }
laenge := laufLaenge(text, i, '`') length := runLength(text, i, '`')
ende := schliessendeBackticks(text, i+laenge, laenge) end := closingBackticks(text, i+length, length)
if ende < 0 { if end < 0 {
i += laenge // kein Gegenstück: kein Code i += length // kein Gegenstück: kein Code
continue continue
} }
bereiche = append(bereiche, [2]int{f[0] + i, f[0] + ende + laenge}) ranges = append(ranges, [2]int{f[0] + i, f[0] + end + length})
i = ende + laenge 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 "". // öffnet oder schließt, sonst "".
func codeZaun(zeile string) string { func codeFence(line string) string {
eingerueckt := len(zeile) - len(strings.TrimLeft(zeile, " ")) indented := len(line) - len(strings.TrimLeft(line, " "))
if eingerueckt > 3 { if indented > 3 {
return "" return ""
} }
rest := zeile[eingerueckt:] rest := line[indented:]
if rest == "" || (rest[0] != '`' && rest[0] != '~') { if rest == "" || (rest[0] != '`' && rest[0] != '~') {
return "" return ""
} }
n := laufLaenge(rest, 0, rest[0]) n := runLength(rest, 0, rest[0])
if n < 3 { if n < 3 {
return "" return ""
} }
return rest[:n] return rest[:n]
} }
// laufLaenge zählt, wie oft das Zeichen c ab Stelle i hintereinander steht. // runLength zählt, wie oft das Zeichen c ab Stelle i hintereinander steht.
func laufLaenge(text string, i int, c byte) int { func runLength(text string, i int, c byte) int {
n := 0 n := 0
for i+n < len(text) && text[i+n] == c { for i+n < len(text) && text[i+n] == c {
n++ n++
@ -212,16 +212,16 @@ func laufLaenge(text string, i int, c byte) int {
return n 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. // Backticks und gibt ihre Stelle zurück, sonst -1.
func schliessendeBackticks(text string, ab, laenge int) int { func closingBackticks(text string, from, length int) int {
for i := ab; i < len(text); { for i := from; i < len(text); {
if text[i] != '`' { if text[i] != '`' {
i++ i++
continue continue
} }
n := laufLaenge(text, i, '`') n := runLength(text, i, '`')
if n == laenge { if n == length {
return i return i
} }
i += n i += n

View file

@ -43,5 +43,5 @@ DROP INDEX idx_entry_links_to;
-- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index -- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index
-- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort -- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort
-- in einem Projekt" ab. -- in einem Projekt" ab.
CREATE INDEX idx_entry_tags_projekt ON entry_tags (project_id, tag); CREATE INDEX idx_entry_tags_project ON entry_tags (project_id, tag);
CREATE INDEX idx_entry_links_projekt ON entry_links (project_id, to_slug); CREATE INDEX idx_entry_links_project ON entry_links (project_id, to_slug);

View file

@ -38,4 +38,4 @@ ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT '';
-- --
-- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg -- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg
-- dorthin bleibt offen. -- 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;

View file

@ -43,5 +43,5 @@ DROP INDEX idx_entry_links_to;
-- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index -- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index
-- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort -- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort
-- in einem Projekt" ab. -- in einem Projekt" ab.
CREATE INDEX idx_entry_tags_projekt ON entry_tags (project_id, tag); CREATE INDEX idx_entry_tags_project ON entry_tags (project_id, tag);
CREATE INDEX idx_entry_links_projekt ON entry_links (project_id, to_slug); CREATE INDEX idx_entry_links_project ON entry_links (project_id, to_slug);

View file

@ -38,4 +38,4 @@ ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT '';
-- --
-- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg -- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg
-- dorthin bleibt offen. -- 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;

View file

@ -69,13 +69,13 @@ func (k APIKey) Revoked() bool {
// Expired sagt, ob der Key abgelaufen ist. Ein abgelaufener Key gilt wie ein // Expired sagt, ob der Key abgelaufen ist. Ein abgelaufener Key gilt wie ein
// zurückgezogener (siehe ResolveKeyHash und sessionFromSubject). // zurückgezogener (siehe ResolveKeyHash und sessionFromSubject).
func (k APIKey) Expired(jetzt time.Time) bool { func (k APIKey) Expired(now time.Time) bool {
return !k.ExpiresAt.IsZero() && !jetzt.Before(k.ExpiresAt) return !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt)
} }
// Valid sagt, ob der Key gerade gilt: weder zurückgezogen noch abgelaufen. // Valid sagt, ob der Key gerade gilt: weder zurückgezogen noch abgelaufen.
func (k APIKey) Valid(jetzt time.Time) bool { func (k APIKey) Valid(now time.Time) bool {
return !k.Revoked() && !k.Expired(jetzt) return !k.Revoked() && !k.Expired(now)
} }
// ExpiresSoonWithin ist die Zeitspanne vor dem Ablauf, in der die Oberfläche // 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 // ExpiresSoon sagt, ob ein gültiger Key innerhalb von ExpiresSoonWithin
// abläuft. // abläuft.
func (k APIKey) ExpiresSoon(jetzt time.Time) bool { func (k APIKey) ExpiresSoon(now time.Time) bool {
return k.Valid(jetzt) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(jetzt) <= ExpiresSoonWithin return k.Valid(now) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(now) <= ExpiresSoonWithin
} }
// UnusedAfter ist die Zeitspanne, nach der ein aktiver Key ohne Benutzung als // 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 // Zurückgezogene Keys sind nie „ungenutzt“, denn sie sind ohnehin kein offener
// Zugang mehr. // 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. // Zeitpunkt vorgeben können.
// //
// Abgelaufene Keys sind ebenso wenig „ungenutzt" — auch sie öffnen nichts mehr. // Abgelaufene Keys sind ebenso wenig „ungenutzt" — auch sie öffnen nichts mehr.
func (k APIKey) Unused(jetzt time.Time) bool { func (k APIKey) Unused(now time.Time) bool {
return ungenutzt(!k.Valid(jetzt), k.LastUsedAt, k.CreatedAt, jetzt) 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 // WorkspaceAdmin.Unused — beide Zugangsarten sollen nach demselben Maßstab
// als „ungenutzt“ gelten. // als „ungenutzt“ gelten.
func ungenutzt(zurueckgezogen bool, zuletztBenutzt, angelegt, jetzt time.Time) bool { func unused(revoked bool, lastUsed, created, now time.Time) bool {
if zurueckgezogen { if revoked {
return false return false
} }
zuletzt := zuletztBenutzt latest := lastUsed
if zuletzt.IsZero() { if latest.IsZero() {
zuletzt = angelegt latest = created
} }
return jetzt.Sub(zuletzt) > UnusedAfter return now.Sub(latest) > UnusedAfter
} }
// Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben. // 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 // 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. // ein Mensch den Eintrag irgendwann bestätigt hat, nicht wann.
func (e Entry) Stale(jetzt time.Time) bool { func (e Entry) Stale(now time.Time) bool {
return StaleSince(e.UpdatedAt, jetzt) return StaleSince(e.UpdatedAt, now)
} }
// StaleSince ist dieselbe Regel für Stellen, die nur den Änderungszeitpunkt // StaleSince ist dieselbe Regel für Stellen, die nur den Änderungszeitpunkt
// kennen, aber keinen ganzen Entry, etwa verwandte Einträge (RelatedEntry). // kennen, aber keinen ganzen Entry, etwa verwandte Einträge (RelatedEntry).
func StaleSince(geaendert, jetzt time.Time) bool { func StaleSince(changed, now time.Time) bool {
return !geaendert.IsZero() && jetzt.Sub(geaendert) > StaleAfter return !changed.IsZero() && now.Sub(changed) > StaleAfter
} }
// StaleCutoff ist der Zeitpunkt, vor dem ein Eintrag zuletzt geändert worden // StaleCutoff ist der Zeitpunkt, vor dem ein Eintrag zuletzt geändert worden
// sein muss, um als veraltet zu gelten — dieselbe Grenze wie StaleSince, nur // sein muss, um als veraltet zu gelten — dieselbe Grenze wie StaleSince, nur
// als Wert für eine Abfrage. // als Wert für eine Abfrage.
func StaleCutoff(jetzt time.Time) time.Time { func StaleCutoff(now time.Time) time.Time {
return jetzt.Add(-StaleAfter) return now.Add(-StaleAfter)
} }
// TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite // 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 // 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. // neue Werte immer hinten angehängt werden statt den Baum zu zerstreuen.
func NewID() string { func NewID() string {
zeit := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36) ts := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36)
zufall := make([]byte, 4) rng := make([]byte, 4)
if _, err := rand.Read(zufall); err != nil { if _, err := rand.Read(rng); err != nil {
// crypto/rand schlägt praktisch nie fehl. Falls doch, reicht der // crypto/rand schlägt praktisch nie fehl. Falls doch, reicht der
// Zeitanteil zur Not aus — er ist auf die Millisekunde genau. // 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. // timeFormat ist das Format aller Zeitstempel in der Datenbank.

90
internal/store/pool.go Normal file
View file

@ -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() }

View file

@ -32,13 +32,13 @@ import (
const pgTestVariable = "MUNIN_TEST_PG" const pgTestVariable = "MUNIN_TEST_PG"
// pgTestAktiv sagt, ob die Tests gegen PostgreSQL laufen sollen. // pgTestEnabled sagt, ob die Tests gegen PostgreSQL laufen sollen.
func pgTestAktiv() bool { return os.Getenv(pgTestVariable) != "" } func pgTestEnabled() bool { return os.Getenv(pgTestVariable) != "" }
var ( var (
pgTestOnce sync.Once pgTestOnce sync.Once
pgTestSpeicher *PostgresSpeicher pgTestBackend *PostgresBackend
pgTestFehler error pgTestErr error
) )
// testPostgres liefert den gemeinsamen Speicher aller PostgreSQL-Tests dieses // 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 // Ein gemeinsamer Speicher statt einem je Test: Er hält eine
// Verwaltungsverbindung offen, und die braucht es nur einmal. // Verwaltungsverbindung offen, und die braucht es nur einmal.
func testPostgres(t *testing.T) *PostgresSpeicher { func testPostgres(t *testing.T) *PostgresBackend {
t.Helper() t.Helper()
if !pgTestAktiv() { if !pgTestEnabled() {
t.Skipf("%s ist nicht gesetzt — PostgreSQL-Test übersprungen", pgTestVariable) t.Skipf("%s ist nicht gesetzt — PostgreSQL-Test übersprungen", pgTestVariable)
} }
pgTestOnce.Do(func() { pgTestOnce.Do(func() {
pgTestSpeicher, pgTestFehler = NewPostgres(context.Background(), os.Getenv(pgTestVariable)) pgTestBackend, pgTestErr = NewPostgres(context.Background(), os.Getenv(pgTestVariable))
}) })
if pgTestFehler != nil { if pgTestErr != nil {
t.Fatalf("PostgreSQL für die Tests nicht erreichbar: %v", pgTestFehler) 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 // 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 // Eigener Speicher mit eigenem Kontroll-Schema, aber derselben
// Verwaltungsverbindung. // Verwaltungsverbindung.
schema := "munin_test_control_" + strings.ReplaceAll(NewID(), "-", "_") schema := "munin_test_control_" + strings.ReplaceAll(NewID(), "-", "_")
eigener := *sp own := *sp
eigener.controlSchema = schema own.controlSchema = schema
ctx := context.Background() ctx := context.Background()
if err := eigener.legeSchemaAn(ctx, schema); err != nil { if err := own.createSchema(ctx, schema); err != nil {
t.Fatal(err) t.Fatal(err)
} }
c, err := eigener.OpenControl() c, err := own.OpenControl()
if err != nil { if err != nil {
t.Fatalf("Kontroll-Schema konnte nicht angelegt werden: %v", err) 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 // Jeden angelegten Workspace mitschreiben. Die Liste aus der
// Kontroll-Datenbank reicht zum Aufräumen nicht: Ein Test, der dort einen // Kontroll-Datenbank reicht zum Aufräumen nicht: Ein Test, der dort einen
// Workspace löscht, ließe sonst dessen Schema liegen. // Workspace löscht, ließe sonst dessen Schema liegen.
merker := &angelegteWorkspaces{PostgresSpeicher: &eigener} tracker := &createdWorkspaces{PostgresBackend: &own}
c.haken = merker c.hooks = tracker
t.Cleanup(func() { t.Cleanup(func() {
for _, id := range merker.ids { for _, id := range tracker.ids {
if err := sp.DeleteWorkspace(id); err != nil { if err := sp.DeleteWorkspace(id); err != nil {
t.Errorf("Aufräumen: %v", err) t.Errorf("Aufräumen: %v", err)
} }
@ -122,32 +122,32 @@ func testPostgresControl(t *testing.T) *ControlStore {
return c 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. // dabei jeden Workspace, für den ein Schema angelegt wurde.
type angelegteWorkspaces struct { type createdWorkspaces struct {
*PostgresSpeicher *PostgresBackend
mu sync.Mutex mu sync.Mutex
ids []string 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() a.mu.Lock()
if !slices.Contains(a.ids, workspaceID) { if !slices.Contains(a.ids, workspaceID) {
a.ids = append(a.ids, workspaceID) a.ids = append(a.ids, workspaceID)
} }
a.mu.Unlock() a.mu.Unlock()
return a.PostgresSpeicher.nachDemBenennen(ctx, workspaceID, name) return a.PostgresBackend.afterRename(ctx, workspaceID, name)
} }
// ── Tests, die nur mit PostgreSQL laufen ───────────────────────────────────── // ── 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. // 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 // Dateien haben. Erst dieser Test merkt, wenn eine Spalte auf einer Seite
// anders angelegt wurde — siehe AUSBAUSTUFEN.md 4.10. // anders angelegt wurde — siehe AUSBAUSTUFEN.md 4.10.
func TestSchemaGleich(t *testing.T) { func TestSchemaMatches(t *testing.T) {
ctx := context.Background() ctx := context.Background()
t.Run("Workspace", func(t *testing.T) { t.Run("Workspace", func(t *testing.T) {
@ -158,7 +158,7 @@ func TestSchemaGleich(t *testing.T) {
defer sqlite.Close() defer sqlite.Close()
pg := testPostgresStore(t) 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) { t.Run("Kontrolle", func(t *testing.T) {
@ -169,19 +169,19 @@ func TestSchemaGleich(t *testing.T) {
defer sqlite.Close() defer sqlite.Close()
pg := testPostgresControl(t) 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, // PostgreSQL: Anlegen erzeugt ein Schema mit dem Namen als Kommentar,
// Umbenennen zieht den Kommentar nach, Löschen entfernt das Schema. // Umbenennen zieht den Kommentar nach, Löschen entfernt das Schema.
func TestPostgresSchemaJeWorkspace(t *testing.T) { func TestPostgresSchemaPerWorkspace(t *testing.T) {
sp := testPostgres(t) sp := testPostgres(t)
c := testPostgresControl(t) c := testPostgresControl(t)
ctx := context.Background() ctx := context.Background()
kommentar := func(schema string) (string, bool) { comment := func(schema string) (string, bool) {
var text sql.NullString var text sql.NullString
err := sp.admin.QueryRowContext(ctx, err := sp.admin.QueryRowContext(ctx,
`SELECT obj_description(oid, 'pg_namespace') FROM pg_namespace WHERE nspname = $1`, schema, `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) t.Fatal(err)
} }
schema, _ := WorkspaceSchema(w.ID) schema, _ := WorkspaceSchema(w.ID)
if ort := sp.Ort(w.ID); ort != schema { if location := sp.Location(w.ID); location != schema {
t.Errorf("Ort = %q, erwartet das Schema %q", ort, schema) t.Errorf("Ort = %q, erwartet das Schema %q", location, schema)
} }
if text, da := kommentar(schema); !da || text != "Vertrieb & Co's" { if text, present := comment(schema); !present || text != "Vertrieb & Co's" {
t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", da, text) t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", present, text)
} }
if err := c.UpdateWorkspace(ctx, w.ID, "Vertrieb Nord"); err != nil { if err := c.UpdateWorkspace(ctx, w.ID, "Vertrieb Nord"); err != nil {
t.Fatal(err) 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) 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 { if err := sp.DeleteWorkspace(w.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, da := kommentar(schema); da { if _, present := comment(schema); present {
t.Error("Schema ist nach DeleteWorkspace noch da") 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. // anderen nicht zu sehen — dieselbe Abschottung wie mit getrennten Dateien.
func TestPostgresWorkspacesGetrennt(t *testing.T) { func TestPostgresWorkspacesSeparated(t *testing.T) {
a := testPostgresStore(t) a := testPostgresStore(t)
b := testPostgresStore(t) b := testPostgresStore(t)
ctx := context.Background() 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. // einer Meldung abgelehnt, die die nötige Zahl nennt.
func TestPostgresKapazitaet(t *testing.T) { func TestPostgresCapacity(t *testing.T) {
sp := testPostgres(t) sp := testPostgres(t)
ctx := context.Background() 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) 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") { if err == nil || !strings.Contains(err.Error(), "max_connections auf mindestens") {
t.Errorf("Fehler = %v, erwartet eine Meldung mit der nötigen Zahl", err) 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 ────────────────────────────────────────── // ── 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 // SQLite und PostgreSQL vergleichbar werden. Jede Spalte wird zu einem Text
// wie "text NOT NULL DEFAULT 0". // wie "text NOT NULL DEFAULT 0".
type schemaBild struct { type schemaSnapshot struct {
spalten map[string]string // "tabelle.spalte" → Beschreibung columns map[string]string // "tabelle.spalte" → Beschreibung
indizes []string // eigene Indizes (idx_…), sortiert 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. // und integer/bigint (PostgreSQL) sind für Munin dasselbe: eine Ganzzahl.
func typKlasse(typ string) string { func typeClass(kind string) string {
switch strings.ToLower(typ) { switch strings.ToLower(kind) {
case "integer", "bigint", "int", "int8", "int4": case "integer", "bigint", "int", "int8", "int4":
return "ganzzahl" return "ganzzahl"
case "text": case "text":
return "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. // 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 { func defaultValue(value sql.NullString) string {
if !wert.Valid { if !value.Valid {
return "" 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() 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_%'`) `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
for _, tabelle := range tabellen { for _, table := range tables {
rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, tabelle) rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, table)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
for rows.Next() { for rows.Next() {
var name, typ string var name, kind string
var nichtNull, pk int var notNull, pk int
var standard sql.NullString var standard sql.NullString
if err := rows.Scan(&name, &typ, &nichtNull, &standard, &pk); err != nil { if err := rows.Scan(&name, &kind, &notNull, &standard, &pk); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Ein Primärschlüssel ist in PostgreSQL immer NOT NULL, in SQLite // Ein Primärschlüssel ist in PostgreSQL immer NOT NULL, in SQLite
// steht es nur dabei, wenn es ausdrücklich geschrieben wurde. // 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() rows.Close()
} }
// Ausdrücklich angelegte Indizes haben einen SQL-Text, die automatischen // Ausdrücklich angelegte Indizes haben einen SQL-Text, die automatischen
// für PRIMARY KEY und UNIQUE nicht. // 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`) `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() t.Helper()
bild := schemaBild{spalten: map[string]string{}} snapshot := schemaSnapshot{columns: map[string]string{}}
rows, err := p.QueryContext(ctx, rows, err := p.QueryContext(ctx,
`SELECT table_name, column_name, data_type, is_nullable, column_default `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) t.Fatal(err)
} }
for rows.Next() { for rows.Next() {
var tabelle, name, typ, nullbar string var table, name, kind, nullable string
var standard sql.NullString 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) t.Fatal(err)
} }
bild.spalten[tabelle+"."+name] = spaltenText(typ, nullbar == "NO", standard) snapshot.columns[table+"."+name] = columnsText(kind, nullable == "NO", standard)
} }
rows.Close() rows.Close()
// Die Indizes hinter PRIMARY KEY und UNIQUE benennt PostgreSQL selbst // Die Indizes hinter PRIMARY KEY und UNIQUE benennt PostgreSQL selbst
// (…_pkey, …_key). Verglichen werden nur die eigenen, die mit idx_ beginnen // (…_pkey, …_key). Verglichen werden nur die eigenen, die mit idx_ beginnen
// — so heißen sie in den Migrationen. // — 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`) `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 { func columnsText(kind string, notNull bool, standard sql.NullString) string {
text := typKlasse(typ) text := typeClass(kind)
if nichtNull { if notNull {
text += " NOT NULL" 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() t.Helper()
rows, err := p.QueryContext(ctx, abfrage) rows, err := p.QueryContext(ctx, query)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer rows.Close() defer rows.Close()
var liste []string var list []string
for rows.Next() { for rows.Next() {
var s string var s string
if err := rows.Scan(&s); err != nil { if err := rows.Scan(&s); err != nil {
t.Fatal(err) 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() t.Helper()
alle := map[string]bool{} all := map[string]bool{}
for k := range sqlite.spalten { for k := range sqlite.columns {
alle[k] = true all[k] = true
} }
for k := range postgres.spalten { for k := range postgres.columns {
alle[k] = true all[k] = true
} }
var namen []string var names []string
for k := range alle { for k := range all {
namen = append(namen, k) names = append(names, k)
} }
sort.Strings(namen) sort.Strings(names)
for _, name := range namen { for _, name := range names {
s, inSQLite := sqlite.spalten[name] s, inSQLite := sqlite.columns[name]
p, inPostgres := postgres.spalten[name] p, inPostgres := postgres.columns[name]
switch { switch {
case !inSQLite: case !inSQLite:
t.Errorf("%s gibt es nur in PostgreSQL (%s)", name, p) 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) { if fmt.Sprint(sqlite.indexes) != fmt.Sprint(postgres.indexes) {
t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indizes, postgres.indizes) t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indexes, postgres.indexes)
} }
} }

View file

@ -26,30 +26,30 @@ func ValidSlug(slug string) bool {
// Wird als Vorschlag verwendet, wenn ein Mensch nur einen Titel eingibt. // Wird als Vorschlag verwendet, wenn ein Mensch nur einen Titel eingibt.
func Slugify(text string) string { func Slugify(text string) string {
var b strings.Builder var b strings.Builder
letzterStrich := false lastDash := false
for _, r := range strings.ToLower(strings.TrimSpace(text)) { for _, r := range strings.ToLower(strings.TrimSpace(text)) {
switch { switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9': case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r) b.WriteRune(r)
letzterStrich = false lastDash = false
case r == 'ä': case r == 'ä':
b.WriteString("ae") b.WriteString("ae")
letzterStrich = false lastDash = false
case r == 'ö': case r == 'ö':
b.WriteString("oe") b.WriteString("oe")
letzterStrich = false lastDash = false
case r == 'ü': case r == 'ü':
b.WriteString("ue") b.WriteString("ue")
letzterStrich = false lastDash = false
case r == 'ß': case r == 'ß':
b.WriteString("ss") b.WriteString("ss")
letzterStrich = false lastDash = false
default: default:
// Alles andere wird zu einem Bindestrich, aber nie zwei am Stück. // Alles andere wird zu einem Bindestrich, aber nie zwei am Stück.
if !letzterStrich && b.Len() > 0 { if !lastDash && b.Len() > 0 {
b.WriteRune('-') b.WriteRune('-')
letzterStrich = true lastDash = true
} }
} }
} }
@ -83,12 +83,12 @@ func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, desc
Name: name, Name: name,
Description: description, 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) `INSERT INTO projects (id, workspace_id, slug, name, description, created_at)
VALUES (?, ?, ?, ?, ?, ?)`, 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) { if isUniqueViolation(err) {
return Project{}, fmt.Errorf("Projekt %q: %w", slug, ErrDuplicate) 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) return Project{}, fmt.Errorf("Projekt konnte nicht angelegt werden: %w", err)
} }
p.CreatedAt = parseTime(jetzt) p.CreatedAt = parseTime(now)
return p, nil return p, nil
} }
@ -122,7 +122,7 @@ func scanProject(scan func(...any) error) (Project, error) {
// ProjectByID liest ein Projekt anhand seiner ID. // ProjectByID liest ein Projekt anhand seiner ID.
func (s *Store) ProjectByID(ctx context.Context, id string) (Project, error) { 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) p, err := scanProject(row.Scan)
if errors.Is(err, sql.ErrNoRows) { 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. // ProjectBySlug liest ein Projekt anhand seines Bezeichners innerhalb eines Workspace.
func (s *Store) ProjectBySlug(ctx context.Context, workspaceID, slug string) (Project, error) { 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 = ?`, `SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? AND slug = ?`,
workspaceID, 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. // ListProjects gibt alle Projekte eines Workspace zurück, alphabetisch sortiert.
func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project, error) { 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`, `SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? ORDER BY name`,
workspaceID, workspaceID,
) )
@ -162,15 +162,15 @@ func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project
} }
defer rows.Close() defer rows.Close()
var projekte []Project var projects []Project
for rows.Next() { for rows.Next() {
p, err := scanProject(rows.Scan) p, err := scanProject(rows.Scan)
if err != nil { if err != nil {
return nil, err 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. // 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) 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 = ?`, `UPDATE projects SET name = ?, description = ?, write_limit_hourly = ? WHERE id = ?`,
u.Name, u.Description, u.WriteLimitHourly, 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 // Das Aufräumen übernehmen die ON DELETE CASCADE-Regeln im Schema — deshalb ist
// wichtig, dass foreign_keys eingeschaltet ist (siehe Open). // wichtig, dass foreign_keys eingeschaltet ist (siehe Open).
func (s *Store) DeleteProject(ctx context.Context, id string) error { 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 { if err != nil {
return fmt.Errorf("Projekt konnte nicht gelöscht werden: %w", err) return fmt.Errorf("Projekt konnte nicht gelöscht werden: %w", err)
} }

View file

@ -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 // 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 // Bezeichner eine leere Liste zurück, und der Agent hielte das Thema für
// unverknüpft statt für nicht vorhanden. // unverknüpft statt für nicht vorhanden.
var vorhanden int var existing int
if err := s.lesen.QueryRowContext(ctx, if err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, `SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+notInTrash,
projectID, slug, projectID, slug,
).Scan(&vorhanden); err != nil { ).Scan(&existing); err != nil {
return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err) return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
} }
if vorhanden == 0 { if existing == 0 {
return nil, ErrNotFound 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 // 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. // als besucht, er soll nicht als sein eigener Nachbar erscheinen.
besucht := map[string]bool{slug: true} visited := map[string]bool{slug: true}
ebene := []string{slug} level := []string{slug}
var ergebnis []RelatedEntry var result []RelatedEntry
for abstand := 1; abstand <= maxDistance && len(ebene) > 0 && len(ergebnis) < limit; abstand++ { for distance := 1; distance <= maxDistance && len(level) > 0 && len(result) < limit; distance++ {
kanten, err := s.nachbarn(ctx, projectID, ebene) edges, err := s.neighbors(ctx, projectID, level)
if err != nil { if err != nil {
return nil, err 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 // Je neu entdecktem Bezeichner nur die erste Kante merken. Welcher
// Vorgänger „gewinnt“, ist egal, aber es muss derselbe sein, egal in // Vorgänger „gewinnt“, ist egal, aber es muss derselbe sein, egal in
// welcher Reihenfolge die Datenbank die Zeilen liefert. Deshalb sind // welcher Reihenfolge die Datenbank die Zeilen liefert. Deshalb sind
// die Kanten vorher sortiert (siehe nachbarn). // die Kanten vorher sortiert (siehe neighbors).
neu := make(map[string]RelatedEntry) newer := make(map[string]RelatedEntry)
var neueSlugs []string var newSlugs []string
for _, k := range kanten { for _, k := range edges {
if besucht[k.nach] { if visited[k.to] {
continue continue
} }
if _, schonDa := neu[k.nach]; schonDa { if _, already := newer[k.to]; already {
continue continue
} }
neu[k.nach] = RelatedEntry{Slug: k.nach, Distance: abstand, Via: k.von, Relation: k.relation} newer[k.to] = RelatedEntry{Slug: k.to, Distance: distance, Via: k.from, Relation: k.relation}
neueSlugs = append(neueSlugs, k.nach) newSlugs = append(newSlugs, k.to)
} }
// Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne // Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne
// Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in // Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in
// der Antwort. // der Antwort.
details, err := s.eintragsKoepfe(ctx, projectID, neueSlugs) details, err := s.entryHeads(ctx, projectID, newSlugs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var dieseEbene []RelatedEntry var thisLevel []RelatedEntry
ebene = ebene[:0] level = level[:0]
for _, nachbar := range neueSlugs { for _, neighbor := range newSlugs {
besucht[nachbar] = true visited[neighbor] = true
kopf, ok := details[nachbar] head, ok := details[neighbor]
if !ok { if !ok {
continue continue
} }
r := neu[nachbar] r := newer[neighbor]
r.Title = kopf.Title r.Title = head.Title
r.UpdatedAt = kopf.UpdatedAt r.UpdatedAt = head.UpdatedAt
dieseEbene = append(dieseEbene, r) thisLevel = append(thisLevel, r)
ebene = append(ebene, nachbar) level = append(level, neighbor)
} }
sort.Slice(dieseEbene, func(i, j int) bool { return dieseEbene[i].Title < dieseEbene[j].Title }) sort.Slice(thisLevel, func(i, j int) bool { return thisLevel[i].Title < thisLevel[j].Title })
ergebnis = append(ergebnis, dieseEbene...) result = append(result, thisLevel...)
} }
if len(ergebnis) > limit { if len(result) > limit {
ergebnis = ergebnis[:limit] result = result[:limit]
} }
return ergebnis, nil return result, nil
} }
// kante ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus. // edge ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus.
type kante struct { type edge struct {
von, nach string from, to string
relation 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. // in beide Richtungen.
// //
// Zwei Abfragen statt einer mit OR: So nutzt jede ihren eigenen Index. Die // 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 // ausgehenden laufen über den Primärschlüssel (from_entry_id, to_slug), die
// eingehenden über idx_entry_links_projekt (project_id, to_slug). // eingehenden über idx_entry_links_project (project_id, to_slug).
func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) ([]kante, error) { func (s *Store) neighbors(ctx context.Context, projectID string, slugs []string) ([]edge, error) {
platz, args := inListe(projectID, slugs) placeholders, args := inList(projectID, slugs)
var kanten []kante var edges []edge
// Ausgehend: Einträge der Ebene verweisen auf etwas. // 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 `SELECT e.slug, l.to_slug
FROM entries e FROM entries e
JOIN entry_links l ON l.from_entry_id = e.id 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..., args...,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err) return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err)
} }
for rows.Next() { for rows.Next() {
var k kante var k edge
if err := rows.Scan(&k.von, &k.nach); err != nil { if err := rows.Scan(&k.from, &k.to); err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
k.relation = RelationLinksTo k.relation = RelationLinksTo
kanten = append(kanten, k) edges = append(edges, k)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
rows.Close() 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 // Eingehend: Etwas verweist auf Einträge der Ebene. Der verweisende
// Eintrag ist hier der Nachbar, deshalb stehen die Spalten andersherum. // 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 `SELECT l.to_slug, e.slug
FROM entry_links l FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id 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..., args...,
) )
if err != nil { if err != nil {
@ -194,12 +194,12 @@ func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string)
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var k kante var k edge
if err := rows.Scan(&k.von, &k.nach); err != nil { if err := rows.Scan(&k.from, &k.to); err != nil {
return nil, err return nil, err
} }
k.relation = RelationLinkedBy k.relation = RelationLinkedBy
kanten = append(kanten, k) edges = append(edges, k)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err 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 // Feste Reihenfolge, damit dasselbe Projekt immer dieselbe Antwort gibt
// (siehe RelatedEntries). // (siehe RelatedEntries).
sort.Slice(kanten, func(i, j int) bool { sort.Slice(edges, func(i, j int) bool {
if kanten[i].nach != kanten[j].nach { if edges[i].to != edges[j].to {
return kanten[i].nach < kanten[j].nach return edges[i].to < edges[j].to
} }
if kanten[i].von != kanten[j].von { if edges[i].from != edges[j].from {
return kanten[i].von < kanten[j].von 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. // entryHead ist das Wenige, was RelatedEntries über einen Nachbarn braucht.
type eintragsKopf struct { type entryHead struct {
Title string Title string
UpdatedAt time.Time 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. // 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) { func (s *Store) entryHeads(ctx context.Context, projectID string, slugs []string) (map[string]entryHead, error) {
koepfe := make(map[string]eintragsKopf, len(slugs)) heads := make(map[string]entryHead, len(slugs))
if len(slugs) == 0 { if len(slugs) == 0 {
return koepfe, nil return heads, nil
} }
platz, args := inListe(projectID, slugs) placeholders, args := inList(projectID, slugs)
rows, err := s.lesen.QueryContext(ctx, rows, err := s.read.QueryContext(ctx,
`SELECT slug, title, updated_at FROM entries `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..., args...,
) )
if err != nil { if err != nil {
@ -245,26 +245,26 @@ func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []st
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var slug, title, geaendert string var slug, title, changed string
if err := rows.Scan(&slug, &title, &geaendert); err != nil { if err := rows.Scan(&slug, &title, &changed); err != nil {
return nil, err 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. // Argumente, mit der Projekt-ID vorneweg.
// //
// Die Werte werden nie in den SQL-Text geschrieben, sondern immer als // Die Werte werden nie in den SQL-Text geschrieben, sondern immer als
// Parameter übergeben. Nur so ist ausgeschlossen, dass ein Bezeichner die // Parameter übergeben. Nur so ist ausgeschlossen, dass ein Bezeichner die
// Abfrage verändert (SQL-Injection). // Abfrage verändert (SQL-Injection).
func inListe(projectID string, werte []string) (string, []any) { func inList(projectID string, values []string) (string, []any) {
args := make([]any, 0, len(werte)+1) args := make([]any, 0, len(values)+1)
args = append(args, projectID) args = append(args, projectID)
for _, w := range werte { for _, w := range values {
args = append(args, w) args = append(args, w)
} }
return strings.TrimSuffix(strings.Repeat("?, ", len(werte)), ", "), args return strings.TrimSuffix(strings.Repeat("?, ", len(values)), ", "), args
} }

View file

@ -7,8 +7,8 @@ import (
"time" "time"
) )
// schreibe legt einen Eintrag an und bricht den Test bei einem Fehler ab. // write legt einen Eintrag an und bricht den Test bei einem Fehler ab.
func schreibe(t *testing.T, s *Store, projectID, slug, content string) { func write(t *testing.T, s *Store, projectID, slug, content string) {
t.Helper() t.Helper()
if _, err := s.PutEntry(context.Background(), EntryWrite{ if _, err := s.PutEntry(context.Background(), EntryWrite{
ProjectID: projectID, Slug: slug, Title: "Titel " + slug, Content: content, ProjectID: projectID, Slug: slug, Title: "Titel " + slug, Content: content,
@ -29,24 +29,24 @@ func TestRelatedEntries(t *testing.T) {
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
schreibe(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]") write(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]")
schreibe(t, s, p.ID, "a", "weiter bei [[fern]]") write(t, s, p.ID, "a", "weiter bei [[fern]]")
schreibe(t, s, p.ID, "b", "hängt an [[start]]") write(t, s, p.ID, "b", "hängt an [[start]]")
schreibe(t, s, p.ID, "fern", "Ende") write(t, s, p.ID, "fern", "Ende")
schreibe(t, s, p.ID, "c", "allein") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
gefunden := make(map[string]RelatedEntry) found := make(map[string]RelatedEntry)
for _, v := range verwandte { for _, v := range related {
gefunden[v.Slug] = v found[v.Slug] = v
} }
erwartet := map[string]struct { want := map[string]struct {
abstand int distance int
via string via string
relation string relation string
}{ }{
@ -54,15 +54,15 @@ func TestRelatedEntries(t *testing.T) {
"b": {1, "start", RelationLinkedBy}, "b": {1, "start", RelationLinkedBy},
"fern": {2, "a", RelationLinksTo}, "fern": {2, "a", RelationLinksTo},
} }
for slug, e := range erwartet { for slug, e := range want {
v, ok := gefunden[slug] v, ok := found[slug]
if !ok { if !ok {
t.Errorf("%q fehlt", slug) t.Errorf("%q fehlt", slug)
continue 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)", 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() { if v.Title != "Titel "+slug || v.UpdatedAt.IsZero() {
t.Errorf("%q: Titel oder Änderungsdatum fehlen: %+v", slug, v) 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"} { 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) t.Errorf("%q darf nicht unter den verwandten Einträgen stehen", slug)
} }
} }
// Näheres vor Fernerem. // Näheres vor Fernerem.
if verwandte[len(verwandte)-1].Slug != "fern" { if related[len(related)-1].Slug != "fern" {
t.Errorf("die zweite Reihe steht nicht am Ende: %+v", verwandte) t.Errorf("die zweite Reihe steht nicht am Ende: %+v", related)
} }
// Mit Abstand 1 nur die direkten Nachbarn. // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(direkt) != 2 { if len(direct) != 2 {
t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direkt), direkt) t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direct), direct)
} }
// Die Obergrenze gilt. // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(begrenzt) != 1 { if len(limited) != 1 {
t.Errorf("Limit 1 lieferte %d Einträge", len(begrenzt)) t.Errorf("Limit 1 lieferte %d Einträge", len(limited))
} }
} }
@ -106,20 +106,20 @@ func TestRelatedEntriesSkipsTrash(t *testing.T) {
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
schreibe(t, s, p.ID, "start", "siehe [[weg]]") write(t, s, p.ID, "start", "siehe [[weg]]")
schreibe(t, s, p.ID, "weg", "siehe [[dahinter]]") write(t, s, p.ID, "weg", "siehe [[dahinter]]")
schreibe(t, s, p.ID, "dahinter", "Ende") write(t, s, p.ID, "dahinter", "Ende")
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "test"); err != nil { if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "test"); err != nil {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(verwandte) != 0 { if len(related) != 0 {
t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", verwandte) t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", related)
} }
// Ein Ausgangseintrag im Papierkorb gilt als nicht vorhanden. // 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). // TestEntryStale prüft die Grenze für veraltetes Wissen (AUSBAUSTUFEN.md 3.5).
func TestEntryStale(t *testing.T) { 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 { cases := []struct {
name string name string
geaendert time.Time changed time.Time
erwartet bool want bool
}{ }{
{"gestern", jetzt.AddDate(0, 0, -1), false}, {"gestern", now.AddDate(0, 0, -1), false},
{"vor elf Monaten", jetzt.AddDate(0, -11, 0), false}, {"vor elf Monaten", now.AddDate(0, -11, 0), false},
{"vor zwei Jahren", jetzt.AddDate(-2, 0, 0), true}, {"vor zwei Jahren", now.AddDate(-2, 0, 0), true},
{"ohne Datum", time.Time{}, false}, {"ohne Datum", time.Time{}, false},
} }
for _, f := range faelle { for _, f := range cases {
t.Run(f.name, func(t *testing.T) { t.Run(f.name, func(t *testing.T) {
if got := (Entry{UpdatedAt: f.geaendert}).Stale(jetzt); got != f.erwartet { if got := (Entry{UpdatedAt: f.changed}).Stale(now); got != f.want {
t.Errorf("Stale = %v, erwartet %v", got, f.erwartet) t.Errorf("Stale = %v, erwartet %v", got, f.want)
} }
}) })
} }
@ -167,31 +167,31 @@ func TestPutEntryKeepsImportedTimestamps(t *testing.T) {
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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{ e, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "x", ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "x",
Source: SourceHuman, CreatedAt: alt, UpdatedAt: alt, Source: SourceHuman, CreatedAt: old, UpdatedAt: old,
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
gelesen, _ := s.EntryBySlug(ctx, p.ID, "alt") read, _ := s.EntryBySlug(ctx, p.ID, "alt")
if !gelesen.CreatedAt.Equal(alt) || !gelesen.UpdatedAt.Equal(alt) || !e.UpdatedAt.Equal(alt) { if !read.CreatedAt.Equal(old) || !read.UpdatedAt.Equal(old) || !e.UpdatedAt.Equal(old) {
t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", gelesen.CreatedAt, gelesen.UpdatedAt) t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", read.CreatedAt, read.UpdatedAt)
} }
// Eine Änderung passiert jetzt, egal was mitgegeben wird. // Eine Änderung passiert jetzt, egal was mitgegeben wird.
if _, err := s.PutEntry(ctx, EntryWrite{ if _, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "neu", ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "neu",
Source: SourceHuman, UpdatedAt: alt, Source: SourceHuman, UpdatedAt: old,
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
gelesen, _ = s.EntryBySlug(ctx, p.ID, "alt") read, _ = s.EntryBySlug(ctx, p.ID, "alt")
if gelesen.UpdatedAt.Equal(alt) { if read.UpdatedAt.Equal(old) {
t.Error("beim Ändern wurde der vorgegebene Zeitstempel übernommen") 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") t.Error("beim Ändern ging das Anlegedatum verloren")
} }
} }

View file

@ -1,7 +1,7 @@
package store package store
// Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden // Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden
// (migrations/006_suchprotokoll.sql). // (migrations/006_search_log.sql).
import ( import (
"context" "context"
@ -32,7 +32,7 @@ type SearchLogEntry struct {
// Serverlog. Der Agent soll seine Treffer bekommen, auch wenn die // Serverlog. Der Agent soll seine Treffer bekommen, auch wenn die
// Protokollzeile nicht geschrieben werden konnte. // Protokollzeile nicht geschrieben werden konnte.
func (s *Store) LogSearch(ctx context.Context, e SearchLogEntry) { 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) `INSERT INTO search_log (ts, project_id, api_key_id, query, synonyms, results)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
NowString(), e.ProjectID, e.APIKeyID, e.Query, strings.Join(e.Synonyms, ", "), e.Results, 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. // PruneSearchLog löscht Suchen, die älter als die angegebene Grenze sind.
// Die Grenze ist ein Zeitstempel im Datenbankformat, wie bei PruneAudit. // Die Grenze ist ein Zeitstempel im Datenbankformat, wie bei PruneAudit.
func (s *Store) PruneSearchLog(ctx context.Context, aelterAls string) (int64, error) { func (s *Store) PruneSearchLog(ctx context.Context, olderThan string) (int64, error) {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, aelterAls) res, err := s.write.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, olderThan)
if err != nil { if err != nil {
return 0, fmt.Errorf("Suchprotokoll konnte nicht aufgeräumt werden: %w", err) 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). // EmptyPercent ist der Anteil der Suchen ohne Treffer in Prozent (0–100).
func (s SearchStats) EmptyPercent() int { 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). // SynonymPercent ist der Anteil der Suchen mit Synonymen in Prozent (0–100).
// Zeigt, ob die Agenten die Bitte aus den instructions befolgen. // Zeigt, ob die Agenten die Bitte aus den instructions befolgen.
func (s SearchStats) SynonymPercent() int { 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. // SearchStatsFor zählt die Suchen eines Projekts im Zeitraum UsageWindow.
func (s *Store) SearchStatsFor(ctx context.Context, projectID string, jetzt time.Time) (SearchStats, error) { func (s *Store) SearchStatsFor(ctx context.Context, projectID string, now time.Time) (SearchStats, error) {
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// Wie in UsageSummaries: CASE … THEN 1 ELSE 0 macht aus jeder Zeile eine // 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. // 1 oder 0, SUM zählt. COALESCE, weil SUM über null Zeilen NULL liefert.
var st SearchStats var st SearchStats
err := s.lesen.QueryRowContext(ctx, err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*), `SELECT COUNT(*),
COALESCE(SUM(CASE WHEN results = 0 THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN results = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN synonyms <> '' THEN 1 ELSE 0 END), 0) COALESCE(SUM(CASE WHEN synonyms <> '' THEN 1 ELSE 0 END), 0)
FROM search_log FROM search_log
WHERE project_id = ? AND ts >= ?`, WHERE project_id = ? AND ts >= ?`,
projectID, seit, projectID, since,
).Scan(&st.Total, &st.Empty, &st.WithSynonyms) ).Scan(&st.Total, &st.Empty, &st.WithSynonyms)
if err != nil { if err != nil {
return SearchStats{}, fmt.Errorf("Suchprotokoll konnte nicht gelesen werden: %w", err) 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 // Gleiche Anfragen werden ohne Rücksicht auf Groß- und Kleinschreibung
// zusammengefasst. lower() kennt in SQLite nur ASCII — „Über" und „über" // zusammengefasst. lower() kennt in SQLite nur ASCII — „Über" und „über"
// bleiben also zwei Zeilen. Für eine Übersicht reicht das. // 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) { func (s *Store) EmptySearches(ctx context.Context, projectID string, now time.Time, limit int) ([]EmptySearch, error) {
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// Je Anfrage (ohne Rücksicht auf Groß-/Kleinschreibung und Leerzeichen am // Je Anfrage (ohne Rücksicht auf Groß-/Kleinschreibung und Leerzeichen am
// Rand) eine Zeile: die jüngste, mit ihren Synonymen. COUNT(*) OVER zählt // 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 // Früher stand hier MAX(ts) mit Spalten ohne Aggregat — eine Eigenheit von
// SQLite, die PostgreSQL ablehnt. Fensterfunktionen können beide (siehe // SQLite, die PostgreSQL ablehnt. Fensterfunktionen können beide (siehe
// CuratedOverrides und AUSBAUSTUFEN.md 4.10). // 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, anzahl, ts FROM (
SELECT query, synonyms, ts, SELECT query, synonyms, ts,
COUNT(*) OVER (PARTITION BY lower(trim(query))) AS anzahl, 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 WHERE nr = 1
ORDER BY anzahl DESC, ts DESC ORDER BY anzahl DESC, ts DESC
LIMIT ?`, LIMIT ?`,
projectID, seit, limit, projectID, since, limit,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Suchen ohne Treffer konnten nicht gelesen werden: %w", err) return nil, fmt.Errorf("Suchen ohne Treffer konnten nicht gelesen werden: %w", err)
} }
defer rows.Close() defer rows.Close()
var liste []EmptySearch var list []EmptySearch
for rows.Next() { for rows.Next() {
var e EmptySearch var e EmptySearch
var zuletzt string var latest string
if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &zuletzt); err != nil { if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &latest); err != nil {
return nil, err return nil, err
} }
e.Last = parseTime(zuletzt) e.Last = parseTime(latest)
liste = append(liste, e) list = append(list, e)
} }
return liste, rows.Err() return list, rows.Err()
} }

View file

@ -6,9 +6,9 @@ import (
"time" "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. // Synonymen — und nur innerhalb des Zeitfensters und des Projekts.
func TestSearchStatsZaehlt(t *testing.T) { func TestSearchStatsCounts(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
@ -18,9 +18,9 @@ func TestSearchStatsZaehlt(t *testing.T) {
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "vpn", Results: 0}) s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "vpn", Results: 0})
// Außerhalb des Fensters: darf nicht mitzählen. // Außerhalb des Fensters: darf nicht mitzählen.
alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano) old := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx, if _, err := s.write.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil { `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, old, p.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -36,8 +36,8 @@ func TestSearchStatsZaehlt(t *testing.T) {
} }
} }
// TestSearchStatsLeer prüft ein Projekt ohne Suchen: alles 0, kein Fehler. // TestSearchStatsEmpty prüft ein Projekt ohne Suchen: alles 0, kein Fehler.
func TestSearchStatsLeer(t *testing.T) { func TestSearchStatsEmpty(t *testing.T) {
s := testStore(t) s := testStore(t)
p := testProject(t, s) 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 // zusammengefasst werden, unabhängig von Groß- und Kleinschreibung, und die
// häufigste vorne steht. // häufigste vorne steht.
func TestEmptySearchesFasstZusammen(t *testing.T) { func TestEmptySearchesAggregates(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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: "drucker", Results: 0})
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "deployment", Results: 2}) // hat Treffer 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(liste) != 2 { if len(list) != 2 {
t.Fatalf("%d Anfragen, erwartet 2: %+v", len(liste), liste) t.Fatalf("%d Anfragen, erwartet 2: %+v", len(list), list)
} }
if liste[0].Count != 3 { if list[0].Count != 3 {
t.Errorf("häufigste Anfrage = %+v, erwartet 3× vpn zugang", liste[0]) t.Errorf("häufigste Anfrage = %+v, erwartet 3× vpn zugang", list[0])
} }
if liste[0].Synonyms != "remote" { if list[0].Synonyms != "remote" {
t.Errorf("Synonyme = %q, erwartet die der jüngsten Suche (remote)", liste[0].Synonyms) t.Errorf("Synonyme = %q, erwartet die der jüngsten Suche (remote)", list[0].Synonyms)
} }
if liste[1].Query != "drucker" { if list[1].Query != "drucker" {
t.Errorf("zweite Anfrage = %q, erwartet drucker", liste[1].Query) t.Errorf("zweite Anfrage = %q, erwartet drucker", list[1].Query)
} }
} }
@ -88,15 +88,15 @@ func TestPruneSearchLog(t *testing.T) {
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
alt := time.Now().UTC().Add(-SearchLogRetention - time.Hour).Format(time.RFC3339Nano) old := time.Now().UTC().Add(-SearchLogRetention - time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx, if _, err := s.write.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil { `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, old, p.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "neu", Results: 0}) s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "neu", Results: 0})
grenze := time.Now().UTC().Add(-SearchLogRetention).Format(time.RFC3339Nano) limit := time.Now().UTC().Add(-SearchLogRetention).Format(time.RFC3339Nano)
n, err := s.PruneSearchLog(ctx, grenze) n, err := s.PruneSearchLog(ctx, limit)
if err != nil { if err != nil {
t.Fatal(err) 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. // sind Inhalt des Projekts und gehen mit ihm.
func TestSuchprotokollVerschwindetMitProjekt(t *testing.T) { func TestSearchLogVanishesWithProject(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
@ -117,11 +117,11 @@ func TestSuchprotokollVerschwindetMitProjekt(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
var anzahl int var count int
if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM search_log`).Scan(&anzahl); err != nil { if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM search_log`).Scan(&count); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if anzahl != 0 { if count != 0 {
t.Errorf("%d Anfragen des gelöschten Projekts sind noch da", anzahl) t.Errorf("%d Anfragen des gelöschten Projekts sind noch da", count)
} }
} }

View file

@ -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")
}

View file

@ -13,9 +13,9 @@
// kann. Die Regeln stehen als Kommentar in migrations/sqlite/001_init.sql. // 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 // Seit AUSBAUSTUFEN.md 4.10 läuft derselbe Store wahlweise auf SQLite oder
// PostgreSQL. Was sich unterscheidet, steht im dialekt (dialekt.go); welche // PostgreSQL. Was sich unterscheidet, steht im dialect (dialect.go); welche
// Datenbank benutzt wird, entscheidet der Speicher (speicher.go). Alle // Datenbank benutzt wird, entscheidet das Backend (backend.go). Alle
// Abfragen laufen über pool (verbindung.go), damit keine am Dialekt // Abfragen laufen über pool (pool.go), damit keine am Dialekt
// vorbeikommt. // vorbeikommt.
// //
// # Warum modernc.org/sqlite? // # Warum modernc.org/sqlite?
@ -77,10 +77,10 @@ var migrationsFS embed.FS
// die Leser sind davon nicht betroffen. // die Leser sind davon nicht betroffen.
// //
// Unter PostgreSQL gilt dieselbe Aufteilung, nur mit anderen Größen (siehe // Unter PostgreSQL gilt dieselbe Aufteilung, nur mit anderen Größen (siehe
// speicher_postgres.go). // backend_postgres.go).
type Store struct { type Store struct {
lesen pool // mehrere Verbindungen — nur lesende Abfragen read pool // mehrere Verbindungen — nur lesende Abfragen
schreiben pool // genau eine Verbindung — alles, was verändert write pool // genau eine Verbindung — alles, was verändert
} }
// Open öffnet die Workspace-Datenbankdatei, setzt die nötigen Einstellungen und // 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. // sich um die einzige Abteilung handelt oder eine von vierzig.
// //
// Open ist der Weg für SQLite. Welche Datenbank der Server benutzt, entscheidet // 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. // brauchen, rufen Open direkt auf.
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
lesen, schreiben, err := openSQLite(path, migrationsFS, "migrations") read, write, err := openSQLite(path, migrationsFS, "migrations")
if err != nil { if err != nil {
return nil, err 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 // 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 // (Workspace-Dateien) und OpenControl (Kontroll-Datenbank) — beide brauchen
// exakt dieselben Einstellungen, nur unterschiedliche Dateien und Migrationen. // exakt dieselben Einstellungen, nur unterschiedliche Dateien und Migrationen.
// //
// Das Gegenstück für PostgreSQL ist PostgresSpeicher.oeffnePools. // Das Gegenstück für PostgreSQL ist PostgresBackend.oeffnePools.
func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (lesen, schreiben pool, err error) { func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (read, write pool, err error) {
d := sqliteDialekt{} d := sqliteDialect{}
// Die Parameter in der URL setzen drei Dinge, ohne die SQLite unter // Die Parameter in der URL setzen drei Dinge, ohne die SQLite unter
// gleichzeitigen Zugriffen unangenehm wird: // 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 // weil SQLite den Wartenden nicht zurücksetzen kann, ohne die Transaktion
// abzubrechen. Genau daran scheiterten 296 von 320 gleichzeitigen // abzubrechen. Genau daran scheiterten 296 von 320 gleichzeitigen
// Schreibvorgängen in einer früheren Messung (siehe AUSBAUSTUFEN.md). // 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 { if err != nil {
return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err) return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err)
} }
schreibDB.SetMaxOpenConns(1) writeDB.SetMaxOpenConns(1)
schreiben = pool{db: schreibDB, d: d} write = pool{db: writeDB, d: d}
if err := schreibDB.Ping(); err != nil { if err := writeDB.Ping(); err != nil {
schreibDB.Close() writeDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err) return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err)
} }
if err := runMigrations(schreiben, migrationsFS, migrationsDir); err != nil { if err := runMigrations(write, migrationsFS, migrationsDir); err != nil {
schreibDB.Close() writeDB.Close()
return pool{}, pool{}, err return pool{}, pool{}, err
} }
// Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits. // 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 { if err != nil {
schreibDB.Close() writeDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err) return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err)
} }
leseDB.SetMaxOpenConns(maxLeseverbindungen) readDB.SetMaxOpenConns(maxReadConns)
lesen = pool{db: leseDB, d: d} read = pool{db: readDB, d: d}
if err := leseDB.Ping(); err != nil { if err := readDB.Ping(); err != nil {
schreibDB.Close() writeDB.Close()
leseDB.Close() readDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht lesbar: %w", err) 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 // Mehr als eine Handvoll bringt nichts: Ab etwa acht Verbindungen ist der
// Durchsatz erreicht, weil dann die Rechenkerne und nicht mehr das Warten die // Durchsatz erreicht, weil dann die Rechenkerne und nicht mehr das Warten die
// Grenze setzen. Jede Verbindung kostet dafür Arbeitsspeicher. // Grenze setzen. Jede Verbindung kostet dafür Arbeitsspeicher.
const maxLeseverbindungen = 8 const maxReadConns = 8
// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen). // PoolStats liefert die Zahlen beider Pools (siehe Verbindungen).
func (s *Store) Verbindungen() Verbindungen { func (s *Store) PoolStats() PoolStats {
return Verbindungen{Lesen: s.lesen.db.Stats(), Schreiben: s.schreiben.db.Stats()} return PoolStats{Read: s.read.db.Stats(), Write: s.write.db.Stats()}
} }
// Close schließt beide Verbindungspools. // Close schließt beide Verbindungspools.
func (s *Store) Close() error { func (s *Store) Close() error {
fehlerLesen := s.lesen.Close() readErr := s.read.Close()
if err := s.schreiben.Close(); err != nil { if err := s.write.Close(); err != nil {
return err return err
} }
return fehlerLesen return readErr
} }
// runMigrations wendet alle noch nicht ausgeführten Migrationen aus // 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 // In schema_migrations steht nur der Dateiname ("001_init.sql"), nicht der
// Ordner. Deshalb hat das Aufteilen in sqlite/ und postgres/ bestehende // Ordner. Deshalb hat das Aufteilen in sqlite/ und postgres/ bestehende
// Datenbanken nicht berührt. // 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() 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, name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL 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) return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err)
} }
angewandt, err := appliedMigrations(schreiben) applied, err := appliedMigrations(write)
if err != nil { if err != nil {
return err return err
} }
dateien, err := migrationFiles(fsys, dir) files, err := migrationFiles(fsys, dir)
if err != nil { if err != nil {
return err return err
} }
for _, name := range dateien { for _, name := range files {
if angewandt[name] { if applied[name] {
continue continue
} }
inhalt, err := fsys.ReadFile(dir + "/" + name) content, err := fsys.ReadFile(dir + "/" + name)
if err != nil { if err != nil {
return fmt.Errorf("Migration %s konnte nicht gelesen werden: %w", name, err) 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 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. // appliedMigrations liest die Namen der bereits ausgeführten Migrationen.
func appliedMigrations(schreiben pool) (map[string]bool, error) { func appliedMigrations(write pool) (map[string]bool, error) {
rows, err := schreiben.QueryContext(context.Background(), `SELECT name FROM schema_migrations`) rows, err := write.QueryContext(context.Background(), `SELECT name FROM schema_migrations`)
if err != nil { if err != nil {
return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err) return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
} }
defer rows.Close() defer rows.Close()
angewandt := make(map[string]bool) applied := make(map[string]bool)
for rows.Next() { for rows.Next() {
var name string var name string
if err := rows.Scan(&name); err != nil { if err := rows.Scan(&name); err != nil {
return nil, err return nil, err
} }
angewandt[name] = true applied[name] = true
} }
return angewandt, rows.Err() return applied, rows.Err()
} }
// migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge. // migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge.
func migrationFiles(fsys embed.FS, dir string) ([]string, error) { func migrationFiles(fsys embed.FS, dir string) ([]string, error) {
eintraege, err := fsys.ReadDir(dir) entries, err := fsys.ReadDir(dir)
if err != nil { if err != nil {
return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err) return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err)
} }
var namen []string var names []string
for _, e := range eintraege { for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { 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 // Die Nummerierung am Anfang macht die alphabetische Sortierung zur
// richtigen Reihenfolge. // richtigen Reihenfolge.
sort.Strings(namen) sort.Strings(names)
return namen, nil return names, nil
} }
// applyMigration führt eine einzelne Migration in einer Transaktion aus. // 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 // Fragezeichen, und die gehören dort hin. Ohne Parameter schickt auch der
// PostgreSQL-Treiber den Text unverändert und erlaubt dabei mehrere // PostgreSQL-Treiber den Text unverändert und erlaubt dabei mehrere
// Anweisungen hintereinander. // Anweisungen hintereinander.
func applyMigration(schreiben pool, name, sqlText string) error { func applyMigration(write pool, name, sqlText string) error {
tx, err := schreiben.db.Begin() tx, err := write.db.Begin()
if err != nil { if err != nil {
return fmt.Errorf("Transaktion für %s konnte nicht gestartet werden: %w", name, err) 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( 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(), name, NowString(),
); err != nil { ); err != nil {
return fmt.Errorf("Migration %s konnte nicht vermerkt werden: %w", name, err) 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 // Damit steht die Fehlerbehandlung einmal hier statt in jeder schreibenden
// Methode — und es kann nicht passieren, dass irgendwo ein Rollback vergessen wird. // Methode — und es kann nicht passieren, dass irgendwo ein Rollback vergessen wird.
func (s *Store) tx(ctx context.Context, fn func(*transaktion) error) error { func (s *Store) tx(ctx context.Context, fn func(*transaction) error) error {
tx, err := s.schreiben.beginTx(ctx) tx, err := s.write.beginTx(ctx)
if err != nil { if err != nil {
return fmt.Errorf("Transaktion konnte nicht gestartet werden: %w", err) return fmt.Errorf("Transaktion konnte nicht gestartet werden: %w", err)
} }

View file

@ -22,7 +22,7 @@ import (
// eigenen Schema (siehe postgres_test.go). // eigenen Schema (siehe postgres_test.go).
func testStore(t *testing.T) *Store { func testStore(t *testing.T) *Store {
t.Helper() t.Helper()
if pgTestAktiv() { if pgTestEnabled() {
return testPostgresStore(t) return testPostgresStore(t)
} }
@ -61,16 +61,16 @@ func testProject(t *testing.T, s *Store) Project {
// TestMigrationsAreIdempotent prüft, dass ein zweiter Start dieselbe Datenbank // TestMigrationsAreIdempotent prüft, dass ein zweiter Start dieselbe Datenbank
// nicht kaputt macht — der Normalfall bei jedem Neustart des Servers. // nicht kaputt macht — der Normalfall bei jedem Neustart des Servers.
func TestMigrationsAreIdempotent(t *testing.T) { 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 { if err != nil {
t.Fatalf("erster Start: %v", err) t.Fatalf("erster Start: %v", err)
} }
p := testProject(t, s1) p := testProject(t, s1)
s1.Close() s1.Close()
s2, err := Open(pfad) s2, err := Open(filePath)
if err != nil { if err != nil {
t.Fatalf("zweiter Start: %v", err) 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. // Datenbank sauber auf 002 gehoben wird — der Fall bei jedem Update.
func TestMigrationBestandsdaten(t *testing.T) { func TestMigrationExistingData(t *testing.T) {
pfad := filepath.Join(t.TempDir(), "alt.db") filePath := filepath.Join(t.TempDir(), "alt.db")
s, err := Open(pfad) s, err := Open(filePath)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -101,18 +101,18 @@ func TestMigrationBestandsdaten(t *testing.T) {
s.Close() s.Close()
// Erneut öffnen — hier läuft die Migration bei Bestandsdaten. // Erneut öffnen — hier läuft die Migration bei Bestandsdaten.
s2, err := Open(pfad) s2, err := Open(filePath)
if err != nil { if err != nil {
t.Fatalf("Migration fehlgeschlagen: %v", err) t.Fatalf("Migration fehlgeschlagen: %v", err)
} }
defer s2.Close() defer s2.Close()
// project_id muss in beiden Nebentabellen nachgetragen worden sein. // project_id muss in beiden Nebentabellen nachgetragen worden sein.
var leereTags, leereLinks int var emptyTags, emptyLinks int
s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&leereTags) s2.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&emptyTags)
s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&leereLinks) s2.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&emptyLinks)
if leereTags != 0 || leereLinks != 0 { if emptyTags != 0 || emptyLinks != 0 {
t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", leereTags, leereLinks) t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", emptyTags, emptyLinks)
} }
// Und die Abfragen liefern weiterhin das Richtige. // 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). // Verbindungspools (siehe Store in store.go).
// //
// Mit einem gemeinsamen Pool für Lesen und Schreiben scheiterten hier 296 von // Mit einem gemeinsamen Pool für Lesen und Schreiben scheiterten hier 296 von
// 320 Schreibvorgängen mit "database is locked" — mehrere Verbindungen begannen // 320 Schreibvorgängen mit "database is locked" — mehrere Verbindungen begannen
// gleichzeitig eine Schreibtransaktion und liefen mitten drin auf die Sperre, // gleichzeitig eine Schreibtransaktion und liefen mitten drin auf die Sperre,
// wo busy_timeout nicht mehr greift. // wo busy_timeout nicht mehr greift.
func TestGleichzeitigeSchreibvorgaenge(t *testing.T) { func TestConcurrentWrites(t *testing.T) {
s := testStore(t) s := testStore(t)
p := testProject(t, s) p := testProject(t, s)
ctx := context.Background() ctx := context.Background()
const agenten, jeAgent = 8, 40 const agents, perAgent = 8, 40
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex 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) wg.Add(1)
go func(a int) { go func(a int) {
defer wg.Done() defer wg.Done()
for i := 0; i < jeAgent; i++ { for i := 0; i < perAgent; i++ {
_, err := s.PutEntry(ctx, EntryWrite{ _, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, ProjectID: p.ID,
Slug: fmt.Sprintf("agent%d-%03d", a, i), Slug: fmt.Sprintf("agent%d-%03d", a, i),
@ -159,7 +159,7 @@ func TestGleichzeitigeSchreibvorgaenge(t *testing.T) {
}) })
if err != nil { if err != nil {
mu.Lock() mu.Lock()
fehler = append(fehler, err) errs = append(errs, err)
mu.Unlock() mu.Unlock()
} }
} }
@ -167,23 +167,23 @@ func TestGleichzeitigeSchreibvorgaenge(t *testing.T) {
} }
wg.Wait() wg.Wait()
if len(fehler) > 0 { if len(errs) > 0 {
t.Errorf("%d von %d Schreibvorgängen fehlgeschlagen, erster: %v", 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) n, err := s.CountEntries(ctx, p.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if n != agenten*jeAgent { if n != agents*perAgent {
t.Errorf("%d Einträge angelegt, erwartet %d", n, agenten*jeAgent) 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. // 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) s := testStore(t)
p := testProject(t, s) p := testProject(t, s)
ctx := context.Background() ctx := context.Background()
@ -194,7 +194,7 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
} }
var wg sync.WaitGroup var wg sync.WaitGroup
var leseFehler, schreibFehler int64 var readErr, writeErr int64
// Schreiber und Leser gleichzeitig laufen lassen. // Schreiber und Leser gleichzeitig laufen lassen.
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
@ -205,7 +205,7 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID,
Slug: fmt.Sprintf("neu-%d-%03d", i, j), Title: "T", Slug: fmt.Sprintf("neu-%d-%03d", i, j), Title: "T",
Content: "Inhalt", Source: SourceAgent}); err != nil { Content: "Inhalt", Source: SourceAgent}); err != nil {
atomic.AddInt64(&schreibFehler, 1) atomic.AddInt64(&writeErr, 1)
} }
} }
}(i) }(i)
@ -213,15 +213,15 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
defer wg.Done() defer wg.Done()
for j := 0; j < 100; j++ { for j := 0; j < 100; j++ {
if _, err := s.ListEntries(ctx, p.ID, EntryFilter{Limit: 20}); err != nil { if _, err := s.ListEntries(ctx, p.ID, EntryFilter{Limit: 20}); err != nil {
atomic.AddInt64(&leseFehler, 1) atomic.AddInt64(&readErr, 1)
} }
} }
}() }()
} }
wg.Wait() wg.Wait()
if leseFehler > 0 || schreibFehler > 0 { if readErr > 0 || writeErr > 0 {
t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", leseFehler, schreibFehler) 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) t.Fatalf("Projekt ändern: %v", err)
} }
gelesen, err := s.ProjectByID(ctx, p.ID) read, err := s.ProjectByID(ctx, p.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if gelesen.Name != "Neuer Name" || gelesen.Description != "Neue Beschreibung" { if read.Name != "Neuer Name" || read.Description != "Neue Beschreibung" {
t.Errorf("Name/Beschreibung = %q/%q", gelesen.Name, gelesen.Description) t.Errorf("Name/Beschreibung = %q/%q", read.Name, read.Description)
} }
if gelesen.WriteLimitHourly != 250 { if read.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly) t.Errorf("WriteLimitHourly = %d, erwartet 250", read.WriteLimitHourly)
} }
// Der Bezeichner bleibt fest — er steckt in URLs und MCP-Konfigurationen. // Der Bezeichner bleibt fest — er steckt in URLs und MCP-Konfigurationen.
if gelesen.Slug != p.Slug { if read.Slug != p.Slug {
t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", gelesen.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. // 0 nimmt die eigene Obergrenze zurück.
if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{Name: "Neuer Name"}); err != nil { if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{Name: "Neuer Name"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
gelesen, _ = s.ProjectByID(ctx, p.ID) read, _ = s.ProjectByID(ctx, p.ID)
if gelesen.WriteLimitHourly != 0 { if read.WriteLimitHourly != 0 {
t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", gelesen.WriteLimitHourly) t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", read.WriteLimitHourly)
} }
} }
@ -383,16 +383,16 @@ func TestResolveKeyHash(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
auf, err := s.ResolveKeyHash(ctx, "hash-abc") resolved, err := s.ResolveKeyHash(ctx, "hash-abc")
if err != nil { if err != nil {
t.Fatalf("gültiger Key wurde nicht aufgelöst: %v", err) 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") t.Error("falscher Key aufgelöst")
} }
// Das Projekt kommt in derselben Abfrage mit — sonst bräuchte jeder Request // Das Projekt kommt in derselben Abfrage mit — sonst bräuchte jeder Request
// einen zweiten Datenbankzugriff. // einen zweiten Datenbankzugriff.
if auf.Project.ID != p.ID { if resolved.Project.ID != p.ID {
t.Error("das Projekt wurde nicht mitgeliefert") 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). // TestAPIKeyUnused prüft, wann ein Key als „ungenutzt“ gilt (AUSBAUSTUFEN.md 4.7).
func TestAPIKeyUnused(t *testing.T) { func TestAPIKeyUnused(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)
tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) } days := func(n int) time.Time { return now.Add(-time.Duration(n) * 24 * time.Hour) }
faelle := []struct { cases := []struct {
name string name string
key APIKey key APIKey
erwartet bool want bool
}{ }{
{"gestern benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(1)}, false}, {"gestern benutzt", APIKey{CreatedAt: days(400), LastUsedAt: days(1)}, false},
{"vor 91 Tagen benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(91)}, true}, {"vor 91 Tagen benutzt", APIKey{CreatedAt: days(400), LastUsedAt: days(91)}, true},
{"genau 90 Tage", APIKey{CreatedAt: tage(400), LastUsedAt: tage(90)}, false}, {"genau 90 Tage", APIKey{CreatedAt: days(400), LastUsedAt: days(90)}, false},
{"nie benutzt, frisch angelegt", APIKey{CreatedAt: tage(3)}, false}, {"nie benutzt, frisch angelegt", APIKey{CreatedAt: days(3)}, false},
{"nie benutzt, lange angelegt", APIKey{CreatedAt: tage(120)}, true}, {"nie benutzt, lange angelegt", APIKey{CreatedAt: days(120)}, true},
{"zurückgezogen", APIKey{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false}, {"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) { t.Run(f.name, func(t *testing.T) {
if got := f.key.Unused(jetzt); got != f.erwartet { if got := f.key.Unused(now); got != f.want {
t.Errorf("Unused = %v, erwartet %v", got, f.erwartet) 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. // TestWorkspaceAdminUnused: Workspace-Admins folgen derselben Regel wie Keys.
func TestWorkspaceAdminUnused(t *testing.T) { func TestWorkspaceAdminUnused(t *testing.T) {
jetzt := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) now := 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) } days := func(n int) time.Time { return now.Add(-time.Duration(n) * 24 * time.Hour) }
faelle := []struct { cases := []struct {
name string name string
admin WorkspaceAdmin admin WorkspaceAdmin
erwartet bool want bool
}{ }{
{"gestern benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(1)}, false}, {"gestern benutzt", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(1)}, false},
{"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(91)}, true}, {"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(91)}, true},
{"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: tage(3)}, false}, {"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: days(3)}, false},
{"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: tage(120)}, true}, {"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: days(120)}, true},
{"zurückgezogen", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false}, {"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) { t.Run(f.name, func(t *testing.T) {
if got := f.admin.Unused(jetzt); got != f.erwartet { if got := f.admin.Unused(now); got != f.want {
t.Errorf("Unused = %v, erwartet %v", got, f.erwartet) t.Errorf("Unused = %v, erwartet %v", got, f.want)
} }
}) })
} }
@ -493,7 +493,7 @@ func TestPutEntryCreatesAndUpdates(t *testing.T) {
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
erst, err := s.PutEntry(ctx, EntryWrite{ first, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "deployment", Title: "Deployment", ProjectID: p.ID, Slug: "deployment", Title: "Deployment",
Content: "Erste Fassung", Tags: []string{"Ops", "ops", " Deploy "}, Content: "Erste Fassung", Tags: []string{"Ops", "ops", " Deploy "},
Source: SourceAgent, Author: "agent-1", Source: SourceAgent, Author: "agent-1",
@ -501,27 +501,27 @@ func TestPutEntryCreatesAndUpdates(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if erst.Version != 1 { if first.Version != 1 {
t.Errorf("Version = %d, erwartet 1", erst.Version) t.Errorf("Version = %d, erwartet 1", first.Version)
} }
// Schlagworte werden vereinheitlicht: klein, ohne Dubletten, sortiert. // Schlagworte werden vereinheitlicht: klein, ohne Dubletten, sortiert.
if len(erst.Tags) != 2 || erst.Tags[0] != "deploy" || erst.Tags[1] != "ops" { if len(first.Tags) != 2 || first.Tags[0] != "deploy" || first.Tags[1] != "ops" {
t.Errorf("Tags = %v, erwartet [deploy ops]", erst.Tags) t.Errorf("Tags = %v, erwartet [deploy ops]", first.Tags)
} }
// Derselbe Slug aktualisiert statt anzulegen — das ist der Kern der // Derselbe Slug aktualisiert statt anzulegen — das ist der Kern der
// Dublettenvermeidung. // Dublettenvermeidung.
zweit, err := s.PutEntry(ctx, EntryWrite{ second, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "deployment", Title: "Deployment", ProjectID: p.ID, Slug: "deployment", Title: "Deployment",
Content: "Zweite Fassung", Source: SourceAgent, Author: "agent-2", Content: "Zweite Fassung", Source: SourceAgent, Author: "agent-2",
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if zweit.Version != 2 { if second.Version != 2 {
t.Errorf("Version = %d, erwartet 2", zweit.Version) 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") t.Error("es wurde ein zweiter Eintrag angelegt statt zu aktualisieren")
} }
if n, _ := s.CountEntries(ctx, p.ID); n != 1 { 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 // Die Schlagworte des ersten Schreibvorgangs sind weg, weil der zweite keine
// mitgebracht hat — der Eintrag ist die Wahrheit, nicht die Summe der Änderungen. // mitgebracht hat — der Eintrag ist die Wahrheit, nicht die Summe der Änderungen.
if len(zweit.Tags) != 0 { if len(second.Tags) != 0 {
t.Errorf("Tags = %v, erwartet leer", zweit.Tags) t.Errorf("Tags = %v, erwartet leer", second.Tags)
} }
} }
@ -540,15 +540,15 @@ func TestPutEntryOptimisticLocking(t *testing.T) {
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Agent A schreibt auf Basis von Version 1 — geht durch. // Agent A schreibt auf Basis von Version 1 — geht durch.
a := basis a := base
a.Content = "von A" a.Content = "von A"
a.ExpectedVersion = e.Version a.ExpectedVersion = e.Version
if _, err := s.PutEntry(ctx, a); err != nil { 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. // Agent B hatte ebenfalls Version 1 gelesen — muss jetzt einen Konflikt bekommen.
b := basis b := base
b.Content = "von B" b.Content = "von B"
b.ExpectedVersion = e.Version b.ExpectedVersion = e.Version
if _, err := s.PutEntry(ctx, b); !errors.Is(err, ErrConflict) { 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: "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}) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(gefiltert) != 2 { if len(filtered) != 2 {
t.Errorf("%d Treffer, erwartet 2", len(gefiltert)) t.Errorf("%d Treffer, erwartet 2", len(filtered))
} }
// Groß-/Kleinschreibung darf keine Rolle spielen. // Groß-/Kleinschreibung darf keine Rolle spielen.
grossGeschrieben, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"}) capitalized, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"})
if len(grossGeschrieben) != 2 { if len(capitalized) != 2 {
t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(grossGeschrieben)) 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"}, "[[a-b_c1]] ist erlaubt": {"a-b_c1"},
"einfache [klammern] sind kein Verweis": nil, "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"}, "Verweis: `[[beispiel]]`, echt: [[echt]]": {"echt"},
"doppelte Backticks: ``a `[[x]]` b`` und [[y]]": {"y"}, "doppelte Backticks: ``a `[[x]]` b`` und [[y]]": {"y"},
"```\n[[im-block]]\n```\n[[danach]]": {"danach"}, "```\n[[im-block]]\n```\n[[danach]]": {"danach"},
@ -719,29 +719,29 @@ func TestExtractLinks(t *testing.T) {
" [[eingerueckt-vier]] zählt (wie Listenzeilen)": {"eingerueckt-vier"}, " [[eingerueckt-vier]] zählt (wie Listenzeilen)": {"eingerueckt-vier"},
} }
for input, erwartet := range cases { for input, want := range cases {
got := ExtractLinks(input) got := ExtractLinks(input)
if len(got) != len(erwartet) { if len(got) != len(want) {
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet) t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, want)
continue continue
} }
for i := range got { for i := range got {
if got[i] != erwartet[i] { if got[i] != want[i] {
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet) t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, want)
break 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. // Verweis in Code bleibt Text, ein echter wird ersetzt.
func TestReplaceLinksLaesstCodeStehen(t *testing.T) { func TestReplaceLinksLeavesCodeAlone(t *testing.T) {
ein := "So verweist man: `[[bezeichner]]`. Siehe [[deployment]].\n```\n[[auch-nicht]]\n```\n" in := "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" expected := "So verweist man: `[[bezeichner]]`. Siehe <deployment>.\n```\n[[auch-nicht]]\n```\n"
got := ReplaceLinks(ein, func(slug string) string { return "<" + slug + ">" }) got := ReplaceLinks(in, func(slug string) string { return "<" + slug + ">" })
if got != soll { if got != expected {
t.Errorf("\nist: %q\nsoll: %q", got, soll) 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, s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryDelete,
EntrySlug: "b", Source: SourceMCP}) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(ereignisse) != 2 { if len(events) != 2 {
t.Fatalf("%d Ereignisse, erwartet 2", len(ereignisse)) t.Fatalf("%d Ereignisse, erwartet 2", len(events))
} }
// Neueste zuerst. // Neueste zuerst.
if ereignisse[0].Action != ActionEntryDelete { if events[0].Action != ActionEntryDelete {
t.Errorf("erstes Ereignis = %q, erwartet %q", ereignisse[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") 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}) s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: "test", Source: SourceSystem})
// Grenze in der Zukunft — alles muss weg. // Grenze in der Zukunft — alles muss weg.
zukunft := time.Now().UTC().Add(time.Hour).Format(timeFormat) future := time.Now().UTC().Add(time.Hour).Format(timeFormat)
n, err := s.PruneAudit(ctx, zukunft) n, err := s.PruneAudit(ctx, future)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -842,33 +842,33 @@ func TestSlugify(t *testing.T) {
"straße": "strasse", "straße": "strasse",
} }
for input, erwartet := range cases { for input, want := range cases {
if got := Slugify(input); got != erwartet { if got := Slugify(input); got != want {
t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, erwartet) t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, want)
} }
} }
} }
func TestNewIDIsSortableAndUnique(t *testing.T) { func TestNewIDIsSortableAndUnique(t *testing.T) {
gesehen := make(map[string]bool) seen := make(map[string]bool)
var vorher string var before string
for i := 0; i < 200; i++ { for i := 0; i < 200; i++ {
id := NewID() id := NewID()
if gesehen[id] { if seen[id] {
t.Fatalf("doppelte ID: %s", id) t.Fatalf("doppelte ID: %s", id)
} }
gesehen[id] = true seen[id] = true
// Der Zeitanteil steht vorne, IDs sind also grob chronologisch sortierbar. // Der Zeitanteil steht vorne, IDs sind also grob chronologisch sortierbar.
if vorher != "" && id < vorher { if before != "" && id < before {
zeitVorher, _, _ := strings.Cut(vorher, "-") earlierTime, _, _ := strings.Cut(before, "-")
zeitJetzt, _, _ := strings.Cut(id, "-") nowTime, _, _ := strings.Cut(id, "-")
if zeitJetzt < zeitVorher { if nowTime < earlierTime {
t.Errorf("IDs sind nicht chronologisch: %s vor %s", vorher, id) 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", c, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C",
Tags: []string{"alt"}, Source: SourceHuman}) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(geaendert) != 2 { if len(changed) != 2 {
t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(geaendert)) t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(changed))
} }
neuA, _ := s.EntryBySlug(ctx, p.ID, "a") freshA, _ := s.EntryBySlug(ctx, p.ID, "a")
if !stringSlicesEqual(neuA.Tags, []string{"neu"}) { if !stringSlicesEqual(freshA.Tags, []string{"neu"}) {
t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags) t.Errorf("Tags von A = %v, erwartet [neu]", freshA.Tags)
} }
if neuA.Version != 2 { if freshA.Version != 2 {
t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", neuA.Version) t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", freshA.Version)
} }
neuB, _ := s.EntryBySlug(ctx, p.ID, "b") freshB, _ := s.EntryBySlug(ctx, p.ID, "b")
if !stringSlicesEqual(neuB.Tags, []string{"bleibt", "neu"}) { if !stringSlicesEqual(freshB.Tags, []string{"bleibt", "neu"}) {
t.Errorf("Tags von B = %v, erwartet [bleibt neu]", neuB.Tags) t.Errorf("Tags von B = %v, erwartet [bleibt neu]", freshB.Tags)
} }
unveraendertC, _ := s.EntryBySlug(ctx, p.ID, "c") unchangedC, _ := s.EntryBySlug(ctx, p.ID, "c")
if !stringSlicesEqual(unveraendertC.Tags, []string{"alt"}) { 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", unveraendertC.Tags) 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") 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", "") a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "")
b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "") 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}) Tags: []string{"x"}, Source: SourceHuman})
// Aufruf mit dem Projekt A, aber einer ID aus Projekt B — darf nichts // Aufruf mit dem Projekt A, aber einer ID aus Projekt B — darf nichts
// bewirken, sonst könnte ein untergeschobenes Formularfeld projektübergreifend // bewirken, sonst könnte ein untergeschobenes Formularfeld projektübergreifend
// Einträge verändern. // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(geaendert) != 0 { if len(changed) != 0 {
t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(geaendert)) 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") unchanged, _ := s.EntryBySlug(ctx, b.ID, "fremd")
if !stringSlicesEqual(unveraendert.Tags, []string{"x"}) { if !stringSlicesEqual(unchanged.Tags, []string{"x"}) {
t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unveraendert.Tags) 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() ctx := context.Background()
p := testProject(t, s) 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"}) Content: "Inhalt 1", Tags: []string{"a"}, Source: SourceHuman, Author: "tom"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Beim erstmaligen Anlegen gibt es noch nichts zu sichern. // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(historie) != 0 { if len(history) != 0 {
t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(historie)) 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", 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) t.Fatal(err)
} }
historie, err = s.EntryVersions(ctx, erste.ID) history, err = s.EntryVersions(ctx, first.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(historie) != 2 { if len(history) != 2 {
t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(historie)) t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(history))
} }
// Neueste zuerst. // Neueste zuerst.
if historie[0].Version != 2 || historie[0].Title != "Zweite Fassung" { if history[0].Version != 2 || history[0].Title != "Zweite Fassung" {
t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", historie[0].Version, historie[0].Title) 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" { if history[1].Version != 1 || history[1].Title != "Erste Fassung" {
t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", historie[1].Version, historie[1].Title) 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"}) { if history[1].Content != "Inhalt 1" || !stringSlicesEqual(history[1].Tags, []string{"a"}) {
t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", historie[1].Content, historie[1].Tags) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if fassung1.Content != "Inhalt 1" { if version1.Content != "Inhalt 1" {
t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", fassung1.Content) 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) t.Errorf("unbekannte Fassung: err = %v, erwartet ErrNotFound", err)
} }
} }
@ -1025,15 +1025,15 @@ func TestBulkUpdateTagsRecordsHistory(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
historie, err := s.EntryVersions(ctx, e.ID) history, err := s.EntryVersions(ctx, e.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(historie) != 1 { if len(history) != 1 {
t.Fatalf("%d Fassungen, erwartet 1", len(historie)) t.Fatalf("%d Fassungen, erwartet 1", len(history))
} }
if !stringSlicesEqual(historie[0].Tags, []string{"alt"}) { if !stringSlicesEqual(history[0].Tags, []string{"alt"}) {
t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", historie[0].Tags) 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 { if err := s.MoveEntryToTrash(ctx, p.ID, "e", "tom"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if historie, _ := s.EntryVersions(ctx, e.ID); len(historie) != 1 { if history, _ := s.EntryVersions(ctx, e.ID); len(history) != 1 {
t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(historie)) 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 { if n, err := s.PurgeFromTrash(ctx, p.ID, []string{"e"}); err != nil || n != 1 {
t.Fatalf("PurgeFromTrash: %d, %v", n, err) t.Fatalf("PurgeFromTrash: %d, %v", n, err)
} }
historie, err := s.EntryVersions(ctx, e.ID) history, err := s.EntryVersions(ctx, e.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(historie) != 0 { if len(history) != 0 {
t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(historie)) 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", s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C",
Content: "allein", Source: SourceHuman}) Content: "allein", Source: SourceHuman})
knoten, kanten, err := s.LinkGraph(ctx, p.ID) nodes, edges, err := s.LinkGraph(ctx, p.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(knoten) != 4 { if len(nodes) != 4 {
t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(knoten)) t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(nodes))
} }
nachSlug := make(map[string]GraphNode, len(knoten)) toSlug := make(map[string]GraphNode, len(nodes))
for _, k := range knoten { for _, k := range nodes {
nachSlug[k.Slug] = k 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") t.Error("vorhandene Einträge sollten Exists = true tragen")
} }
fehlt, ok := nachSlug["fehlt"] missing, ok := toSlug["fehlt"]
if !ok { if !ok {
t.Fatal("Verweis auf einen nicht existierenden Bezeichner erzeugte keinen Knoten") 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") t.Error("Exists = true für einen Bezeichner ohne Eintrag")
} }
if len(kanten) != 3 { if len(edges) != 3 {
t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(kanten)) 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", s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "anderes", Title: "Anderes",
Content: "nur in B", Source: SourceHuman}) Content: "nur in B", Source: SourceHuman})
knoten, _, err := s.LinkGraph(ctx, a.ID) nodes, _, err := s.LinkGraph(ctx, a.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(knoten) != 1 || knoten[0].Slug != "geheim" { if len(nodes) != 1 || nodes[0].Slug != "geheim" {
t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", knoten) 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 // CountStaleEntries finden genau die Einträge, die seit über einem Jahr
// unverändert sind — und keine im Papierkorb. // unverändert sind — und keine im Papierkorb.
func TestVeralteteEintraegeFilternUndZaehlen(t *testing.T) { func TestStaleEntriesFilterAndCount(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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{ for _, w := range []EntryWrite{
{Slug: "alt", Title: "Alt", CreatedAt: alt, UpdatedAt: alt}, {Slug: "alt", Title: "Alt", CreatedAt: old, UpdatedAt: old},
{Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: alt, UpdatedAt: alt}, {Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: old, UpdatedAt: old},
{Slug: "frisch", Title: "Frisch"}, {Slug: "frisch", Title: "Frisch"},
} { } {
w.ProjectID, w.Content, w.Source = p.ID, "x", SourceHuman w.ProjectID, w.Content, w.Source = p.ID, "x", SourceHuman
@ -1158,17 +1158,17 @@ func TestVeralteteEintraegeFilternUndZaehlen(t *testing.T) {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(liste) != 1 || liste[0].Slug != "alt" { if len(list) != 1 || list[0].Slug != "alt" {
t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(liste)) t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(list))
} }
anzahl, err := s.CountStaleEntries(ctx, p.ID, jetzt) count, err := s.CountStaleEntries(ctx, p.ID, now)
if err != nil || anzahl != 1 { if err != nil || count != 1 {
t.Errorf("CountStaleEntries = %d, %v; erwartet 1", anzahl, err) t.Errorf("CountStaleEntries = %d, %v; erwartet 1", count, err)
} }
} }
@ -1179,86 +1179,86 @@ func TestAuditFilter(t *testing.T) {
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
jetzt := time.Now().UTC() now := time.Now().UTC()
for _, z := range []struct { for _, z := range []struct {
alter time.Duration age time.Duration
wer, vorgang string who, event string
}{ }{
{48 * time.Hour, "anna", ActionEntryWrite}, {48 * time.Hour, "anna", ActionEntryWrite},
{24 * time.Hour, "bernd", ActionEntryDelete}, {24 * time.Hour, "bernd", ActionEntryDelete},
{1 * time.Hour, "anna", 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) `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, '', ?, ?, '', 'web', '', '')`, 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) t.Fatal(err)
} }
} }
zaehle := func(f AuditFilter) int { count := func(f AuditFilter) int {
t.Helper() t.Helper()
f.ProjectID = p.ID f.ProjectID = p.ID
liste, err := s.ListAudit(ctx, f) list, err := s.ListAudit(ctx, f)
if err != nil { if err != nil {
t.Fatal(err) 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) 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) 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) 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) t.Errorf("Zeitraum: %d, erwartet 1 (bernd)", n)
} }
zugaenge, err := s.AuditActors(ctx, p.ID) keys, err := s.AuditActors(ctx, p.ID)
if err != nil || strings.Join(zugaenge, ",") != "anna,bernd" { if err != nil || strings.Join(keys, ",") != "anna,bernd" {
t.Errorf("AuditActors = %v, %v; erwartet anna,bernd", zugaenge, err) 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 // sich nicht mehr auflösen. Ein Ablauf in der Vergangenheit wird gar nicht
// erst angenommen. // erst angenommen.
func TestKeyAblauf(t *testing.T) { func TestKeyExpiry(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := s.ResolveKeyHash(ctx, "h1"); err != nil { if _, err := s.ResolveKeyHash(ctx, "h1"); err != nil {
t.Errorf("gültiger Key lässt sich nicht auflösen: %v", err) t.Errorf("gültiger Key lässt sich nicht auflösen: %v", err)
} }
gelesen, _ := s.APIKeyByID(ctx, k.ID) read, _ := s.APIKeyByID(ctx, k.ID)
if gelesen.ExpiresAt.IsZero() || !gelesen.ExpiresSoon(jetzt) || gelesen.Expired(jetzt) { if read.ExpiresAt.IsZero() || !read.ExpiresSoon(now) || read.Expired(now) {
t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", gelesen) t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", read)
} }
// Ablauf in die Vergangenheit setzen — wie ein Key, dessen Tag vorbei ist. // 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 = ?`, if _, err := s.write.ExecContext(ctx, `UPDATE api_keys SET expires_at = ? WHERE id = ?`,
jetzt.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil { now.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := s.ResolveKeyHash(ctx, "h1"); !errors.Is(err, ErrNotFound) { if _, err := s.ResolveKeyHash(ctx, "h1"); !errors.Is(err, ErrNotFound) {
t.Errorf("abgelaufener Key: err = %v, erwartet ErrNotFound", err) t.Errorf("abgelaufener Key: err = %v, erwartet ErrNotFound", err)
} }
gelesen, _ = s.APIKeyByID(ctx, k.ID) read, _ = s.APIKeyByID(ctx, k.ID)
if gelesen.Valid(jetzt) || gelesen.Unused(jetzt.Add(200*24*time.Hour)) { if read.Valid(now) || read.Unused(now.Add(200*24*time.Hour)) {
t.Error("abgelaufener Key gilt als gültig oder als ungenutzt") t.Error("abgelaufener Key gilt als gültig oder als ungenutzt")
} }

View file

@ -4,10 +4,10 @@ package store
// (AUSBAUSTUFEN.md 5.16): Bestand, Platz auf der Platte, Aktivität je Tag und // (AUSBAUSTUFEN.md 5.16): Bestand, Platz auf der Platte, Aktivität je Tag und
// Stand des Schemas. // 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 // Projekt. Die Zahlen gelten für die ganze Workspace-Datei bzw. das ganze
// Schema; zusammengezählt über alle Workspaces werden sie in // Schema; zusammengezählt über alle Workspaces werden sie in
// internal/statistik. // internal/stats.
import ( import (
"context" "context"
@ -17,57 +17,57 @@ import (
"time" "time"
) )
// Bestand zählt, was in einem Workspace liegt. // Inventory zählt, was in einem Workspace liegt.
type Bestand struct { type Inventory struct {
Projekte int Projects int
Eintraege int // ohne Papierkorb Entries int // ohne Papierkorb
Fassungen int // ältere Fassungen in entry_versions Versions int // ältere Fassungen in entry_versions
Papierkorb int Trash int
// Keys: nur gültige (weder zurückgezogen noch abgelaufen). Ungenutzt und // Keys: nur gültige (weder zurückgezogen noch abgelaufen). Ungenutzt und
// LaufenAb sind Teilmengen davon, nach denselben Regeln wie auf der Seite // LaufenAb sind Teilmengen davon, nach denselben Regeln wie auf der Seite
// "Zugänge" (APIKey.Unused, APIKey.ExpiresSoon). // "Zugänge" (APIKey.Unused, APIKey.ExpiresSoon).
Keys int Keys int
KeysUngenutzt int KeysUnused int
KeysLaufenAb int KeysExpiring int
} }
// Plus zählt zwei Bestände zusammen — für die Summenzeile über alle // Plus zählt zwei Bestände zusammen — für die Summenzeile über alle
// Workspaces. // Workspaces.
func (b Bestand) Plus(o Bestand) Bestand { func (b Inventory) Plus(o Inventory) Inventory {
return Bestand{ return Inventory{
Projekte: b.Projekte + o.Projekte, Projects: b.Projects + o.Projects,
Eintraege: b.Eintraege + o.Eintraege, Entries: b.Entries + o.Entries,
Fassungen: b.Fassungen + o.Fassungen, Versions: b.Versions + o.Versions,
Papierkorb: b.Papierkorb + o.Papierkorb, Trash: b.Trash + o.Trash,
Keys: b.Keys + o.Keys, Keys: b.Keys + o.Keys,
KeysUngenutzt: b.KeysUngenutzt + o.KeysUngenutzt, KeysUnused: b.KeysUnused + o.KeysUnused,
KeysLaufenAb: b.KeysLaufenAb + o.KeysLaufenAb, KeysExpiring: b.KeysExpiring + o.KeysExpiring,
} }
} }
// Bestand zählt Projekte, Einträge, Fassungen, Papierkorb und Keys des // Inventory zählt Projekte, Einträge, Fassungen, Papierkorb und Keys des
// Workspace. jetzt entscheidet, welche Keys als ungenutzt oder bald // Workspace. now entscheidet, welche Keys als ungenutzt oder bald
// ablaufend gelten. // ablaufend gelten.
func (s *Store) Bestand(ctx context.Context, jetzt time.Time) (Bestand, error) { func (s *Store) Inventory(ctx context.Context, now time.Time) (Inventory, error) {
var b Bestand 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) return b, fmt.Errorf("Projekte konnten nicht gezählt werden: %w", err)
} }
// Eine Abfrage für beide Zahlen. COALESCE, weil SUM über eine leere // Eine Abfrage für beide Zahlen. COALESCE, weil SUM über eine leere
// Tabelle NULL ergibt, nicht 0. // 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), `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) COALESCE(SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END), 0)
FROM entries`, FROM entries`,
).Scan(&b.Eintraege, &b.Papierkorb) ).Scan(&b.Entries, &b.Trash)
if err != nil { if err != nil {
return b, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err) 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) 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 // 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 // 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. // 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 { if err != nil {
return b, fmt.Errorf("Keys konnten nicht gelesen werden: %w", err) 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 { if err != nil {
return b, err return b, err
} }
if !k.Valid(jetzt) { if !k.Valid(now) {
continue continue
} }
b.Keys++ b.Keys++
if k.Unused(jetzt) { if k.Unused(now) {
b.KeysUngenutzt++ b.KeysUnused++
} }
if k.ExpiresSoon(jetzt) { if k.ExpiresSoon(now) {
b.KeysLaufenAb++ b.KeysExpiring++
} }
} }
return b, rows.Err() return b, rows.Err()
} }
// Belegung ist der Platz, den ein Workspace in der Datenbank belegt, in Bytes. // DiskUsage ist der Platz, den ein Workspace in der Datenbank belegt, in Bytes.
type Belegung struct { type DiskUsage struct {
// Daten: bei SQLite die Datei selbst, bei PostgreSQL alle Tabellen des // Data: bei SQLite die Datei selbst, bei PostgreSQL alle Tabellen des
// Schemas samt Indizes. // Schemas samt Indizes.
Daten int64 Data int64
// WAL ist nur bei SQLite gesetzt: die Datei <id>.db-wal. In ihr sammelt // WAL ist nur bei SQLite gesetzt: die Datei <id>.db-wal. In ihr sammelt
// SQLite Änderungen, bevor es sie in die Datenbank zurückschreibt // SQLite Änderungen, bevor es sie in die Datenbank zurückschreibt
@ -113,80 +113,80 @@ type Belegung struct {
WAL int64 WAL int64
} }
// Gesamt ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte. // Total ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte.
func (b Belegung) Gesamt() int64 { return b.Daten + b.WAL } func (b DiskUsage) Total() int64 { return b.Data + b.WAL }
// Belegung misst den Platz des Workspace. Wie, hängt von der Datenbank ab // DiskUsage misst den Platz des Workspace. Wie, hängt von der Datenbank ab
// (siehe dialekt.belegung). // (siehe dialect.diskUsage).
func (s *Store) Belegung(ctx context.Context) (Belegung, error) { func (s *Store) DiskUsage(ctx context.Context) (DiskUsage, error) {
return s.lesen.d.belegung(ctx, s.lesen) 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 // 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 // nicht als Zahl; ihr Pfad ergibt sich aber aus dem der Datenbank, und den
// nennt PRAGMA database_list. // nennt PRAGMA database_list.
func (sqliteDialekt) belegung(ctx context.Context, p pool) (Belegung, error) { func (sqliteDialect) diskUsage(ctx context.Context, p pool) (DiskUsage, error) {
var b Belegung var b DiskUsage
var seiten, seitengroesse int64 var pages, pageSize int64
if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&seiten); err != nil { if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pages); err != nil {
return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err) 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) 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 // database_list liefert je angehängter Datenbank eine Zeile: Nummer, Name
// ("main" ist die eigene) und Pfad. // ("main" ist die eigene) und Pfad.
var nummer int var number int
var name, pfad string var name, filePath string
if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&nummer, &name, &pfad); err != nil { if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&number, &name, &filePath); err != nil {
return b, fmt.Errorf("Pfad der Datenbank unbekannt: %w", err) return b, fmt.Errorf("Pfad der Datenbank unbekannt: %w", err)
} }
// Ohne WAL-Datei (gerade alles zurückgeschrieben) bleibt WAL 0 — das ist // Ohne WAL-Datei (gerade alles zurückgeschrieben) bleibt WAL 0 — das ist
// kein Fehler. // kein Fehler.
if info, err := os.Stat(pfad + "-wal"); err == nil { if info, err := os.Stat(filePath + "-wal"); err == nil {
b.WAL = info.Size() b.WAL = info.Size()
} }
return b, nil 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) // pg_total_relation_size rechnet Indizes und ausgelagerte große Werte (TOAST)
// mit ein; deshalb nur über die Tabellen (relkind 'r') summieren, sonst // mit ein; deshalb nur über die Tabellen (relkind 'r') summieren, sonst
// zählten die Indizes doppelt. // zählten die Indizes doppelt.
// //
// current_schema() ist das Schema des Workspace, weil jeder Pool seinen // 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. // wird nach bigint gewandelt, damit sie sich als int64 lesen lässt.
func (postgresDialekt) belegung(ctx context.Context, p pool) (Belegung, error) { func (postgresDialect) diskUsage(ctx context.Context, p pool) (DiskUsage, error) {
var b Belegung var b DiskUsage
err := p.QueryRowContext(ctx, err := p.QueryRowContext(ctx,
`SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)::bigint `SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)::bigint
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema() AND c.relkind = 'r'`, WHERE n.nspname = current_schema() AND c.relkind = 'r'`,
).Scan(&b.Daten) ).Scan(&b.Data)
if err != nil { if err != nil {
return b, fmt.Errorf("Größe des Schemas unbekannt: %w", err) return b, fmt.Errorf("Größe des Schemas unbekannt: %w", err)
} }
return b, nil return b, nil
} }
// Tagesaktivitaet zählt, was an einem Tag in einem Projekt geschah. // DailyActivity zählt, was an einem Tag in einem Projekt geschah.
type Tagesaktivitaet struct { type DailyActivity struct {
ProjektID string ProjectID string
Tag string // "2026-10-16", in UTC — so steht es im Zeitstempel Tag string // "2026-10-16", in UTC — so steht es im Zeitstempel
SchreibenAgent int // über MCP angelegt, geändert oder gelöscht AgentWrites int // über MCP angelegt, geändert oder gelöscht
SchreibenMensch int // dasselbe über die Weboberfläche HumanWrites int // dasselbe über die Weboberfläche
Suchen int // recall-Aufrufe aus dem Suchprotokoll Searches int // recall-Aufrufe aus dem Suchprotokoll
} }
// Aktivitaet liefert je Projekt und Tag die Schreibvorgänge und Suchen seit // Activity liefert je Projekt und Tag die Schreibvorgänge und Suchen seit
// seit. Tage und Projekte ohne jede Aktivität fehlen. // 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 // geändert, in den Papierkorb gelegt. Gelesen wird nur gezählt, was im
// Suchprotokoll steht, also recall. Ein get taucht nirgends auf — es gibt // 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. // 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). // Der Tag sind die ersten zehn Zeichen des Zeitstempels (RFC3339 in UTC).
// SUBSTR gibt es in beiden Datenbanken, und gruppiert wird nach genau // SUBSTR gibt es in beiden Datenbanken, und gruppiert wird nach genau
// diesem Ausdruck, nicht nach einer Spaltennummer. // diesem Ausdruck, nicht nach einer Spaltennummer.
func (s *Store) Aktivitaet(ctx context.Context, seit time.Time) ([]Tagesaktivitaet, error) { func (s *Store) Activity(ctx context.Context, since time.Time) ([]DailyActivity, error) {
grenze := seit.UTC().Format(time.RFC3339Nano) limit := since.UTC().Format(time.RFC3339Nano)
// Projekt und Tag zusammen sind der Schlüssel; beide Abfragen tragen in // Projekt und Tag zusammen sind der Schlüssel; beide Abfragen tragen in
// dieselbe map ein. // dieselbe map ein.
type schluessel struct{ projekt, tag string } type activityKey struct{ project, tag string }
je := map[schluessel]*Tagesaktivitaet{} byKey := map[activityKey]*DailyActivity{}
eintrag := func(projekt, tag string) *Tagesaktivitaet { entry := func(project, tag string) *DailyActivity {
k := schluessel{projekt, tag} k := activityKey{project, tag}
if je[k] == nil { if byKey[k] == nil {
je[k] = &Tagesaktivitaet{ProjektID: projekt, Tag: tag} 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(*) `SELECT project_id, SUBSTR(ts, 1, 10), source, COUNT(*)
FROM audit_events FROM audit_events
WHERE ts >= ? AND action IN (?, ?) WHERE ts >= ? AND action IN (?, ?)
GROUP BY project_id, SUBSTR(ts, 1, 10), source`, GROUP BY project_id, SUBSTR(ts, 1, 10), source`,
grenze, ActionEntryWrite, ActionEntryDelete, limit, ActionEntryWrite, ActionEntryDelete,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Schreibvorgänge konnten nicht gezählt werden: %w", err) return nil, fmt.Errorf("Schreibvorgänge konnten nicht gezählt werden: %w", err)
} }
for rows.Next() { for rows.Next() {
var projekt, tag, quelle string var project, tag, source string
var anzahl int var count int
if err := rows.Scan(&projekt, &tag, &quelle, &anzahl); err != nil { if err := rows.Scan(&project, &tag, &source, &count); err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
switch quelle { switch source {
case SourceMCP: case SourceMCP:
eintrag(projekt, tag).SchreibenAgent += anzahl entry(project, tag).AgentWrites += count
case SourceWeb: case SourceWeb:
eintrag(projekt, tag).SchreibenMensch += anzahl entry(project, tag).HumanWrites += count
} }
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -239,55 +239,55 @@ func (s *Store) Aktivitaet(ctx context.Context, seit time.Time) ([]Tagesaktivita
} }
rows.Close() rows.Close()
rows, err = s.lesen.QueryContext(ctx, rows, err = s.read.QueryContext(ctx,
`SELECT project_id, SUBSTR(ts, 1, 10), COUNT(*) `SELECT project_id, SUBSTR(ts, 1, 10), COUNT(*)
FROM search_log FROM search_log
WHERE ts >= ? WHERE ts >= ?
GROUP BY project_id, SUBSTR(ts, 1, 10)`, GROUP BY project_id, SUBSTR(ts, 1, 10)`,
grenze, limit,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Suchen konnten nicht gezählt werden: %w", err) return nil, fmt.Errorf("Suchen konnten nicht gezählt werden: %w", err)
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var projekt, tag string var project, tag string
var anzahl int var count int
if err := rows.Scan(&projekt, &tag, &anzahl); err != nil { if err := rows.Scan(&project, &tag, &count); err != nil {
return nil, err return nil, err
} }
eintrag(projekt, tag).Suchen += anzahl entry(project, tag).Searches += count
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
} }
liste := make([]Tagesaktivitaet, 0, len(je)) list := make([]DailyActivity, 0, len(byKey))
for _, a := range je { for _, a := range byKey {
liste = append(liste, *a) list = append(list, *a)
} }
return liste, nil return list, nil
} }
// Schemastand nennt die zuletzt angewandte Migration des Workspace (ist) und // SchemaVersion nennt die zuletzt angewandte Migration des Workspace (actual) und
// die neueste, die dieses Programm mitbringt (soll), z.B. "007_key_ablauf.sql". // die neueste, die dieses Programm mitbringt (expected), z.B. "007_key_expiry.sql".
// //
// Beim Öffnen eines Workspace laufen fehlende Migrationen ohnehin durch // Beim Öffnen eines Workspace laufen fehlende Migrationen ohnehin durch
// (runMigrations). Weichen beide trotzdem voneinander ab, hat eine neuere // (runMigrations). Weichen beide trotzdem voneinander ab, hat eine neuere
// Fassung von Munin die Datenbank schon weitergebracht als dieses Programm — // Fassung von Munin die Datenbank schon weitergebracht als dieses Programm —
// etwa nach dem Zurückrollen auf eine ältere Fassung. // 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 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) 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 { if err != nil {
return "", "", err return "", "", err
} }
if len(dateien) > 0 { if len(files) > 0 {
soll = dateien[len(dateien)-1] expected = files[len(files)-1]
} }
return name.String, soll, nil return name.String, expected, nil
} }

View file

@ -6,22 +6,22 @@ import (
"time" "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). // 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". // Keys nach denselben Regeln wie die Seite "Zugänge".
func TestBestand(t *testing.T) { func TestInventory(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
jetzt := time.Now() now := time.Now()
leer, err := s.Bestand(ctx, jetzt) empty, err := s.Inventory(ctx, now)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if leer != (Bestand{}) { if empty != (Inventory{}) {
t.Errorf("leerer Workspace: %+v, erwartet nur Nullen", leer) t.Errorf("leerer Workspace: %+v, erwartet nur Nullen", empty)
} }
p := testProject(t, s) 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 { if _, err := s.CreateAPIKey(ctx, p.ID, "frisch", "h1", "write"); err != nil {
t.Fatal(err) 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) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := s.RevokeAPIKey(ctx, weg.ID); err != nil { if err := s.RevokeAPIKey(ctx, gone.ID); err != nil {
t.Fatal(err) 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) t.Fatal(err)
} }
// Zwei Stunden später: "abgelaufen" ist es dann. // 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
erwartet := Bestand{Projekte: 1, Eintraege: 2, Fassungen: 1, Papierkorb: 1, Keys: 2, KeysLaufenAb: 1} want := Inventory{Projects: 1, Entries: 2, Versions: 1, Trash: 1, Keys: 2, KeysExpiring: 1}
if b != erwartet { if b != want {
t.Errorf("Bestand = %+v\nerwartet %+v", b, erwartet) t.Errorf("Bestand = %+v\nerwartet %+v", b, want)
} }
// 100 Tage später ist "frisch" nie benutzt worden und damit ungenutzt; // 100 Tage später ist "frisch" nie benutzt worden und damit ungenutzt;
// "bald" ist bis dahin abgelaufen und zählt gar nicht mehr. // "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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if b.Keys != 1 || b.KeysUngenutzt != 1 || b.KeysLaufenAb != 0 { 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.KeysUngenutzt, b.KeysLaufenAb) 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 // von der Datenbank ab — geprüft wird nur, dass die Zahl plausibel ist und
// mit dem Inhalt wächst. // mit dem Inhalt wächst.
func TestBelegung(t *testing.T) { func TestDiskUsage(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
vorher, err := s.Belegung(ctx) before, err := s.DiskUsage(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if vorher.Daten <= 0 { if before.Data <= 0 {
t.Fatalf("leerer Workspace belegt %d Bytes — die Tabellen allein brauchen Platz", vorher.Daten) t.Fatalf("leerer Workspace belegt %d Bytes — die Tabellen allein brauchen Platz", before.Data)
} }
p := testProject(t, s) p := testProject(t, s)
inhalt := make([]byte, 64<<10) content := make([]byte, 64<<10)
for i := range inhalt { for i := range content {
inhalt[i] = 'a' + byte(i%26) content[i] = 'a' + byte(i%26)
} }
for _, slug := range []string{"gross-1", "gross-2", "gross-3", "gross-4"} { 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) t.Fatal(err)
} }
} }
nachher, err := s.Belegung(ctx) after, err := s.DiskUsage(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Bei SQLite landet das Geschriebene zunächst in der WAL-Datei, bei // Bei SQLite landet das Geschriebene zunächst in der WAL-Datei, bei
// PostgreSQL in den Tabellen. Zusammen muss es mehr geworden sein. // PostgreSQL in den Tabellen. Zusammen muss es mehr geworden sein.
if nachher.Gesamt() <= vorher.Gesamt() { if after.Total() <= before.Total() {
t.Errorf("Belegung vorher %d, nachher %d — sollte gewachsen sein", vorher.Gesamt(), nachher.Gesamt()) 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. // und lässt alles vor der Grenze weg.
func TestAktivitaet(t *testing.T) { func TestActivity(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
// Mit festen Zeitstempeln direkt eintragen — LogAudit und LogSearch // Mit festen Zeitstempeln direkt eintragen — LogAudit und LogSearch
// nehmen immer "jetzt". // nehmen immer "jetzt".
audit := func(ts, aktion, quelle string) { audit := func(ts, action, source string) {
t.Helper() 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', ?, ?)`, `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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
suche := func(ts string) { search := func(ts string) {
t.Helper() 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) `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'frage', 1)`, ts, p.ID)
if err != nil { if err != nil {
t.Fatal(err) 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-01T11:00:00Z", ActionLoginSuccess, SourceWeb) // kein Schreibvorgang
audit("2026-10-02T08:00:00Z", ActionEntryWrite, SourceWeb) audit("2026-10-02T08:00:00Z", ActionEntryWrite, SourceWeb)
audit("2026-09-20T08:00:00Z", ActionEntryWrite, SourceMCP) // vor der Grenze audit("2026-09-20T08:00:00Z", ActionEntryWrite, SourceMCP) // vor der Grenze
suche("2026-10-02T12:00:00Z") search("2026-10-02T12:00:00Z")
suche("2026-10-02T13:00:00Z") search("2026-10-02T13:00:00Z")
suche("2026-09-20T13:00:00Z") // vor der Grenze 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
je := map[string]Tagesaktivitaet{} per := map[string]DailyActivity{}
for _, a := range liste { for _, a := range list {
if a.ProjektID != p.ID { if a.ProjectID != p.ID {
t.Errorf("fremdes Projekt %q", a.ProjektID) t.Errorf("fremdes Projekt %q", a.ProjectID)
} }
je[a.Tag] = a per[a.Tag] = a
} }
if len(je) != 2 { if len(per) != 2 {
t.Fatalf("%d Tage, erwartet 2: %+v", len(je), liste) 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) 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) 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. // Migration, die das Programm mitbringt.
func TestSchemastand(t *testing.T) { func TestSchemaVersion(t *testing.T) {
s := testStore(t) s := testStore(t)
ist, soll, err := s.Schemastand(context.Background()) actual, expected, err := s.SchemaVersion(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if soll == "" || ist != soll { if expected == "" || actual != expected {
t.Errorf("Schemastand ist %q, soll %q", ist, soll) t.Errorf("Schemastand ist %q, soll %q", actual, expected)
} }
} }

View file

@ -1,6 +1,6 @@
package store 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: // 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 // Inhalt, Schlagworte, Verweise und Verlauf bleiben unverändert stehen, auch
// die Fassungsnummer: Löschen ist keine neue Fassung, sondern eine Markierung // 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. // 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 // Liegt der Eintrag schon im Papierkorb (oder gibt es ihn nicht), kommt
// ErrNotFound — aus Sicht des Aufrufers gibt es ihn ja nicht mehr. // ErrNotFound — aus Sicht des Aufrufers gibt es ihn ja nicht mehr.
func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloeschtVon string) error { func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, deletedBy string) error {
res, err := s.schreiben.ExecContext(ctx, res, err := s.write.ExecContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ? `UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, WHERE project_id = ? AND slug = ? AND `+notInTrash,
NowString(), geloeschtVon, projectID, slug, NowString(), deletedBy, projectID, slug,
) )
if err != nil { if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err) 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 // Alles läuft in einer Transaktion: Entweder liegen danach alle gültigen
// Einträge im Papierkorb oder keiner. Zurück kommen die Bezeichner der // Einträge im Papierkorb oder keiner. Zurück kommen die Bezeichner der
// tatsächlich gelöschten Einträge — für Suchindex und Protokoll. // 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) { func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryIDs []string, deletedBy string) ([]string, error) {
var geloescht []string var deleted []string
jetzt := NowString() now := NowString()
err := s.tx(ctx, func(tx *transaktion) error { err := s.tx(ctx, func(tx *transaction) error {
for _, id := range entryIDs { for _, id := range entryIDs {
// RETURNING liefert die Spalte der geänderten Zeile gleich mit — // RETURNING liefert die Spalte der geänderten Zeile gleich mit —
// so braucht es keine zweite Abfrage, um den Bezeichner zu holen. // so braucht es keine zweite Abfrage, um den Bezeichner zu holen.
var slug string var slug string
err := tx.QueryRowContext(ctx, err := tx.QueryRowContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ? `UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb+` WHERE id = ? AND project_id = ? AND `+notInTrash+`
RETURNING slug`, RETURNING slug`,
jetzt, geloeschtVon, id, projectID, now, deletedBy, id, projectID,
).Scan(&slug) ).Scan(&slug)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
continue // fremdes Projekt, schon im Papierkorb oder gibt es nicht 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 { if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err) return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err)
} }
geloescht = append(geloescht, slug) deleted = append(deleted, slug)
} }
return nil return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
return geloescht, nil return deleted, nil
} }
// ListTrash gibt den Papierkorb eines Projekts zurück, zuletzt gelöschte zuerst. // 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 // alles, was ein bestimmter Agent gelöscht hat, auf einen Blick sehen und in
// einem Zug zurückholen. // einem Zug zurückholen.
func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) ([]TrashedEntry, error) { func (s *Store) ListTrash(ctx context.Context, projectID, deletedBy string) ([]TrashedEntry, error) {
abfrage := `SELECT id, slug, title, version, deleted_at, deleted_by query := `SELECT id, slug, title, version, deleted_at, deleted_by
FROM entries FROM entries
WHERE project_id = ? AND deleted_at IS NOT NULL` WHERE project_id = ? AND deleted_at IS NOT NULL`
werte := []any{projectID} values := []any{projectID}
if geloeschtVon != "" { if deletedBy != "" {
abfrage += ` AND deleted_by = ?` query += ` AND deleted_by = ?`
werte = append(werte, geloeschtVon) 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 { if err != nil {
return nil, fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err) return nil, fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
} }
defer rows.Close() defer rows.Close()
var liste []TrashedEntry var list []TrashedEntry
for rows.Next() { for rows.Next() {
var t TrashedEntry var t TrashedEntry
var geloeschtAm string var deletedAt string
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &geloeschtAm, &t.DeletedBy); err != nil { if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &deletedAt, &t.DeletedBy); err != nil {
return nil, err return nil, err
} }
t.DeletedAt = parseTime(geloeschtAm) t.DeletedAt = parseTime(deletedAt)
liste = append(liste, t) list = append(list, t)
} }
return liste, rows.Err() return list, rows.Err()
} }
// RestoreFromTrash holt Einträge aus dem Papierkorb zurück. // 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, // Zurückgegeben werden die wiederhergestellten Einträge samt Schlagworten,
// damit der Aufrufer sie wieder in den Suchindex aufnehmen kann. // damit der Aufrufer sie wieder in den Suchindex aufnehmen kann.
func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []string) ([]Entry, error) { 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 { for _, slug := range slugs {
res, err := tx.ExecContext(ctx, res, err := tx.ExecContext(ctx,
`UPDATE entries SET deleted_at = NULL, deleted_by = '' `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 { if err != nil {
return fmt.Errorf("Eintrag %q konnte nicht neu gelesen werden: %w", slug, err) return fmt.Errorf("Eintrag %q konnte nicht neu gelesen werden: %w", slug, err)
} }
zurueck = append(zurueck, e) back = append(back, e)
} }
return nil 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 // Die Schlagworte erst nach der Transaktion nachladen: attachTags liest
// über den Lese-Pool, und der sieht die Änderung erst nach dem Commit. // ü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, // 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, // Eintrag, der nicht im Papierkorb liegt, wird nie angefasst — auch nicht,
// wenn jemand seinen Bezeichner ins Formular schmuggelt. // wenn jemand seinen Bezeichner ins Formular schmuggelt.
func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []string) (int, error) { func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []string) (int, error) {
geloescht := 0 deleted := 0
err := s.tx(ctx, func(tx *transaktion) error { err := s.tx(ctx, func(tx *transaction) error {
for _, slug := range slugs { for _, slug := range slugs {
res, err := tx.ExecContext(ctx, res, err := tx.ExecContext(ctx,
`DELETE FROM entries WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`, `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) return fmt.Errorf("Eintrag %q konnte nicht endgültig gelöscht werden: %w", slug, err)
} }
n, _ := res.RowsAffected() n, _ := res.RowsAffected()
geloescht += int(n) deleted += int(n)
} }
return nil return nil
}) })
if err != nil { if err != nil {
return 0, err 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 // Papierkorb liegen — über alle Projekte dieser Workspace-Datei. Gedacht für
// die tägliche Aufgabe in main.go, die auch das Protokoll aufräumt. // 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 // Gibt die Bezeichner der entfernten Einträge je Projekt zurück, damit der
// Aufrufer jeden Fall im Protokoll vermerken kann. // Aufrufer jeden Fall im Protokoll vermerken kann.
func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]string, error) { func (s *Store) PurgeTrash(ctx context.Context, olderThan string) (map[string][]string, error) {
entfernt := map[string][]string{} 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. // Erst lesen, was gleich verschwindet — nach dem DELETE wäre es weg.
rows, err := tx.QueryContext(ctx, 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 { if err != nil {
return fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err) return fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
} }
for rows.Next() { for rows.Next() {
var projektID, slug string var projectID, slug string
if err := rows.Scan(&projektID, &slug); err != nil { if err := rows.Scan(&projectID, &slug); err != nil {
rows.Close() rows.Close()
return err return err
} }
entfernt[projektID] = append(entfernt[projektID], slug) removed[projectID] = append(removed[projectID], slug)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
rows.Close() rows.Close()
@ -241,7 +241,7 @@ func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]
rows.Close() rows.Close()
if _, err := tx.ExecContext(ctx, 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 { ); err != nil {
return fmt.Errorf("Papierkorb konnte nicht aufgeräumt werden: %w", err) 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 { if err != nil {
return nil, err return nil, err
} }
return entfernt, nil return removed, nil
} }

View file

@ -7,22 +7,22 @@ import (
"time" "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 // lesende Weg muss einen gelöschten Eintrag ausblenden. Kommt später eine
// neue Abfrage auf entries dazu, gehört sie hier mit hinein (siehe // 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 // Aufbau: "lebt" verweist auf "weg", "weg" verweist auf "lebt". Beide tragen
// das Schlagwort "gemeinsam", "weg" zusätzlich "nur-weg". Dann wird "weg" // das Schlagwort "gemeinsam", "weg" zusätzlich "nur-weg". Dann wird "weg"
// gelöscht. // gelöscht.
func TestPapierkorbUnsichtbar(t *testing.T) { func TestTrashInvisible(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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}) 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}) Content: "siehe [[lebt]]", Tags: []string{"gemeinsam", "nur-weg"}, Source: SourceHuman, Curated: true})
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "agent-1"); err != nil { 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"}, "mit Schlagwort": {Tag: "nur-weg"},
"nur kuratierte": {Curated: true}, "nur kuratierte": {Curated: true},
} { } {
liste, err := s.ListEntries(ctx, p.ID, filter) list, err := s.ListEntries(ctx, p.ID, filter)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, e := range liste { for _, e := range list {
if e.Slug == "weg" { if e.Slug == "weg" {
t.Errorf("%s: gelöschter Eintrag in der Liste", name) 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) { t.Run("AllEntries (Suchindex, Export)", func(t *testing.T) {
alle, _ := s.AllEntries(ctx, p.ID) all, _ := s.AllEntries(ctx, p.ID)
if len(alle) != 1 || alle[0].Slug != "lebt" { if len(all) != 1 || all[0].Slug != "lebt" {
t.Errorf("AllEntries = %v, erwartet nur „lebt“", alle) t.Errorf("AllEntries = %v, erwartet nur „lebt“", all)
} }
}) })
@ -89,15 +89,15 @@ func TestPapierkorbUnsichtbar(t *testing.T) {
}) })
t.Run("LinkGraph", func(t *testing.T) { t.Run("LinkGraph", func(t *testing.T) {
knoten, kanten, _ := s.LinkGraph(ctx, p.ID) nodes, edges, _ := s.LinkGraph(ctx, p.ID)
for _, k := range knoten { for _, k := range nodes {
// "lebt" verweist weiter auf "weg" — der Knoten darf bleiben, aber // "lebt" verweist weiter auf "weg" — der Knoten darf bleiben, aber
// nur als gestrichelter "gibt es nicht". // nur als gestrichelter "gibt es nicht".
if k.Slug == "weg" && k.Exists { if k.Slug == "weg" && k.Exists {
t.Error("gelöschter Eintrag erscheint im Graph als vorhanden") t.Error("gelöschter Eintrag erscheint im Graph als vorhanden")
} }
} }
for _, e := range kanten { for _, e := range edges {
if e.From == "weg" { if e.From == "weg" {
t.Error("Verweis eines gelöschten Eintrags erscheint im Graph") 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) { t.Run("BulkUpdateTags", func(t *testing.T) {
geaendert, _ := s.BulkUpdateTags(ctx, p.ID, []string{lebt.ID, weg.ID}, []string{"neu"}, nil) changed, _ := s.BulkUpdateTags(ctx, p.ID, []string{alive.ID, gone.ID}, []string{"neu"}, nil)
if len(geaendert) != 1 || geaendert[0].Slug != "lebt" { if len(changed) != 1 || changed[0].Slug != "lebt" {
t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", geaendert) 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, "a", "agent-1")
s.MoveEntryToTrash(ctx, p.ID, "b", "tom") s.MoveEntryToTrash(ctx, p.ID, "b", "tom")
alle, _ := s.ListTrash(ctx, p.ID, "") all, _ := s.ListTrash(ctx, p.ID, "")
if len(alle) != 2 { if len(all) != 2 {
t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(alle)) t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(all))
} }
vomAgenten, _ := s.ListTrash(ctx, p.ID, "agent-1") fromAgent, _ := s.ListTrash(ctx, p.ID, "agent-1")
if len(vomAgenten) != 1 || vomAgenten[0].Slug != "a" || vomAgenten[0].DeletedBy != "agent-1" { if len(fromAgent) != 1 || fromAgent[0].Slug != "a" || fromAgent[0].DeletedBy != "agent-1" {
t.Errorf("Filter nach Zugang: %+v", vomAgenten) t.Errorf("Filter nach Zugang: %+v", fromAgent)
} }
} }
@ -138,31 +138,31 @@ func TestRestoreFromTrash(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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", s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "Inhalt",
Tags: []string{"x"}, Source: SourceHuman}) 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, 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. // "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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(zurueck) != 1 || zurueck[0].Slug != "e" { if len(back) != 1 || back[0].Slug != "e" {
t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", zurueck) t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", back)
} }
if len(zurueck[0].Tags) != 1 || zurueck[0].Tags[0] != "x" { if len(back[0].Tags) != 1 || back[0].Tags[0] != "x" {
t.Errorf("Schlagworte nach Wiederherstellen = %v", zurueck[0].Tags) t.Errorf("Schlagworte nach Wiederherstellen = %v", back[0].Tags)
} }
e, err := s.EntryBySlug(ctx, p.ID, "e") e, err := s.EntryBySlug(ctx, p.ID, "e")
if err != nil || e.Content != "Inhalt" || e.Version != 1 { 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) 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") 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.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Alt", Content: "gelöscht", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !neu.RestoredFromTrash { if !newer.RestoredFromTrash {
t.Error("RestoredFromTrash nicht gesetzt") t.Error("RestoredFromTrash nicht gesetzt")
} }
if neu.Version != 2 { if newer.Version != 2 {
t.Errorf("Fassung %d, erwartet 2", neu.Version) t.Errorf("Fassung %d, erwartet 2", newer.Version)
} }
if _, err := s.EntryBySlug(ctx, p.ID, "e"); err != nil { if _, err := s.EntryBySlug(ctx, p.ID, "e"); err != nil {
t.Errorf("Eintrag nach dem Überschreiben nicht sichtbar: %v", err) 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") t.Error("Eintrag liegt nach dem Überschreiben noch im Papierkorb")
} }
alt, err := s.EntryVersionByNumber(ctx, neu.ID, 1) old, err := s.EntryVersionByNumber(ctx, newer.ID, 1)
if err != nil || alt.Content != "gelöscht" { if err != nil || old.Content != "gelöscht" {
t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, alt.Content) t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, old.Content)
} }
// Ein normaler Schreibvorgang danach meldet nichts mehr vom Papierkorb. // 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}) more, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent})
if weiter.RestoredFromTrash { if more.RestoredFromTrash {
t.Error("RestoredFromTrash bei einem gewöhnlichen Update gesetzt") t.Error("RestoredFromTrash bei einem gewöhnlichen Update gesetzt")
} }
} }
@ -223,7 +223,7 @@ func TestPutEntryConflictWhenDeletedMeanwhile(t *testing.T) {
if !errors.Is(err, ErrConflict) { if !errors.Is(err, ErrConflict) {
t.Errorf("Fehler = %v, erwartet ErrConflict", err) 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") 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. // 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}) newer, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
if err != nil || neu.Version != 1 || neu.RestoredFromTrash { if err != nil || newer.Version != 1 || newer.RestoredFromTrash {
t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", neu, err) 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 // "alt" künstlich vor 31 Tage zurückdatieren — die Tests sollen nicht
// einen Monat warten. // einen Monat warten.
vorEinemMonat := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano) oneMonthAgo := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx, if _, err := s.write.ExecContext(ctx,
`UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, vorEinemMonat); err != nil { `UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, oneMonthAgo); err != nil {
t.Fatal(err) t.Fatal(err)
} }
grenze := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano) limit := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano)
entfernt, err := s.PurgeTrash(ctx, grenze) removed, err := s.PurgeTrash(ctx, limit)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(entfernt[p.ID]) != 1 || entfernt[p.ID][0] != "alt" { if len(removed[p.ID]) != 1 || removed[p.ID][0] != "alt" {
t.Errorf("entfernt: %v, erwartet nur „alt“", entfernt) t.Errorf("entfernt: %v, erwartet nur „alt“", removed)
} }
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 || liste[0].Slug != "frisch" { if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 1 || list[0].Slug != "frisch" {
t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", liste) t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", list)
} }
if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil { if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil {
t.Error("ein normaler Eintrag wurde beim Aufräumen entfernt") t.Error("ein normaler Eintrag wurde beim Aufräumen entfernt")
@ -302,7 +302,7 @@ func TestMoveEntriesToTrash(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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 { if err != nil {
t.Fatal(err) 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}) 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}) 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}) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(geloescht) != 2 || geloescht[0] != "a" || geloescht[1] != "b" { if len(deleted) != 2 || deleted[0] != "a" || deleted[1] != "b" {
t.Errorf("gelöscht = %v, erwartet [a b]", geloescht) 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) t.Errorf("der Eintrag eines fremden Projekts wurde angetastet: %v", err)
} }
if _, err := s.EntryBySlug(ctx, p.ID, "bleibt"); err != nil { if _, err := s.EntryBySlug(ctx, p.ID, "bleibt"); err != nil {
t.Errorf("ein nicht ausgewählter Eintrag ist weg: %v", err) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(papierkorb) != 2 || papierkorb[0].DeletedBy != "mensch" { if len(trash) != 2 || trash[0].DeletedBy != "mensch" {
t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", papierkorb) t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", trash)
} }
// Doppelt abgeschickt: nichts mehr zu tun, kein Fehler. // Doppelt abgeschickt: nichts mehr zu tun, kein Fehler.
nochmal, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch") again, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch")
if err != nil || len(nochmal) != 0 { if err != nil || len(again) != 0 {
t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", nochmal, err) t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", again, err)
} }
} }

View file

@ -77,7 +77,7 @@ func (u UsageSummary) Total() WriteCounts {
// CuratedPercent ist der Anteil kuratierter Einträge in Prozent (0–100). // CuratedPercent ist der Anteil kuratierter Einträge in Prozent (0–100).
func (u UsageSummary) CuratedPercent() int { 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 // 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 { if t.Created+t.Updated == 0 {
return -1 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 // Ganzzahlig statt mit Kommazahlen: "37 %" reicht für diese Übersicht, und so
// gibt es keine Rundungsfragen in der Anzeige. // gibt es keine Rundungsfragen in der Anzeige.
func prozent(teil, ganz int) int { func percent(part, whole int) int {
if ganz == 0 { if whole == 0 {
return 0 return 0
} }
// Die Hälfte des Nenners dazuzählen, bevor ganzzahlig geteilt wird — // Die Hälfte des Nenners dazuzählen, bevor ganzzahlig geteilt wird —
// das ist Runden statt Abschneiden. // 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 // 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 // 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, // Aufrufer bekommt für sie beim Nachschlagen den Nullwert — also überall 0,
// und genau das stimmt ja auch. // und genau das stimmt ja auch.
func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time.Time) (map[string]UsageSummary, error) { func (s *Store) UsageSummaries(ctx context.Context, projectID string, now time.Time) (map[string]UsageSummary, error) {
ergebnis := map[string]UsageSummary{} result := map[string]UsageSummary{}
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// ── 1. Schreibvorgänge aus dem Protokoll ──────────────────────────────── // ── 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). // angelegt oder aktualisiert wurde (siehe DetailEntryCreated in audit.go).
// Alles, was nicht "angelegt" und nicht leer ist, ist eine Aktualisierung: // Alles, was nicht "angelegt" und nicht leer ist, ist eine Aktualisierung:
// "aktualisiert", "wiederhergestellt: …", "Sammelbearbeitung: …". // "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 = ? THEN 1 ELSE 0 END),
SUM(CASE WHEN action = ? AND detail <> ? 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 = ? AND detail = '' THEN 1 ELSE 0 END),
SUM(CASE WHEN action = ? THEN 1 ELSE 0 END) SUM(CASE WHEN action = ? THEN 1 ELSE 0 END)
FROM audit_events FROM audit_events
WHERE ts >= ? AND action IN (?, ?)` WHERE ts >= ? AND action IN (?, ?)`
werte := []any{ values := []any{
ActionEntryWrite, DetailEntryCreated, ActionEntryWrite, DetailEntryCreated,
ActionEntryWrite, DetailEntryCreated, ActionEntryWrite, DetailEntryCreated,
ActionEntryWrite, ActionEntryWrite,
ActionEntryDelete, ActionEntryDelete,
seit, ActionEntryWrite, ActionEntryDelete, since, ActionEntryWrite, ActionEntryDelete,
} }
if projectID != "" { if projectID != "" {
abfrage += ` AND project_id = ?` query += ` AND project_id = ?`
werte = append(werte, projectID) 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 { if err != nil {
return nil, fmt.Errorf("Nutzung konnte nicht gelesen werden: %w", err) return nil, fmt.Errorf("Nutzung konnte nicht gelesen werden: %w", err)
} }
for rows.Next() { for rows.Next() {
var projekt, quelle string var project, source string
var z WriteCounts 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() rows.Close()
return nil, err return nil, err
} }
// Werte aus einer map sind in Go Kopien: erst herausholen, ändern, // Werte aus einer map sind in Go Kopien: erst herausholen, ändern,
// dann wieder hineinschreiben. // dann wieder hineinschreiben.
u := ergebnis[projekt] u := result[project]
switch quelle { switch source {
case SourceWeb: case SourceWeb:
u.Human = u.Human.add(z) u.Human = u.Human.add(z)
case SourceMCP: case SourceMCP:
u.Agent = u.Agent.add(z) u.Agent = u.Agent.add(z)
} }
ergebnis[projekt] = u result[project] = u
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
rows.Close() 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 // 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 // gibt. Das läuft über den Primärschlüssel (entry_id, tag) und ist damit
// ein kurzer Blick in den Index je Eintrag. // 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), 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) MAX(updated_at)
FROM entries e FROM entries e
WHERE e.deleted_at IS NULL` WHERE e.deleted_at IS NULL`
werte = nil values = nil
if projectID != "" { if projectID != "" {
abfrage += ` AND project_id = ?` query += ` AND project_id = ?`
werte = append(werte, projectID) 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 { if err != nil {
return nil, fmt.Errorf("Bestand konnte nicht gelesen werden: %w", err) return nil, fmt.Errorf("Bestand konnte nicht gelesen werden: %w", err)
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var projekt string var project string
var zuletzt sql.NullString var latest sql.NullString
var anzahl, kuratiert, ohneSchlagwort int var count, curated, untagged int
if err := rows.Scan(&projekt, &anzahl, &kuratiert, &ohneSchlagwort, &zuletzt); err != nil { if err := rows.Scan(&project, &count, &curated, &untagged, &latest); err != nil {
return nil, err return nil, err
} }
u := ergebnis[projekt] u := result[project]
u.Entries, u.Curated, u.Untagged = anzahl, kuratiert, ohneSchlagwort u.Entries, u.Curated, u.Untagged = count, curated, untagged
if zuletzt.Valid { if latest.Valid {
u.LastUpdatedAt = parseTime(zuletzt.String) 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. // 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 // UntaggedEntries gibt Einträge ohne Schlagwort zurück, zuletzt geänderte
// zuerst, höchstens limit Stück. // zuerst, höchstens limit Stück.
func (s *Store) UntaggedEntries(ctx context.Context, projectID string, limit int) ([]EntryRef, error) { 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 `SELECT slug, title FROM entries e
WHERE project_id = ? AND e.deleted_at IS NULL WHERE project_id = ? AND e.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) 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() defer rows.Close()
var liste []EntryRef var list []EntryRef
for rows.Next() { for rows.Next() {
var e EntryRef var e EntryRef
if err := rows.Scan(&e.Slug, &e.Title); err != nil { if err := rows.Scan(&e.Slug, &e.Title); err != nil {
return nil, err 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 // 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 // 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. // Einträge dagegen schon — für alle Leser ist das Ziel ja weg.
func (s *Store) MissingLinks(ctx context.Context, projectID string) ([]MissingLink, error) { 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 `SELECT l.to_slug, COUNT(*) AS anzahl
FROM entry_links l FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id 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() defer rows.Close()
var liste []MissingLink var list []MissingLink
for rows.Next() { for rows.Next() {
var m MissingLink var m MissingLink
if err := rows.Scan(&m.Slug, &m.Count); err != nil { if err := rows.Scan(&m.Slug, &m.Count); err != nil {
return nil, err 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 // RecentlyUsedKeys gibt die zuletzt benutzten, nicht zurückgezogenen Keys
// eines Projekts zurück, höchstens limit Stück. Nie benutzte Keys fehlen — // eines Projekts zurück, höchstens limit Stück. Nie benutzte Keys fehlen —
// die gehören in eine andere Frage (AUSBAUSTUFEN.md 4.7). // die gehören in eine andere Frage (AUSBAUSTUFEN.md 4.7).
func (s *Store) RecentlyUsedKeys(ctx context.Context, projectID string, limit int) ([]APIKey, error) { 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 `SELECT `+keyColumns+` FROM api_keys
WHERE project_id = ? AND revoked_at = '' AND last_used_at <> '' WHERE project_id = ? AND revoked_at = '' AND last_used_at <> ''
ORDER BY last_used_at DESC 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 // SQLite (es nimmt die übrigen Spalten dann aus der Zeile mit dem
// Höchstwert); PostgreSQL lehnt es ab. Fensterfunktionen können beide // Höchstwert); PostgreSQL lehnt es ab. Fensterfunktionen können beide
// (SQLite ab 3.25), siehe AUSBAUSTUFEN.md 4.10. // (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 entry_slug, title, actor, detail, ts FROM (
SELECT a.entry_slug, e.title, a.actor, a.detail, a.ts, 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 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() defer rows.Close()
var liste []CuratedOverride var list []CuratedOverride
for rows.Next() { for rows.Next() {
var c CuratedOverride var c CuratedOverride
var detail, zeitpunkt string var detail, at string
if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &zeitpunkt); err != nil { if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &at); err != nil {
return nil, err return nil, err
} }
c.Reason = strings.TrimSpace(strings.TrimPrefix( c.Reason = strings.TrimSpace(strings.TrimPrefix(
strings.TrimPrefix(detail, DetailCuratedOverridden), ":")) strings.TrimPrefix(detail, DetailCuratedOverridden), ":"))
c.At = parseTime(zeitpunkt) c.At = parseTime(at)
liste = append(liste, c) list = append(list, c)
} }
return liste, rows.Err() return list, rows.Err()
} }

View file

@ -8,41 +8,41 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und // TestUsageSummariesCountsWrites: Neuanlagen, Aktualisierungen und
// Löschungen werden getrennt nach Mensch (Web) und Agent (MCP) gezählt — und // Löschungen werden getrennt nach Mensch (Web) und Agent (MCP) gezählt — und
// nur innerhalb des Zeitfensters. // nur innerhalb des Zeitfensters.
func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) { func TestUsageSummariesCountsWrites(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
protokoll := func(quelle, aktion, detail string) { log := func(source, action, detail string) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: aktion, Source: quelle, Detail: detail}) s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: action, Source: source, Detail: detail})
} }
protokoll(SourceMCP, ActionEntryWrite, DetailEntryCreated) log(SourceMCP, ActionEntryWrite, DetailEntryCreated)
protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated) log(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated) log(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
protokoll(SourceMCP, ActionEntryDelete, "in den Papierkorb") log(SourceMCP, ActionEntryDelete, "in den Papierkorb")
protokoll(SourceWeb, ActionEntryWrite, DetailEntryCreated) log(SourceWeb, ActionEntryWrite, DetailEntryCreated)
protokoll(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung log(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung
protokoll(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso log(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso
protokoll(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung log(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung
protokoll(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht log(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht
// Ein Schreibvorgang außerhalb des Fensters darf nicht mitzählen. // Ein Schreibvorgang außerhalb des Fensters darf nicht mitzählen.
alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano) old := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
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) `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, '', 'x', ?, '', ?, '', ?)`, VALUES (?, ?, ?, '', 'x', ?, '', ?, '', ?)`,
NewID(), alt, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil { NewID(), old, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil {
t.Fatal(err) t.Fatal(err)
} }
alle, err := s.UsageSummaries(ctx, "", time.Now()) all, err := s.UsageSummaries(ctx, "", time.Now())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
u := alle[p.ID] u := all[p.ID]
if (u.Agent != WriteCounts{Created: 1, Updated: 2, Deleted: 1}) { if (u.Agent != WriteCounts{Created: 1, Updated: 2, Deleted: 1}) {
t.Errorf("Agent = %+v", u.Agent) t.Errorf("Agent = %+v", u.Agent)
@ -56,15 +56,15 @@ func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) {
} }
// Eingeschränkt auf das Projekt kommt dasselbe heraus. // Eingeschränkt auf das Projekt kommt dasselbe heraus.
einzeln, _ := s.UsageSummaries(ctx, p.ID, time.Now()) single, _ := s.UsageSummaries(ctx, p.ID, time.Now())
if einzeln[p.ID] != u { if single[p.ID] != u {
t.Errorf("mit Projektfilter: %+v, ohne: %+v", einzeln[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. // ohne Papierkorb.
func TestUsageSummariesBestand(t *testing.T) { func TestUsageSummariesInventory(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Curated: true, Source: SourceAgent})
s.MoveEntryToTrash(ctx, p.ID, "weg", "tom") s.MoveEntryToTrash(ctx, p.ID, "weg", "tom")
alle, _ := s.UsageSummaries(ctx, "", time.Now()) all, _ := s.UsageSummaries(ctx, "", time.Now())
u := alle[p.ID] u := all[p.ID]
if u.Entries != 3 || u.Curated != 1 || u.Untagged != 2 { 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", 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()) t.Errorf("UpdatePercent ohne Schreibvorgänge = %d, erwartet -1", u.UpdatePercent())
} }
ohne, _ := s.UntaggedEntries(ctx, p.ID, 10) without, _ := s.UntaggedEntries(ctx, p.ID, 10)
if len(ohne) != 2 { if len(without) != 2 {
t.Errorf("UntaggedEntries = %v, erwartet b und c", ohne) 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, "geloescht", "tom")
s.MoveEntryToTrash(ctx, p.ID, "im-papierkorb", "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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
erwartet := []MissingLink{{"fehlt", 2}, {"geloescht", 1}} want := []MissingLink{{"fehlt", 2}, {"geloescht", 1}}
if len(liste) != len(erwartet) { if len(list) != len(want) {
t.Fatalf("MissingLinks = %v, erwartet %v", liste, erwartet) t.Fatalf("MissingLinks = %v, erwartet %v", list, want)
} }
for i := range erwartet { for i := range want {
if liste[i] != erwartet[i] { if list[i] != want[i] {
t.Errorf("[%d] = %v, erwartet %v", i, liste[i], erwartet[i]) t.Errorf("[%d] = %v, erwartet %v", i, list[i], want[i])
} }
} }
} }
@ -134,13 +134,13 @@ func TestRecentlyUsedKeys(t *testing.T) {
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) 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") 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() now := NowString()
s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, jetzt, benutzt.ID, weg.ID) s.write.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, now, used.ID, gone.ID)
s.RevokeAPIKey(ctx, weg.ID) s.RevokeAPIKey(ctx, gone.ID)
keys, err := s.RecentlyUsedKeys(ctx, p.ID, 5) keys, err := s.RecentlyUsedKeys(ctx, p.ID, 5)
if err != nil { 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. // gekürzt wird — in Zeichen, nicht in Bytes, damit kein Umlaut zerbricht.
func TestCuratedOverrideDetailKuerzt(t *testing.T) { func TestCuratedOverrideDetailTruncates(t *testing.T) {
lang := strings.Repeat("ü", maxBegruendung+50) long := strings.Repeat("ü", maxJustification+50)
got := CuratedOverrideDetail(lang) got := CuratedOverrideDetail(long)
if !strings.HasPrefix(got, DetailCuratedOverridden+": ") { if !strings.HasPrefix(got, DetailCuratedOverridden+": ") {
t.Errorf("Anfang fehlt: %q", got[:40]) t.Errorf("Anfang fehlt: %q", got[:40])
@ -164,19 +164,19 @@ func TestCuratedOverrideDetailKuerzt(t *testing.T) {
if !utf8.ValidString(got) { if !utf8.ValidString(got) {
t.Error("die Kürzung hat ein Zeichen zerbrochen") 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) 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 // einem Eintrag Zugang und Begründung der JÜNGSTEN Änderung erscheinen — und
// der Eintrag nur einmal. // der Eintrag nur einmal.
// //
// Früher hing das an einer Eigenheit von SQLite (MAX() mit Spalten ohne // Früher hing das an einer Eigenheit von SQLite (MAX() mit Spalten ohne
// Aggregat), die PostgreSQL nicht kennt. Die Zeitstempel sind hier fest // Aggregat), die PostgreSQL nicht kennt. Die Zeitstempel sind hier fest
// gesetzt, damit die Reihenfolge eindeutig ist. // gesetzt, damit die Reihenfolge eindeutig ist.
func TestCuratedOverridesJuengsterVorgang(t *testing.T) { func TestCuratedOverridesLatestEvent(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
@ -185,39 +185,39 @@ func TestCuratedOverridesJuengsterVorgang(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
jetzt := time.Now().UTC() now := time.Now().UTC()
for _, v := range []struct { for _, v := range []struct {
alter time.Duration age time.Duration
zugang, grund string key, reason string
}{ }{
{2 * time.Hour, "agent-alt", "alter Grund"}, {2 * time.Hour, "agent-alt", "alter Grund"},
{1 * time.Minute, "agent-neu", "neuer Grund"}, {1 * time.Minute, "agent-neu", "neuer Grund"},
{1 * time.Hour, "agent-mitte", "mittlerer 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) `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, '', ?, ?, 'vpn', ?, '', ?)`, VALUES (?, ?, ?, '', ?, ?, 'vpn', ?, '', ?)`,
NewID(), jetzt.Add(-v.alter).Format(time.RFC3339Nano), p.ID, v.zugang, NewID(), now.Add(-v.age).Format(time.RFC3339Nano), p.ID, v.key,
ActionEntryWrite, SourceMCP, CuratedOverrideDetail(v.grund)); err != nil { ActionEntryWrite, SourceMCP, CuratedOverrideDetail(v.reason)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
liste, err := s.CuratedOverrides(ctx, p.ID, 10) list, err := s.CuratedOverrides(ctx, p.ID, 10)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(liste) != 1 { if len(list) != 1 {
t.Fatalf("%d Zeilen, erwartet 1 (je Eintrag nur der jüngste Vorgang): %+v", len(liste), liste) 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" { if list[0].Actor != "agent-neu" || list[0].Reason != "neuer Grund" {
t.Errorf("Zeile = %+v, erwartet agent-neu mit „neuer Grund“", liste[0]) 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. // 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) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
@ -228,13 +228,13 @@ func TestCuratedOverridesOhneGeloeschte(t *testing.T) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryWrite, s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryWrite,
EntrySlug: "weg", Source: SourceMCP, Detail: CuratedOverrideDetail("Grund")}) EntrySlug: "weg", Source: SourceMCP, Detail: CuratedOverrideDetail("Grund")})
if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 1 { if list, _ := s.CuratedOverrides(ctx, p.ID, 10); len(list) != 1 {
t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(liste)) t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(list))
} }
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "mensch"); err != nil { if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "mensch"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 0 { if list, _ := s.CuratedOverrides(ctx, p.ID, 10); len(list) != 0 {
t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", liste) t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", list)
} }
} }

View file

@ -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() }

View file

@ -34,8 +34,8 @@ func (a WorkspaceAdmin) Revoked() bool {
// //
// Bei einem Workspace-Admin wiegt das schwerer als bei einem Projekt-Key: Ein // Bei einem Workspace-Admin wiegt das schwerer als bei einem Projekt-Key: Ein
// vergessenes Token öffnet nicht ein Projekt, sondern den ganzen Workspace. // vergessenes Token öffnet nicht ein Projekt, sondern den ganzen Workspace.
func (a WorkspaceAdmin) Unused(jetzt time.Time) bool { func (a WorkspaceAdmin) Unused(now time.Time) bool {
return ungenutzt(a.Revoked(), a.LastUsedAt, a.CreatedAt, jetzt) return unused(a.Revoked(), a.LastUsedAt, a.CreatedAt, now)
} }
// workspaceAdminColumns steht in jeder Abfrage auf workspace_admins. // workspaceAdminColumns steht in jeder Abfrage auf workspace_admins.
@ -71,17 +71,17 @@ func (c *ControlStore) CreateWorkspaceAdmin(ctx context.Context, workspaceID, na
Name: name, Name: name,
Hash: hash, 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) `INSERT INTO workspace_admins (id, workspace_id, name, hash, created_at)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
a.ID, a.WorkspaceID, a.Name, a.Hash, jetzt, a.ID, a.WorkspaceID, a.Name, a.Hash, now,
); err != nil { ); err != nil {
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht angelegt werden: %w", err) return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht angelegt werden: %w", err)
} }
a.CreatedAt = parseTime(jetzt) a.CreatedAt = parseTime(now)
return a, nil 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. // Zurückgezogene Zugänge gelten als nicht vorhanden, wie bei ResolveKeyHash.
func (c *ControlStore) ResolveWorkspaceAdminHash(ctx context.Context, hash string) (WorkspaceAdmin, error) { 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) `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE hash = ? AND revoked_at = ''`, hash)
a, err := scanWorkspaceAdmin(row.Scan) 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 // WorkspaceAdminByID liest einen Zugang anhand seiner ID — auch einen
// zurückgezogenen. Ob er noch gilt, entscheidet der Aufrufer über Revoked(). // zurückgezogenen. Ob er noch gilt, entscheidet der Aufrufer über Revoked().
func (c *ControlStore) WorkspaceAdminByID(ctx context.Context, id string) (WorkspaceAdmin, error) { 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) `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE id = ?`, id)
a, err := scanWorkspaceAdmin(row.Scan) 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. // ListWorkspaceAdmins gibt alle Zugänge eines Workspace zurück, neueste zuerst.
// Zurückgezogene sind dabei, damit die Verwaltung sie noch anzeigen kann. // Zurückgezogene sind dabei, damit die Verwaltung sie noch anzeigen kann.
func (c *ControlStore) ListWorkspaceAdmins(ctx context.Context, workspaceID string) ([]WorkspaceAdmin, error) { 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 `SELECT `+workspaceAdminColumns+` FROM workspace_admins
WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID) WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID)
if err != nil { 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 // für die Übersicht des Plattform-Admins, in einer Abfrage statt einer je
// Workspace. // Workspace.
func (c *ControlStore) ListActiveWorkspaceAdmins(ctx context.Context) ([]WorkspaceAdmin, error) { 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 = ''`) `SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE revoked_at = ''`)
if err != nil { if err != nil {
return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err) 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 // Die Workspace-ID steht mit in der Bedingung: Eine untergeschobene ID aus
// einem anderen Workspace trifft dann keine Zeile und endet als ErrNotFound. // einem anderen Workspace trifft dann keine Zeile und endet als ErrNotFound.
func (c *ControlStore) RevokeWorkspaceAdmin(ctx context.Context, workspaceID, id string) error { 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 = ? `UPDATE workspace_admins SET revoked_at = ?
WHERE id = ? AND workspace_id = ? AND revoked_at = ''`, WHERE id = ? AND workspace_id = ? AND revoked_at = ''`,
NowString(), id, workspaceID, NowString(), id, workspaceID,
@ -185,7 +185,7 @@ func (c *ControlStore) TouchWorkspaceAdmin(ctx context.Context, id string, lastU
if time.Since(lastUsed) < time.Hour { if time.Since(lastUsed) < time.Hour {
return nil return nil
} }
_, err := c.schreiben.ExecContext(ctx, _, err := c.write.ExecContext(ctx,
`UPDATE workspace_admins SET last_used_at = ? WHERE id = ?`, NowString(), id) `UPDATE workspace_admins SET last_used_at = ? WHERE id = ?`, NowString(), id)
return err return err
} }

191
internal/web/chart.go Normal file
View file

@ -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
}
}
}
}

View file

@ -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)
}
}
}

Some files were not shown because too many files have changed in this diff Show more