Bugfixes, Security und Umbau auf einheitliche Namen
This commit is contained in:
parent
1ce9be1e2e
commit
6fa86b5941
167 changed files with 9606 additions and 9361 deletions
|
|
@ -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=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
170
AUSBAUSTUFEN.md
170
AUSBAUSTUFEN.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
119
REPOMAP.md
119
REPOMAP.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +277,7 @@ func TestCookiePolicySecure(t *testing.T) {
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,37 +68,37 @@ type Exporter struct {
|
||||||
// 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 + `"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
141
internal/help/help.go
Normal 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
188
internal/help/help_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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
|
|
@ -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"`
|
||||||
|
|
@ -251,7 +251,7 @@ type rememberOutput struct {
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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++ {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,15 @@ type Registry struct {
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
@ -75,30 +75,30 @@ func boostFuer(e store.Entry) float64 {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
290
internal/stats/stats.go
Normal 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
|
||||||
|
}
|
||||||
124
internal/stats/stats_test.go
Normal file
124
internal/stats/stats_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -69,13 +69,13 @@ const (
|
||||||
// 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'.
|
||||||
81
internal/store/backend_sqlite.go
Normal file
81
internal/store/backend_sqlite.go
Normal 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")
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
90
internal/store/pool.go
Normal 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() }
|
||||||
|
|
@ -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, ¬Null, &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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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() }
|
|
||||||
|
|
@ -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
191
internal/web/chart.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/web/chart_test.go
Normal file
82
internal/web/chart_test.go
Normal 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
Loading…
Add table
Reference in a new issue