Compare commits

...

4 commits

Author SHA1 Message Date
Thomas Adamek
c4753c5fdb Ausbaustufen verfeinert 2026-09-25 10:40:15 +02:00
Thomas Adamek
cf35c3f5db Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)
- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:47 +02:00
Thomas Adamek
c4a1941e7f Ungenutzte Zugänge markieren (Ausbaustufe 4.7, Stufe 1)
Aktive Keys, die seit über 90 Tagen nicht benutzt wurden (nie benutzt:
gemessen ab Anlegen), tragen unter Zugänge die Marke „ungenutzt“; über der
Tabelle steht ein Hinweis mit der Anzahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:46:35 +02:00
Thomas Adamek
466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00
93 changed files with 12635 additions and 627 deletions

View file

@ -18,8 +18,10 @@ ADMIN_TOKEN=
# ── Server ──────────────────────────────────────────────────────────────────── # ── Server ────────────────────────────────────────────────────────────────────
# Pfad zur SQLite-Datenbank. Der Ordner wird beim Start angelegt. # Datenverzeichnis: enthält die zentrale Kontroll-Datenbank (control.db) und
DB_PATH=data/munin.db # den Ordner workspaces/ mit einer SQLite-Datei je Workspace (Abteilung).
# Wird beim Start angelegt.
DATA_DIR=data
# Port, auf dem der Server lauscht. # Port, auf dem der Server lauscht.
PORT=8080 PORT=8080

3
.gitignore vendored
View file

@ -4,6 +4,9 @@ munin
# Datenbank und alles darunter — gehört nicht ins Repository # Datenbank und alles darunter — gehört nicht ins Repository
data/ data/
# Ziel von scripts/backup.sh, falls lokal ohne eigenes BACKUP_DIR aufgerufen
backups/
# Geheimnisse — NIEMALS einchecken # Geheimnisse — NIEMALS einchecken
.env .env

View file

@ -3,6 +3,10 @@
Wie man KI-Werkzeuge so einrichtet und anspricht, dass sie den Munin 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
> `/hilfe/mit-agenten-arbeiten` (Quelle: `internal/hilfe/seiten/`). Wer hier
> etwas ändert, sollte dort nachziehen.
--- ---
## 1. Verbinden ## 1. Verbinden
@ -33,6 +37,7 @@ und im Protokoll.
| `recall` | `query`, `tags?`, `limit?` | read | | `recall` | `query`, `tags?`, `limit?` | read |
| `get` | `slug` | read | | `get` | `slug` | read |
| `list` | `tag?`, `curated?`, `limit?` | read | | `list` | `tag?`, `curated?`, `limit?` | read |
| `related` | `slug`, `distance?`, `limit?` | read |
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write | | `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write |
| `forget` | `slug` | write | | `forget` | `slug` | write |
@ -58,11 +63,14 @@ eigenen Anwendung:
## Munin ## Munin
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall, Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams. get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
LESEN — vor jeder Antwort zu projektspezifischen Fragen: LESEN — vor jeder Antwort zu projektspezifischen Fragen:
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten. - Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
- Passenden Treffer mit `get` vollständig laden. - Passenden Treffer mit `get` vollständig laden.
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen
und in der Antwort erwähnen, dass der Stand alt ist.
- Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das - Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das
kenntlich machen. kenntlich machen.
- Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht. - Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht.
@ -171,7 +179,7 @@ sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Hak
| Rolle | darf | wofür | | Rolle | darf | wofür |
|---|---|---| |---|---|---|
| `read` | recall, get, list | Agenten, die nur auskunftsfähig sein sollen | | `read` | recall, get, list, related | Agenten, die nur auskunftsfähig sein sollen |
| `write` | zusätzlich remember, forget | der Normalfall | | `write` | zusätzlich remember, forget | der Normalfall |
| `admin` | zusätzlich Zugänge verwalten | Menschen | | `admin` | zusätzlich Zugänge verwalten | Menschen |
@ -184,8 +192,15 @@ die Einträge auch durchsieht.
- **Suche ist wortbasiert.** „wie deployen wir" findet „Release-Prozess" nicht. - **Suche ist wortbasiert.** „wie deployen wir" findet „Release-Prozess" nicht.
Im Zweifel `list` für den Überblick. Im Zweifel `list` für den Überblick.
- **Kein Zurück.** `forget` löscht endgültig, alte Fassungen werden nicht - **`forget` legt in den Papierkorb.** Der Eintrag ist für Agenten sofort
aufgehoben. Veraltetes lieber mit `remember` richtigstellen. unsichtbar, ein Mensch kann ihn 30 Tage lang in der Weboberfläche
zurückholen. Agenten können weder wiederherstellen noch den Papierkorb
leeren. Änderungen mit `remember` sind trotzdem der bessere Weg: Die
vorherige Fassung bleibt im Verlauf (Weboberfläche → *Verlauf ansehen*),
und der Eintrag bleibt auffindbar.
- **`remember` auf einen Bezeichner im Papierkorb** holt den Eintrag zurück
und überschreibt ihn; der gelöschte Stand wandert in den Verlauf. Die
Antwort sagt das ausdrücklich.
- **Was drinsteht, landet im Kontext aller.** Wer schreiben darf, schreibt - **Was drinsteht, landet im Kontext aller.** Wer schreiben darf, schreibt
indirekt in die Prompts der anderen — Herkunft und Autor stehen deshalb an indirekt in die Prompts der anderen — Herkunft und Autor stehen deshalb an
jedem Eintrag. jedem Eintrag.

View file

@ -54,7 +54,12 @@ bliebe also erhalten.
### 1.2 Eine SQLite-Datei je Projekt ### 1.2 Eine SQLite-Datei je Projekt
**Stand heute:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen > **Nachtrag:** Mit 4.4 gibt es inzwischen eine Datei je *Workspace* und eine
> zentrale Kontroll-Datenbank für die Zuordnung Key → Workspace. Die hier
> besprochene Aufteilung je *Projekt* bleibt aufgeschoben. Die Messwerte unten
> stammen aus der Zeit mit nur einer Datei und gelten jetzt je Workspace-Datei.
**Stand damals:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen
zum Lesen, eine zum Schreiben. zum Lesen, eine zum Schreiben.
**Gemessen:** **Gemessen:**
@ -194,7 +199,7 @@ Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht.
**Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben. **Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben.
### 3.2 Importer als Gegenstück zum Export ### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
Felder im Kopf, ein Einlesen wäre also möglich. Felder im Kopf, ein Einlesen wäre also möglich.
@ -205,6 +210,40 @@ Erstbefüllung aus vorhandener Dokumentation.
**Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert **Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert
— überschreiben, überspringen oder abbrechen. — überschreiben, überspringen oder abbrechen.
**✅ Umgesetzt 2026-09-25:**
- **Weg:** ZIP-Upload unter `/p/{projekt}/import`, Rolle `admin`. Bewusst kein
Kommandozeilenbefehl: Der Suchindex liegt im Speicher des laufenden
Servers, ein zweiter Prozess könnte ihn nicht nachziehen. Nebenbei können
Projekt-Admins so selbst importieren.
- **Zusammenstoß:** *überspringen* (Standard) oder *überschreiben*. Beim
Überschreiben wandert der alte Stand in den Verlauf, der Autor ist dann der
importierende Zugang. Ein *Abbrechen* gibt es nicht, dafür eine Vorschau,
die beim ersten Hochladen angehakt ist. Dateien, die nichts ändern würden,
werden nie geschrieben. Ein wiederholter Import ist damit harmlos.
- **Papierkorb:** Im Modus *überspringen* holt ein Import keinen gelöschten
Eintrag zurück. Das war die Falle, weil `AllEntries` den Papierkorb nicht
sieht.
- **Felder:** Beim Anlegen bleiben Autor, Herkunft, Prüfvermerk und beide
Zeitstempel erhalten (neu: `EntryWrite.CreatedAt/UpdatedAt`). Sonst sähe
nach einer Wiederherstellung alles frisch aus, und 3.5 liefe ins Leere. Der
Verlauf wird nicht übernommen, der Export enthält ihn nicht.
- **Fremde Dateien:** Ohne Kopf gilt der Dateiname als Bezeichner und die
erste Überschrift als Titel. Fremde Köpfe (Hugo, Obsidian) werden gelesen,
soweit sie `title` und `tags` betreffen. Kein YAML-Paket, ein kleiner
eigener Leser in `internal/importer/parse.go`.
- **Grenzen:** 20 MB Upload, 5.000 Dateien, 1 MB je Datei, 100 MB ausgepackt.
Gezählt wird, was tatsächlich ausgepackt wird, nicht was das Archiv
behauptet.
- **Protokoll:** ein Eintrag `entry_import` je Import mit Zusammenfassung,
nicht einer je Datei. So verzerrt ein einmaliger Import die
Nutzungsübersicht (4.6) nicht.
- **Nebenbei behoben:** Das 1-MB-Limit für Formulare lag über dem ganzen Mux,
auch über `/mcp`. Zwei geschachtelte `MaxBytesReader` begrenzen auf den
kleineren Wert, MCP war also nie bei den dokumentierten 4 MB, und
`MAX_API_BODY_BYTES` wirkte nur nach unten. Jetzt wählt `LimitBodies` die
Grenze einmal außen je Route.
### 3.3 Dubletten über Ähnlichkeit erkennen ### 3.3 Dubletten über Ähnlichkeit erkennen
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
@ -251,7 +290,7 @@ Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen
**Vorbild:** Engrams `mem_suggest_topic_key`. **Vorbild:** Engrams `mem_suggest_topic_key`.
### 3.5 Aktualität von Wissen ### 3.5 Aktualität von Wissen — kleiner Schritt ✅ umgesetzt
Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von
vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit
@ -271,23 +310,46 @@ schnell abläuft — ein Zugangsverfahren altert schneller als eine
Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein
Bestätigen, das die Frist zurücksetzt. Bestätigen, das die Frist zurücksetzt.
**✅ Kleiner Schritt umgesetzt 2026-09-25:**
- **Regel:** `Entry.Stale` in `internal/store/models.go`. Ein Eintrag, der seit
über einem Jahr (`StaleAfter`) nicht geändert wurde, gilt als veraltet. Das
gilt auch für geprüfte Einträge: Das Häkchen sagt nicht, *wann* geprüft
wurde. Ein Jahr ist bewusst eher lang. Ein Vermerk an jedem zweiten Eintrag
liest bald niemand mehr.
- **MCP:** `recall`, `list` und `related` tragen `"stale": true`, `get`
zusätzlich einen `hint` mit Handlungsanweisung. `recall` nennt im Hinweis,
wie viele Treffer alt sind. Alte Treffer werden weder ausgeblendet noch
nach hinten sortiert, denn ein alter Eintrag kann der einzige zum Thema
sein. Die `instructions` erklären das Feld.
- **Oberfläche:** Auf der Eintragsseite steht die Marke *über ein Jahr alt*.
- **Offen:** der größere Schritt (Frist je Art, „fällige Einträge“,
Bestätigen).
**Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung, **Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung,
mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich
ausgebauter als das, was wir bräuchten — aber die Richtung stimmt. ausgebauter als das, was wir bräuchten — aber die Richtung stimmt.
### 3.6 Änderungshistorie ### 3.6 Änderungshistorie — ✅ umgesetzt
`entries.version` zählt hoch, aber alte Fassungen werden nicht aufgehoben. `entries.version` zählt hoch, aber alte Fassungen wurden nicht aufgehoben.
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, ist das Richtige weg. Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg.
**Lösung:** Tabelle `entry_versions` mit dem vorherigen Inhalt bei jedem **Umgesetzt:** Tabelle `entry_versions`
Schreibvorgang. Kostet Platz, gibt aber ein Zurück — und macht die Frage „wer hat (`migrations/004_aenderungshistorie.sql`). `entries` bleibt die aktuelle
das wann geändert" beantwortbar. Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht
beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen
Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird.
Läuft in derselben Transaktion wie die Änderung selbst, sowohl beim normalen
Schreiben (`PutEntry`) als auch bei der Sammelbearbeitung
(`BulkUpdateTags`, siehe 5). Löschen eines Eintrags nimmt seine Historie über
`ON DELETE CASCADE` mit — konsistent mit Tags und Verweisen.
**Alternative ohne Aufwand:** der Markdown-Export in einem Git-Repository. Dann Die Alternative "Markdown-Export in einem Git-Repository" bleibt weiterhin
liegt die Historie dort, mit `git log` und `git diff`. möglich, ersetzt das hier aber nicht: Der Export läuft nur, wenn `EXPORT_DIR`
gesetzt ist, während die Historie in der Datenbank immer mitläuft.
### 3.7 Verwandte Einträge ### 3.7 Verwandte Einträge — ✅ umgesetzt
Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein
Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten
@ -295,6 +357,16 @@ ein zweiter Weg zum Kontext — neben der Suche.
**Aufwand:** gering, die Daten liegen vor. **Aufwand:** gering, die Daten liegen vor.
**✅ Umgesetzt 2026-09-25:** MCP-Werkzeug `related(slug, distance?, limit?)`.
Liefert Nachbarn in beide Richtungen (wer verweist auf mich, worauf verweise
ich), standardmäßig bis zur zweiten Reihe, höchstens 50. Jeder Treffer nennt
Abstand, den Eintrag, über den er gefunden wurde (`via`), die Richtung und
`stale`. Verweise ins Leere und Einträge im Papierkorb fallen heraus, auch als
Brücke in die zweite Reihe. Mehr als zwei Schritte gibt es bewusst nicht: In
einem gut verknüpften Projekt hängt in der dritten Reihe fast alles mit allem
zusammen. `get` liefert weiterhin `links_to`/`linked_by` als nackte
Bezeichner. `related` ergänzt Titel, Datum und die zweite Reihe.
### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt ### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt
Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in
@ -337,6 +409,73 @@ nach einem Neustart weg. Als Netz gegen eine Schleife reicht das. Sobald eine
verbindliche Zusage gebraucht wird — oder mehr als ein Prozess läuft —, gehört verbindliche Zusage gebraucht wird — oder mehr als ein Prozess läuft —, gehört
der Zähler in die Datenbank. der Zähler in die Datenbank.
### 3.9 Papierkorb statt endgültigem Löschen — ✅ umgesetzt
`forget` löscht heute sofort und endgültig, samt aller Fassungen (3.6,
`ON DELETE CASCADE`). Das gilt auch, wenn ein Agent es aufruft. Überschreiben
lässt sich über den Verlauf reparieren, Löschen nicht. Damit ist Löschen der
schlimmste Fall, den ein fehlgeleiteter Agent heute anrichten kann.
**Lösung:** Löschen setzt nur eine Markierung (`deleted_at`, `deleted_by`).
Markierte Einträge verschwinden aus Suche, Liste, Graph und MCP-Antworten,
bleiben aber samt Verlauf in der Datenbank. Eine Seite *Papierkorb* je Projekt
zeigt sie. Menschen mit Schreibrecht können sie dort wiederherstellen oder
endgültig löschen. Nach einer Frist (Vorschlag 30 Tage) räumt die tägliche
Aufgabe sie endgültig weg, im selben Lauf wie das Protokoll (siehe 4.4,
WP12).
**Zu bedenken:**
- Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt.
Schreibt jemand mit demselben Bezeichner neu, muss klar sein, was passiert.
Vorschlag: Das stellt den Eintrag wieder her und überschreibt ihn, wobei der
alte Stand wie gewohnt in den Verlauf wandert.
- Jede Abfrage auf `entries` braucht die Bedingung `deleted_at IS NULL`. Das ist
dieselbe Art Risiko, die 4.4 für Workspaces bewusst vermeiden wollte. Hier ist
es vertretbar, weil ein vergessenes `WHERE` nur einen gelöschten Eintrag des
*eigenen* Projekts zeigt. Trotzdem gehört ein Test dazu, der jeden lesenden
Weg (Suche, Liste, `get`, `recall`, Graph, Rückverweise, Export) prüft.
**Aufwand:** mittel.
**✅ Umgesetzt 2026-09-24:**
- **Schema:** `migrations/005_papierkorb.sql`: `deleted_at` (NULL = normal),
`deleted_by` und ein Teilindex nur über den Papierkorb.
- **Store:** `internal/store/papierkorb.go`. `DeleteEntry` gibt es nicht mehr,
an seine Stelle tritt `MoveEntryToTrash`. Der neue Name sorgt dafür, dass
jeder alte Aufrufer beim Kompilieren auffällt. Endgültig löschen können nur
`PurgeFromTrash` und `PurgeTrash`, und beide fassen nur an, was schon im
Papierkorb liegt.
- **Lesewege:** Alle tragen `nichtImPapierkorb`. `ListTags` schließt die
Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex
aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen.
`TestPapierkorbUnsichtbar` prüft jeden Weg einzeln, `TestForgetMovesToTrash`
dasselbe aus Sicht eines Agenten (`get`, `recall`, `list`).
- **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag
zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf
(`Entry.RestoredFromTrash`). Web und `remember` sagen das dem Schreiber, das
Protokoll vermerkt `entry_restore`. Ausnahme: Wer eine gelesene Fassung
mitschickt (`ExpectedVersion`), bekommt einen Konflikt. Das ist der Fall
„Formular offen, währenddessen hat ein Agent gelöscht“, und eine Löschung
soll nicht unbemerkt rückgängig werden.
- **MCP:** `forget` legt in den Papierkorb und sagt das in Beschreibung und
Antwort. Wiederherstellen oder endgültig löschen gibt es über MCP bewusst
nicht. `remember` erkennt „angelegt“ jetzt an der Fassung nach dem Schreiben
statt über eine Nachfrage vorher, die den Papierkorb nicht gesehen hätte.
- **Oberfläche:** `/p/{project}/trash`, nur mit Schreibrecht, auch das Ansehen:
Gelöscht wird oft, was nicht jeder sehen soll. Es gibt Wiederherstellen und
endgültiges Löschen je Zeile oder für eine Auswahl, dazu einen Filter nach
löschendem Zugang mit *Alle … wiederherstellen* für den Fall „Agent hat zu
gründlich aufgeräumt“. `confirm.js` liest die Rückfrage jetzt auch am
gedrückten Knopf (`event.submitter`).
- **Frist:** `store.TrashRetention` = 30 Tage. `papierkorbLeerenAlle` in
`main.go` läuft im selben täglichen Takt wie das Protokoll-Aufräumen und
schreibt je Eintrag eine Protokollzeile mit Herkunft `system`.
Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie
dort mitziehen.
- **Protokoll:** Neue Aktionen `entry_restore` und `entry_purge`.
`entry_delete` bedeutet jetzt „in den Papierkorb“ (Detail sagt es).
--- ---
## Teil 4 — Betrieb ## Teil 4 — Betrieb
@ -388,32 +527,482 @@ Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf
**Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an **Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an
Personen statt an Keys. Personen statt an Keys.
### 4.4 Mehrere Workspaces ### 4.4 Mehrere Workspaces — ✅ umgesetzt
Die Spalte `workspace_id` steckt von Anfang an in allen Tabellen, es gibt aber nur **Ausgangslage:** Die Spalte `workspace_id` stand von Anfang an in allen Tabellen,
einen und keine Oberfläche dafür. Nötig, sobald Abteilungen sich gegenseitig es gab aber nur einen Workspace und keine Oberfläche dafür. Nötig, sobald
nicht sehen sollen. Abteilungen sich gegenseitig nicht sehen sollen.
**Entscheidung:** Abteilungen dürfen sich strukturell nie sehen — nicht nur "in
der Praxis meistens nicht". Eine gemeinsame Tabelle mit Filterung über
`WHERE workspace_id = ?` hängt davon ab, dass diese Bedingung in jeder
gegenwärtigen und jeder künftigen Abfrage korrekt steht — bei einem einzelnen
Entwickler ohne Review durch Dritte ein zu hohes Risiko für ein einziges
vergessenes `WHERE`.
Gleichzeitig muss der Betrieb zentral bleiben: ein Deployment für
anfangs 10, später bis zu 40 Teams (1–5 Projekte je Team) statt eines eigenen
Deployments pro Abteilung, das niemand von Hand pflegen kann. Eigene Backups je
Team sind Pflicht.
**Konzept:** eine dünne Kontroll-Datenbank (Workspaces, Key-Hash →
Workspace-Routing) plus eine eigene SQLite-Datei je Workspace mit dem heutigen,
unveränderten Schema. Trennung ist damit strukturell (eigene Datei), nicht durch
Disziplin in jeder Abfrage erzwungen. Backups werden dadurch nebenbei
trivial — eine Datei je Team lässt sich einzeln sichern.
Bei 10–40 Workspaces ist das unproblematisch — deutlich unterhalb der in 1.2
genannten Schwelle, ab der Verbindungsverwaltung mit Verdrängung nötig würde
(die galt für *Projekte*, nicht für Workspaces, und für eine viel höhere Zahl).
**Arbeitspakete:**
1. ✅ Kontroll-Schema: `workspaces` + `key_index` (hash → workspace_id) —
`internal/store/migrations_control/001_init.sql`
2. ✅ `ControlStore`: kleines Store-Objekt für die Kontroll-DB —
`internal/store/control.go`
3. ✅ Key-Routing zweistufig: `ControlStore.ResolveKeyWorkspace` (hash →
workspace_id) davor, danach die heutige `ResolveKeyHash`-Logik unverändert
auf der passenden Workspace-Datei — sowohl beim Web-Login
(`handlers_auth.go`) als auch bei MCP-Aufrufen (`APIAuth` in
`middleware.go`). `CreateAPIKey`/`RevokeAPIKey` pflegen `key_index` im
selben Zug.
4. ✅ Verbindungscache je Workspace: `internal/workspace.Manager` öffnet
Workspace-Dateien bei Bedarf und hält sie offen (`map[workspaceID]*Workspace`,
ein `sync.Mutex`). Bei 10–40 Workspaces keine Verdrängung nötig.
5. ✅ Workspace-Auflösung pro Request ersetzt das feste `WorkspaceID` in
`web.Config`: Das Session-Subject trägt die Workspace-ID direkt
(`key:<workspace-id>:<key-id>` bzw. `platform:<workspace-id>`, siehe
`sessionFromSubject` in `middleware.go`) — signiert, also fälschungssicher,
und ohne Umweg über die Kontroll-DB bei jedem Request. Für den
Plattform-Admin (ein globales `ADMIN_TOKEN`, siehe Entscheidung oben) kommt
die Workspace-Wahl über `/admin/workspaces` dazu
(`handlers_workspaces.go`) — eine frische Admin-Sitzung ohne gewählten
Workspace kommt an keine Projektseite, `requireWorkspace` in
`middleware.go` erzwingt das. MCP-Werkzeuge lesen ihren Workspace aus dem
Context (`web.WorkspaceFromContext`, siehe `mcpserver/tools.go`) statt aus
einer festen Handler-Instanz — ein `mcpserver.Handler` bedient dadurch
beliebig viele Workspaces gleichzeitig.
6. ✅ Migrationen laufen für die Kontroll-DB und beim Öffnen jeder
Workspace-Datei (`store.Open`/`store.OpenControl`, `main.go` legt
`DATA_DIR/control.db` und `DATA_DIR/workspaces/<id>.db` an)
7. Datenmigration für eine bereits befüllte Bestandsinstallation (einmaliges
Skript) — bislang nicht nötig, es läuft noch keine produktive Instanz
8. ✅ Workspace-Verwaltung, vollständig: `/admin/workspaces` zeigt alle
Workspaces (neueste zuerst, mit Anlegedatum und einer
"Aktuell"-Markierung), legt neue an (springt danach direkt zur
Projektverwaltung, kein Umweg über die Auswahl) und verlinkt je Zeile auf
`/admin/workspaces/{id}/edit` zum Umbenennen. Dort auch die Gefahrenzone:
Löschen entfernt erst die Kontroll-Zeile (samt `key_index` über
`ON DELETE CASCADE`), dann die Workspace-Datei von der Platte
(`workspace.Manager.Delete`) — mit Bestätigung durch Eintippen des Namens,
wie beim Löschen eines Projekts. Betrifft das den Workspace der eigenen
Sitzung, wird die Sitzung auf "kein Workspace gewählt" zurückgesetzt statt
den Admin auszusperren. Dazu ein Workspace-Umschalter in der Kopfleiste
(nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace
auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die
Workspace-Umschalter von Slack/Linear/Notion (`templates/partials.html`,
`.arbeitsbereich*` in `static/style.css`).
9. ✅ `scripts/backup.sh`: sichert Kontroll-DB und jede Workspace-Datei
einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner,
optionale Aufräumfrist über `RETENTION_DAYS`. README verlinkt es
samt Cron-Beispiel.
10. ✅ Tests: `internal/store/control_test.go` (`ControlStore`/Routing),
`internal/workspace/workspace_test.go` (Manager cached, trennt Dateien,
schließt sauber), `internal/web/web_test.go`
(`TestWorkspaceIsolation` — zwei gleichzeitig offene Workspace-Dateien im
selben Prozess kommen sich nicht in die Quere)
11. ✅ README, `.env.example`, `Dockerfile`: `DB_PATH` → `DATA_DIR`
**Stand:** Alle Arbeitspakete umgesetzt bis auf eines: eine Datenmigration für
eine bereits befüllte Bestandsinstallation, falls die je nötig wird (7,
bislang läuft keine produktive Instanz). Mehrere Workspaces gleichzeitig in
einem Prozess, strukturell getrennt, mit Oberfläche zum Anlegen und Wechseln
und eigenem Backup-Skript — fertig und end-to-end geprüft (Tests plus
manueller Durchlauf gegen den laufenden Server bzw. das Skript).
**Nachgezogen** (beim Abgleich der Doku mit dem Code am 2026-09-24 gefunden
und am selben Tag behoben):
12. ✅ Wiederkehrende Aufgaben über alle Workspaces. Vorher bekamen
`pruneAuditRegularly` und `exportRegularly` nur den ältesten Workspace.
Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1)
griff dort nicht. Jetzt gehen beide in jedem Durchgang über
`fuerJedenWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus
der Kontroll-Datenbank, also sind auch nach dem Start angelegte Workspaces
dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber
gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`.
13. ✅ Export je Workspace. Vorher war der Exporter beim Start fest an den Store
des ältesten Workspace gebunden (*Jetzt exportieren* in einem anderen
Workspace schrieb nichts), und die Zielordner hießen nur
`EXPORT_DIR/<projekt>`, sodass sich Workspaces mit gleichem
Projekt-Bezeichner gegenseitig überschrieben und Dateien wegräumten. Jetzt
kennt `export.Exporter` keine Datenbank mehr, der Aufrufer gibt den Store
je Lauf mit (`ExportWorkspace`). Die Ordner heißen
`EXPORT_DIR/<workspace-id>/<projekt>/`, dazu `_workspace.md` mit dem Namen.
Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner
erzeugt. `./munin -export` exportiert alle Workspaces und endet mit
Fehlercode, wenn einer scheitert. Tests: `TestExportTrenntWorkspaces`,
`TestExportKnopfImZweitenWorkspace`, `main_test.go`.
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.
### 4.5 Kennzahlen ### 4.5 Kennzahlen
Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht. ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht.
### 4.6 Nutzungsübersicht je Projekt — ✅ umgesetzt
Anders als 4.5 nicht für ein Überwachungssystem, sondern für Menschen in der
Oberfläche. Die Hilfe (*Mit Agenten arbeiten*) rät, zu prüfen, ob
Aktualisierungen gegenüber Neuanlagen zunehmen. Das lässt sich heute nur
mühsam aus dem Protokoll ablesen.
**Inhalt einer kleinen Übersichtsseite je Projekt:**
- Schreibvorgänge der letzten 30 Tage, aufgeteilt in Neuanlagen und
Aktualisierungen, getrennt nach Mensch und Agent
- Anteil kuratierter Einträge
- Einträge ohne Schlagwort, Verweise auf nicht vorhandene Einträge
- zuletzt aktive Zugänge
Für den Plattform-Admin dieselben Zahlen als Spalten in `/admin/projects`. So
fällt auf, welches Team Munin gar nicht benutzt.
**Voraussetzung — ✅ erledigt (2026-09-24):** Das Protokoll vermerkt bei
`entry_write` im Feld `detail`, ob der Eintrag `angelegt` oder `aktualisiert`
wurde (`store.EntryWriteDetail`, Konstanten `DetailEntryCreated` und
`DetailEntryUpdated` in `internal/store/audit.go`), sowohl bei `remember` als
auch in der Weboberfläche. Maßgeblich ist die Fassung nach dem Schreiben
(1 = angelegt), das stimmt auch bei gleichzeitigem Schreiben. Die
Sammelbearbeitung schreibt weiterhin ihren eigenen Text und zählt als
Aktualisierung. Schreibvorgänge vor diesem Datum lassen sich nicht
unterscheiden.
**Aufwand:** gering bis mittel, die Daten liegen im Protokoll und in `entries`.
**✅ Umgesetzt 2026-09-24:**
- **Seite:** `/p/{project}/usage` (`handlers_nutzung.go`), für jede Rolle.
Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle
sieht.
- **Zahlen:** `store.UsageSummaries` (`internal/store/nutzung.go`) liefert
Schreibvorgänge der letzten 30 Tage (`UsageWindow`) aus dem Protokoll und den
Bestand aus `entries`, für ein Projekt oder alle, in zwei Abfragen. Es gibt
keine eigene Zähltabelle, die auseinanderlaufen könnte.
- **Mensch oder Agent:** Unterschieden wird nach Weg: Web = Mensch, MCP =
Agent. Ein Mensch, der selbst über MCP schreibt, zählt als Agent.
- **Neu oder aktualisiert:** Maßgeblich ist `detail`. `angelegt` ist neu;
alles andere außer leer ist eine Aktualisierung („aktualisiert“,
„wiederhergestellt: …“, „Sammelbearbeitung: …“). Leer heißt „ohne Angabe“:
Das sind Zeilen aus der Zeit vor der Unterscheidung, die nach 30 Tagen von
selbst herausfallen.
- **Der entscheidende Wert:** der Anteil der Aktualisierungen, mit einem
Hinweis zur Deutung. Eine feste Schwelle „gut/schlecht“ gibt es bewusst
nicht, denn ein junges Projekt legt naturgemäß vor allem an.
- **Weitere Abschnitte:** Verweise ins Leere (meistgenannte zuerst, mit
Schreibrecht „anlegen“ über `/new?slug=…`), Einträge ohne Schlagwort, die
fünf zuletzt aktiven Zugänge.
- **Admin:** `/admin/projects` hat die Spalten *Schreibvorgänge* (30 Tage, mit
„keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl
kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt.
### 4.7 Ungenutzte Zugänge sichtbar machen — Stufe 1 ✅ umgesetzt
`last_used_at` wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem
Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben,
oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang.
**Lösung, in zwei Stufen:**
1. Unter *Zugänge* Keys markieren, die seit mehr als 90 Tagen (oder nie)
benutzt wurden, mit einem Knopf zum Zurückziehen. Gering.
2. Optional ein Ablaufdatum beim Anlegen. Abgelaufene Keys gelten wie
zurückgezogene. Braucht eine Migration (`expires_at`) und eine Prüfung in
`ResolveKeyHash`. Mittel.
**✅ Stufe 1 umgesetzt 2026-09-25:**
- **Regel:** `APIKey.Unused` in `internal/store/models.go`. Ein aktiver Key
gilt als ungenutzt, wenn er seit mehr als 90 Tagen (`UnusedAfter`) nicht
benutzt wurde. Wurde er noch nie benutzt, zählt das Anlegedatum. Sonst stünde
jeder frische Key sofort als ungenutzt da. Zurückgezogene Keys sind nie
ungenutzt.
- **Oberfläche:** Unter *Zugänge* trägt so ein Key die Marke *ungenutzt*, über
der Tabelle steht ein Hinweis mit der Anzahl. Den Knopf zum Zurückziehen gab
es schon. Keine Migration, `last_used_at` reicht.
- **Unschärfe:** `TouchAPIKey` schreibt höchstens einmal pro Stunde. Für eine
Grenze von 90 Tagen spielt das keine Rolle.
- **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`.
- **Offen:** Stufe 2 (Ablaufdatum).
### 4.8 Admin je Workspace
Heute legt nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40
Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle
Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen.
**Lösung:** Eine Rolle „Workspace-Admin“, die im eigenen Workspace Projekte
anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch
das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt).
**Abhängigkeit:** Hängt eng mit 4.3 (Keycloak) zusammen. Mit Personen statt Keys
wäre „Workspace-Admin“ einfach eine Gruppenmitgliedschaft. Ohne 4.3 bräuchte es
eine eigene Key-Art ohne Projektbindung. Beides zusammen entscheiden, nicht
4.8 vorziehen und später umbauen.
**Aufwand:** hoch.
### 4.9 Zugriff über ein Projekt hinaus — aufgeschoben
Zwei verwandte Wünsche, besprochen am 2026-09-25 und bewusst aufgeschoben:
- **Öffentliches Projekt:** Ein Projekt, das jeder gültige Key lesen darf,
auch aus anderen Workspaces. Etwa für firmenweite Richtlinien.
- **Workspace-Zugang:** Ein Key für alle Projekte eines Workspace, mit einem
MCP-Server statt einem je Projekt. Für Teams, die projektübergreifend
arbeiten und Wissen aus mehreren Projekten brauchen.
**Stand heute:** Ein Key gehört zu genau einem Projekt, und jedes Projekt hat
einen eigenen MCP-Server, dessen Werkzeuge fest an dieses Projekt gebunden
sind. Wer mehrere Projekte braucht, trägt mehrere Server ein, je einen Key pro
Projekt. Das Routing Key → Workspace in der Kontroll-Datenbank (4.4) kennt
dagegen schon keine Projektbindung.
**Geht schon heute, ohne Code:** Für ein „öffentliches“ Projekt bekommt jedes
Team einen `read`-Key und trägt das Projekt als zweiten Server ein. Das kostet
Verwaltung, aber keine Entwicklung, und das Protokoll zeigt je Team, wer liest.
#### Öffentliches Projekt
| | Idee | Aufwand |
|---|---|---|
| **A** (empfohlen) | Eigener Endpunkt `/mcp/public/<projekt>`, jeder gültige Key darf lesen (`recall`, `get`, `list`, `related`), schreiben nur mit Keys des Projekts. Schalter nur für den Plattform-Admin. | 2½–3½ Tage |
| **B** | `recall` sucht automatisch zusätzlich im öffentlichen Projekt | 4–6 Tage, ändert den MCP-Vertrag |
| Lesen in der Oberfläche | Menschen aus anderen Projekten sehen es im Browser | +2–3 Tage |
Von Schreibrecht für alle wird abgeraten: Das Schreibbudget (3.8) gälte für
die ganze Firma zusammen, und niemand fühlte sich für die Qualität zuständig.
#### Workspace-Zugang
**Nötige Änderungen:** Workspace-Keys ohne Projektbindung (`api_keys.project_id`
ist heute `NOT NULL`, bei SQLite heißt das Tabelle neu aufbauen),
`Session.CanAccessProject` erweitern, Verwaltung unter `/admin/workspaces`, und
auf der MCP-Seite eine von zwei Stufen:
| | Idee | Aufwand gesamt |
|---|---|---|
| **Stufe 1** | Projekt wählbar über den Pfad (`/mcp/<projekt>`), ein Key für alle. Löst den Zugriff, der Agent muss aber weiter wissen, wo er sucht. | 4½–6 Tage |
| **Stufe 2** | Ein Server je Workspace, `recall` über alle Projekte, jeder Treffer nennt sein Projekt, `get`/`related`/`remember` bekommen einen Parameter `project`. | 7½–10 Tage |
**Risiken:**
- *Sicherheit:* Ein verlorener Workspace-Key öffnet alle Projekte statt einem.
Die Grenze zwischen Workspaces bleibt strukturell dicht (eigene Datei je
Workspace). Innerhalb eines Workspace wird die Projektgrenze aber zu einer
Bedingung in der Abfrage statt einer Closure. Das größte Risiko ist, dass
Projekt-Keys versehentlich über denselben Codepfad laufen und mehr sehen als
heute. Deshalb Projekt-Keys unverändert auf dem heutigen Weg lassen und den
Workspace-Server getrennt aufbauen.
- *Last:* Ein workspace-weites `recall` lädt beim ersten Aufruf die
Suchindizes *aller* Projekte in den Speicher (~65 MB und ~170 ms je 10.000
Einträge, siehe 1.1). Heute nur die, die jemand benutzt. SQLite wird nicht
stärker belastet, alle Projekte eines Workspace liegen schon in einer Datei.
- *Schreibbudget:* Es zählt je Projekt. Ein durchdrehender Agent mit
Workspace-Key könnte in *jedem* Projekt bis zur Grenze schreiben. Dagegen
hilft nur ein zusätzliches Budget je Key oder Workspace, oder kein
Schreibrecht für Workspace-Keys.
- *Qualität:* Agenten schreiben ins falsche Projekt (deshalb keine
Standardangabe für `project` bei `remember`), vermischen Wissen verschiedener
Projekte, legen Dubletten über Projektgrenzen an. Außerdem sind BM25-Werte
getrennter Indizes nicht direkt vergleichbar, das Mischen der Treffer
braucht eigene Logik.
- *Wartung:* zwei Anschlussarten statt einer. Mit Keycloak (4.3) wird aus dem
Workspace-*Key* ein Workspace-*Recht* einer Person, der Rechte-Teil wird dann
umgebaut.
**Warum aufgeschoben:** Keycloak ist absehbar, aber nicht sofort. Mit wenigen
Projekten je Team (etwa zwei) ist ein Server je Projekt stabil und die
Schwäche nur Komfort. Die Risiken oben wiegen das noch nicht auf.
**Auslöser:**
- Teams arbeiten regelmäßig mit vier bis fünf oder mehr Projekten zugleich
(Werkzeuge vervielfachen sich im Kontext des Agenten)
- Die Verteilung von `read`-Keys für gemeinsam genutzte Projekte wird zur
spürbaren Last
- Die Entscheidung zu 4.3 und 4.8 steht an. Dann zusammen entscheiden, nicht
vorher Workspace-Keys bauen und später umbauen.
**Wenn es so weit ist, vorsichtig beginnen:** Workspace-Keys nur `read`,
projektübergreifend `recall`/`get`/`list`/`related`, schreiben weiter über
die Projekt-Server, vorher Speicher und Kaltstart mit echten Projektgrößen
messen. Innerhalb eines Workspace erledigt ein solcher `read`-Key nebenbei
auch das öffentliche Projekt. Übrig bliebe nur „öffentlich über
Workspaces hinweg“ (Variante A oben).
--- ---
## Teil 5 — Oberfläche ## Teil 5 — Oberfläche
| | Nutzen | Aufwand | | | Nutzen | Aufwand |
|---|---|---| |---|---|---|
| Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) | | ✅ Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) |
| Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel | | ✅ Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel |
| Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch | | ✅ Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch |
| Vergleich zweier Fassungen | setzt 3.6 voraus | mittel | | ✅ Vergleich zweier Fassungen | setzt 3.6 voraus | mittel |
| Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering | | ✅ Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering |
| ✅ Eingebaute Hilfe unter `/hilfe` | Onboarding ohne Umweg über das Repository | mittel |
| ✅ Fassung wiederherstellen (5.1) | Fehler eines Agenten mit einem Klick zurücknehmen | gering |
| ✅ Echte Server-Adresse auf der Zugangsseite (5.2) | Konfiguration direkt kopierbar | gering |
| ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | gering |
| ✅ Hilfe an Ort und Stelle verlinken (5.4) | Hilfe dort, wo man hängt | gering |
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
der erste. der erste.
### 5.1 Fassung wiederherstellen — ✅ umgesetzt
Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus
dem Vergleich kopieren und von Hand einfügen. **Lösung:** Knopf *Diese Fassung
wiederherstellen* in der Versionsansicht (nur mit Schreibrecht). Technisch ist
das ein gewöhnliches `PutEntry` mit Titel, Inhalt und Schlagworten der alten
Fassung und `ExpectedVersion` der aktuellen. Der aktuelle Stand wandert dabei
selbst in den Verlauf, es geht nichts verloren. Das Protokoll sollte vermerken,
dass es eine Wiederherstellung war und von welcher Fassung.
**✅ Umgesetzt 2026-09-24:** `POST /p/{project}/e/{slug}/versions/{version}/restore`
(`handleEntryRestore`, `internal/web/handlers_entries.go`), Knopf auf der
Vergleichsseite und je Zeile im Verlauf, nur mit Schreibrecht. Das Formular
schickt die beim Anzeigen aktuelle Fassung als `current_version` mit; fehlt sie,
wird abgelehnt (400) statt ohne Konfliktprüfung zu schreiben, bei einem Konflikt
kommt die Verlaufsseite mit Hinweis (409). Die Markierung *kuratiert* bleibt,
wie sie ist — sie beschreibt den Eintrag, nicht einen Textstand. Im Protokoll
steht `wiederhergestellt: Fassung n` (`store.EntryRestoreDetail`); für die
Zählung in 4.6 zählt das als Aktualisierung (`detail LIKE 'wiederhergestellt%'`).
### 5.2 Echte Server-Adresse auf der Zugangsseite — ✅ umgesetzt
`keys.html` zeigt in den Konfigurationsblöcken noch `https://DEIN-SERVER/mcp`.
Die Hilfe setzt die Adresse schon ein (`serverURL` in
`internal/web/handlers_hilfe.go`). Dieselbe Funktion für `keyListData`
verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen.
**✅ Umgesetzt 2026-09-24:** `keyListData.ServerURL`, gesetzt in
`renderKeyList`; `keys.html` nutzt es in allen drei Konfigurationsblöcken.
### 5.3 Mehr Markdown in Einträgen — ✅ umgesetzt
`entry.RenderHTML` nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in
Einträgen deshalb als Text, in der Hilfe (`internal/hilfe`) dagegen als
Tabelle. **Lösung:** `extension.GFM` einschalten (Tabellen, Durchstreichen,
Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die
Sicherheitsannahme in `RenderHTML` ändert sich nicht. Danach den Hinweis auf
der Hilfeseite *Weboberfläche* entfernen und Hilfe und Einträge über denselben
Renderer laufen lassen.
**✅ Umgesetzt 2026-09-24:** Neues Paket `internal/markdown` — ein
goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und
`hilfe.Render` rufen nur noch dort hinein. Einziger Unterschied:
`RenderMitAnkern` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht —
Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche"
soll nicht mit einer id der Seite selbst zusammenstoßen können.
`markdown_test.go` prüft, dass auch mit GFM weder Roh-HTML noch
`javascript:`-Links durchkommen. Die Hilfe beschreibt jetzt Tabellen,
Checklisten, Durchstreichen und automatische Links.
### 5.4 Hilfe an Ort und Stelle verlinken — ✅ umgesetzt
Kleine „?“-Links von einer Seite zur passenden Hilfe, zum Beispiel *Zugänge* →
`/hilfe/zugaenge`, Editor → Markdown-Abschnitt, leere Eintragsliste →
*Agent anbinden*. Dazu Überschrift-Anker in den Hilfeseiten
(`parser.WithAutoHeadingID`), damit Links auf einen Abschnitt zeigen können.
**✅ Umgesetzt 2026-09-24:** Partial `hilfelink` in `partials.html`, ein
kleines „?" neben Überschriften und dem Inhaltsfeld des Editors: Einträge,
Editor (Markdown-Abschnitt), Verlauf, Vergleich, Zugänge, Protokoll (je nach
Ansicht Projekt- oder Admin-Hilfe), Workspaces, Projekte. Die leere
Eintragsliste verlinkt auf *Agent anbinden* und *Mit Agenten arbeiten*. Die
Hilfe öffnet in einem neuen Tab, damit beim Nachschlagen im Editor kein
ungespeicherter Text verloren geht.
Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute
einfach fallen („zugnge-verwalten") —, sondern `ankerIDs` in
`internal/markdown` über `store.Slugify` („zugaenge-verwalten"), dieselbe
Regel wie für Eintragsbezeichner. `TestHilfelinksZeigenAufVorhandeneAbschnitte`
liest alle Templates, sucht jedes Hilfe-Ziel und prüft, dass Seite und
Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so
im Test, welcher Link dadurch ins Leere zeigen würde.
**✅ Sammelbearbeitung umgesetzt:** `entries.html` zeigt bei Schreibrecht ein
Auswahlkästchen je Eintrag plus eine Sammelleiste (Schlagworte hinzufügen/
entfernen, kommagetrennt). `Store.BulkUpdateTags` (`internal/store/entries.go`)
prüft je ID, dass sie wirklich zum aufrufenden Projekt gehört — eine
untergeschobene ID aus einem fremden Projekt wird stillschweigend übersprungen,
nicht verändert. Nur tatsächlich geänderte Einträge zählen als Treffer und
zählen ihre `version` hoch; der Suchindex wird für sie nachgezogen. Kein
JavaScript nötig — ein einziges Formular um die ganze Liste.
**✅ Fassungsvergleich umgesetzt:** Baut auf 3.6 auf. `/p/{project}/e/{slug}/versions`
zeigt den Verlauf, `/versions/{version}` vergleicht eine abgelöste Fassung mit
ihrer Nachfolgerin — entweder der nächsten abgelösten oder, wenn es keine mehr
gibt, der aktuell gültigen. Der Zeilenvergleich selbst
(`internal/entry/diff.go`, `DiffLines`) ist eine klassische
LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für
außergewöhnlich lange Einträge (`grobDiff` oberhalb von vier Millionen
Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern.
**✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte
JavaScript im Projekt (`static/preview.js`, ~90 Zeilen) neben `confirm.js`.
Reiter "Schreiben"/"Vorschau" über dem Inhaltsfeld in `edit.html`; ein Klick
auf "Vorschau" schickt den aktuellen Textarea-Inhalt per `fetch` an
`POST /p/{project}/preview` (`handleEntryPreview`,
`internal/web/handlers_entries.go`) und zeigt das Ergebnis an. Bewusst kein
Markdown-Parsing im Browser: Der Endpunkt ruft denselben `entry.RenderHTML`
auf wie die Detailansicht, Vorschau und späteres Ergebnis können also nicht
auseinanderlaufen — und es ist keine externe JS-Bibliothek nötig, die an der
Content-Security-Policy (`script-src 'self'`) vorbeigemusst hätte. Ohne
JavaScript bleibt einfach der Reiter "Schreiben" aktiv, das Formular
funktioniert unverändert. Kein Live-Rendern beim Tippen, nur auf Klick —
hält die Zahl der Aufrufe klein.
**✅ Autovervollständigung umgesetzt:** `static/autocomplete.js` erkennt
beim Tippen ein noch nicht geschlossenes `[[`, fragt
`GET /p/{project}/entries/suggest?q=…` (`handleEntrySuggest`,
`internal/web/handlers_entries.go`) und zeigt die Treffer als Liste unter dem
Feld — bewusst nicht an der Schreibmarke ausgerichtet, das bräuchte einen
Spiegel-Trick zur Pixel-Positionierung im Textfeld, den die Liste unter dem
Feld erspart. Tastatur (Pfeiltasten, Enter/Tab, Escape) und Maus bedienbar.
Die Suche selbst dupliziert keine Logik: Mit Suchbegriff läuft dieselbe
BM25-Volltextsuche wie bei `recall` (MCP) und der Projektsuche
(`search.Registry`), ohne Suchbegriff kommen die zuletzt geänderten
Einträge. Ausgewählt wird der Bezeichner (slug), nicht der Titel — das ist,
was `[[…]]` erwartet.
**✅ Graph-Ansicht umgesetzt:** `/p/{project}/graph` (Seite) und
`/p/{project}/graph/data` (JSON, `handleEntryGraphData`). Datenseitig neu ist
nur `Store.LinkGraph` (`internal/store/entries.go`) — eine Abfrage über den
bereits vorhandenen, indizierten `entry_links`. Verweise auf einen
Bezeichner ohne Eintrag erzeugen einen eigenen, gestrichelten Knoten
(`Exists: false`) statt stillschweigend zu verschwinden — sichtbar macht,
wohin schon verwiesen wird, was aber noch fehlt.
Begrenzung wie in der Aufwands-/Risikoabschätzung besprochen:
`graphNodeLimit = 150` — bei mehr Einträgen bleiben die am stärksten
verknüpften übrig (nach Grad sortiert), die Antwort trägt `truncated: true`
und die Gesamtzahl, die Seite zeigt einen Hinweis. Das hält sowohl die
Kräftesimulation (`static/graph.js`, O(Knoten²) je Durchlauf) als auch die
SVG-Darstellung in einem Bereich, der im Browser flüssig bleibt.
Kein externes Diagramm-Werkzeug: Die Kräftesimulation (Abstoßung zwischen
allen Knotenpaaren, Anziehung entlang der Kanten, schwache Zentrierung) ist
selbst geschrieben, läuft einmal beim Laden durch und rendert dann reines
SVG. Texte werden ausschließlich über `textContent` gesetzt, nie
`innerHTML` — Eintragstitel kommen von Menschen und Agenten und sind damit
nicht vertrauenswürdig. Zoom (Mausrad) und Pan (Ziehen) über eine simple
Transform-Gruppe, keine Abhängigkeit auf eine Bibliothek.
--- ---
## Empfohlene Reihenfolge ## Empfohlene Reihenfolge
@ -428,12 +1017,39 @@ sichtbarer Gewinn:
Als Nächstes: Als Nächstes:
1. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung. *Stand der Priorisierung: 2026-09-24.*
Das Zugriffsprotokoll und `/p/<projekt>/audit` liefern jetzt die Daten dafür:
Wie viele `remember`-Aufrufe legen neu an, wie viele aktualisieren? **Vor dem zweiten Workspace**
2. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
beide ändern den MCP-Vertrag und sollten bewusst entschieden werden, nicht 1. ~~**Offene Reste aus 4.4** (WP12 und WP13): Protokoll aufräumen und Export
nebenbei. für alle Workspaces.~~ — erledigt 2026-09-24, zusammen mit der
Unterscheidung angelegt/aktualisiert im Protokoll (Voraussetzung für 4.6).
**Klein, sofort spürbar für die Teams**
2. ~~**Fassung wiederherstellen** (5.1) und **Server-Adresse auf der
Zugangsseite** (5.2). Zusammen ein kleines Paket.~~ — erledigt 2026-09-24.
3. ~~**Mehr Markdown** (5.3) und **Hilfe an Ort und Stelle** (5.4).~~ —
erledigt 2026-09-24.
**Mittel, wichtig bei vielen Teams**
4. ~~**Papierkorb** (3.9): schließt den einzigen Schaden, der sich heute nicht
rückgängig machen lässt.~~ — erledigt 2026-09-24.
5. ~~**Nutzungsübersicht** (4.6): zeigt, ob die Teams Munin wirklich benutzen,
und liefert die Zahlen für Punkt 7.~~ — erledigt 2026-09-24.
6. ~~**Ungenutzte Zugänge** (4.7), zunächst nur Stufe 1.~~ — erledigt
2026-09-25. Stufe 2 (Ablaufdatum) erst, wenn Stufe 1 nicht reicht.
**Bewusst entscheiden, nicht nebenbei**
7. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
Das Zugriffsprotokoll und 4.6 liefern die Daten dafür: Wie viele
`remember`-Aufrufe legen neu an, wie viele aktualisieren?
8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
beide ändern den MCP-Vertrag.
9. **Admin je Workspace** (4.8) zusammen mit **Keycloak** (4.3). Wird dringend,
sobald das Anlegen von Projekten spürbar auf den Plattform-Admin wartet.
Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die
Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es
@ -441,8 +1057,12 @@ sich in der Spalte „Schreibbudget" unter `/admin/projects`. Wenn dort nie etwa
knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder
das Limit zu eng oder ein Agent tut etwas Unsinniges. das Limit zu eng oder ein Agent tut etwas Unsinniges.
Zusätzlich vorgezogen und erledigt 2026-09-25: **Verwandte Einträge** (3.7),
**Aktualität, kleiner Schritt** (3.5) und **Importer** (3.2).
Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird. Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird.
**Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken, **Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken,
semantische Suche. Alle drei sind gute Ideen für den Fall, dass ihre Auslöser semantische Suche, Zugriff über ein Projekt hinaus (4.9). Alle vier sind gute
eintreten — und drei gute Wege, Zeit in Probleme zu stecken, die es noch nicht gibt. Ideen für den Fall, dass ihre Auslöser eintreten — und vier gute Wege, Zeit in
Probleme zu stecken, die es noch nicht gibt.

View file

@ -38,7 +38,7 @@ COPY --from=builder /app/munin .
EXPOSE 8080 EXPOSE 8080
ENV DB_PATH=/data/munin.db ENV DATA_DIR=/data
ENV PORT=8080 ENV PORT=8080
# Im Container steht praktisch immer ein Reverse-Proxy mit HTTPS davor. # Im Container steht praktisch immer ein Reverse-Proxy mit HTTPS davor.

170
README.md
View file

@ -14,8 +14,8 @@ Unterschied ist ein Feld am Eintrag, kein zweites System.
``` ```
KI-Werkzeug ──MCP──┐ KI-Werkzeug ──MCP──┐
├──▶ Projekt ──▶ Einträge (SQLite) ├──▶ Workspace ──▶ Projekt ──▶ Einträge
Browser ──HTTP─────┘ Browser ──HTTP─────┘ (eine SQLite-Datei je Workspace)
``` ```
--- ---
@ -52,9 +52,12 @@ Dann `http://localhost:8080` öffnen und mit dem `ADMIN_TOKEN` anmelden.
Der weitere Weg: Der weitere Weg:
1. **Projekt anlegen** unter *Projekte* 1. **Workspace wählen** — nach der Anmeldung landet der Plattform-Admin unter
2. **Zugang erzeugen** — der Key wird genau einmal angezeigt *Workspaces*. Beim ersten Start legt der Server den Workspace „Intern“ selbst
3. **MCP-Client einrichten** (die Anleitung erscheint direkt neben dem neuen Key) an; weitere (etwa einer je Abteilung) lassen sich dort anlegen.
2. **Projekt anlegen** unter *Projekte*
3. **Zugang erzeugen** — der Key wird genau einmal angezeigt
4. **MCP-Client einrichten** (die Anleitung erscheint direkt neben dem neuen Key)
--- ---
@ -62,11 +65,13 @@ Der weitere Weg:
| Begriff | Bedeutung | | Begriff | Bedeutung |
|---|---| |---|---|
| **Projekt** | Die Einheit der Abschottung. Eigene Einträge, eigene Zugänge, eigener MCP-Endpunkt. Projekte sehen sich gegenseitig nicht. | | **Workspace** | Ein Bereich für eine Abteilung oder ein Team, mit einer eigenen Datenbankdatei. Enthält beliebig viele Projekte. Workspaces sind strukturell getrennt: Keine Abfrage kann über eine Workspace-Grenze hinweg lesen, weil die Daten nicht in derselben Datei liegen. |
| **Projekt** | Die Einheit der Abschottung innerhalb eines Workspace. Eigene Einträge, eigene Zugänge, eigener MCP-Server. Projekte sehen sich gegenseitig nicht. |
| **Eintrag** | Ein Stück Wissen: Bezeichner, Titel, Markdown-Inhalt, Schlagworte. Von Menschen oder Agenten geschrieben. | | **Eintrag** | Ein Stück Wissen: Bezeichner, Titel, Markdown-Inhalt, Schlagworte. Von Menschen oder Agenten geschrieben. |
| **Bezeichner** (slug) | Kurzer, dauerhafter Name eines Eintrags (`deployment-prozess`). Der Schlüssel gegen Dubletten: Zweimal derselbe Bezeichner aktualisiert, statt eine zweite Fassung danebenzustellen. | | **Bezeichner** (slug) | Kurzer, dauerhafter Name eines Eintrags (`deployment-prozess`). Der Schlüssel gegen Dubletten: Zweimal derselbe Bezeichner aktualisiert, statt eine zweite Fassung danebenzustellen. |
| **API-Key** | Zugang zu genau einem Projekt, mit Namen und Rolle. Wird nur als Prüfsumme gespeichert. | | **API-Key** | Zugang zu genau einem Projekt, mit Namen und Rolle. Wird nur als Prüfsumme gespeichert. |
| **Plattform-Admin** | Der `ADMIN_TOKEN`. Legt Projekte an, kommt an jedes Projekt. | | **Plattform-Admin** | Der `ADMIN_TOKEN`. Legt Workspaces und Projekte an und kommt an jedes Projekt. Arbeitet immer in genau einem gewählten Workspace und wechselt über den Umschalter in der Kopfleiste. |
| **Fassung** (version) | Zählt bei jeder Änderung eines Eintrags hoch. Die abgelösten Fassungen bleiben erhalten und lassen sich in der Weboberfläche vergleichen. |
### Warum Bezeichner statt Ordner ### Warum Bezeichner statt Ordner
@ -84,7 +89,7 @@ der Detailansicht wie auch in der MCP-Antwort.
## MCP einrichten ## MCP einrichten
Nach dem Erzeugen eines Keys zeigt die Weboberfläche den passenden Abschnitt an. Nach dem Erzeugen eines Keys zeigt die Weboberfläche den passenden Abschnitt an.
Der Key bestimmt das Projekt — ein zusätzlicher Parameter ist nicht nötig. Wer mit Der Key bestimmt Workspace und Projekt — ein zusätzlicher Parameter ist nicht nötig. Wer mit
mehreren Projekten arbeitet, trägt mehrere Server mit je eigenem Key ein. mehreren Projekten arbeitet, trägt mehrere Server mit je eigenem Key ein.
Alle Clients sprechen denselben Endpunkt `…/mcp` über Streamable HTTP an. Die Alle Clients sprechen denselben Endpunkt `…/mcp` über Streamable HTTP an. Die
@ -143,13 +148,13 @@ Kommandozeilenfassung liegt dieselbe Datei unter `~/.cline/mcp.json`.
"url": "https://munin.intern/mcp", "url": "https://munin.intern/mcp",
"headers": { "Authorization": "Bearer wk_…" }, "headers": { "Authorization": "Bearer wk_…" },
"disabled": false, "disabled": false,
"autoApprove": ["recall", "get", "list"] "autoApprove": ["recall", "get", "list", "related"]
} }
} }
} }
``` ```
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei `autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier
lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und
ein Agent, der für jeden Blick ins Gedächtnis nachfragen muss, benutzt es nicht. ein Agent, der für jeden Blick ins Gedächtnis nachfragen muss, benutzt es nicht.
`remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern `remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern
@ -203,8 +208,9 @@ Wie man Agenten so anspricht, dass sie den Speicher auch nutzen:
| `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. | | `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. |
| `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. | | `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. |
| `list` | read | Überblick: zuletzt geändert, alle Schlagworte. | | `list` | read | Überblick: zuletzt geändert, alle Schlagworte. |
| `related` | read | Über `[[Verweise]]` verknüpfte Einträge, direkt und über einen Zwischeneintrag. |
| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. | | `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. |
| `forget` | write | Löscht einen Eintrag. | | `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). |
Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das
Vorgehen beschreibt — vor allem: *vor* dem Schreiben erst `recall` aufrufen. Das Vorgehen beschreibt — vor allem: *vor* dem Schreiben erst `recall` aufrufen. Das
@ -252,14 +258,54 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
| Weg | Zweck | | Weg | Zweck |
|---|---| |---|---|
| `/login` | Anmeldung mit API-Key oder Admin-Token | | `/login` | Anmeldung mit API-Key oder Admin-Token |
| `/admin/projects` | Projekte anlegen und auflisten (nur Plattform-Admin) | | `/admin/workspaces` | Workspaces auflisten, anlegen und wählen (nur Plattform-Admin) |
| `/admin/workspaces/{id}/edit` | Workspace umbenennen oder löschen |
| `/admin/projects` | Projekte des gewählten Workspace anlegen und auflisten, mit Schreibvorgängen der letzten 30 Tage je Projekt; Markdown-Export anstoßen |
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen | | `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern | | `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace |
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, Schlagworte für viele Einträge auf einmal ändern |
| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor) |
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen | | `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen |
| `/p/{projekt}/e/{bezeichner}/edit` | Eintrag bearbeiten, mit Vorschau |
| `/p/{projekt}/e/{bezeichner}/versions` | Alle Fassungen eines Eintrags |
| `/p/{projekt}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen |
| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph |
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) | | `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) |
| `/p/{projekt}/usage` | Nutzung: Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Bestand, Verweise ins Leere, aktive Zugänge |
| `/p/{projekt}/audit` | Protokoll des Projekts | | `/p/{projekt}/audit` | Protokoll des Projekts |
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) |
| `/hilfe` | Eingebaute Onboarding-Hilfe für alle Rollen: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Die Seite für Plattform-Admins sieht nur der Plattform-Admin. |
| `/healthz` | Healthcheck, ohne Anmeldung | | `/healthz` | Healthcheck, ohne Anmeldung |
### Funktionen beim Bearbeiten und Lesen
- **Vorschau:** Der Reiter *Vorschau* über dem Textfeld zeigt das Ergebnis. Der
Server rendert es mit demselben Code wie die Detailansicht, deshalb kann die
Vorschau nicht vom späteren Ergebnis abweichen.
- **Autovervollständigung für Verweise:** Nach `[[` schlägt das Textfeld passende
Einträge vor, mit derselben Suche wie `recall`. Auswählen mit Pfeiltasten und
Enter/Tab oder mit der Maus. Eingefügt wird der Bezeichner, nicht der Titel.
- **Änderungshistorie:** Vor jeder Änderung wird die bisherige Fassung gesichert,
auch bei Änderungen durch Agenten. Über *Verlauf ansehen* in der Detailansicht lässt sich jede abgelöste
Fassung zeilenweise mit ihrer Nachfolgerin vergleichen und mit Schreibrecht per
*Wiederherstellen* zurückholen — der bisherige Stand bleibt dabei selbst im
Verlauf.
- **Papierkorb:** Löschen, ob in der Oberfläche oder per `forget`, legt einen
Eintrag nur in den Papierkorb des Projekts. Dort bleibt er samt Verlauf 30
Tage und lässt sich von Menschen mit Schreibrecht wiederherstellen oder
sofort endgültig löschen. Danach räumt die tägliche Aufgabe ihn ab.
- **Graph:** Zeigt, welche Einträge aufeinander verweisen. Verweise auf
Bezeichner, zu denen es noch keinen Eintrag gibt, erscheinen als gestrichelte
Knoten. Bei mehr als 150 Einträgen bleiben die am stärksten verknüpften
sichtbar, die Seite weist darauf hin. Zoomen mit dem Mausrad, verschieben durch
Ziehen.
- **Schlagworte für viele Einträge:** Mit Schreibrecht hat die Eintragsliste
Auswahlkästchen. Für die ausgewählten Einträge lassen sich Schlagworte auf
einmal hinzufügen oder entfernen.
Vorschau, Autovervollständigung und Graph brauchen JavaScript. Alles andere
funktioniert auch ohne.
### Rollen ### Rollen
| Rolle | Darf | | Rolle | Darf |
@ -276,7 +322,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
|---|---|---| |---|---|---|
| `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 16 Zeichen. |
| `DB_PATH` | `data/munin.db` | Pfad zur SQLite-Datenbank | | `DATA_DIR` | `data` | Datenverzeichnis: Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace |
| `PORT` | `8080` | Port des Servers | | `PORT` | `8080` | Port des Servers |
| `COOKIE_SECURE` | automatisch | `Secure`-Flag der Cookies. Ohne Angabe: an, außer bei localhost. | | `COOKIE_SECURE` | automatisch | `Secure`-Flag der Cookies. Ohne Angabe: an, außer bei localhost. |
| `ENABLE_HSTS` | `false` | HSTS-Header senden. Nur bei gültigem Zertifikat. | | `ENABLE_HSTS` | `false` | HSTS-Header senden. Nur bei gültigem Zertifikat. |
@ -297,6 +343,7 @@ besser als unsicherer Betrieb.
| Schutz | Wo | | Schutz | Wo |
|---|---| |---|---|
| Workspaces in getrennten Datenbankdateien | `internal/workspace/workspace.go`, `internal/store/control.go` |
| Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` | | Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.go` |
| API-Keys nur als SHA-256-Prüfsumme gespeichert | `internal/auth/keys.go` | | API-Keys nur als SHA-256-Prüfsumme gespeichert | `internal/auth/keys.go` |
| Zurückgezogener Key beendet laufende Sitzungen sofort | `internal/web/middleware.go` | | Zurückgezogener Key beendet laufende Sitzungen sofort | `internal/web/middleware.go` |
@ -312,6 +359,9 @@ besser als unsicherer Betrieb.
Sie ist **strukturell**, nicht eine Frage der richtigen Filterbedingung: Sie ist **strukturell**, nicht eine Frage der richtigen Filterbedingung:
- Jeder Workspace liegt in einer **eigenen Datenbankdatei**. Welche Datei zu
einem Key gehört, steht in der zentralen Kontroll-Datenbank. Ein Key führt
deshalb nur in genau eine Datei.
- Jedes Projekt hat einen **eigenen MCP-Server**, dessen Werkzeuge fest auf - Jedes Projekt hat einen **eigenen MCP-Server**, dessen Werkzeuge fest auf
dieses Projekt verdrahtet sind dieses Projekt verdrahtet sind
- Jedes Projekt hat einen **eigenen Suchindex** — auch die BM25-Gewichtung wird - Jedes Projekt hat einen **eigenen Suchindex** — auch die BM25-Gewichtung wird
@ -347,8 +397,8 @@ trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jed
Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist
eine Berechtigungsstufe weniger den Umweg wert. eine Berechtigungsstufe weniger den Umweg wert.
- **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor. - **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor.
- **Ein Workspace.** Die Struktur für mehrere ist im Schema angelegt - **Ein Admin-Token für alle Workspaces.** Es gibt keinen Admin je Workspace.
(`workspace_id`), aber nicht in der Oberfläche. Wer den `ADMIN_TOKEN` hat, kommt an jeden Workspace.
- **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es - **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es
wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche
Zusage ist es nicht. Zusage ist es nicht.
@ -389,25 +439,52 @@ gleich alle aussperren.
### Datensicherung ### Datensicherung
**Datenbank.** Alles steckt in einer Datei. Weil SQLite im WAL-Modus läuft, sollte **Datenbank.** Jeder Workspace (Abteilung) liegt in einer eigenen Datei unter
sie nicht im laufenden Betrieb kopiert werden: `DATA_DIR/workspaces/`, dazu kommt die zentrale Kontroll-Datenbank
`DATA_DIR/control.db`. Weil SQLite im WAL-Modus läuft, sollte im laufenden
Betrieb nicht einfach kopiert werden — `scripts/backup.sh` sichert beides
mit `sqlite3 .backup`, in einem zeitgestempelten Ordner je Lauf:
```bash ```bash
sqlite3 /pfad/munin.db ".backup '/sicherung/wissen-$(date +%F).db'" DATA_DIR=/pfad/data BACKUP_DIR=/pfad/sicherungen scripts/backup.sh
``` ```
Für Cron, täglich um 3 Uhr mit 14 Tagen Aufbewahrung:
```cron
0 3 * * * DATA_DIR=/srv/munin/data BACKUP_DIR=/srv/backups RETENTION_DAYS=14 \
/srv/munin/scripts/backup.sh >> /var/log/munin-backup.log 2>&1
```
Da jede Abteilung ihre eigene Datei hat, lässt sich eine einzelne davon auch
gezielt sichern oder wiederherstellen, ohne die übrigen anzufassen: Server
stoppen, die gewünschte `.db`-Datei aus einem Sicherungsordner zurückkopieren,
Server wieder starten.
**Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand **Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand
täglich als Markdown-Dateien dorthin — ein Ordner je Projekt, eine Datei je aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace,
Eintrag, dazu eine `_index.md` als Übersicht: darin ein Ordner je Projekt, eine Datei je Eintrag und eine `_index.md` als
Übersicht:
``` ```
export/ export/
mfk3x9a2-1a2b3c4d/ ← Workspace-ID
_workspace.md ← Name des Workspace
plattform/ plattform/
_index.md _index.md
deployment.md deployment.md
server-zugang.md server-zugang.md
``` ```
Die Workspace-Ebene ist nötig, weil Projekt-Bezeichner nur innerhalb eines
Workspace eindeutig sind. Der Ordner heißt nach der ID und nicht nach dem Namen,
damit ein Umbenennen keinen neuen Ordner erzeugt. Welcher Workspace dahinter
steckt, steht in `_workspace.md`.
> **Umstieg von älteren Fassungen:** Früher lagen die Projektordner direkt unter
> `EXPORT_DIR/`. Diese alten Ordner werden nicht mehr angefasst und können nach
> einem erfolgreichen Export gelöscht werden.
Jede Datei trägt alle Felder im Kopf (Bezeichner, Schlagworte, Herkunft, Autor, Jede Datei trägt alle Felder im Kopf (Bezeichner, Schlagworte, Herkunft, Autor,
Fassung, Zeitstempel), sodass sich der Bestand daraus auch wieder einlesen ließe. Fassung, Zeitstempel), sodass sich der Bestand daraus auch wieder einlesen ließe.
@ -416,10 +493,16 @@ keine Datei an und erzeugt deshalb auch keinen Diff.
```bash ```bash
# Auf Knopfdruck: "Jetzt exportieren" unter /admin/projects # Auf Knopfdruck: "Jetzt exportieren" unter /admin/projects
# Für Cron oder ein Sicherungsskript: # (exportiert nur den gerade gewählten Workspace)
# Für Cron oder ein Sicherungsskript, alle Workspaces:
./munin -export ./munin -export
``` ```
Scheitert der Export eines Workspace, laufen die übrigen trotzdem durch.
`./munin -export` endet dann aber mit einem Fehlercode, damit ein Cronjob die
Sicherung nicht für vollständig hält.
Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien Der Export ist eine Sicherung, keine zweite Wahrheit — Änderungen an den Dateien
wirken nicht zurück und gehen beim nächsten Lauf verloren. wirken nicht zurück und gehen beim nächsten Lauf verloren.
@ -430,6 +513,15 @@ Vorgänge werden 90 Tage aufbewahrt und danach automatisch gelöscht
aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die
Voraussetzung dafür, es überhaupt führen zu dürfen. Voraussetzung dafür, es überhaupt führen zu dürfen.
Jeder Workspace führt sein eigenes Protokoll in seiner eigenen Datei.
`/admin/audit` zeigt das Protokoll des gerade gewählten Workspace, nicht alle
auf einmal. Das tägliche Aufräumen läuft über alle Workspaces, auch über die,
die erst nach dem Serverstart angelegt wurden.
Bei Schreibvorgängen an Einträgen steht in der Spalte *Details*, ob der Eintrag
**angelegt** oder **aktualisiert** wurde. Daran lässt sich ablesen, ob Agenten
vorhandene Einträge fortschreiben oder immer neue anlegen.
--- ---
## Entwicklung ## Entwicklung
@ -447,15 +539,33 @@ gofmt -l . # Formatierung prüfen
main.go Konfiguration, Verdrahtung, Serverstart main.go Konfiguration, Verdrahtung, Serverstart
internal/ internal/
store/ SQLite: Schema, Migrationen, alle Abfragen store/ SQLite: Schema, Migrationen, alle Abfragen
entry/ Fachlogik: Markdown, Verweise, Textausschnitte (Workspace-Dateien und Kontroll-Datenbank)
workspace/ Öffnet Workspace-Dateien bei Bedarf und hält sie offen
entry/ Fachlogik: Verweise, Textausschnitte, Zeilenvergleich
markdown/ Markdown nach HTML — ein Renderer für Einträge und Hilfe
search/ BM25-Volltextindex, einer je Projekt search/ BM25-Volltextindex, einer je Projekt
auth/ Sitzungen, CSRF, Cookies, Keys, Rate-Limiting auth/ Sitzungen, CSRF, Cookies, Keys, Rate-Limiting
export/ Markdown-Export als Sicherung export/ Markdown-Export als Sicherung
web/ Routen, Middleware, Handler web/ Routen, Middleware, Handler
mcpserver/ MCP-Werkzeuge mcpserver/ MCP-Werkzeuge
hilfe/ Eingebaute Hilfeseiten (Markdown in seiten/)
templates/ static/ eingebettet ins Binary templates/ static/ eingebettet ins Binary
scripts/backup.sh Sicherung aller Datenbankdateien
``` ```
Eine Übersicht über jede einzelne Datei steht in [REPOMAP.md](REPOMAP.md).
**Hilfeseiten pflegen:** Die Hilfe unter `/hilfe` sind Markdown-Dateien in
`internal/hilfe/seiten/`, die mit ins Binary gebaut werden. Ändert sich eine
Funktion, gehört die Anpassung der Hilfe in denselben Commit. Eine neue Seite
braucht eine Datei *und* einen Eintrag in der Liste `seiten` in
`internal/hilfe/hilfe.go`. Ein Test prüft, dass beides zusammenpasst und dass
alle Links auf `/hilfe/…` zu existierenden Seiten führen. Seiten mit
`NurAdmin: true` sieht nur der Plattform-Admin; allgemeine Seiten dürfen
nicht auf sie verlinken (auch das prüft ein Test). In den Seiten
werden `{{SERVER}}` und `{{PROJEKT}}` beim Anzeigen durch die Adresse des
Servers und den Bezeichner des eigenen Projekts ersetzt.
Die Richtung der Abhängigkeiten zeigt immer nach innen: `web` und `mcpserver` Die Richtung der Abhängigkeiten zeigt immer nach innen: `web` und `mcpserver`
kennen `store`, aber `store` kennt keinen von beiden. kennen `store`, aber `store` kennt keinen von beiden.
@ -470,9 +580,17 @@ Datumsfunktionen, keine `INSERT OR REPLACE`), damit ein Umstieg auf PostgreSQL
offenbleibt. Die Regeln stehen als Kommentar in offenbleibt. Die Regeln stehen als Kommentar in
`internal/store/migrations/001_init.sql`. `internal/store/migrations/001_init.sql`.
**Schema ändern:** eine neue Datei `003_….sql` in `migrations/` anlegen. Sie wird Es gibt zwei Schemata mit je eigenem Migrationsordner:
beim nächsten Start automatisch angewandt und vermerkt; bestehende Migrationen
werden nie verändert. | Ordner | Gilt für |
|---|---|
| `internal/store/migrations/` | jede Workspace-Datei (Projekte, Einträge, Keys, Protokoll) |
| `internal/store/migrations_control/` | die Kontroll-Datenbank `control.db` (Workspaces, Zuordnung Key → Workspace) |
**Schema ändern:** im passenden Ordner eine neue Datei mit der nächsten Nummer
anlegen, für Workspace-Dateien derzeit `005_….sql`. Sie wird automatisch
angewandt und vermerkt: für die Kontroll-Datenbank beim Start, für eine
Workspace-Datei beim ersten Öffnen. Bestehende Migrationen werden nie verändert.
--- ---

View file

@ -6,11 +6,15 @@
munin/ munin/
├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die ├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die
│ Pflichtangaben (SESSION_SECRET, ADMIN_TOKEN inkl. │ Pflichtangaben (SESSION_SECRET, ADMIN_TOKEN inkl.
│ Mindestlänge), öffnet die Datenbank, setzt die │ Mindestlänge), öffnet Kontroll-Datenbank und
│ Workspace-Verwaltung (legt beim ersten Start den
│ Workspace "Intern" an), setzt die
│ Bestandteile zusammen und startet den Server. │ Bestandteile zusammen 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ägliche Aufräumaufgabe fürs Protokoll. │ täglichen Aufgaben (Protokoll aufräumen,
│ Export), die über fuerJedenWorkspace alle
│ Workspaces erreichen. Tests: main_test.go.
│ │
├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten. ├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten.
│ │
@ -37,11 +41,18 @@ munin/
├── internal/ ├── internal/
│ │ │ │
│ ├── store/ ── Die einzige Stelle, die SQL kennt ────────────── │ ├── store/ ── Die einzige Stelle, die SQL kennt ──────────────
│ │ ├── store.go ← Öffnet die Datenbank, setzt die nötigen PRAGMAs │ │ ├── store.go ← Öffnet eine Workspace-Datei (Open), setzt die
│ │ │ (WAL, busy_timeout, foreign_keys) und wendet die │ │ │ nötigen PRAGMAs (WAL, busy_timeout, foreign_keys)
│ │ │ Migrationen an. Erklärt auch, warum der Treiber │ │ │ und wendet die Migrationen an. Erklärt auch, warum
│ │ │ modernc.org/sqlite ist: Er ist reines Go und │ │ │ der Treiber modernc.org/sqlite ist: Er ist reines
│ │ │ erlaubt damit ein statisches Binary. │ │ │ Go und erlaubt damit ein statisches Binary.
│ │ │
│ │ ├── control.go ← Die zentrale Kontroll-Datenbank (OpenControl):
│ │ │ Workspaces anlegen, umbenennen, löschen, dazu die
│ │ │ Zuordnung Key-Prüfsumme → Workspace
│ │ │ (ResolveKeyWorkspace). Das ist der erste Schritt
│ │ │ jeder Anmeldung: Erst danach ist klar, welche
│ │ │ Workspace-Datei den Key kennt.
│ │ │ │ │ │
│ │ ├── models.go ← Die Datentypen (Project, APIKey, Entry, …), die │ │ ├── models.go ← Die Datentypen (Project, APIKey, Entry, …), die
│ │ │ Fehlerwerte (ErrNotFound, ErrConflict, │ │ │ Fehlerwerte (ErrNotFound, ErrConflict,
@ -50,8 +61,8 @@ munin/
│ │ │ AUTOINCREMENT — das hält den Umstieg auf eine │ │ │ AUTOINCREMENT — das hält den Umstieg auf eine
│ │ │ andere Datenbank offen. │ │ │ andere Datenbank offen.
│ │ │ │ │ │
│ │ ├── projects.go ← Workspace und Projekte: anlegen, lesen, ändern, │ │ ├── projects.go ← Projekte: anlegen, lesen, ändern, löschen.
│ │ │ löschen. Enthält ValidSlug und Slugify sowie │ │ │ Enthält ValidSlug und Slugify sowie
│ │ │ SetProjectWriteLimit für die eigene Obergrenze │ │ │ SetProjectWriteLimit für die eigene Obergrenze
│ │ │ eines Projekts. │ │ │ eines Projekts.
│ │ │ │ │ │
@ -66,7 +77,37 @@ munin/
│ │ │ Mechanismus gegen Dubletten. Enthält das │ │ │ Mechanismus gegen Dubletten. Enthält das
│ │ │ optimistische Sperren (ExpectedVersion) und das │ │ │ optimistische Sperren (ExpectedVersion) und das
│ │ │ Nachladen der Schlagworte in einer Abfrage statt │ │ │ Nachladen der Schlagworte in einer Abfrage statt
│ │ │ einer pro Eintrag. │ │ │ einer pro Eintrag. Außerdem:
│ │ │ · snapshotEntryVersion sichert vor jeder Änderung
│ │ │ die bisherige Fassung, in derselben Transaktion;
│ │ │ EntryVersions/EntryVersionByNumber lesen sie
│ │ │ · BulkUpdateTags ändert Schlagworte vieler
│ │ │ Einträge auf einmal und überspringt IDs aus
│ │ │ fremden Projekten
│ │ │ · LinkGraph liefert Knoten und Kanten für die
│ │ │ Graph-Ansicht
│ │ │ Jede lesende Abfrage trägt nichtImPapierkorb
│ │ │ (deleted_at IS NULL) — außer PutEntry, das
│ │ │ einen Eintrag im Papierkorb bewusst sieht und
│ │ │ ihn beim Überschreiben zurückholt.
│ │ │
│ │ ├── nutzung.go ← Kennzahlen der Nutzungsübersicht: UsageSummaries
│ │ │ (Schreibvorgänge der letzten 30 Tage aus dem
│ │ │ Protokoll, Bestand aus entries — für ein oder
│ │ │ alle Projekte in zwei Abfragen), dazu
│ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys.
│ │ │ Keine eigene Zähltabelle.
│ │ │
│ │ ├── papierkorb.go ← Papierkorb: MoveEntryToTrash (statt DELETE),
│ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash
│ │ │ (nur was schon im Papierkorb liegt) und
│ │ │ PurgeTrash für die tägliche Aufgabe. Die Frist
│ │ │ TrashRetention (30 Tage) steht hier, damit
│ │ │ Oberfläche und Aufräumen dieselbe Zahl nutzen.
│ │ │
│ │ ├── related.go ← RelatedEntries: Nachbarn im Verweisgraphen, in
│ │ │ beide Richtungen, bis zu zwei Schritte weit
│ │ │ (Breitensuche, eine Abfrage je Richtung und Ebene).
│ │ │ │ │ │
│ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie │ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie
│ │ │ beim Rendern. Das ersetzt die frühere │ │ │ beim Rendern. Das ersetzt die frühere
@ -75,9 +116,15 @@ munin/
│ │ ├── audit.go ← Protokoll: schreiben, lesen, aufräumen. Die │ │ ├── audit.go ← Protokoll: schreiben, lesen, aufräumen. Die
│ │ │ Aufbewahrungsfrist ist nicht nur eine Frage des │ │ │ Aufbewahrungsfrist ist nicht nur eine Frage des
│ │ │ Plattenplatzes — das Protokoll enthält Adressen │ │ │ Plattenplatzes — das Protokoll enthält Adressen
│ │ │ und Zugangsnamen. │ │ │ und Zugangsnamen. EntryWriteDetail vermerkt bei
│ │ │ Schreibvorgängen "angelegt" oder "aktualisiert".
│ │ │ │ │ │
│ │ └── migrations/ │ │ ├── migrations_control/
│ │ │ └── 001_init.sql ← Schema der Kontroll-Datenbank: workspaces und
│ │ │ key_index (Key-Prüfsumme → Workspace). Bewusst
│ │ │ dünn — Inhalte liegen nie hier.
│ │ │
│ │ └── migrations/ ← Schema jeder Workspace-Datei
│ │ ├── 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.
@ -89,11 +136,26 @@ 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_schreibbudget_je_projekt.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
│ │ ← Tabelle entry_versions für abgelöste Fassungen.
│ │ Wird mit dem Eintrag gelöscht (ON DELETE CASCADE).
│ │ └── 005_papierkorb.sql
│ │ ← deleted_at/deleted_by an entries und ein
│ │ Teilindex nur über den Papierkorb. Löschen setzt
│ │ nur noch die Markierung; CASCADE greift erst
│ │ beim endgültigen Löschen.
│ │
│ ├── workspace/ ── Welche Datei gehört zu welcher Abteilung? ──────
│ │ └── workspace.go ← Manager öffnet Workspace-Dateien bei Bedarf und
│ │ hält sie offen, jeweils mit eigenem Suchindex.
│ │ Datenbank und Index gehören zusammen, damit kein
│ │ Index versehentlich an der falschen Datei hängt.
│ │ Delete schließt eine Datei und entfernt sie.
│ │ │ │
│ ├── auth/ ── Wer ist das? ──────────────────────────────────── │ ├── auth/ ── Wer ist das? ────────────────────────────────────
│ │ ├── doc.go ← Erklärt die beiden Wege hinein (API-Key im Header, │ │ ├── doc.go ← Erklärt die beiden Wege hinein (API-Key im Header,
@ -132,19 +194,51 @@ munin/
│ │ Header ignoriert — er ist frei fälschbar. │ │ Header ignoriert — er ist frei fälschbar.
│ │ │ │
│ ├── entry/ ── Wie sieht ein Eintrag aus? ───────────────────── │ ├── entry/ ── Wie sieht ein Eintrag aus? ─────────────────────
│ │ └── entry.go ← Markdown nach HTML (goldmark, ohne Roh-HTML), │ │ ├── entry.go ← Markdown nach HTML (über internal/markdown),
│ │ Auflösen der [[…]]-Verweise, Textausschnitte für │ │ │ Auflösen der [[…]]-Verweise, Textausschnitte für
│ │ Trefferlisten samt sicherer Hervorhebung. │ │ │ Trefferlisten samt sicherer Hervorhebung.
│ │ │
│ │ └── diff.go ← Zeilenvergleich zweier Fassungen (DiffLines) als
│ │ LCS ohne externe Abhängigkeit. Bei sehr langen
│ │ Einträgen weicht er auf einen groben Vergleich
│ │ aus (grobDiff), damit Speicher und Rechenzeit
│ │ begrenzt bleiben.
│ │
│ ├── markdown/ ── Markdown nach HTML, eine Regel für alles ───────
│ │ └── markdown.go ← Der gemeinsame goldmark-Renderer für Einträge und
│ │ Hilfe: GFM (Tabellen, Checklisten,
│ │ Durchstreichen, automatische Links), Roh-HTML
│ │ gesperrt. Nur die Hilfe bekommt Überschrift-
│ │ Anker (RenderMitAnkern) — mit gefalteten
│ │ Umlauten über store.Slugify, damit die
│ │ "?"-Links der Oberfläche darauf zeigen können.
│ │ │ │
│ ├── export/ ── Sicherung als Markdown ───────────────────────── │ ├── export/ ── Sicherung als Markdown ─────────────────────────
│ │ └── export.go ← Schreibt jedes Projekt als Ordner voller │ │ └── export.go ← Schreibt jeden Workspace als Ordner
│ │ (<workspace-id>/, mit _workspace.md) und darin
│ │ jedes Projekt als Ordner voller
│ │ Markdown-Dateien, mit allen Feldern im Dateikopf. │ │ Markdown-Dateien, mit allen Feldern im Dateikopf.
│ │ Kennt selbst keine Datenbank: Der Aufrufer gibt
│ │ die Workspace-Datei je Lauf mit.
│ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne │ │ Gedacht als Git-fähige Sicherung: Ein Lauf ohne
│ │ inhaltliche Änderungen fasst keine Datei an und │ │ inhaltliche Änderungen fasst keine Datei an und
│ │ erzeugt deshalb auch keinen Diff. Verwaiste │ │ erzeugt deshalb auch keinen Diff. Verwaiste
│ │ Dateien gelöschter Einträge werden entfernt, │ │ Dateien gelöschter Einträge werden entfernt,
│ │ fremde Dateien im Zielordner bleiben unberührt. │ │ fremde Dateien im Zielordner bleiben unberührt.
│ │ │ │
│ ├── importer/ ── Gegenstück zum Export ──────────────────────────
│ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit
│ │ │ Grenzen für Anzahl und ausgepackte Größe.
│ │ │ Übergeht _index.md, versteckte Ordner, __MACOSX.
│ │ ├── parse.go ← Kleiner Frontmatter-Leser (kein YAML-Paket):
│ │ │ Munin-Export und fremde Köpfe (Hugo, Obsidian).
│ │ │ Ohne Kopf: Bezeichner aus dem Dateinamen, Titel
│ │ │ aus der ersten Überschrift.
│ │ └── importer.go ← Importiere: Vorschau oder Schreiben, bei
│ │ belegtem Bezeichner überspringen oder
│ │ überschreiben (alter Stand in den Verlauf).
│ │ Erkennt Unverändertes und den Papierkorb.
│ │
│ ├── search/ ── Volltextsuche ────────────────────────────────── │ ├── search/ ── Volltextsuche ──────────────────────────────────
│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen │ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen
│ │ │ dreifach, Schlagworte zweifach. Enthält die │ │ │ dreifach, Schlagworte zweifach. Enthält die
@ -157,6 +251,15 @@ munin/
│ │ die Abschottung strukturell, und die BM25-Werte │ │ die Abschottung strukturell, und die BM25-Werte
│ │ werden nicht über Projektgrenzen hinweg verzerrt. │ │ werden nicht über Projektgrenzen hinweg verzerrt.
│ │ │ │
│ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ───────────────────
│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel) und
│ │ │ das Rendern mit Überschrift-Ankern. Erklärt,
│ │ │ warum die Hilfe im Binary steckt statt als
│ │ │ Einträge in der Datenbank: Sie muss zur
│ │ │ laufenden Fassung des Programms passen.
│ │ └── seiten/*.md ← Die Inhalte, je Seite eine Datei. {{SERVER}} und
│ │ {{PROJEKT}} werden beim Anzeigen ersetzt.
│ │
│ ├── web/ ── HTTP: Browser und API ────────────────────────── │ ├── web/ ── HTTP: Browser und API ──────────────────────────
│ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die │ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die
│ │ │ Ablage im Request-Context. CanAccessProject ist │ │ │ Ablage im Request-Context. CanAccessProject ist
@ -164,6 +267,10 @@ munin/
│ │ │ │ │ │
│ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"), │ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"),
│ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung. │ │ │ CSRF, Projektauflösung aus der URL, Rollenprüfung.
│ │ │ Die Sitzung trägt die Workspace-ID signiert mit
│ │ │ (sessionFromSubject); requireWorkspace hält einen
│ │ │ Plattform-Admin ohne gewählten Workspace von den
│ │ │ Projektseiten fern.
│ │ │ Dazu die allgemeinen Schutzschichten │ │ │ Dazu die allgemeinen Schutzschichten
│ │ │ SecurityHeaders und LimitBody sowie APIAuth für │ │ │ SecurityHeaders und LimitBody sowie APIAuth für
│ │ │ den MCP-Endpunkt. │ │ │ den MCP-Endpunkt.
@ -173,9 +280,34 @@ munin/
│ │ │ sonst gäbe es bei einem Fehler eine halbe Seite │ │ │ sonst gäbe es bei einem Fehler eine halbe Seite
│ │ │ mit Status 200). │ │ │ mit Status 200).
│ │ │ │ │ │
│ │ ├── handlers_auth.go ← Anmelden, Abmelden, Startseite. │ │ ├── handlers_auth.go ← Anmelden, Abmelden, Startseite. Der
│ │ │ Plattform-Admin ohne gewählten Workspace landet
│ │ │ auf /admin/workspaces.
│ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten. │ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten.
│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht. Die │ │ │ Dazu Vorschau (preview), Vorschläge für [[…]]
│ │ │ (suggest), Fassungen und Vergleich, Graph-Daten
│ │ │ und Schlagworte für viele Einträge (bulk-tags).
│ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage),
│ │ │ für jede Rolle. Die Admin-Projektliste nutzt
│ │ │ dieselben Zahlen (projectListItems).
│ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import),
│ │ │ nur mit Rolle admin. Läuft im Server, damit der
│ │ │ Suchindex im selben Zug nachgezogen wird.
│ │ ├── handlers_papierkorb.go
│ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen,
│ │ │ endgültig löschen, Filter nach löschendem Zugang.
│ │ │ Alles nur mit Schreibrecht, und bewusst nur hier
│ │ │ — über MCP gibt es kein Wiederherstellen.
│ │ ├── handlers_hilfe.go ← Zeigt die eingebauten Hilfeseiten unter /hilfe
│ │ │ für jede Sitzung. Setzt vorher Server-Adresse
│ │ │ und Projekt-Bezeichner in die Beispiele ein
│ │ │ (serverURL, mit geprüftem Host-Header).
│ │ ├── handlers_workspaces.go
│ │ │ ← Workspaces auflisten, anlegen, wählen, umbenennen,
│ │ │ löschen (nur Plattform-Admin). Löscht jemand den
│ │ │ Workspace der eigenen Sitzung, wird die Auswahl
│ │ │ zurückgesetzt, statt ihn auszusperren.
│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht, Export. Die
│ │ Bearbeitungsseite je Projekt (Name, │ │ Bearbeitungsseite je Projekt (Name,
│ │ Beschreibung, Schreibbudget, Löschen) liegt │ │ Beschreibung, Schreibbudget, Löschen) liegt
│ │ hier — bewusst eine Seite je Projekt statt │ │ hier — bewusst eine Seite je Projekt statt
@ -190,9 +322,11 @@ 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.
│ │ │ │
│ └── tools.go ← Die fünf Werkzeuge: recall, get, list, remember, │ └── tools.go ← Die sechs Werkzeuge: recall, get, list, related,
│ forget. Jedes ist eine Closure über genau ein │ remember, forget. Alte Einträge tragen in den
│ Projekt und kann strukturell nicht an ein anderes. │ Antworten "stale": true. Jedes ist eine Closure
│ über genau ein Projekt und kann strukturell
│ nicht an ein anderes.
│ │
├── templates/ ← HTML der Weboberfläche. partials.html enthält die ├── templates/ ← HTML der Weboberfläche. partials.html enthält die
│ gemeinsamen Blöcke "kopf" und "fuss", die jede │ gemeinsamen Blöcke "kopf" und "fuss", die jede
@ -201,13 +335,33 @@ munin/
├── static/ ├── static/
│ ├── style.css ← Das gesamte Aussehen, ohne Framework. Farben als │ ├── style.css ← Das gesamte Aussehen, ohne Framework. Farben als
│ │ Variablen ganz oben, helle und dunkle Darstellung. │ │ Variablen ganz oben, helle und dunkle Darstellung.
│ └── confirm.js ← Bestätigungsabfrage vor dem Löschen. Liest das │ ├── confirm.js ← Bestätigungsabfrage vor dem Löschen. Liest das
│ Attribut data-confirm, statt onclick="" im HTML zu │ │ Attribut data-confirm, statt onclick="" im HTML zu
│ verwenden — Inline-JavaScript verbietet die │ │ verwenden — Inline-JavaScript verbietet die
│ Content-Security-Policy. │ │ Content-Security-Policy.
│ ├── preview.js ← Reiter "Schreiben"/"Vorschau" im Editor. Holt das
│ │ HTML vom Server, statt Markdown im Browser zu
│ │ rendern. Ohne JavaScript bleibt das Formular
│ │ unverändert nutzbar.
│ ├── autocomplete.js ← Vorschläge nach "[[" im Textfeld, per Tastatur
│ │ und Maus bedienbar.
│ ├── graph.js ← Selbst geschriebene Kräftesimulation und
│ │ SVG-Darstellung für die Graph-Ansicht. Texte nur
│ │ über textContent, nie innerHTML.
│ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne
│ Hintergrund). Die Anmeldeseite wählt je nach
│ Farbschema die passende.
│ │
└── data/ ← Datenbank (nicht im Repository, als Volume gemountet) ├── scripts/
└── munin.db │ └── backup.sh ← Sichert control.db und jede Workspace-Datei
│ einzeln über sqlite3 .backup in einen
│ zeitgestempelten Ordner; RETENTION_DAYS räumt
│ alte Sicherungen auf.
│
└── data/ ← Daten (nicht im Repository, als Volume gemountet)
├── control.db ← Kontroll-Datenbank: Workspaces, Key-Zuordnung
└── workspaces/
└── <workspace-id>.db ← Eine Datei je Workspace
``` ```
## Tests ## Tests
@ -220,19 +374,32 @@ werden.
| 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/store_test.go` | Migrationen, Slug-Prüfung, Abschottung, Dublettenvermeidung, optimistisches Sperren, Verweise, Protokoll | | `internal/store/nutzung_test.go` | Zählung nach Art und Weg samt Zeitfenster, Bestand ohne Papierkorb, Verweise ins Leere, aktive Zugänge |
| `internal/store/papierkorb_test.go` | **Jeder lesende Weg blendet den Papierkorb aus** (Einzelabruf, Liste mit Filtern, Suchindex/Export, Zählen, Schlagworte, Rückverweise, Graph, Sammelbearbeitung); Wiederherstellen, Zurückholen durch Überschreiben, Konflikt, endgültiges Löschen, Frist |
| `internal/store/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/workspace/workspace_test.go` | Manager hält Dateien offen, trennt Workspaces, schließt sauber |
| `internal/entry/entry_test.go` | HTML-Escaping, Verweisauflösung, Textausschnitte, sichere Hervorhebung | | `internal/entry/entry_test.go` | HTML-Escaping, Verweisauflösung, Textausschnitte, sichere Hervorhebung |
| `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe |
| `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen |
| `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen | | `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen |
| `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**, 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/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, Rollen, Header | | `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 |
| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** | | `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** |
| `internal/hilfe/hilfe_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
``` ```
KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall", … } } KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall", … } }
→ web/middleware.go (SecurityHeaders, LimitBody) → web/middleware.go (SecurityHeaders, LimitBody)
→ web/middleware.go (APIAuth) Key → Prüfsumme → Lookup → Projekt + Rolle → web/middleware.go (APIAuth) Key → Prüfsumme
→ store/control.go (ResolveKeyWorkspace) → Workspace
→ workspace/workspace.go (Get) → dessen Datei und Suchindex
→ store/keys.go (ResolveKeyHash) → Projekt + Rolle
in den Context; Fehlversuche in die Sperre in den Context; Fehlversuche in die Sperre
→ mcpserver/server.go (ServeHTTP) Projekt aus dem Context → dessen MCP-Server → mcpserver/server.go (ServeHTTP) Projekt aus dem Context → dessen MCP-Server
→ mcpserver/tools.go (recall) → mcpserver/tools.go (recall)

View file

@ -67,7 +67,7 @@ func (p CookiePolicy) Secure(r *http.Request) bool {
if p.explicit != nil { if p.explicit != nil {
return *p.explicit return *p.explicit
} }
return !isLocalhost(r) return !IsLocalhost(r)
} }
// Apply setzt Pfad und alle Sicherheits-Flags eines Cookies. // Apply setzt Pfad und alle Sicherheits-Flags eines Cookies.
@ -85,13 +85,13 @@ func (p CookiePolicy) Apply(c *http.Cookie, r *http.Request) {
c.SameSite = http.SameSiteLaxMode c.SameSite = http.SameSiteLaxMode
} }
// isLocalhost prüft, ob der Request an localhost gerichtet war. // IsLocalhost prüft, ob der Request an localhost gerichtet war.
// //
// Ausgewertet wird r.Host (die vom Client angefragte Adresse), nicht r.RemoteAddr: // Ausgewertet wird r.Host (die vom Client angefragte Adresse), nicht r.RemoteAddr:
// Wer über http://localhost:8080 entwickelt, soll das lockere Verhalten bekommen; // Wer über http://localhost:8080 entwickelt, soll das lockere Verhalten bekommen;
// wer über eine echte Domain zugreift, das strenge — auch wenn der Request // wer über eine echte Domain zugreift, das strenge — auch wenn der Request
// zufällig von derselben Maschine kommt. // zufällig von derselben Maschine kommt.
func isLocalhost(r *http.Request) bool { func IsLocalhost(r *http.Request) bool {
host := r.Host host := r.Host
// Den Port abtrennen, falls einer dranhängt ("localhost:8080" → "localhost"). // Den Port abtrennen, falls einer dranhängt ("localhost:8080" → "localhost").

117
internal/entry/diff.go Normal file
View file

@ -0,0 +1,117 @@
package entry
import "strings"
// DiffOp sagt, was mit einer Zeile im Vergleich zweier Fassungen passiert ist.
type DiffOp int
const (
DiffEqual DiffOp = iota
DiffRemove
DiffAdd
)
// DiffLine ist eine Zeile im Ergebnis von DiffLines.
type DiffLine struct {
Op DiffOp
Text string
}
// maxDiffCells begrenzt die Größe der Vergleichstabelle (Zeilen_alt ×
// Zeilen_neu). Jenseits davon bräuchte eine genaue Zeilenzuordnung mehr
// Speicher, als für eine einzelne Vergleichsseite sinnvoll ist — dann liefert
// DiffLines nur noch "alles Alte weg, alles Neue dazu" statt einer
// Zeile-für-Zeile-Zuordnung. Bei zwei mittelgroßen Wiki-Einträgen (je ein paar
// hundert Zeilen) wird diese Grenze nicht erreicht.
const maxDiffCells = 4_000_000
// DiffLines vergleicht zwei Fassungen eines Eintrags zeilenweise.
//
// Klassischer Ansatz über die längste gemeinsame Teilfolge (LCS): Zeilen, die
// in beiden Fassungen in derselben Reihenfolge vorkommen, gelten als
// unverändert; alles dazwischen als entfernt bzw. hinzugefügt. Ein
// verschobener, aber unveränderter Absatz wird dabei nicht zwingend als
// "gleich" erkannt, wenn sich in seiner Umgebung etwas geändert hat — für die
// Frage "was hat sich bei diesem Schreibvorgang geändert" reicht das.
func DiffLines(alt, neu string) []DiffLine {
altZeilen := splitLines(alt)
neuZeilen := splitLines(neu)
if len(altZeilen)*len(neuZeilen) > maxDiffCells {
return grobDiff(altZeilen, neuZeilen)
}
return lcsDiff(altZeilen, neuZeilen)
}
func splitLines(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
// grobDiff dient als Rückfallebene für außergewöhnlich lange Einträge: keine
// Zeilenzuordnung, nur der komplette alte Text als entfernt und der
// komplette neue als hinzugefügt.
func grobDiff(altZeilen, neuZeilen []string) []DiffLine {
lines := make([]DiffLine, 0, len(altZeilen)+len(neuZeilen))
for _, z := range altZeilen {
lines = append(lines, DiffLine{Op: DiffRemove, Text: z})
}
for _, z := range neuZeilen {
lines = append(lines, DiffLine{Op: DiffAdd, Text: z})
}
return lines
}
// lcsDiff berechnet die Zeilenzuordnung über eine klassische LCS-Tabelle.
//
// dp[i][j] ist die Länge der längsten gemeinsamen Teilfolge von a[i:] und
// b[j:], rückwärts aufgebaut. Der anschließende Vorwärtslauf liest daraus die
// eigentliche Zuordnung ab: Stimmen die Zeilen überein, sind sie Teil der
// gemeinsamen Folge; sonst wird der Seite gefolgt, die die längere
// Teilfolge verspricht.
func lcsDiff(a, b []string) []DiffLine {
n, m := len(a), len(b)
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
switch {
case a[i] == b[j]:
dp[i][j] = dp[i+1][j+1] + 1
case dp[i+1][j] >= dp[i][j+1]:
dp[i][j] = dp[i+1][j]
default:
dp[i][j] = dp[i][j+1]
}
}
}
var lines []DiffLine
i, j := 0, 0
for i < n && j < m {
switch {
case a[i] == b[j]:
lines = append(lines, DiffLine{Op: DiffEqual, Text: a[i]})
i++
j++
case dp[i+1][j] >= dp[i][j+1]:
lines = append(lines, DiffLine{Op: DiffRemove, Text: a[i]})
i++
default:
lines = append(lines, DiffLine{Op: DiffAdd, Text: b[j]})
j++
}
}
for ; i < n; i++ {
lines = append(lines, DiffLine{Op: DiffRemove, Text: a[i]})
}
for ; j < m; j++ {
lines = append(lines, DiffLine{Op: DiffAdd, Text: b[j]})
}
return lines
}

View file

@ -0,0 +1,73 @@
package entry
import "testing"
func formatDiff(lines []DiffLine) string {
out := ""
for _, l := range lines {
switch l.Op {
case DiffEqual:
out += " " + l.Text + "\n"
case DiffRemove:
out += "- " + l.Text + "\n"
case DiffAdd:
out += "+ " + l.Text + "\n"
}
}
return out
}
func TestDiffLinesIdentical(t *testing.T) {
lines := DiffLines("a\nb\nc", "a\nb\nc")
for _, l := range lines {
if l.Op != DiffEqual {
t.Errorf("Op = %v bei identischem Text, erwartet DiffEqual", l.Op)
}
}
if len(lines) != 3 {
t.Errorf("%d Zeilen, erwartet 3", len(lines))
}
}
func TestDiffLinesChangeInMiddle(t *testing.T) {
alt := "Zeile 1\nZeile 2\nZeile 3"
neu := "Zeile 1\nGeändert\nZeile 3"
got := formatDiff(DiffLines(alt, neu))
erwartet := " Zeile 1\n- Zeile 2\n+ Geändert\n Zeile 3\n"
if got != erwartet {
t.Errorf("DiffLines =\n%s\nerwartet:\n%s", got, erwartet)
}
}
func TestDiffLinesPureAddition(t *testing.T) {
lines := DiffLines("a\nb", "a\nb\nc")
if len(lines) != 3 || lines[2].Op != DiffAdd || lines[2].Text != "c" {
t.Errorf("DiffLines = %+v, erwartet [gleich gleich +c]", lines)
}
}
func TestDiffLinesEmptyOldContent(t *testing.T) {
lines := DiffLines("", "neu")
if len(lines) != 1 || lines[0].Op != DiffAdd || lines[0].Text != "neu" {
t.Errorf("DiffLines(\"\", \"neu\") = %+v, erwartet [+neu]", lines)
}
}
func TestDiffLinesFallsBackForHugeInput(t *testing.T) {
// Erzwingt den grobDiff-Pfad ohne wirklich riesige Strings zu bauen: die
// Grenze fällt auf Zeilenanzahl, nicht Textlänge.
altGross := make([]byte, 0, 2200*2)
neuGross := make([]byte, 0, 2200*2)
for i := 0; i < 2200; i++ {
altGross = append(altGross, 'a', '\n')
neuGross = append(neuGross, 'b', '\n')
}
lines := DiffLines(string(altGross), string(neuGross))
for _, l := range lines {
if l.Op == DiffEqual {
t.Fatal("grobDiff sollte keine DiffEqual-Zeilen liefern")
}
}
}

View file

@ -8,39 +8,34 @@
package entry package entry
import ( import (
"bytes"
"fmt" "fmt"
"html" "html"
"strings" "strings"
"github.com/yuin/goldmark" "munin/internal/markdown"
"munin/internal/store" "munin/internal/store"
) )
// RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML. // RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML.
// //
// # Sicherheitshinweis // Das eigentliche Umwandeln übernimmt internal/markdown — derselbe Renderer
// // wie für die Hilfe, damit dort beschriebenes Markdown (Tabellen, Checklisten,
// goldmark lässt in der Standardeinstellung eingebettetes Roh-HTML NICHT durch — // Durchstreichen) im Eintrag genauso aussieht. Dort steht auch der
// aus <script>alert(1)</script> im Markdown wird ein Kommentar, kein Skript. // Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf
// Genau darauf verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne // verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres
// weiteres Escaping einsetzt. // Escaping einsetzt.
// func RenderHTML(quelle string, linkBase string) (string, error) {
// Diese Einstellung darf deshalb nie auf "unsafe" gestellt werden. Als zweite
// Verteidigungslinie steht die Content-Security-Policy (siehe internal/web).
func RenderHTML(markdown 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.
vorbereitet := store.ReplaceLinks(markdown, func(slug string) string { //
// (Der Parameter heißt "quelle" statt "markdown", weil "markdown" jetzt der
// Name des importierten Pakets ist — sonst wäre das Paket in dieser
// Funktion verdeckt.)
vorbereitet := store.ReplaceLinks(quelle, func(slug string) string {
return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug) return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug)
}) })
var puffer bytes.Buffer return markdown.Render(vorbereitet)
if err := goldmark.Convert([]byte(vorbereitet), &puffer); err != nil {
return "", fmt.Errorf("Inhalt konnte nicht dargestellt werden: %w", err)
}
return puffer.String(), nil
} }
// 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

View file

@ -38,14 +38,35 @@ import (
) )
// Exporter schreibt Projekte in ein Zielverzeichnis. // Exporter schreibt Projekte in ein Zielverzeichnis.
//
// # Aufbau des Zielverzeichnisses
//
// <root>/
// <workspace-id>/
// _workspace.md Name des Workspace, damit man den Ordner zuordnen kann
// <projekt>/
// _index.md
// <eintrag>.md
//
// Die Ebene je Workspace ist nötig, weil Projekt-Bezeichner nur innerhalb eines
// Workspace eindeutig sind. Ohne sie schrieben zwei Abteilungen mit einem
// Projekt "plattform" in denselben Ordner — und das Aufräumen verwaister
// Dateien löschte bei jedem Lauf die Dateien der jeweils anderen.
//
// Benannt wird der Ordner nach der ID, nicht nach dem Namen: Ein Workspace
// lässt sich umbenennen, und dann entstünde ein neuer Ordner neben dem alten.
//
// Der Exporter kennt keine Datenbank. Welche Workspace-Datei gerade exportiert
// wird, gibt der Aufrufer bei jedem Aufruf mit — so kann ein einzelner
// Exporter alle Workspaces bedienen, statt beim Start an einen gebunden zu
// sein.
type Exporter struct { type Exporter struct {
store *store.Store
root string root string
} }
// New erstellt einen Exporter, der unter root schreibt. // New erstellt einen Exporter, der unter root schreibt.
func New(s *store.Store, root string) *Exporter { func New(root string) *Exporter {
return &Exporter{store: s, root: root} return &Exporter{root: root}
} }
// Stats fasst zusammen, was ein Lauf bewirkt hat. // Stats fasst zusammen, was ein Lauf bewirkt hat.
@ -54,14 +75,23 @@ type Stats struct {
Removed int // entfernte Dateien gelöschter Einträge Removed int // entfernte Dateien gelöschter Einträge
} }
// ExportProject schreibt alle Einträge eines Projekts nach <root>/<projekt-slug>/. // ExportProject schreibt alle Einträge eines Projekts nach
// <root>/<workspace-id>/<projekt-slug>/. s muss die Workspace-Datei sein, zu
// der das Projekt gehört.
// //
// Der Ablauf ist bewusst schlicht: alles schreiben, danach aufräumen, was nicht // Der Ablauf ist bewusst schlicht: alles schreiben, danach aufräumen, was nicht
// mehr dazugehört. Ein Abgleich wäre sparsamer, aber deutlich fehleranfälliger — // mehr dazugehört. Ein Abgleich wäre sparsamer, aber deutlich fehleranfälliger —
// und der Export läuft selten genug, dass das nicht ins Gewicht fällt. // und der Export läuft selten genug, dass das nicht ins Gewicht fällt.
func (e *Exporter) ExportProject(ctx context.Context, project store.Project) (Stats, error) { func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project store.Project) (Stats, error) {
var stats Stats var stats Stats
// Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim
// Projekt-Bezeichner direkt darunter. IDs aus store.NewID bestehen nur aus
// Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese Prüfung.
if !store.ValidSlug(project.WorkspaceID) {
return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID)
}
// 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
@ -71,12 +101,12 @@ func (e *Exporter) ExportProject(ctx context.Context, project store.Project) (St
return stats, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug) return stats, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug)
} }
ziel := filepath.Join(e.root, project.Slug) ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug)
if err := os.MkdirAll(ziel, 0750); err != nil { if err := os.MkdirAll(ziel, 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)
} }
eintraege, err := e.store.AllEntries(ctx, project.ID) eintraege, err := s.AllEntries(ctx, project.ID)
if err != nil { if err != nil {
return stats, err return stats, err
} }
@ -114,17 +144,33 @@ func (e *Exporter) ExportProject(ctx context.Context, project store.Project) (St
return stats, nil return stats, nil
} }
// ExportAll exportiert alle Projekte eines Workspace. // ExportWorkspace exportiert alle Projekte eines Workspace. s muss die
func (e *Exporter) ExportAll(ctx context.Context, workspaceID string) (Stats, error) { // Workspace-Datei von ws sein.
func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store.Workspace) (Stats, error) {
var gesamt Stats var gesamt Stats
projekte, err := e.store.ListProjects(ctx, workspaceID) if !store.ValidSlug(ws.ID) {
return gesamt, fmt.Errorf("ungültige Workspace-ID %q", ws.ID)
}
ordner := filepath.Join(e.root, ws.ID)
if err := os.MkdirAll(ordner, 0750); err != nil {
return gesamt, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
}
// Ohne diese Datei wäre der Ordner nur eine ID — niemand wüsste beim
// Wiederherstellen, welche Abteilung darin steckt.
if err := schreibeDatei(filepath.Join(ordner, workspaceDateiname), rendereWorkspace(ws)); err != nil {
return gesamt, err
}
gesamt.Written++
projekte, err := s.ListProjects(ctx, ws.ID)
if err != nil { if err != nil {
return gesamt, err return gesamt, err
} }
for _, p := range projekte { for _, p := range projekte {
stats, err := e.ExportProject(ctx, 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 gesamt, fmt.Errorf("Projekt %q: %w", p.Slug, err)
} }
@ -138,6 +184,21 @@ func (e *Exporter) ExportAll(ctx context.Context, workspaceID string) (Stats, er
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird. // indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
const indexDateiname = "_index.md" const indexDateiname = "_index.md"
// workspaceDateiname nennt den Workspace, zu dem ein ID-Ordner gehört.
const workspaceDateiname = "_workspace.md"
// rendereWorkspace baut den Inhalt von _workspace.md. Wie beim Index ohne
// Zeitstempel des Laufs, damit ein unveränderter Workspace keinen Diff erzeugt.
func rendereWorkspace(ws store.Workspace) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "id", ws.ID)
schreibeFeld(&b, "name", ws.Name)
b.WriteString("---\n\n")
b.WriteString("# Workspace " + ws.Name + "\n")
return b.String()
}
// schreibeDatei legt eine Datei an — nur, wenn sich der Inhalt geändert hat. // schreibeDatei legt eine Datei an — nur, wenn sich der Inhalt geändert hat.
// //
// # Warum der Vergleich? // # Warum der Vergleich?

View file

@ -21,17 +21,20 @@ func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
ctx := context.Background() ctx := context.Background()
ws, err := db.EnsureWorkspace(ctx, "Test") // Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr — die lebt in
if err != nil { // der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für
t.Fatal(err) // den Test reicht eine beliebige ID.
} workspaceID := store.NewID()
projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform-Team", "Wissen des Teams") projekt, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
ziel := t.TempDir() // Der Exporter schreibt unter <root>/<workspace-id>/. Zurückgegeben wird
return New(db, ziel), db, projekt, ziel // gleich der Workspace-Ordner, damit die Tests Pfade wie
// ziel/plattform/eintrag.md prüfen können, ohne die ID zu kennen.
root := t.TempDir()
return New(root), db, projekt, filepath.Join(root, workspaceID)
} }
// lies gibt den Inhalt einer exportierten Datei zurück. // lies gibt den Inhalt einer exportierten Datei zurück.
@ -57,7 +60,7 @@ func TestExportWritesEntries(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
stats, err := e.ExportProject(ctx, projekt) stats, err := e.ExportProject(ctx, db, projekt)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@ -107,7 +110,7 @@ func TestExportEscapesYAML(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if _, err := e.ExportProject(ctx, projekt); err != nil { if _, err := e.ExportProject(ctx, db, projekt); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -138,18 +141,19 @@ func TestExportRemovesOrphans(t *testing.T) {
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "geht", Title: "Geht", db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "geht", Title: "Geht",
Content: "x", Source: store.SourceHuman}) Content: "x", Source: store.SourceHuman})
if _, err := e.ExportProject(ctx, projekt); err != nil { if _, err := e.ExportProject(ctx, db, projekt); 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(ziel, "plattform", "geht.md")); err != nil {
t.Fatal("die Datei wurde gar nicht erst angelegt") t.Fatal("die Datei wurde gar nicht erst angelegt")
} }
if err := db.DeleteEntry(ctx, projekt.ID, "geht"); err != nil { // Ein Eintrag im Papierkorb gehört nicht mehr in den Export.
if err := db.MoveEntryToTrash(ctx, projekt.ID, "geht", "test"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
stats, err := e.ExportProject(ctx, projekt) stats, err := e.ExportProject(ctx, db, projekt)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -167,7 +171,7 @@ 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, _, projekt, ziel := aufbau(t) e, db, projekt, ziel := aufbau(t)
ctx := context.Background() ctx := context.Background()
ordner := filepath.Join(ziel, "plattform") ordner := filepath.Join(ziel, "plattform")
@ -178,7 +182,7 @@ func TestExportLeavesForeignFilesAlone(t *testing.T) {
os.WriteFile(filepath.Join(ordner, "notizen.txt"), []byte("privat"), 0640) os.WriteFile(filepath.Join(ordner, "notizen.txt"), []byte("privat"), 0640)
os.MkdirAll(filepath.Join(ordner, "anhaenge"), 0750) os.MkdirAll(filepath.Join(ordner, "anhaenge"), 0750)
if _, err := e.ExportProject(ctx, projekt); err != nil { if _, err := e.ExportProject(ctx, db, projekt); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -199,7 +203,7 @@ func TestExportIsStable(t *testing.T) {
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag", db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag",
Content: "Inhalt", Source: store.SourceHuman}) Content: "Inhalt", Source: store.SourceHuman})
if _, err := e.ExportProject(ctx, projekt); err != nil { if _, err := e.ExportProject(ctx, db, projekt); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -209,7 +213,7 @@ func TestExportIsStable(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if _, err := e.ExportProject(ctx, projekt); err != nil { if _, err := e.ExportProject(ctx, db, projekt); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -234,7 +238,7 @@ func TestExportProducesNoDiffWhenUnchanged(t *testing.T) {
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag", db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.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, projekt); err != nil { if _, err := e.ExportProject(ctx, db, projekt); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -246,7 +250,7 @@ func TestExportProducesNoDiffWhenUnchanged(t *testing.T) {
vorher[d.Name()] = lies(t, ordner, d.Name()) vorher[d.Name()] = lies(t, ordner, d.Name())
} }
if _, err := e.ExportProject(ctx, projekt); err != nil { if _, err := e.ExportProject(ctx, db, projekt); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -268,7 +272,7 @@ func TestExportIndex(t *testing.T) {
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "Beta", db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "Beta",
Content: "x", Source: store.SourceAgent}) Content: "x", Source: store.SourceAgent})
if _, err := e.ExportProject(ctx, projekt); err != nil { if _, err := e.ExportProject(ctx, db, projekt); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -299,8 +303,9 @@ func TestExportAllProjects(t *testing.T) {
e, db, projektA, ziel := aufbau(t) e, db, projektA, ziel := aufbau(t)
ctx := context.Background() ctx := context.Background()
ws, _ := db.EnsureWorkspace(ctx, "Test") // Beide Projekte liegen in derselben Workspace-Datei — dieselbe
projektB, err := db.CreateProject(ctx, ws.ID, "anderes", "Anderes Team", "") // workspace_id wie projektA reicht, eine eigene Workspace ist nicht nötig.
projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "anderes", "Anderes Team", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -310,7 +315,8 @@ func TestExportAllProjects(t *testing.T) {
db.PutEntry(ctx, store.EntryWrite{ProjectID: projektB.ID, Slug: "b-wissen", Title: "B", db.PutEntry(ctx, store.EntryWrite{ProjectID: projektB.ID, Slug: "b-wissen", Title: "B",
Content: "gehört zu B", Source: store.SourceHuman}) Content: "gehört zu B", Source: store.SourceHuman})
if _, err := e.ExportAll(ctx, ws.ID); err != nil { ws := store.Workspace{ID: projektA.WorkspaceID, Name: "Intern"}
if _, err := e.ExportWorkspace(ctx, db, ws); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -328,9 +334,9 @@ func TestExportAllProjects(t *testing.T) {
} }
func TestExportEmptyProject(t *testing.T) { func TestExportEmptyProject(t *testing.T) {
e, _, projekt, ziel := aufbau(t) e, db, projekt, ziel := aufbau(t)
stats, err := e.ExportProject(context.Background(), projekt) stats, err := e.ExportProject(context.Background(), db, projekt)
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)
} }
@ -342,3 +348,57 @@ func TestExportEmptyProject(t *testing.T) {
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
// Bezeichners dürfen sich im Export weder überschreiben noch gegenseitig
// Dateien wegräumen. Vor der Ebene je Workspace passierte genau das.
func TestExportTrenntWorkspaces(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
e := New(root)
// Zwei getrennte Workspace-Dateien, wie in echt.
type workspace struct {
ws store.Workspace
db *store.Store
}
var beide []workspace
for _, name := range []string{"Vertrieb", "Technik"} {
db, err := store.Open(filepath.Join(t.TempDir(), name+".db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ws := store.Workspace{ID: store.NewID(), Name: name}
projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform", "")
if err != nil {
t.Fatal(err)
}
if _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "nur-" + strings.ToLower(name),
Title: name, Content: "gehört zu " + name, Source: store.SourceHuman}); err != nil {
t.Fatal(err)
}
beide = append(beide, workspace{ws, db})
}
// Zweimal hintereinander: Beim zweiten Lauf würde das Aufräumen die
// Dateien des anderen Workspace löschen, wenn sie im selben Ordner lägen.
for lauf := 0; lauf < 2; lauf++ {
for _, w := range beide {
if _, err := e.ExportWorkspace(ctx, w.db, w.ws); err != nil {
t.Fatal(err)
}
}
}
for _, w := range beide {
datei := "nur-" + strings.ToLower(w.ws.Name) + ".md"
if inhalt := lies(t, root, w.ws.ID, "plattform", datei); !strings.Contains(inhalt, "gehört zu "+w.ws.Name) {
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) {
t.Errorf("_workspace.md nennt %s nicht", w.ws.Name)
}
}
}

110
internal/hilfe/hilfe.go Normal file
View file

@ -0,0 +1,110 @@
// 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
// NurAdmin blendet die Seite für alle aus, die nicht als Plattform-Admin
// angemeldet sind: Sie steht dann weder in der Navigation, noch lässt sie
// sich direkt aufrufen. Geheim ist darin nichts — aber für ein Team mit
// Projekt-Key beschreibt sie nur Knöpfe, die es gar nicht sieht.
NurAdmin bool
}
// 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: "plattform-admin", Titel: "Für Plattform-Admins", Kurz: "Workspaces und Projekte anlegen, Schreibbudget, Export.", NurAdmin: true},
}
// 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 jemand sehen darf: alle für einen
// Plattform-Admin, sonst alle außer den NurAdmin-Seiten.
func Sichtbar(istAdmin bool) []Seite {
var ergebnis []Seite
for _, s := range seiten {
if s.NurAdmin && !istAdmin {
continue
}
ergebnis = append(ergebnis, s)
}
return ergebnis
}
// Finde sucht eine Seite über ihren Slug.
func Finde(slug string) (Seite, bool) {
for _, s := range seiten {
if s.Slug == slug {
return s, true
}
}
return Seite{}, false
}
// Markdown liefert den Rohtext einer Seite.
func (s Seite) Markdown() (string, error) {
inhalt, err := seitenFS.ReadFile("seiten/" + s.Slug + ".md")
if err != nil {
return "", fmt.Errorf("Hilfeseite %q fehlt: %w", s.Slug, err)
}
return string(inhalt), nil
}
// Render wandelt eine Hilfeseite in HTML.
//
// Es ist derselbe Renderer wie für Einträge (internal/markdown), damit die
// Hilfe nichts zeigt, was im eigenen Eintrag dann anders aussieht. Einziger
// Unterschied: Überschriften bekommen hier eine id, damit die Oberfläche mit
// "?"-Links direkt auf einen Abschnitt zeigen kann, etwa
// /hilfe/weboberflaeche#markdown-im-inhalt.
//
// Roh-HTML bleibt auch hier gesperrt. Die Seiten stammen zwar aus dem
// Repository und nicht von Nutzern, aber die Platzhalter (siehe
// web/handlers_hilfe.go) setzen Werte aus der Anfrage ein — und dann soll
// daraus auf keinen Fall HTML werden können.
func Render(quelle string) (string, error) {
return markdown.RenderMitAnkern(quelle)
}

View file

@ -0,0 +1,110 @@
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")
}
}
func TestSichtbarBlendetAdminSeitenAus(t *testing.T) {
for _, s := range Sichtbar(false) {
if s.NurAdmin {
t.Errorf("Seite %q ist nur für Admins, wird aber allen gezeigt", s.Slug)
}
}
if len(Sichtbar(true)) != len(Alle()) {
t.Error("ein Admin sieht nicht alle Seiten")
}
}
// TestAllgemeineSeitenVerlinkenKeineAdminSeiten: Ein Link auf eine Admin-Seite
// liefe für alle anderen ins Leere (404).
func TestAllgemeineSeitenVerlinkenKeineAdminSeiten(t *testing.T) {
link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`)
for _, s := range Sichtbar(false) {
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.NurAdmin {
t.Errorf("Seite %q verlinkt auf die Admin-Seite %q", s.Slug, ziel.Slug)
}
}
}
}

View file

@ -0,0 +1,88 @@
## Wie alles zusammenhängt
```
Workspace (z. B. eine Abteilung)
└── Projekt (z. B. ein Team oder ein Vorhaben)
├── Einträge ← das eigentliche Wissen
└── Zugänge ← API-Keys mit Rolle
```
## Die Begriffe
**Workspace**
Ein Bereich für eine Abteilung oder eine größere Gruppe von Teams. Jeder
Workspace liegt in einer eigenen Datenbankdatei. Workspaces sehen sich
gegenseitig nie: Die Trennung ist technisch erzwungen, nicht nur eine
Filtereinstellung. Mit einem Projekt-Key bist du immer in genau einem Workspace.
Du musst ihn nicht auswählen.
**Projekt**
Die Einheit, in der Wissen geteilt wird, meist ein Team oder ein Vorhaben. Jedes
Projekt hat eigene Einträge, eigene Zugänge und eine eigene Suche. Projekte
sehen sich gegenseitig nicht, auch nicht innerhalb desselben Workspace.
**Eintrag**
Ein Stück Wissen: Titel, Inhalt in Markdown, Schlagworte. Ein guter Eintrag
beantwortet genau eine Frage, etwa „Wie läuft das Deployment?“ oder „Warum
nutzen wir SQLite?“.
**Bezeichner** (slug)
Der kurze, dauerhafte Name eines Eintrags, zum Beispiel `deployment-prozess`.
Er steht in der Adresse und dient als Schlüssel gegen Dubletten: Wer zweimal
mit demselben Bezeichner schreibt, **aktualisiert** den Eintrag, statt einen
zweiten anzulegen. Erlaubt sind `a-z`, `0-9`, `-` und `_`.
**Schlagworte** (tags)
Ordnen Einträge thematisch ein, zum Beispiel `ops` oder `sicherheit`. Es gibt
keine Ordner. Wo etwas „hingehört“, ist selten eindeutig, deshalb kann ein
Eintrag mehrere Schlagworte tragen. Besser wenige, einheitliche Schlagworte als
viele leicht verschiedene.
**Verweis**
`[[bezeichner]]` im Inhalt verlinkt auf einen anderen Eintrag. Die Gegenrichtung
entsteht automatisch: Jeder Eintrag zeigt, welche anderen auf ihn verweisen.
Ein Verweis darf auch auf einen Eintrag zeigen, den es noch nicht gibt. Das
markiert eine Lücke, die jemand füllen sollte.
**Fassung** (version)
Zählt bei jeder Änderung eines Eintrags hoch. Die vorherigen Fassungen bleiben
erhalten und lassen sich über *Verlauf ansehen* vergleichen. Ein gelöschter
Eintrag behält seinen Verlauf, solange er im Papierkorb liegt.
**Papierkorb**
Gelöschte Einträge liegen dort 30 Tage, bevor sie endgültig verschwinden. Für
Agenten und die Suche sind sie sofort unsichtbar; Menschen mit Schreibrecht
können sie zurückholen.
**Herkunft**
Jeder Eintrag vermerkt, ob zuletzt ein **Mensch** (über die Weboberfläche) oder
ein **Agent** (über MCP) geschrieben hat, und unter welchem Zugangsnamen.
**Kuratiert**
Ein Haken, den ein Mensch setzt, wenn er einen Eintrag geprüft hat. Einträge
von Agenten ohne diesen Haken sind Beobachtungen, keine bestätigten Fakten.
Agenten sehen den Haken ebenfalls und können danach filtern.
**API-Key** (Zugang)
Ein Schlüssel, der mit `wk_` beginnt und genau zu einem Projekt gehört. Er
dient zur Anmeldung im Browser und im KI-Werkzeug. Munin speichert nur eine
Prüfsumme, daher wird der Key beim Anlegen ein einziges Mal angezeigt.
**Protokoll**
Hält fest, wer wann etwas geschrieben oder gelöscht hat, wer sich angemeldet hat
(auch Fehlversuche) und wann Zugänge angelegt oder zurückgezogen wurden. Lesen
und Suchen stehen nicht darin. Protokolleinträge werden nach 90 Tagen
automatisch gelöscht.
## Rollen
Jeder Key hat genau eine Rolle.
| Rolle | Darf | Gedacht für |
|---|---|---|
| `read` | lesen und suchen | Agenten, die nur Auskunft geben sollen |
| `write` | zusätzlich schreiben und löschen | der Normalfall für Menschen und beaufsichtigte Agenten |
| `admin` | zusätzlich die Zugänge des Projekts verwalten | Menschen, die für das Projekt verantwortlich sind |
Darüber steht der **Plattform-Admin**. Er meldet sich mit einem eigenen
Admin-Token an, legt Workspaces und Projekte an und kommt an jedes Projekt.

View file

@ -0,0 +1,127 @@
Über **MCP** (Model Context Protocol) greifen KI-Werkzeuge auf Munin zu. Die
Einrichtung dauert wenige Minuten.
## 1. Einen Key besorgen
Du brauchst einen eigenen API-Key für das Projekt. Wer im Projekt die Rolle
`admin` hat, legt ihn unter *Zugänge* an. Bitte um einen **eigenen Key je
Werkzeug**, zum Beispiel `anna-claude-code` und `anna-cline`. Dann steht im
Protokoll, welches Werkzeug was geschrieben hat.
Der Key wird nur ein einziges Mal angezeigt. Direkt darunter stehen die
fertigen Konfigurationsblöcke mit dem Key schon eingesetzt.
## 2. Das Werkzeug einrichten
Alle Werkzeuge sprechen denselben Endpunkt an:
```
{{SERVER}}/mcp
```
Der Key legt fest, welches Projekt du siehst. Weitere Angaben braucht es nicht.
Wer mit mehreren Projekten arbeitet, trägt mehrere Server ein, jeden mit
seinem eigenen Key.
> **Achtung:** Die drei Werkzeuge verwenden unterschiedliche Schreibweisen.
> Ein aus Claude Code kopierter Block funktioniert in Cline nicht.
| Werkzeug | Datei | Abschnitt | `type` |
|---|---|---|---|
| Claude Code | `.mcp.json` oder `~/.claude.json` | `mcpServers` | `http` |
| Cline | `cline_mcp_settings.json` | `mcpServers` | `streamableHttp` |
| OpenCode | `opencode.json` | `mcp` | `remote` |
### Claude Code
Am schnellsten auf der Kommandozeile:
```bash
claude mcp add --transport http munin-{{PROJEKT}} {{SERVER}}/mcp \
--header "Authorization: Bearer wk_DEIN_KEY"
```
Mit `--scope user` gilt der Eintrag in allen Verzeichnissen, sonst nur im
aktuellen.
Soll die Einrichtung für das ganze Team ins Repository, dann als `.mcp.json`
**ohne** den Key im Klartext. Jede Person setzt die Umgebungsvariable
`MUNIN_KEY` auf ihren eigenen Key:
```json
{
"mcpServers": {
"munin-{{PROJEKT}}": {
"type": "http",
"url": "{{SERVER}}/mcp",
"headers": { "Authorization": "Bearer ${MUNIN_KEY}" }
}
}
}
```
### Cline
In Cline: *MCP Servers* → *Configure* → *Configure MCP Servers*.
```json
{
"mcpServers": {
"munin-{{PROJEKT}}": {
"type": "streamableHttp",
"url": "{{SERVER}}/mcp",
"headers": { "Authorization": "Bearer wk_DEIN_KEY" },
"disabled": false,
"autoApprove": ["recall", "get", "list", "related"]
}
}
}
```
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier
lesenden gehören hinein, sonst muss jede Suche einzeln bestätigt werden.
`remember` und `forget` gehören **nicht** hinein, denn sie verändern das
gemeinsame Wissen.
### OpenCode
In `opencode.json` im Projektverzeichnis oder global unter
`~/.config/opencode/opencode.json`:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"munin-{{PROJEKT}}": {
"type": "remote",
"url": "{{SERVER}}/mcp",
"enabled": true,
"headers": { "Authorization": "Bearer {env:MUNIN_KEY}" }
}
}
}
```
`{env:MUNIN_KEY}` liest den Key aus der Umgebung.
## 3. Prüfen, ob es geht
Das Werkzeug sollte fünf Munin-Werkzeuge anbieten: `recall`, `get`, `list`,
`remember` und `forget`. Ein einfacher Test:
```
Rufe list auf und sag mir, welche Schlagworte es im Munin gibt.
```
## Wenn es nicht klappt
| Anzeichen | Wahrscheinliche Ursache |
|---|---|
| Fehler `401` | Key falsch, unvollständig kopiert oder zurückgezogen. Bei OpenCode: Die Umgebungsvariable ist nicht gesetzt, dann wird sie zu einem leeren Text. |
| Werkzeug zeigt keine Munin-Werkzeuge | Falscher `type` für dieses Werkzeug (siehe Tabelle oben) oder Tippfehler in der Adresse. |
| Schreiben schlägt fehl, Lesen geht | Der Key hat nur die Rolle `read`. |
| Meldung über ein erschöpftes Schreibbudget | Das Projekt hat seine Obergrenze an Schreibvorgängen je Stunde erreicht. Das deutet meist auf einen Agenten in einer Schleife hin. Siehe [Zugänge und Sicherheit](/hilfe/zugaenge). |
Wenn die Verbindung steht, geht es mit
[Mit Agenten arbeiten](/hilfe/mit-agenten-arbeiten) weiter. Ohne eine kurze
Anweisung benutzt ein Agent das Gedächtnis selten von selbst.

View file

@ -0,0 +1,155 @@
## Das eigentliche Problem
Munin schickt jedem Werkzeug eine kurze Gebrauchsanleitung mit. Viele Werkzeuge
gewichten sie aber schwach. **Ohne eigene Anweisung antwortet ein Agent lieber
aus seinem eigenen Wissen**, statt im Gedächtnis nachzusehen, und schreibt
Neues, ohne vorher zu suchen.
Abhilfe schafft ein kurzer Block im Projekt- oder Systemprompt.
## Der Projektprompt
In `CLAUDE.md`, `.clinerules`, *Custom Instructions* oder den Systemprompt
deiner Anwendung übernehmen:
```markdown
## Munin
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
- Passenden Treffer mit `get` vollständig laden.
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen
und in der Antwort erwähnen, dass der Stand alt ist.
- Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das
kenntlich machen.
- Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht.
SCHREIBEN — sobald etwas festgehalten werden sollte:
- IMMER zuerst `recall`, ob es zum Thema schon einen Eintrag gibt.
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
- Nur wenn wirklich nichts passt, einen neuen slug wählen.
- Vor dem Aktualisieren `get` aufrufen und die gelieferte `version` als
`expected_version` mitgeben.
WAS festgehalten wird:
- Entscheidungen mit Begründung
- Abläufe, die sich wiederholen
- Lösungen für Probleme, die länger gedauert haben
- Nicht: Zwischenstände, Diskussionsverlauf, was im Code steht
WIE festgehalten wird:
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
- Verwandtes mit [[bezeichner]] verknüpfen.
- Schlagworte sparsam: 1–3 aus dem bestehenden Bestand (`list` zeigt sie).
```
## Die Werkzeuge
| Werkzeug | Rolle | Wofür |
|---|---|---|
| `recall` | read | Suchen. Der wichtigste Einstieg, auch vor dem Schreiben. |
| `get` | read | Einen Eintrag vollständig laden, mit Verweisen und Fassungsnummer. |
| `list` | read | Überblick: zuletzt geänderte Einträge und alle Schlagworte. |
| `related` | read | Verknüpfte Einträge finden, auch solche, die die Suche nicht trifft. |
| `remember` | write | Einen Eintrag anlegen oder aktualisieren. |
| `forget` | write | Einen Eintrag in den Papierkorb legen. |
## Gute Bezeichner
Der Bezeichner entscheidet, ob ein Eintrag aktualisiert oder neu angelegt wird.
| Gut | Schlecht | Warum |
|---|---|---|
| `deployment-prozess` | `notiz-2026-01-15` | Ein Datum ist kein Thema. |
| `fehler-zugriff-verweigert` | `problem-3` | Nicht wiederzufinden. |
| `entscheidung-sqlite` | `sqlite-entscheidung-final-v2` | Fassungen zählt das System selbst. |
Kurz, sprechend und dauerhaft. Ohne Angabe wird der Bezeichner aus dem Titel
abgeleitet.
## Muster für den Alltag
**Nachschlagen**
```
Wie läuft bei uns das Deployment? Schau zuerst im Munin nach.
```
**Nach getaner Arbeit festhalten**
```
Halte fest, was wir über den abgelaufenen Token herausgefunden haben.
Prüfe vorher mit recall, ob es dazu schon einen Eintrag gibt — dann
ergänze diesen, statt einen neuen anzulegen.
```
**Vorhandenes fortschreiben**
```
Lies deployment-prozess mit get, ergänze den Schritt mit dem Rollback und
schreibe zurück — mit expected_version aus dem get.
```
**Aufräumen**
```
Rufe list auf und nenne mir Einträge, die dasselbe Thema doppelt behandeln.
Nichts ändern, nur auflisten.
```
**Einarbeiten**
```
Verschaffe dir mit list und recall einen Überblick über den Munin
und fasse zusammen, was dieses Team über Betrieb und Deployment festgehalten hat.
```
## Wenn zwei gleichzeitig schreiben
`get` liefert eine Fassungsnummer (`version`). Gibt ein Agent sie beim
Schreiben als `expected_version` mit, lehnt Munin die Änderung ab, falls
inzwischen jemand anderes den Eintrag geändert hat. Der Agent lädt dann neu und
führt beides zusammen. Ohne diese Angabe würde die fremde Änderung einfach
überschrieben. Sie wäre zwar im Verlauf noch zu finden, aber niemand würde es
merken.
## Grenzen, die man kennen sollte
- **Die Suche ist wortbasiert.** „Wie deployen wir“ findet „Release-Prozess“
nicht. Im Zweifel hilft `list` für den Überblick.
- **`forget` legt in den Papierkorb.** Der Eintrag ist für alle Agenten sofort
weg, lässt sich aber 30 Tage lang von einem Menschen zurückholen. Agenten
selbst können nichts wiederherstellen und den Papierkorb nicht leeren.
Veraltetes trotzdem besser mit `remember` richtigstellen: Dann bleibt der
Eintrag auffindbar, und der Verlauf zeigt, was sich geändert hat.
- **Schreibt ein Agent unter einem Bezeichner, der im Papierkorb liegt**, holt
er den Eintrag zurück und überschreibt ihn. Der gelöschte Stand steht danach
im Verlauf, und die Antwort an den Agenten sagt, was passiert ist.
- **Was drinsteht, landet im Kontext aller Agenten des Projekts.** Wer schreiben
darf, schreibt damit indirekt in deren Prompts. Deshalb stehen Herkunft und
Autor an jedem Eintrag.
## Prüfen, ob es wirkt
Die Seite **Nutzung** in der Seitenleiste zählt die Schreibvorgänge der letzten
30 Tage, getrennt nach Agenten und Menschen und nach „neu angelegt“ und
„aktualisiert“. Der wichtigste Wert steht darunter: welcher Anteil der
Schreibvorgänge einen vorhandenen Eintrag fortgeschrieben hat.
Am Anfang legen Teams naturgemäß vor allem an. Nach ein paar Wochen sollte der
Anteil der Aktualisierungen steigen. Viele Neuanlagen zu ähnlichen Themen
bedeuten: Der Projektprompt greift nicht.
Außerdem zeigt die Seite:
- **Verweise ins Leere:** Bezeichner, auf die mit `[[…]]` verwiesen wird, ohne
dass es den Eintrag gibt. Die meistgenannten sind gute Kandidaten für den
nächsten Eintrag; mit Schreibrecht führt *anlegen* direkt dorthin.
- **Einträge ohne Schlagwort**, zum Nachtragen mit der Sammelbearbeitung.
- **Zuletzt aktive Zugänge**, um zu sehen, welche Agenten wirklich arbeiten.
Einzelne Vorgänge mit Zugang und Zeit stehen unter *Protokoll*.

View file

@ -0,0 +1,60 @@
## Der Ablauf für ein neues Team
1. **Workspace wählen oder anlegen.** Nach der Anmeldung landest du unter
*Workspaces*. Ein Workspace ist gedacht für eine Abteilung. Beim ersten
Start gibt es bereits den Workspace „Intern“.
2. **Projekt anlegen** unter *Projekte → Verwalten…*: Name, Bezeichner (erscheint
in Adressen und in der MCP-Konfiguration) und optional eine Beschreibung.
3. **Ersten Key anlegen:** Im neuen Projekt unter *Zugänge* einen Key mit der
Rolle `admin` für die verantwortliche Person erzeugen und ihr sicher
übergeben.
4. **Ab da verwaltet das Team sich selbst:** Die verantwortliche Person legt
weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe
(`{{SERVER}}/hilfe`) reicht als Einstieg.
## Workspaces
| Aktion | Wo |
|---|---|
| Anlegen, wählen | *Workspaces* in der Seitenleiste oder Klick auf den Namen oben in der Kopfleiste |
| Umbenennen, löschen | *Workspaces* → *Bearbeiten* |
Der Name des gerade gewählten Workspace steht immer oben in der Kopfleiste.
Alles, was du unter *Projekte* und *Protokoll (alle)* siehst, bezieht sich nur
auf diesen Workspace.
**Löschen** entfernt die Datenbankdatei des Workspace mit allen Projekten,
Einträgen und Keys. Zur Bestätigung muss der Name eingetippt werden. Rückgängig
machen lässt sich das nur aus einer Sicherung.
## Projekte
Unter *Projekte → Verwalten…* stehen alle Projekte des Workspace mit
Eintragszahl und dem Stand ihres Schreibbudgets. Über *Bearbeiten* lassen sich
Name, Beschreibung und die **eigene Obergrenze fürs Schreibbudget** ändern.
Dort liegt auch das Löschen.
Das Schreibbudget kann nur der Plattform-Admin ändern. Sonst könnte ein Team
die Grenze selbst aufheben, die gerade vor durchdrehenden Agenten schützen soll.
## Protokoll über alle Projekte
*Protokoll (alle)* zeigt die Vorgänge aller Projekte des gewählten Workspace,
auch das Anlegen und Löschen von Projekten.
## Sicherung und Export
Die eigentliche Sicherung läuft außerhalb der Oberfläche über
`scripts/backup.sh`. Das Skript sichert jede Workspace-Datei einzeln, sodass
sich auch eine einzelne Abteilung wiederherstellen lässt.
Ist auf dem Server ein Exportverzeichnis eingerichtet, schreibt Munin einmal
täglich alle Einträge aller Workspaces als Markdown-Dateien dorthin, als
zusätzliche, lesbare Sicherung, etwa für ein Git-Repository. Unter *Projekte*
exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten
Workspace, zum Beispiel vor einer größeren Änderung.
Zurück kommt ein Export über den [Import](/hilfe/weboberflaeche#import) im
jeweiligen Projekt: den Projektordner aus dem Export als ZIP-Archiv packen und
hochladen. So lässt sich auch ein einzelnes Projekt wiederherstellen, ohne
die ganze Workspace-Datei zurückzuspielen.

View file

@ -0,0 +1,45 @@
Munin ist das **gemeinsame Gedächtnis** eines Teams: für Menschen und für
KI-Agenten. Was ein Agent bei der Arbeit herausfindet, legt er hier ab. Was ein
Mensch festhält, kann jeder Agent wieder abrufen. Beides landet im selben
Speicher.
> In der nordischen Überlieferung sind Hugin und Munin die Raben Odins. Munin
> steht für das Gedächtnis: Er fliegt aus, sammelt und kehrt zurück, um zu
> berichten.
## Zwei Wege hinein
| Weg | Für wen | Wie |
|---|---|---|
| **MCP** | KI-Werkzeuge wie Claude Code, Cline, OpenCode oder eigene Anwendungen | Über einen API-Key, der im Werkzeug eingetragen wird |
| **Weboberfläche** | Menschen | Genau hier, mit demselben API-Key angemeldet |
Es gibt kein zweites System für Menschen. Jeder Eintrag vermerkt, ob ein Mensch
oder ein Agent ihn geschrieben hat, und welcher Zugang das war.
## Wo fange ich an?
**Ich möchte meinen KI-Agenten anbinden.**
Lies [Agent anbinden](/hilfe/mcp-einrichten). Danach
[Mit Agenten arbeiten](/hilfe/mit-agenten-arbeiten): Ohne eine kurze Anweisung
im Projektprompt benutzt ein Agent das Gedächtnis kaum.
**Ich möchte im Browser nachlesen oder schreiben.**
Lies [Weboberfläche](/hilfe/weboberflaeche).
**Ich verwalte die Zugänge meines Projekts.**
Lies [Zugänge und Sicherheit](/hilfe/zugaenge).
Wenn dir ein Wort unklar ist, schau unter [Begriffe](/hilfe/begriffe) nach.
## Die drei wichtigsten Regeln
1. **Erst suchen, dann schreiben.** Wer vor dem Schreiben nachsieht, ob es zum
Thema schon einen Eintrag gibt, ergänzt diesen, statt eine Dublette
danebenzustellen. Das gilt für Menschen und Agenten.
2. **Ein Eintrag beantwortet eine Frage.** Festgehalten wird das Ergebnis, nicht
der Weg dorthin: Entscheidungen mit Begründung, wiederkehrende Abläufe,
Lösungen für Probleme, die lange gedauert haben.
3. **Ein Key je Person und je Agent.** Nur dann steht im Protokoll, wer etwas
geschrieben hat, und ein einzelner Zugang lässt sich zurückziehen, ohne die
anderen zu stören.

View file

@ -0,0 +1,183 @@
Die Weboberfläche ist der Zugang für Menschen. Sie zeigt dasselbe Wissen, das
auch die Agenten sehen.
## Anmelden
Angemeldet wird mit dem API-Key des Projekts, also demselben, den auch das
KI-Werkzeug benutzt. Einen Benutzernamen gibt es nicht: Der Key bestimmt, wer
du bist, in welchem Projekt du arbeitest und was du darfst. Wird der Key
zurückgezogen, endet auch die Sitzung im Browser sofort.
## Finden
- **Suchen:** Das Suchfeld über der Eintragsliste durchsucht Titel,
Schlagworte und Inhalt. Titel zählen am stärksten. Umlaute sind egal:
„Uberblick“ findet auch „Überblick“. Ein Wortanfang reicht, „deploy“ findet
auch „Deployment“.
- **Nach Schlagwort filtern:** In der Seitenleiste stehen alle Schlagworte des
Projekts, die häufigsten zuerst. Ein Klick filtert die Liste.
- **Verweisen folgen:** Unter jedem Eintrag steht, welche anderen Einträge auf
ihn verweisen.
- **Graph:** Unter *Graph* in der Seitenleiste zeigt Munin, wie die Einträge
über Verweise zusammenhängen. Gestrichelte Knoten sind Verweisziele, zu denen
es noch keinen Eintrag gibt. Mausrad zoomt, Ziehen verschiebt, ein Klick
öffnet den Eintrag. Bei sehr großen Projekten zeigt der Graph nur die 150 am
stärksten verknüpften Einträge.
## Schreiben
Mit der Rolle `write` oder `admin` gibt es über der Liste den Knopf
**Neuer Eintrag** und in jedem Eintrag **Bearbeiten**.
| Feld | Bedeutung |
|---|---|
| Titel | Worum es geht, am besten als Frage oder klares Thema |
| Bezeichner | Der dauerhafte Name, siehe [Begriffe](/hilfe/begriffe). Leer lassen, dann wird er aus dem Titel abgeleitet. |
| Schlagworte | Durch Komma getrennt. Möglichst vorhandene wiederverwenden. |
| Inhalt | Markdown (siehe unten) |
| Kuratiert | Haken setzen, wenn der Inhalt geprüft und verlässlich ist |
**Bevor du einen neuen Eintrag anlegst**, such kurz, ob es das Thema schon gibt.
Ergänzen ist fast immer besser als ein zweiter Eintrag.
### Markdown im Inhalt
Unterstützt werden die üblichen Auszeichnungen:
```markdown
## Zwischenüberschrift
Normaler Text mit **fett**, *kursiv* und `Code`.
- Aufzählung
- noch ein Punkt
1. Nummerierte Schritte
2. zweiter Schritt
[Ein Link](https://example.org)
Siehe auch [[anderer-eintrag]].
```
Dazu kommen die Erweiterungen, die man von GitHub oder GitLab kennt:
```markdown
| Umgebung | Adresse |
|----------|----------------|
| Test | test.intern |
| Prod | prod.intern |
- [x] erledigt
- [ ] noch offen
~~veraltet~~ — durchgestrichen
https://example.org wird ohne Klammern zum Link.
```
Codeblöcke stehen zwischen drei Backticks. Eingebettetes HTML wird aus
Sicherheitsgründen nicht ausgeführt.
### Verweise mit Autovervollständigung
Tippe `[[` in das Inhaltsfeld. Munin schlägt passende Einträge vor. Mit den
Pfeiltasten auswählen und mit Enter oder Tab übernehmen, oder einfach anklicken.
Eingefügt wird der Bezeichner, nicht der Titel.
### Vorschau
Über dem Inhaltsfeld wechselst du zwischen **Schreiben** und **Vorschau**. Die
Vorschau zeigt den Eintrag genau so, wie er nach dem Speichern aussehen wird.
### Wenn jemand gleichzeitig ändert
Hat jemand anderes (ein Mensch oder ein Agent) den Eintrag geändert, während du
ihn bearbeitet hast, meldet Munin beim Speichern einen Konflikt, statt die
fremde Änderung zu überschreiben. Dein Text bleibt im Formular erhalten. Öffne
den Eintrag in einem zweiten Tab, übernimm dort deine Änderungen in den
aktuellen Stand und speichere dann dort.
## Verlauf und Vergleich
In jedem Eintrag führt **Fassung n, Verlauf ansehen** zu allen früheren
Fassungen. Bei jeder Fassung zeigt **Änderungen ansehen** Zeile für Zeile, was
sich zur nächsten Fassung geändert hat. So lässt sich nachvollziehen, was ein
Agent geändert hat.
Mit Schreibrecht holt **Wiederherstellen** (im Verlauf oder auf der
Vergleichsseite) eine alte Fassung mit einem Klick zurück: Titel, Inhalt und
Schlagworte. Dabei geht nichts verloren — der bisherige Stand wird selbst zu
einer Fassung im Verlauf, und das Protokoll vermerkt, welche Fassung
zurückgeholt wurde. Hat jemand den Eintrag geändert, während du den Verlauf
angesehen hast, wird nichts überschrieben; prüfe dann den neuen Stand und
versuche es erneut.
## Papierkorb
**Löschen** legt einen Eintrag in den Papierkorb, egal ob ein Mensch in der
Oberfläche löscht oder ein Agent über `forget`. Der Eintrag verschwindet sofort
aus Liste, Suche, Graph und allen Antworten an Agenten. Mit Verlauf und
Schlagworten bleibt er aber 30 Tage erhalten; danach entfernt Munin ihn
endgültig.
Den Papierkorb findest du mit Schreibrecht in der Seitenleiste. Dort steht zu
jedem Eintrag, wer ihn wann gelöscht hat:
- **Wiederherstellen** holt einen Eintrag unverändert zurück.
- **Endgültig löschen** entfernt ihn sofort, samt Verlauf. Das ist gedacht für
Inhalte, die nicht 30 Tage liegen bleiben sollen, etwa ein versehentlich
abgelegtes Passwort. Rückgängig machen lässt sich das nicht.
- Ein Klick auf den **Namen eines Zugangs** zeigt nur, was dieser gelöscht hat.
Hat ein Agent zu gründlich aufgeräumt, holt *Alle … wiederherstellen* alles
auf einmal zurück.
Wer mit Leserecht angemeldet ist, sieht den Papierkorb nicht. Oft wird ein
Eintrag ja gerade deshalb gelöscht, weil nicht jeder ihn sehen soll.
Legst du einen Eintrag mit einem Bezeichner an, der noch im Papierkorb liegt,
holt Munin den alten Eintrag zurück und überschreibt ihn. Der gelöschte Stand
steht dann im Verlauf, und ein Hinweis sagt dir, dass das passiert ist.
Veraltetes trotzdem lieber überarbeiten als löschen: Überarbeitetes bleibt
auffindbar, und der Verlauf zeigt, was sich geändert hat.
## Viele Einträge auf einmal
Mit Schreibrecht hat die Eintragsliste vor jedem Eintrag ein Auswahlkästchen.
Unter der Liste lassen sich für alle ausgewählten Einträge Schlagworte
hinzufügen oder entfernen, mehrere durch Komma getrennt. Praktisch, um ein
Schlagwort umzubenennen: das neue hinzufügen, das alte entfernen.
## Import
Mit der Rolle `admin` gibt es in der Seitenleiste den Punkt *Import*. Dort
lässt sich ein ZIP-Archiv mit Markdown-Dateien hochladen. Jede Datei wird ein
Eintrag. Das passt für drei Fälle:
- **Wiederherstellen** aus dem Markdown-Export: den Ordner eines Projekts
packen und hochladen. Schlagworte, Autor, Herkunft, Prüfvermerk und
Änderungsdatum bleiben erhalten.
- **Umzug** eines Projekts auf einen anderen Server oder in einen anderen
Workspace, auf demselben Weg.
- **Vorhandene Dokumentation** übernehmen. Der Bezeichner kommt aus dem
Dateinamen, der Titel aus der ersten Überschrift `# …`. Unterordner sind
erlaubt, im Projekt stehen die Einträge danach aber nebeneinander.
Gibt es einen Bezeichner schon, wird die Datei entweder **übersprungen**
(Standard) oder sie **überschreibt** den Eintrag. Beim Überschreiben wandert
der bisherige Stand in den Verlauf, verloren geht also nichts. Dateien, die
nichts ändern würden, werden in beiden Fällen nicht geschrieben. Derselbe
Import lässt sich deshalb gefahrlos wiederholen.
**Nur Vorschau** ist beim ersten Hochladen angehakt. Die Tabelle zeigt dann
für jede Datei, was passieren würde, ohne etwas zu schreiben. Passt alles,
dasselbe Archiv ohne den Haken noch einmal hochladen.
Nicht übernommen wird der Verlauf, denn der Export enthält nur den aktuellen
Stand. Ein importierter Eintrag beginnt deshalb wieder bei Fassung 1.
## Protokoll
Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas
geschrieben, gelöscht oder sich angemeldet hat.

View file

@ -0,0 +1,82 @@
## Zugänge verwalten
Wer im Projekt die Rolle `admin` hat, sieht in der Seitenleiste den Punkt
**Zugänge**. Dort werden Keys angelegt und zurückgezogen.
**Anlegen:** Name und Rolle wählen. Der Name sollte sagen, wer oder was den
Key benutzt, zum Beispiel `anna-cline` oder `ci-pipeline`. Er erscheint als
Autor an jedem Eintrag und im Protokoll.
Der Key wird **genau einmal** angezeigt, zusammen mit fertigen
Konfigurationsblöcken für Claude Code, Cline und OpenCode. Munin speichert nur
eine Prüfsumme. Geht der Key verloren, lässt er sich nicht wiederherstellen.
Dann einfach einen neuen anlegen und den alten zurückziehen.
**Zurückziehen:** Der Key funktioniert sofort nicht mehr, auch laufende
Browser-Sitzungen mit diesem Key enden. Im Protokoll bleibt er mit seinem Namen
erhalten, damit sich weiterhin nachvollziehen lässt, was er geschrieben hat.
**Ungenutzte Keys:** Ein aktiver Key, der seit über 90 Tagen nicht benutzt
wurde, trägt die Marke *ungenutzt*. Über der Tabelle steht dann ein Hinweis.
Wurde ein Key noch nie benutzt, zählt das Anlegedatum. Ein frisch angelegter
Key ist also nicht sofort markiert. Solche Keys stammen oft von Personen, die
das Team gewechselt haben, oder von abgeschalteten Pipelines. Jeder davon ist
ein offener Zugang. Wenn niemand mehr weiß, wofür er da ist, am besten
zurückziehen.
## Welche Rolle für wen?
| Wer | Empfohlene Rolle |
|---|---|
| Agent, der ohne Aufsicht läuft (z. B. in einer Pipeline) | `read` |
| Agent, mit dem ein Mensch zusammenarbeitet | `write` |
| Teammitglied im Browser | `write` |
| Verantwortliche für das Projekt | `admin` |
Faustregel: Schreibrecht nur dort, wo jemand die Einträge auch durchsieht.
## Ein Key je Person und je Agent
Keys zu teilen ist bequem, aber dann steht im Protokoll nur noch „team-key“.
Außerdem lässt sich ein geteilter Key nicht zurückziehen, ohne alle anderen
auszusperren. Keys kosten nichts, also besser einen mehr anlegen.
Gehört ein Key in eine Konfigurationsdatei im Repository, dann nicht im
Klartext, sondern über eine Umgebungsvariable (siehe
[Agent anbinden](/hilfe/mcp-einrichten)).
## Schreibbudget
Jedes Projekt darf über MCP nur eine begrenzte Zahl an Schreibvorgängen je
Stunde ausführen (`remember` und `forget` zusammen, standardmäßig 1.000). Lesen
ist nie begrenzt. Das Budget gilt für das ganze Projekt, nicht je Key.
Das ist ein Sicherheitsnetz gegen Agenten, die in einer Schleife hängen. Im
normalen Betrieb wird es nicht erreicht. Ist es erschöpft, bekommt der Agent
die Anweisung, abzubrechen und das zu melden. Die Weboberfläche ist davon nicht
betroffen. Eine andere Obergrenze für ein Projekt kann nur der Plattform-Admin
einstellen.
## Protokoll
Unter **Protokoll** steht, wer im Projekt wann geschrieben oder gelöscht hat,
wer sich angemeldet hat (auch Fehlversuche) und wann Zugänge angelegt oder
zurückgezogen wurden. Einträge werden nach 90 Tagen gelöscht, weil sie Namen
und Adressen enthalten.
## Was man beim Teilen von Wissen bedenken sollte
**Keine Geheimnisse in Einträge.** Passwörter, Tokens und private Schlüssel
gehören in einen Passwortspeicher. Ein Eintrag darf beschreiben, *wo* ein
Geheimnis liegt, aber nicht das Geheimnis selbst. Alles, was hier steht, landet
im Kontext von KI-Agenten.
**Was drinsteht, lesen Agenten als Anweisung.** Wer in ein Projekt schreiben
darf, schreibt damit indirekt in die Prompts aller Agenten dieses Projekts.
Darum stehen Herkunft und Autor an jedem Eintrag, und darum lohnt sich der
Haken *Kuratiert* für geprüftes Wissen. Wirkt ein Eintrag merkwürdig oder
fordert er zu ungewöhnlichen Handlungen auf, sieh im Verlauf nach, wer ihn
geschrieben hat.
**Projekte sind voneinander getrennt.** Ein Key sieht nur sein eigenes Projekt.
Fremde Projekte sind für ihn nicht einmal als vorhanden erkennbar.

122
internal/importer/archiv.go Normal file
View file

@ -0,0 +1,122 @@
package importer
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"path"
"strings"
)
// Grenzen für ein hochgeladenes Archiv.
//
// Ein ZIP-Archiv kann ausgepackt ein Vielfaches seiner Größe haben. Ein
// „Zip-Bomb“ macht aus wenigen Kilobyte Gigabytes. Deshalb zählt hier, was
// tatsächlich ausgepackt wird, nicht was das Archiv über sich behauptet: Die
// Größenangaben im Inneren eines ZIP-Archivs kann jeder frei hineinschreiben.
const (
// MaxDateien ist die Höchstzahl an Markdown-Dateien je Import.
MaxDateien = 5000
// MaxDateiBytes entspricht der Grenze für ein Formular der Weboberfläche.
// Ein Eintrag, der sich nicht im Browser anlegen ließe, soll auch über
// den Import nicht hineinkommen.
MaxDateiBytes = 1 << 20 // 1 MB
// MaxGesamtBytes begrenzt den ausgepackten Inhalt insgesamt.
MaxGesamtBytes = 100 << 20 // 100 MB
)
// Datei ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet.
type Datei struct {
Pfad string
Inhalt []byte
}
// LeseZip liest alle Markdown-Dateien aus einem ZIP-Archiv.
//
// Übergangen werden:
// - alles, was nicht auf .md oder .markdown endet (Bilder, PDFs, …)
// - Dateien, deren Name mit "_" beginnt: _index.md und _workspace.md sind
// Übersichten aus dem Export, keine Einträge
// - versteckte Dateien und Ordner (".git", ".obsidian") und der Ordner
// "__MACOSX", den der Finder beim Packen dazulegt
//
// Unterordner sind erlaubt. So lässt sich ein ganzer Doku-Ordner auf einmal
// hochladen. Die Einträge landen trotzdem alle nebeneinander im Projekt,
// denn Munin kennt keine Ordner.
func LeseZip(daten []byte) ([]Datei, error) {
archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten)))
if err != nil {
return nil, errors.New("die Datei ist kein lesbares ZIP-Archiv")
}
var dateien []Datei
var gesamt int64
for _, f := range archiv.File {
if f.FileInfo().IsDir() || !istEintragsdatei(f.Name) {
continue
}
if len(dateien) >= MaxDateien {
return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxDateien)
}
inhalt, err := leseBegrenzt(f, MaxDateiBytes)
if err != nil {
return nil, fmt.Errorf("%s: %w", f.Name, err)
}
gesamt += int64(len(inhalt))
if gesamt > MaxGesamtBytes {
return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxGesamtBytes>>20)
}
dateien = append(dateien, Datei{Pfad: f.Name, Inhalt: inhalt})
}
if len(dateien) == 0 {
return nil, errors.New("im Archiv liegt keine Markdown-Datei (.md)")
}
return dateien, nil
}
// istEintragsdatei entscheidet anhand des Pfads, ob eine Datei ein Eintrag
// werden soll (siehe LeseZip).
func istEintragsdatei(pfad string) bool {
for _, teil := range strings.Split(pfad, "/") {
if strings.HasPrefix(teil, ".") || teil == "__MACOSX" {
return false
}
}
name := path.Base(pfad)
if strings.HasPrefix(name, "_") {
return false
}
endung := strings.ToLower(path.Ext(name))
return endung == ".md" || endung == ".markdown"
}
// leseBegrenzt packt eine Datei aus und bricht ab, sobald sie größer als
// grenze wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die
// Datei zu groß.
func leseBegrenzt(f *zip.File, grenze int64) ([]byte, error) {
r, err := f.Open()
if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
}
defer r.Close()
inhalt, err := io.ReadAll(io.LimitReader(r, grenze+1))
if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
}
if int64(len(inhalt)) > grenze {
return nil, fmt.Errorf("größer als %d KB", grenze>>10)
}
return inhalt, nil
}

View file

@ -0,0 +1,254 @@
// Package importer liest Markdown-Dateien in ein Projekt ein, als Gegenstück
// zum Export (siehe AUSBAUSTUFEN.md 3.2).
//
// # Wozu?
//
// - Umzug auf einen anderen Server
// - Wiederherstellung aus der Markdown-Sicherung
// - Erstbefüllung aus vorhandener Dokumentation eines Teams
//
// # Was passiert bei einem Zusammenstoß?
//
// Gibt es einen Bezeichner schon im Projekt, entscheidet der Modus:
//
// - Überspringen (Standard): Der vorhandene Eintrag bleibt, wie er ist. So
// lässt sich derselbe Import gefahrlos wiederholen, und nichts, was ein
// Team seit der Sicherung geschrieben hat, geht verloren.
// - Überschreiben: Die Datei wird zur neuen Fassung. Die bisherige wandert
// wie bei jeder Änderung in den Verlauf und lässt sich von dort
// zurückholen. Überschreiben ist also nie endgültig.
//
// Ein „Abbrechen bei Zusammenstoß“ gibt es nicht. Dafür ist die Vorschau da:
// Sie zeigt vorher Zeile für Zeile, was passieren würde, ohne etwas zu
// schreiben.
//
// Ist der Inhalt einer Datei gleich dem vorhandenen Eintrag, wird in beiden
// Modi nichts geschrieben. Ein wiederholter Import erzeugt so keine leeren
// Fassungen im Verlauf.
package importer
import (
"context"
"fmt"
"slices"
"munin/internal/store"
)
// Modus legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist.
type Modus string
const (
ModusUeberspringen = Modus("skip")
ModusUeberschreiben = Modus("overwrite")
)
// Aktion ist das Ergebnis für eine einzelne Datei.
type Aktion string
const (
AktionNeu = Aktion("neu")
AktionUeberschrieben = Aktion("überschrieben")
AktionUnveraendert = Aktion("unverändert")
AktionUebersprungen = Aktion("übersprungen")
AktionFehler = Aktion("Fehler")
)
// Optionen steuern einen Import.
type Optionen struct {
Modus Modus
// Vorschau: nur ausrechnen, was passieren würde, nichts schreiben.
Vorschau bool
// Autor steht an Einträgen, deren Datei keinen Autor nennt, etwa bei
// vorhandener Dokumentation. Üblich ist der Name des importierenden
// Zugangs.
Autor string
}
// Zeile ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt.
type Zeile struct {
Pfad string
Slug string
Title string
Aktion Aktion
Grund string // Erläuterung, etwa bei Fehler oder Übersprungen
}
// Bericht fasst einen Import zusammen.
type Bericht struct {
Zeilen []Zeile
Neu int
Ueberschrieben int
Unveraendert int
Uebersprungen int
Fehler int
// Geschrieben sind die tatsächlich geschriebenen Einträge. Der Aufrufer
// braucht sie, um den Suchindex nachzuziehen. Bei einer Vorschau leer.
Geschrieben []store.Entry
}
// Zusammenfassung ist eine Zeile für Meldung und Protokoll.
func (b Bericht) Zusammenfassung() string {
return fmt.Sprintf("%d neu, %d überschrieben, %d unverändert, %d übersprungen, %d Fehler",
b.Neu, b.Ueberschrieben, b.Unveraendert, b.Uebersprungen, b.Fehler)
}
// Importiere liest die Dateien in das Projekt ein, oder rechnet bei
// opt.Vorschau nur aus, was passieren würde.
//
// Ein Fehler in einer einzelnen Datei bricht den Import nicht ab. Er steht im
// Bericht, und die übrigen Dateien laufen weiter. Nur ein Fehler der
// Datenbank selbst beendet den Import. Was bis dahin geschrieben war, bleibt
// geschrieben: Jeder Eintrag ist für sich vollständig oder gar nicht da (eine
// Transaktion je Eintrag), aber der Import als Ganzes ist keine Transaktion.
// Ihn danach im Modus „Überspringen“ zu wiederholen, macht den Rest.
func Importiere(ctx context.Context, s *store.Store, projectID string, dateien []Datei, opt Optionen) (Bericht, error) {
var b Bericht
if opt.Modus != ModusUeberspringen && opt.Modus != ModusUeberschreiben {
return b, fmt.Errorf("unbekannter Modus %q", opt.Modus)
}
// Den Bestand einmal vorab laden statt je Datei nachzufragen: Bei einigen
// tausend Dateien sind das zwei Abfragen statt einiger tausend.
vorhanden, err := s.AllEntries(ctx, projectID)
if err != nil {
return b, err
}
bestand := make(map[string]store.Entry, len(vorhanden))
for _, e := range vorhanden {
bestand[e.Slug] = e
}
// Einträge im Papierkorb belegen ihren Bezeichner weiter (siehe
// store.PutEntry). Sie müssen eigens erkannt werden: Sonst holte ein
// Import im Modus „Überspringen“ gelöschte Einträge stillschweigend
// zurück, weil sie für AllEntries nicht existieren.
geloescht, err := s.ListTrash(ctx, projectID, "")
if err != nil {
return b, err
}
imPapierkorb := make(map[string]bool, len(geloescht))
for _, e := range geloescht {
imPapierkorb[e.Slug] = true
}
// Welche Datei hat welchen Bezeichner schon belegt? Zwei Dateien mit
// demselben Bezeichner in einem Archiv (etwa "ops/deploy.md" und
// "alt/deploy.md") würden sich sonst gegenseitig überschreiben.
belegtDurch := make(map[string]string)
for _, d := range dateien {
e, err := Parse(d.Pfad, d.Inhalt)
zeile := Zeile{Pfad: d.Pfad, Slug: e.Slug, Title: e.Title}
switch {
case err != nil:
zeile.Aktion, zeile.Grund = AktionFehler, err.Error()
case belegtDurch[e.Slug] != "":
zeile.Aktion = AktionFehler
zeile.Grund = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, belegtDurch[e.Slug])
default:
belegtDurch[e.Slug] = d.Pfad
alt, gibtEs := bestand[e.Slug]
switch {
case gibtEs && gleicherInhalt(alt, e):
zeile.Aktion = AktionUnveraendert
case (gibtEs || imPapierkorb[e.Slug]) && opt.Modus == ModusUeberspringen:
zeile.Aktion = AktionUebersprungen
zeile.Grund = "Bezeichner schon vorhanden"
if imPapierkorb[e.Slug] {
zeile.Grund = "Bezeichner gehört einem Eintrag im Papierkorb"
}
case gibtEs || imPapierkorb[e.Slug]:
zeile.Aktion = AktionUeberschrieben
zeile.Grund = "bisheriger Stand steht im Verlauf"
if imPapierkorb[e.Slug] {
zeile.Grund = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf"
}
default:
zeile.Aktion = AktionNeu
}
if !opt.Vorschau && (zeile.Aktion == AktionNeu || zeile.Aktion == AktionUeberschrieben) {
geschrieben, err := s.PutEntry(ctx, schreibauftrag(projectID, e, opt.Autor, zeile.Aktion == AktionNeu))
if err != nil {
// Ungültige Angaben (etwa ein zu langer Titel) meldet
// PutEntry vor jedem Schreibzugriff. Sie betreffen nur
// diese Datei. Ein Fehler der Datenbank dagegen würde
// auch jede weitere Datei treffen: Dann lieber aufhören.
if ctx.Err() != nil {
return b, ctx.Err()
}
zeile.Aktion, zeile.Grund = AktionFehler, err.Error()
} else {
b.Geschrieben = append(b.Geschrieben, geschrieben)
}
}
}
b.Zeilen = append(b.Zeilen, zeile)
switch zeile.Aktion {
case AktionNeu:
b.Neu++
case AktionUeberschrieben:
b.Ueberschrieben++
case AktionUnveraendert:
b.Unveraendert++
case AktionUebersprungen:
b.Uebersprungen++
case AktionFehler:
b.Fehler++
}
}
return b, nil
}
// schreibauftrag macht aus einer ausgewerteten Datei einen Schreibvorgang.
//
// Der Autor hängt davon ab, was passiert. Beim Anlegen bleibt der aus der
// Datei, denn eine Wiederherstellung soll zeigen, wer den Eintrag
// ursprünglich geschrieben hat. Beim Überschreiben steht der importierende
// Zugang da: Diese Fassung hat er erzeugt, und so steht es auch im Verlauf.
func schreibauftrag(projectID string, e Eintrag, autor string, neu bool) store.EntryWrite {
w := store.EntryWrite{
ProjectID: projectID,
Slug: e.Slug,
Title: e.Title,
Content: e.Content,
Tags: e.Tags,
Source: e.Source,
Author: e.Author,
Curated: e.Curated,
// Wirken nur beim Anlegen, siehe store.EntryWrite.
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if w.Source == "" {
w.Source = store.SourceHuman
}
if w.Author == "" || !neu {
w.Author = autor
}
return w
}
// gleicherInhalt sagt, ob eine Datei nichts am vorhandenen Eintrag ändern
// würde. Verglichen wird, was ein Mensch als Inhalt ansieht: Titel, Text,
// Schlagworte, Prüfvermerk. Autor und Zeitstempel zählen nicht. Sonst wäre
// nach einem Export und Re-Import nie etwas „unverändert“.
func gleicherInhalt(alt store.Entry, neu Eintrag) bool {
return alt.Title == neu.Title &&
alt.Content == neu.Content &&
alt.Curated == neu.Curated &&
slices.Equal(alt.Tags, store.NormalizeTags(neu.Tags))
}

View file

@ -0,0 +1,319 @@
package importer
import (
"archive/zip"
"bytes"
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"munin/internal/export"
"munin/internal/store"
)
// testStore öffnet eine frische Datenbank mit einem Projekt.
func testStore(t *testing.T, projektSlug string) (*store.Store, store.Project) {
t.Helper()
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
p, err := s.CreateProject(context.Background(), store.NewID(), projektSlug, "Projekt "+projektSlug, "")
if err != nil {
t.Fatal(err)
}
return s, p
}
// packe baut ein ZIP-Archiv aus Pfad → Inhalt.
func packe(t *testing.T, dateien map[string]string) []byte {
t.Helper()
var puffer bytes.Buffer
w := zip.NewWriter(&puffer)
for pfad, inhalt := range dateien {
f, err := w.Create(pfad)
if err != nil {
t.Fatal(err)
}
f.Write([]byte(inhalt))
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return puffer.Bytes()
}
// packeOrdner packt alle Dateien eines Ordners, so wie ein Mensch den
// Exportordner eines Projekts zippen würde.
func packeOrdner(t *testing.T, ordner string) []byte {
t.Helper()
dateien := map[string]string{}
eintraege, err := os.ReadDir(ordner)
if err != nil {
t.Fatal(err)
}
for _, e := range eintraege {
inhalt, err := os.ReadFile(filepath.Join(ordner, e.Name()))
if err != nil {
t.Fatal(err)
}
dateien["projekt/"+e.Name()] = string(inhalt)
}
return packe(t, dateien)
}
// TestRoundTripFromExport ist der wichtigste Test: Was der Export schreibt,
// kommt beim Import unverändert wieder heraus, samt Schlagworten, Herkunft,
// Autor, Prüfvermerk und Änderungsdatum.
func TestRoundTripFromExport(t *testing.T) {
ctx := context.Background()
quelle, qp := testStore(t, "quelle")
alt := time.Date(2023, 5, 1, 8, 30, 0, 0, time.UTC)
original := []store.EntryWrite{
{Slug: "deployment", Title: "Deployment: so geht's", Content: "Schritt 1\n\n# Keine Titelzeile\n\nsiehe [[rollback]]",
Tags: []string{"ops", "release"}, Source: store.SourceAgent, Author: "agent-1", Curated: true,
CreatedAt: alt, UpdatedAt: alt},
{Slug: "rollback", Title: `Rollback mit "Anführungszeichen"`, Content: "Zurück.\nZweite Zeile",
Source: store.SourceHuman, Author: "anna"},
}
for _, w := range original {
w.ProjectID = qp.ID
if _, err := quelle.PutEntry(ctx, w); err != nil {
t.Fatal(err)
}
}
exportOrdner := t.TempDir()
qp.WorkspaceID = "ws-test"
if _, err := export.New(exportOrdner).ExportProject(ctx, quelle, qp); err != nil {
t.Fatal(err)
}
dateien, err := LeseZip(packeOrdner(t, filepath.Join(exportOrdner, "ws-test", "quelle")))
if err != nil {
t.Fatal(err)
}
if len(dateien) != 2 {
t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(dateien))
}
ziel, zp := testStore(t, "ziel")
bericht, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "importeur"})
if err != nil {
t.Fatal(err)
}
if bericht.Neu != 2 || bericht.Fehler != 0 {
t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen)
}
for _, w := range original {
a, _ := quelle.EntryBySlug(ctx, qp.ID, w.Slug)
b, err := ziel.EntryBySlug(ctx, zp.ID, w.Slug)
if err != nil {
t.Fatalf("%s fehlt nach dem Import: %v", w.Slug, err)
}
if a.Title != b.Title || a.Content != b.Content || !slices.Equal(a.Tags, b.Tags) ||
a.Source != b.Source || a.Author != b.Author || a.Curated != b.Curated {
t.Errorf("%s nach dem Import verändert:\nvorher %+v\nnachher %+v", w.Slug, a, b)
}
if !a.UpdatedAt.Truncate(time.Second).Equal(b.UpdatedAt) {
t.Errorf("%s: Änderungsdatum %v, erwartet %v", w.Slug, b.UpdatedAt, a.UpdatedAt)
}
}
// Die Verweise sind wieder da, rollback wird von deployment verlinkt.
rueck, _ := ziel.Backlinks(ctx, zp.ID, "rollback")
if len(rueck) != 1 || rueck[0].Slug != "deployment" {
t.Errorf("Rückverweise nach dem Import: %+v", rueck)
}
// Ein zweiter Lauf ändert nichts und erzeugt keine neuen Fassungen.
nochmal, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "importeur"})
if err != nil {
t.Fatal(err)
}
if nochmal.Unveraendert != 2 {
t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", nochmal.Zusammenfassung())
}
}
func TestConflictModes(t *testing.T) {
ctx := context.Background()
s, p := testStore(t, "test")
if _, err := s.PutEntry(ctx, store.EntryWrite{
ProjectID: p.ID, Slug: "vorhanden", Title: "Vorhanden", Content: "alt",
Source: store.SourceHuman, Author: "anna",
}); err != nil {
t.Fatal(err)
}
dateien := []Datei{
{Pfad: "vorhanden.md", Inhalt: []byte("# Vorhanden\n\nneu")},
{Pfad: "frisch.md", Inhalt: []byte("# Frisch\n\nText")},
}
// Vorschau schreibt nichts.
vorschau, err := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Vorschau: true, Autor: "imp"})
if err != nil {
t.Fatal(err)
}
if vorschau.Neu != 1 || vorschau.Ueberschrieben != 1 || len(vorschau.Geschrieben) != 0 {
t.Errorf("Vorschau: %s", vorschau.Zusammenfassung())
}
if _, err := s.EntryBySlug(ctx, p.ID, "frisch"); err == nil {
t.Error("die Vorschau hat geschrieben")
}
// Überspringen lässt Vorhandenes in Ruhe.
ueber, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "imp"})
if ueber.Neu != 1 || ueber.Uebersprungen != 1 {
t.Errorf("Überspringen: %s", ueber.Zusammenfassung())
}
if e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden"); e.Content != "alt" {
t.Error("Überspringen hat überschrieben")
}
// Überschreiben legt eine neue Fassung an, die alte steht im Verlauf.
neu, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "imp"})
if neu.Ueberschrieben != 1 || neu.Unveraendert != 1 {
t.Errorf("Überschreiben: %s", neu.Zusammenfassung())
}
e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden")
if e.Content != "neu" || e.Version != 2 || e.Author != "imp" {
t.Errorf("nach dem Überschreiben: Inhalt %q, Fassung %d, Autor %q", e.Content, e.Version, e.Author)
}
}
// TestTrashIsNotRevivedWhenSkipping prüft, dass ein Import im Modus
// „Überspringen“ keinen gelöschten Eintrag zurückholt.
func TestTrashIsNotRevivedWhenSkipping(t *testing.T) {
ctx := context.Background()
s, p := testStore(t, "test")
s.PutEntry(ctx, store.EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Content: "x", Source: store.SourceHuman})
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "anna"); err != nil {
t.Fatal(err)
}
dateien := []Datei{{Pfad: "weg.md", Inhalt: []byte("# Weg\n\nwieder da")}}
b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen})
if b.Uebersprungen != 1 || !strings.Contains(b.Zeilen[0].Grund, "Papierkorb") {
t.Errorf("Bericht: %s, Grund %q", b.Zusammenfassung(), b.Zeilen[0].Grund)
}
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); err == nil {
t.Error("der gelöschte Eintrag wurde zurückgeholt")
}
}
func TestDuplicateSlugInArchive(t *testing.T) {
ctx := context.Background()
s, p := testStore(t, "test")
dateien := []Datei{
{Pfad: "ops/deploy.md", Inhalt: []byte("eins")},
{Pfad: "alt/deploy.md", Inhalt: []byte("zwei")},
}
b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen})
if b.Neu != 1 || b.Fehler != 1 || !strings.Contains(b.Zeilen[1].Grund, "ops/deploy.md") {
t.Errorf("Bericht: %s — %+v", b.Zusammenfassung(), b.Zeilen)
}
}
func TestParsePlainMarkdown(t *testing.T) {
e, err := Parse("Doku/Mein Leitfaden.md", []byte("\uFEFF# Leitfaden für Neue\r\n\r\nErster Absatz.\r\n"))
if err != nil {
t.Fatal(err)
}
if e.Slug != "mein-leitfaden" || e.Title != "Leitfaden für Neue" || e.Content != "Erster Absatz." {
t.Errorf("gelesen: %+v", e)
}
ohneUeberschrift, err := Parse("notiz.md", []byte("Nur Text"))
if err != nil {
t.Fatal(err)
}
if ohneUeberschrift.Title != "notiz" {
t.Errorf("Titel ohne Überschrift: %q", ohneUeberschrift.Title)
}
}
func TestParseForeignFrontmatter(t *testing.T) {
datei := `---
title: 'It''s a title'
date: 2024-01-01
tags:
- Ops
- "release notes"
draft: false
---
Inhalt`
e, err := Parse("post.md", []byte(datei))
if err != nil {
t.Fatal(err)
}
if e.Title != "It's a title" || !slices.Equal(e.Tags, []string{"Ops", "release notes"}) || e.Content != "Inhalt" {
t.Errorf("gelesen: %+v", e)
}
}
func TestParseInlineListWithCommaInQuotes(t *testing.T) {
e, err := Parse("x.md", []byte("---\ntags: [\"a, b\", c]\n---\nText"))
if err != nil {
t.Fatal(err)
}
if !slices.Equal(e.Tags, []string{"a, b", "c"}) {
t.Errorf("Schlagworte: %q", e.Tags)
}
}
func TestParseRejects(t *testing.T) {
faelle := map[string]string{
"offener Kopf": "---\ntitle: x\nText ohne Ende",
"leer": "---\ntitle: x\n---\n\n",
"ungültiger slug": "---\nslug: \"Mit Leerzeichen\"\n---\nText",
"nur eine Überschrift": "# Titel\n",
}
for name, inhalt := range faelle {
if _, err := Parse("x.md", []byte(inhalt)); err == nil {
t.Errorf("%s: kein Fehler", name)
}
}
}
func TestLeseZipSkipsAndLimits(t *testing.T) {
archiv := packe(t, map[string]string{
"projekt/_index.md": "Übersicht",
"projekt/eintrag.md": "Text",
"projekt/bild.png": "PNG",
"__MACOSX/projekt/._eintrag.md": "Müll",
"projekt/.obsidian/workspace.md": "Einstellungen",
})
dateien, err := LeseZip(archiv)
if err != nil {
t.Fatal(err)
}
if len(dateien) != 1 || dateien[0].Pfad != "projekt/eintrag.md" {
t.Errorf("gelesen: %+v", dateien)
}
zuGross := packe(t, map[string]string{"gross.md": strings.Repeat("x", MaxDateiBytes+1)})
if _, err := LeseZip(zuGross); err == nil {
t.Error("eine zu große Datei wurde angenommen")
}
if _, err := LeseZip([]byte("kein zip")); err == nil {
t.Error("Unsinn wurde als Archiv angenommen")
}
if _, err := LeseZip(packe(t, map[string]string{"bild.png": "x"})); err == nil {
t.Error("ein Archiv ohne Markdown wurde angenommen")
}
}

274
internal/importer/parse.go Normal file
View file

@ -0,0 +1,274 @@
package importer
import (
"errors"
"fmt"
"path"
"strings"
"time"
"munin/internal/store"
)
// Eintrag ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben.
type Eintrag struct {
Pfad string // Pfad innerhalb des Archivs, für die Anzeige
Slug string
Title string
Content string
Tags []string
Source string // leer = nicht angegeben, der Importer setzt dann "human"
Author string // leer = nicht angegeben, der Importer setzt den importierenden Zugang
Curated bool
// Zeitstempel aus dem Kopf der Datei. Leer, wenn keiner angegeben oder
// lesbar war; dann gilt beim Anlegen „jetzt“.
CreatedAt time.Time
UpdatedAt time.Time
}
// Parse liest eine Markdown-Datei.
//
// # Zwei Arten von Dateien
//
// 1. Dateien aus dem Munin-Export (internal/export). Sie tragen im Kopf alle
// Felder: slug, title, tags, source, author, curated, created_at,
// updated_at. Daraus entsteht der Eintrag so, wie er exportiert wurde.
//
// 2. Beliebige Markdown-Dateien, etwa vorhandene Dokumentation eines Teams.
// Sie haben meist keinen Kopf oder einen mit anderen Feldern. Dann gilt:
// Bezeichner aus dem Dateinamen, Titel aus der ersten Überschrift „# …“
// oder, falls es keine gibt, aus dem Dateinamen.
//
// Felder, die Munin nicht kennt, werden ignoriert. So scheitert der Import
// nicht an einem Kopf aus Hugo, Obsidian oder Jekyll.
func Parse(pfad string, inhalt []byte) (Eintrag, error) {
e := Eintrag{Pfad: pfad}
// Windows-Zeilenenden und ein Byte-Order-Mark am Anfang würden sonst den
// Kopf unkenntlich machen. Beides kommt bei Dateien aus Editoren unter
// Windows häufig vor.
text := strings.ReplaceAll(string(inhalt), "\r\n", "\n")
text = strings.TrimPrefix(text, "\uFEFF")
kopf, rumpf, err := trenneKopf(text)
if err != nil {
return e, err
}
for name, wert := range kopf {
switch name {
case "slug":
e.Slug = wert.text
case "title":
e.Title = wert.text
case "tags":
e.Tags = wert.liste
if len(e.Tags) == 0 && wert.text != "" {
// "tags: ops" ohne Klammern: ein einzelnes Schlagwort.
e.Tags = []string{wert.text}
}
case "source":
if wert.text == store.SourceHuman || wert.text == store.SourceAgent {
e.Source = wert.text
}
case "author":
e.Author = wert.text
case "curated":
e.Curated = wert.text == "true"
case "created_at":
e.CreatedAt = leseZeit(wert.text)
case "updated_at":
e.UpdatedAt = leseZeit(wert.text)
}
}
// Der Export schreibt den Titel zusätzlich als Überschrift über den
// Inhalt (siehe export.rendere). Die gehört nicht zum Inhalt und muss
// wieder weg, sonst stünde sie nach jedem Export und Import einmal mehr
// da. Eine Überschrift, die NICHT dem Titel entspricht, bleibt stehen:
// Dann ist sie Teil des Textes.
rumpf = strings.TrimLeft(rumpf, "\n")
if ueberschrift, rest, ok := ersteUeberschrift(rumpf); ok {
switch {
case e.Title == "":
e.Title = ueberschrift
rumpf = rest
case ueberschrift == e.Title:
rumpf = rest
}
}
e.Content = strings.TrimSpace(rumpf)
name := strings.TrimSuffix(path.Base(pfad), path.Ext(pfad))
if e.Title == "" {
e.Title = name
}
if e.Slug == "" {
e.Slug = store.Slugify(name)
}
if !store.ValidSlug(e.Slug) {
return e, fmt.Errorf("ungültiger Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", e.Slug)
}
if e.Content == "" {
return e, errors.New("die Datei hat keinen Inhalt")
}
return e, nil
}
// ersteUeberschrift erkennt eine Überschrift erster Ebene („# Titel“) in der
// ersten Zeile und gibt sie samt dem Rest des Textes zurück.
func ersteUeberschrift(text string) (ueberschrift, rest string, ok bool) {
zeile, rest, _ := strings.Cut(text, "\n")
if !strings.HasPrefix(zeile, "# ") {
return "", text, false
}
return strings.TrimSpace(zeile[2:]), rest, true
}
// leseZeit versteht die Schreibweise des Exports (RFC 3339) und ein bloßes
// Datum. Alles andere ergibt den Nullzeitpunkt, also „jetzt“ beim Anlegen.
// Ein unlesbares Datum ist kein Grund, eine ganze Datei abzulehnen.
func leseZeit(s string) time.Time {
for _, format := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(format, s); err == nil {
return t
}
}
return time.Time{}
}
// ── Kopf (Frontmatter) ───────────────────────────────────────────────────────
// kopfWert ist ein Feld aus dem Kopf: entweder ein einzelner Text oder eine
// Liste.
type kopfWert struct {
text string
liste []string
}
// trenneKopf trennt einen Kopf zwischen zwei Zeilen „---“ vom Rest der Datei.
// Ohne Kopf ist die ganze Datei der Rumpf.
//
// # Warum kein YAML-Paket?
//
// YAML ist groß und voller Überraschungen: „no“ wird zu false, „1.10“ zu 1.1.
// Gebraucht wird nur ein kleiner Teil davon: der, den der Export schreibt,
// plus die üblichen Formen von Schlagwortlisten aus anderen Werkzeugen. Dafür
// reichen ein paar Zeilen, die man vollständig lesen kann, und das Programm
// bekommt keine zusätzliche Abhängigkeit.
//
// Verstanden wird:
//
// name: "Text mit \"Maskierung\"" (so schreibt es der Export)
// name: 'Text'
// name: Text ohne Anführungszeichen
// tags: ["a", "b"] oder tags: [a, b]
// tags:
// - a
// - b
func trenneKopf(text string) (map[string]kopfWert, string, error) {
if !strings.HasPrefix(text, "---\n") {
return nil, text, nil
}
zeilen := strings.Split(text, "\n")
kopf := make(map[string]kopfWert)
var offeneListe string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt
for i := 1; i < len(zeilen); i++ {
zeile := zeilen[i]
if strings.TrimSpace(zeile) == "---" {
return kopf, strings.Join(zeilen[i+1:], "\n"), nil
}
// Listenpunkt einer Blockliste, etwa " - ops" unter "tags:".
if eingerueckt := strings.TrimSpace(zeile); offeneListe != "" && strings.HasPrefix(eingerueckt, "- ") {
w := kopf[offeneListe]
w.liste = append(w.liste, einzelwert(strings.TrimSpace(eingerueckt[2:])))
kopf[offeneListe] = w
continue
}
offeneListe = ""
if strings.TrimSpace(zeile) == "" || strings.HasPrefix(strings.TrimSpace(zeile), "#") {
continue
}
name, wert, ok := strings.Cut(zeile, ":")
if !ok {
continue // keine Zuweisung, etwas, das dieser kleine Leser nicht kennt
}
name = strings.TrimSpace(name)
wert = strings.TrimSpace(wert)
switch {
case wert == "":
offeneListe = name
kopf[name] = kopfWert{}
case strings.HasPrefix(wert, "["):
kopf[name] = kopfWert{liste: inlineListe(wert)}
default:
kopf[name] = kopfWert{text: einzelwert(wert)}
}
}
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
// der Export schreibt (siehe export.yamlWert).
func einzelwert(s string) string {
switch {
case len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"':
return strings.NewReplacer(`\\`, `\`, `\"`, `"`, `\n`, "\n", `\t`, "\t").Replace(s[1 : len(s)-1])
case len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'':
// In YAML steht '' innerhalb einfacher Anführungszeichen für ein '.
return strings.ReplaceAll(s[1:len(s)-1], "''", "'")
}
return s
}
// inlineListe zerlegt „[a, "b, c", d]“ in seine Bestandteile. Kommas innerhalb
// von Anführungszeichen trennen nicht.
func inlineListe(s string) []string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
var teile []string
var aktuell strings.Builder
var inZeichen byte // 0 = außerhalb, sonst das öffnende Anführungszeichen
fertig := func() {
if t := strings.TrimSpace(aktuell.String()); t != "" {
teile = append(teile, einzelwert(t))
}
aktuell.Reset()
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inZeichen == '"' && c == '\\' && i+1 < len(s):
// Maskiertes Zeichen: beide Bytes übernehmen, einzelwert löst es auf.
aktuell.WriteByte(c)
i++
aktuell.WriteByte(s[i])
case inZeichen != 0 && c == inZeichen:
inZeichen = 0
aktuell.WriteByte(c)
case inZeichen == 0 && (c == '"' || c == '\''):
inZeichen = c
aktuell.WriteByte(c)
case inZeichen == 0 && c == ',':
fertig()
default:
aktuell.WriteByte(c)
}
}
fertig()
return teile
}

View file

@ -0,0 +1,148 @@
// Package markdown ist die eine Stelle, an der Munin Markdown in HTML wandelt —
// für Einträge (internal/entry) genauso wie für die eingebaute Hilfe
// (internal/hilfe).
//
// # Warum ein eigenes Paket?
//
// Vorher hatten Einträge und Hilfe je einen eigenen goldmark-Aufruf mit
// unterschiedlichen Einstellungen. Die Folge: Eine Tabelle erschien in der
// Hilfe als Tabelle, im Eintrag aber als Text — und wer die Hilfe las, konnte
// nicht wissen, was im eigenen Eintrag funktioniert. Mit einem gemeinsamen
// Paket gilt eine Regel für beide (AUSBAUSTUFEN.md 5.3).
//
// # Sicherheitshinweis
//
// goldmark lässt in der Standardeinstellung eingebettetes Roh-HTML NICHT durch —
// aus <script>alert(1)</script> im Markdown wird ein Kommentar, kein Skript.
// Außerdem entfernt es gefährliche Link-Ziele wie "javascript:". Die
// Weboberfläche verlässt sich darauf, wenn sie das Ergebnis ohne weiteres
// Escaping einsetzt.
//
// Deshalb darf hier nie html.WithUnsafe() dazukommen. Als zweite
// Verteidigungslinie steht die Content-Security-Policy (siehe internal/web).
package markdown
import (
"bytes"
"fmt"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"munin/internal/store"
)
// neuerRenderer baut einen goldmark-Renderer mit den Erweiterungen, die überall
// gelten. Zusätzliche Parser-Optionen (etwa Überschrift-Anker) kommen als
// Parameter dazu.
//
// Das "..." vor dem Typ heißt in Go "beliebig viele Werte dieses Typs"
// (variadische Funktion) — man kann neuerRenderer() also auch ganz ohne
// Zusatzoptionen aufrufen.
func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown {
return goldmark.New(
// GFM ("GitHub Flavored Markdown") bündelt vier Erweiterungen:
// Tabellen, ~~Durchstreichen~~, Checklisten (- [ ] / - [x]) und
// automatische Links für nackte Adressen wie https://example.org.
// Das ist, was die meisten aus GitHub oder GitLab gewohnt sind — und was
// Agenten ohnehin schreiben.
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(zusatz...),
)
}
// Die beiden Renderer werden einmal beim Programmstart gebaut und dann für
// jede Anfrage wiederverwendet. goldmark erlaubt das ausdrücklich: Ein
// Renderer hat keinen Zustand, der sich zwischen zwei Aufrufen ändert, und ist
// damit auch bei gleichzeitigen Anfragen sicher.
var (
// ohneAnker ist für Einträge. Überschriften bekommen hier bewusst KEINE
// id-Attribute: Eintragstexte kommen von Menschen und Agenten, und eine
// Überschrift wie "## suche" ergäbe id="suche" — die könnte mit einer id
// der Seite selbst zusammenstoßen, auf die sich das JavaScript verlässt.
ohneAnker = neuerRenderer()
// mitAnker ist für die Hilfe. Jede Überschrift bekommt eine id aus ihrem
// Text ("## Markdown im Inhalt" → id="markdown-im-inhalt"), damit Links
// aus der Oberfläche direkt auf einen Abschnitt zeigen können
// (AUSBAUSTUFEN.md 5.4). Die Hilfeseiten liegen im Repository, ihre
// Überschriften sind also bekannt und unbedenklich.
mitAnker = neuerRenderer(parser.WithAutoHeadingID())
)
// Render wandelt Markdown in HTML, ohne Überschrift-Anker. Für Einträge.
func Render(quelle string) (string, error) {
return umwandeln(ohneAnker, quelle)
}
// RenderMitAnkern wandelt Markdown in HTML und gibt jeder Überschrift eine id.
// Für die Hilfe.
func RenderMitAnkern(quelle string) (string, error) {
// Jede Seite bekommt ihren eigenen Anker-Generator. Er merkt sich, welche
// ids schon vergeben sind — und das darf nicht in die nächste Seite
// hinüberwirken (und bei gleichzeitigen Anfragen schon gar nicht).
kontext := parser.NewContext(parser.WithIDs(&ankerIDs{vergeben: map[string]bool{}}))
return umwandeln(mitAnker, quelle, parser.WithContext(kontext))
}
// umwandeln ist der gemeinsame Teil beider Funktionen.
func umwandeln(renderer goldmark.Markdown, quelle string, optionen ...parser.ParseOption) (string, error) {
// bytes.Buffer ist ein wachsender Speicher, in den goldmark das Ergebnis
// schreibt. Das & übergibt einen Zeiger darauf, damit goldmark genau diesen
// Puffer füllt und nicht eine Kopie davon.
var puffer bytes.Buffer
if err := renderer.Convert([]byte(quelle), &puffer, optionen...); err != nil {
return "", fmt.Errorf("Markdown konnte nicht dargestellt werden: %w", err)
}
return puffer.String(), nil
}
// ankerIDs erzeugt die id-Attribute der Überschriften in der Hilfe.
//
// # Warum nicht goldmarks eingebaute Anker?
//
// goldmark lässt alle Zeichen außerhalb von a–z und 0–9 einfach weg. Aus
// "## Zugänge verwalten" würde so id="zugnge-verwalten" — unschön, und wer
// einen Link darauf schreibt, rät garantiert falsch. store.Slugify faltet
// Umlaute dagegen so, wie man es erwartet (ä → ae, ß → ss), und ist dieselbe
// Regel, nach der auch Eintragsbezeichner aus Titeln entstehen. Eine Regel
// für beides ist leichter zu merken als zwei.
//
// # Wie goldmark das benutzt
//
// goldmark erwartet hier ein Interface (parser.IDs) mit zwei Methoden:
// Generate und Put. In Go muss man ein Interface nicht ausdrücklich
// "implementieren" — es reicht, dass der Typ Methoden mit genau diesen Namen
// und Signaturen hat. Der Compiler prüft das an der Stelle, wo &ankerIDs{…}
// an parser.WithIDs übergeben wird.
type ankerIDs struct {
vergeben map[string]bool
}
// Generate bildet aus dem Text einer Überschrift eine id. Kommt dieselbe
// Überschrift zweimal vor, bekommt die zweite ein "-1" angehängt, die dritte
// ein "-2" — ids müssen auf einer Seite eindeutig sein.
func (a *ankerIDs) Generate(text []byte, _ ast.NodeKind) []byte {
basis := store.Slugify(string(text))
if basis == "" {
// Eine Überschrift nur aus Sonderzeichen ("## ???") — dann eben
// ein neutraler Name, eindeutig gemacht wie jeder andere.
basis = "abschnitt"
}
id := basis
for nummer := 1; a.vergeben[id]; nummer++ {
id = fmt.Sprintf("%s-%d", basis, nummer)
}
a.vergeben[id] = true
return []byte(id)
}
// Put merkt sich eine von Hand vergebene id, damit Generate sie nicht noch
// einmal erzeugt. (Von Hand vergebene ids nutzen die Hilfeseiten derzeit
// nicht, goldmark verlangt die Methode aber.)
func (a *ankerIDs) Put(id []byte) {
a.vergeben[string(id)] = true
}

View file

@ -0,0 +1,90 @@
package markdown
import (
"strings"
"testing"
)
// TestGFMErweiterungen: Tabellen, Durchstreichen, Checklisten und automatische
// Links funktionieren — in Einträgen genauso wie in der Hilfe (AUSBAUSTUFEN.md 5.3).
func TestGFMErweiterungen(t *testing.T) {
faelle := []struct {
name, quelle, erwartet string
}{
{"Tabelle", "| a | b |\n|---|---|\n| 1 | 2 |", "<table>"},
{"Durchstreichen", "~~veraltet~~", "<del>veraltet</del>"},
{"Checkliste offen", "- [ ] offen", `<input disabled="" type="checkbox">`},
{"Checkliste erledigt", "- [x] erledigt", `<input checked="" disabled="" type="checkbox">`},
{"automatischer Link", "siehe https://example.org", `<a href="https://example.org">`},
}
// Beide Renderer prüfen: Sie sollen sich nur in den Ankern unterscheiden.
renderer := map[string]func(string) (string, error){
"Render": Render,
"RenderMitAnkern": RenderMitAnkern,
}
for rName, render := range renderer {
for _, f := range faelle {
html, err := render(f.quelle)
if err != nil {
t.Fatalf("%s/%s: %v", rName, f.name, err)
}
if !strings.Contains(html, f.erwartet) {
t.Errorf("%s/%s: %q fehlt in %q", rName, f.name, f.erwartet, html)
}
}
}
}
// TestKeinRohHTML ist der wichtigste Test dieses Pakets: Die Weboberfläche setzt
// das Ergebnis ohne weiteres Escaping in die Seite. Das geht nur, solange hier
// weder Skripte noch gefährliche Link-Ziele durchkommen — auch nicht mit den
// GFM-Erweiterungen.
func TestKeinRohHTML(t *testing.T) {
boese := []string{
"<script>alert(1)</script>",
`<img src=x onerror="alert(1)">`,
"[klick](javascript:alert(1))",
"| a |\n|---|\n| <script>alert(1)</script> |",
"- [ ] <script>alert(1)</script>",
}
for _, quelle := range boese {
for _, render := range []func(string) (string, error){Render, RenderMitAnkern} {
html, _ := render(quelle)
if strings.Contains(html, "<script") || strings.Contains(html, "onerror") || strings.Contains(html, "javascript:") {
t.Errorf("gefährlicher Inhalt durchgelassen: %q → %q", quelle, html)
}
}
}
}
// TestAnker: Nur die Hilfe bekommt ids an den Überschriften, mit gefalteten
// Umlauten und eindeutig je Seite.
func TestAnker(t *testing.T) {
quelle := "## Zugänge verwalten\n\n## Protokoll\n\n## Protokoll\n\n## ???"
html, err := RenderMitAnkern(quelle)
if err != nil {
t.Fatal(err)
}
for _, id := range []string{`id="zugaenge-verwalten"`, `id="protokoll"`, `id="protokoll-1"`, `id="abschnitt"`} {
if !strings.Contains(html, id) {
t.Errorf("%s fehlt in %q", id, html)
}
}
// Ein zweiter Aufruf fängt wieder von vorn an — die vergebenen ids der
// ersten Seite dürfen nicht hinüberwirken.
nochmal, _ := RenderMitAnkern("## Protokoll")
if !strings.Contains(nochmal, `id="protokoll"`) {
t.Errorf("zweite Seite bekommt nicht die einfache id: %q", nochmal)
}
// Einträge: keine ids, damit Eintragstext nicht mit ids der Seite
// kollidieren kann.
eintrag, _ := Render(quelle)
if strings.Contains(eintrag, "id=") {
t.Errorf("Eintrag hat Überschrift-ids bekommen: %q", eintrag)
}
}

View file

@ -14,6 +14,7 @@ import (
"munin/internal/search" "munin/internal/search"
"munin/internal/store" "munin/internal/store"
"munin/internal/web" "munin/internal/web"
"munin/internal/workspace"
) )
// aufbau legt Datenbank, Projekt und Handler an. // aufbau legt Datenbank, Projekt und Handler an.
@ -21,13 +22,13 @@ import (
// Das Schreibbudget ist bewusst großzügig: Die meisten Tests haben mit der // Das Schreibbudget ist bewusst großzügig: Die meisten Tests haben mit der
// Begrenzung nichts zu tun, sollen aber trotzdem durch denselben Code laufen wie // Begrenzung nichts zu tun, sollen aber trotzdem durch denselben Code laufen wie
// der Betrieb. Wer die Grenze prüfen will, nimmt aufbauMitBudget. // der Betrieb. Wer die Grenze prüfen will, nimmt aufbauMitBudget.
func aufbau(t *testing.T) (*Handler, *store.Store, store.Project) { func aufbau(t *testing.T) (*Handler, *workspace.Workspace, store.Project) {
t.Helper() t.Helper()
return aufbauMitBudget(t, 1000) return aufbauMitBudget(t, 1000)
} }
// aufbauMitBudget wie aufbau, aber mit einer wählbaren Obergrenze je Stunde. // aufbauMitBudget wie aufbau, aber mit einer wählbaren Obergrenze je Stunde.
func aufbauMitBudget(t *testing.T, limit int) (*Handler, *store.Store, store.Project) { func aufbauMitBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, store.Project) {
t.Helper() t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
@ -37,17 +38,19 @@ func aufbauMitBudget(t *testing.T, limit int) (*Handler, *store.Store, store.Pro
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
ctx := context.Background() ctx := context.Background()
ws, err := db.EnsureWorkspace(ctx, "Test") // Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr — die lebt in
if err != nil { // der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für
t.Fatal(err) // den Test reicht eine beliebige ID.
} workspaceID := store.NewID()
projekt, err := db.CreateProject(ctx, ws.ID, "test", "Testprojekt", "") projekt, err := db.CreateProject(ctx, workspaceID, "test", "Testprojekt", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
ws := &workspace.Workspace{ID: workspaceID, Store: db, Search: search.NewRegistry(db)}
budget := auth.NewWriteBudget(limit, time.Hour) budget := auth.NewWriteBudget(limit, time.Hour)
return New(db, search.NewRegistry(db), budget), db, projekt return New(budget), ws, projekt
} }
// ruf ruft ein MCP-Werkzeug auf. // ruf ruft ein MCP-Werkzeug auf.
@ -56,16 +59,18 @@ func aufbauMitBudget(t *testing.T, limit int) (*Handler, *store.Store, store.Pro
// In-Memory-Transportpaar direkt mit dem Server — dieselbe Logik, ohne Netzwerk. // In-Memory-Transportpaar direkt mit dem Server — dieselbe Logik, ohne Netzwerk.
// Die Sitzung (Projekt und Rolle) wird im Context mitgegeben, so wie es sonst // Die Sitzung (Projekt und Rolle) wird im Context mitgegeben, so wie es sonst
// web.APIAuth tut. // web.APIAuth tut.
func ruf(t *testing.T, h *Handler, projekt store.Project, rolle auth.Role, werkzeug string, args map[string]any) *mcp.CallToolResult { func ruf(t *testing.T, h *Handler, ws *workspace.Workspace, projekt store.Project, rolle auth.Role, werkzeug string, args map[string]any) *mcp.CallToolResult {
t.Helper() t.Helper()
sess := web.Session{ sess := web.Session{
Subject: "key:test", Subject: "key:" + ws.ID + ":test",
WorkspaceID: ws.ID,
Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: projekt.ID, Role: string(rolle)}, Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: projekt.ID, Role: string(rolle)},
Project: projekt, Project: projekt,
Role: rolle, Role: rolle,
} }
ctx := web.WithSessionForTest(context.Background(), sess) ctx := web.WithSessionForTest(context.Background(), sess)
ctx = web.WithWorkspaceForTest(ctx, ws)
clientTransport, serverTransport := mcp.NewInMemoryTransports() clientTransport, serverTransport := mcp.NewInMemoryTransports()
@ -138,7 +143,7 @@ func TestToolsAndInstructions(t *testing.T) {
vorhanden[tool.Name] = true vorhanden[tool.Name] = true
} }
for _, name := range []string{"recall", "get", "list", "remember", "forget"} { for _, name := range []string{"recall", "get", "list", "remember", "forget", "related"} {
if !vorhanden[name] { if !vorhanden[name] {
t.Errorf("Werkzeug %q fehlt", name) t.Errorf("Werkzeug %q fehlt", name)
} }
@ -150,12 +155,13 @@ func TestToolsAndInstructions(t *testing.T) {
// TestRememberDeduplicatesBySlug prüft den Kern des Gedächtnisses: Zweimal // TestRememberDeduplicatesBySlug prüft den Kern des Gedächtnisses: Zweimal
// dasselbe Thema unter demselben Bezeichner ergibt einen Eintrag, nicht zwei. // dasselbe Thema unter demselben Bezeichner ergibt einen Eintrag, nicht zwei.
func TestRememberDeduplicatesBySlug(t *testing.T) { func TestRememberDeduplicatesBySlug(t *testing.T) {
h, db, projekt := aufbau(t) h, ws, projekt := aufbau(t)
db := ws.Store
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "Erste Erkenntnis", "slug": "deployment", "title": "Deployment", "content": "Erste Erkenntnis",
}) })
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "Zweite, bessere Erkenntnis", "slug": "deployment", "title": "Deployment", "content": "Zweite, bessere Erkenntnis",
}) })
@ -178,9 +184,10 @@ func TestRememberDeduplicatesBySlug(t *testing.T) {
// TestRememberRecordsProvenance prüft, dass nachvollziehbar bleibt, wer geschrieben hat. // TestRememberRecordsProvenance prüft, dass nachvollziehbar bleibt, wer geschrieben hat.
func TestRememberRecordsProvenance(t *testing.T) { func TestRememberRecordsProvenance(t *testing.T) {
h, db, projekt := aufbau(t) h, ws, projekt := aufbau(t)
db := ws.Store
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "notiz", "title": "Notiz", "content": "Inhalt", "slug": "notiz", "title": "Notiz", "content": "Inhalt",
}) })
@ -196,9 +203,10 @@ func TestRememberRecordsProvenance(t *testing.T) {
// TestRememberDerivesSlug prüft, dass ein fehlender Bezeichner aus dem Titel // TestRememberDerivesSlug prüft, dass ein fehlender Bezeichner aus dem Titel
// abgeleitet wird — das Modell soll nicht an Formalien scheitern. // abgeleitet wird — das Modell soll nicht an Formalien scheitern.
func TestRememberDerivesSlug(t *testing.T) { func TestRememberDerivesSlug(t *testing.T) {
h, db, projekt := aufbau(t) h, ws, projekt := aufbau(t)
db := ws.Store
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"title": "Der Deployment-Prozess", "content": "Inhalt", "title": "Der Deployment-Prozess", "content": "Inhalt",
}) })
@ -209,18 +217,18 @@ func TestRememberDerivesSlug(t *testing.T) {
// TestRememberDetectsConflict prüft das optimistische Sperren. // TestRememberDetectsConflict prüft das optimistische Sperren.
func TestRememberDetectsConflict(t *testing.T) { func TestRememberDetectsConflict(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "Fassung 1", "slug": "geteilt", "title": "T", "content": "Fassung 1",
}) })
// Agent A schreibt und erhöht damit auf Fassung 2. // Agent A schreibt und erhöht damit auf Fassung 2.
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "von A", "slug": "geteilt", "title": "T", "content": "von A",
}) })
// Agent B hatte noch Fassung 1 gelesen. // Agent B hatte noch Fassung 1 gelesen.
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "geteilt", "title": "T", "content": "von B", "expected_version": 1, "slug": "geteilt", "title": "T", "content": "von B", "expected_version": 1,
}) })
@ -237,7 +245,8 @@ func TestRememberDetectsConflict(t *testing.T) {
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang nichts verändern kann. // TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang nichts verändern kann.
func TestReadRoleCannotWrite(t *testing.T) { func TestReadRoleCannotWrite(t *testing.T) {
h, db, projekt := aufbau(t) h, ws, projekt := aufbau(t)
db := ws.Store
// Jedes Werkzeug bekommt nur die Parameter, die es kennt — sonst weist schon // Jedes Werkzeug bekommt nur die Parameter, die es kennt — sonst weist schon
// die Schemaprüfung des SDK den Aufruf ab und die Rollenprüfung käme nie dran. // die Schemaprüfung des SDK den Aufruf ab und die Rollenprüfung käme nie dran.
@ -248,7 +257,7 @@ func TestReadRoleCannotWrite(t *testing.T) {
for werkzeug, args := range faelle { for werkzeug, args := range faelle {
t.Run(werkzeug, func(t *testing.T) { t.Run(werkzeug, func(t *testing.T) {
ergebnis := ruf(t, h, projekt, auth.RoleRead, werkzeug, args) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, werkzeug, args)
if !ergebnis.IsError { if !ergebnis.IsError {
t.Error("der Nur-Lese-Zugang durfte schreiben") t.Error("der Nur-Lese-Zugang durfte schreiben")
} }
@ -264,9 +273,9 @@ func TestReadRoleCannotWrite(t *testing.T) {
} }
func TestReadRoleCanRead(t *testing.T) { func TestReadRoleCanRead(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "notiz", "title": "Notiz", "content": "Inhalt", "slug": "notiz", "title": "Notiz", "content": "Inhalt",
}) })
@ -275,7 +284,7 @@ func TestReadRoleCanRead(t *testing.T) {
if werkzeug == "recall" { if werkzeug == "recall" {
args["query"] = "Notiz" args["query"] = "Notiz"
} }
if ergebnis := ruf(t, h, projekt, auth.RoleRead, werkzeug, args); ergebnis.IsError { if ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, werkzeug, args); ergebnis.IsError {
t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", werkzeug, text(ergebnis)) t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", werkzeug, text(ergebnis))
} }
} }
@ -287,14 +296,14 @@ func TestReadRoleCanRead(t *testing.T) {
// "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis, // "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis,
// und das Modell erfährt nie, dass es den Eintrag gäbe. // und das Modell erfährt nie, dass es den Eintrag gäbe.
func TestRecallFindsByPrefix(t *testing.T) { func TestRecallFindsByPrefix(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess", "slug": "deployment-prozess", "title": "Deployment-Prozess",
"content": "Wir deployen über GitHub Actions.", "content": "Wir deployen über GitHub Actions.",
}) })
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", map[string]any{"query": "deploy"}) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "deploy"})
if ergebnis.IsError { if ergebnis.IsError {
t.Fatalf("recall schlug fehl: %s", text(ergebnis)) t.Fatalf("recall schlug fehl: %s", text(ergebnis))
} }
@ -306,25 +315,25 @@ func TestRecallFindsByPrefix(t *testing.T) {
// TestRecallEmptyGivesHint prüft, dass ein leeres Ergebnis dem Modell sagt, was // TestRecallEmptyGivesHint prüft, dass ein leeres Ergebnis dem Modell sagt, was
// als Nächstes zu tun ist. // als Nächstes zu tun ist.
func TestRecallEmptyGivesHint(t *testing.T) { func TestRecallEmptyGivesHint(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"}) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"})
if !strings.Contains(text(ergebnis), "remember") { if !strings.Contains(text(ergebnis), "remember") {
t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(ergebnis)) t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(ergebnis))
} }
} }
func TestRecallFiltersByTag(t *testing.T) { func TestRecallFiltersByTag(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "a", "title": "Deployment A", "content": "deployment", "tags": []any{"ops"}, "slug": "a", "title": "Deployment A", "content": "deployment", "tags": []any{"ops"},
}) })
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"}, "slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"},
}) })
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall",
map[string]any{"query": "deployment", "tags": []any{"ops"}}) map[string]any{"query": "deployment", "tags": []any{"ops"}})
if strings.Contains(text(ergebnis), `"slug": "b"`) || strings.Contains(text(ergebnis), `"slug":"b"`) { if strings.Contains(text(ergebnis), `"slug": "b"`) || strings.Contains(text(ergebnis), `"slug":"b"`) {
@ -335,30 +344,30 @@ func TestRecallFiltersByTag(t *testing.T) {
// TestGetReturnsBacklinks prüft die Verweise in beide Richtungen — sie ersetzen // TestGetReturnsBacklinks prüft die Verweise in beide Richtungen — sie ersetzen
// die frühere Ordnerhierarchie als Struktur zwischen Einträgen. // die frühere Ordnerhierarchie als Struktur zwischen Einträgen.
func TestGetReturnsBacklinks(t *testing.T) { func TestGetReturnsBacklinks(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "ziel", "title": "Ziel", "content": "Inhalt", "slug": "ziel", "title": "Ziel", "content": "Inhalt",
}) })
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "quelle", "title": "Quelle", "content": "siehe [[ziel]]", "slug": "quelle", "title": "Quelle", "content": "siehe [[ziel]]",
}) })
ergebnis := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "ziel"}) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "ziel"})
if !strings.Contains(text(ergebnis), "quelle") { if !strings.Contains(text(ergebnis), "quelle") {
t.Errorf("der Rückverweis fehlt: %s", text(ergebnis)) t.Errorf("der Rückverweis fehlt: %s", text(ergebnis))
} }
andersherum := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "quelle"}) andersherum := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "quelle"})
if !strings.Contains(text(andersherum), "ziel") { if !strings.Contains(text(andersherum), "ziel") {
t.Error("der ausgehende Verweis fehlt") t.Error("der ausgehende Verweis fehlt")
} }
} }
func TestGetUnknownSlugExplains(t *testing.T) { func TestGetUnknownSlugExplains(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
ergebnis := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"}) ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"})
if !ergebnis.IsError { if !ergebnis.IsError {
t.Fatal("ein unbekannter Bezeichner wurde nicht als Fehler gemeldet") t.Fatal("ein unbekannter Bezeichner wurde nicht als Fehler gemeldet")
} }
@ -373,38 +382,38 @@ func TestGetUnknownSlugExplains(t *testing.T) {
// TestProjectIsolation ist der wichtigste Test: Die Werkzeuge eines Projekts // TestProjectIsolation ist der wichtigste Test: Die Werkzeuge eines Projekts
// dürfen unter keinen Umständen an die Daten eines anderen kommen. // dürfen unter keinen Umständen an die Daten eines anderen kommen.
func TestProjectIsolation(t *testing.T) { func TestProjectIsolation(t *testing.T) {
h, db, projektA := aufbau(t) h, ws, projektA := aufbau(t)
db := ws.Store
ctx := context.Background() ctx := context.Background()
ws, _ := db.EnsureWorkspace(ctx, "Test") projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "fremd", "Fremdes Projekt", "")
projektB, err := db.CreateProject(ctx, ws.ID, "fremd", "Fremdes Projekt", "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Wissen in Projekt B ablegen. // Wissen in Projekt B ablegen.
ruf(t, h, projektB, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projektB, auth.RoleWrite, "remember", map[string]any{
"slug": "geheim", "title": "Geheim", "content": "Vertrauliches aus Projekt B", "slug": "geheim", "title": "Geheim", "content": "Vertrauliches aus Projekt B",
}) })
// Mit den Werkzeugen von Projekt A danach suchen. // Mit den Werkzeugen von Projekt A danach suchen.
suche := ruf(t, h, projektA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"}) suche := ruf(t, h, ws, projektA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"})
if strings.Contains(text(suche), "Vertrauliches") { if strings.Contains(text(suche), "Vertrauliches") {
t.Error("recall in Projekt A fand Inhalte aus Projekt B") t.Error("recall in Projekt A fand Inhalte aus Projekt B")
} }
lesen := ruf(t, h, projektA, auth.RoleRead, "get", map[string]any{"slug": "geheim"}) lesen := ruf(t, h, ws, projektA, auth.RoleRead, "get", map[string]any{"slug": "geheim"})
if !lesen.IsError { if !lesen.IsError {
t.Error("get in Projekt A lieferte einen Eintrag aus Projekt B") t.Error("get in Projekt A lieferte einen Eintrag aus Projekt B")
} }
liste := ruf(t, h, projektA, auth.RoleRead, "list", map[string]any{}) liste := ruf(t, h, ws, projektA, auth.RoleRead, "list", map[string]any{})
if strings.Contains(text(liste), "geheim") { if strings.Contains(text(liste), "geheim") {
t.Error("list in Projekt A zeigte einen Eintrag aus Projekt B") t.Error("list in Projekt A zeigte einen Eintrag aus Projekt B")
} }
// Und der Gegentest: Löschen darf ebenfalls nicht durchschlagen. // Und der Gegentest: Löschen darf ebenfalls nicht durchschlagen.
ruf(t, h, projektA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"}) ruf(t, h, ws, projektA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"})
if n, _ := db.CountEntries(ctx, projektB.ID); n != 1 { if n, _ := db.CountEntries(ctx, projektB.ID); n != 1 {
t.Error("forget in Projekt A hat einen Eintrag in Projekt B gelöscht") t.Error("forget in Projekt A hat einen Eintrag in Projekt B gelöscht")
} }
@ -415,17 +424,17 @@ func TestProjectIsolation(t *testing.T) {
// TestRecallLimitIsCapped prüft, dass ein Client nicht beliebig viele Treffer // TestRecallLimitIsCapped prüft, dass ein Client nicht beliebig viele Treffer
// anfordern kann — eine riesige Antwort sprengt das Kontextfenster des Modells. // anfordern kann — eine riesige Antwort sprengt das Kontextfenster des Modells.
func TestRecallLimitIsCapped(t *testing.T) { func TestRecallLimitIsCapped(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
for i := 0; i < 60; i++ { for i := 0; i < 60; i++ {
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)), "slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)),
"title": "Eintrag", "title": "Eintrag",
"content": "gemeinsames Stichwort", "content": "gemeinsames Stichwort",
}) })
} }
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall",
map[string]any{"query": "gemeinsames", "limit": 9999}) map[string]any{"query": "gemeinsames", "limit": 9999})
// Mehr als recallMaxLimit Treffer dürfen nicht zurückkommen. // Mehr als recallMaxLimit Treffer dürfen nicht zurückkommen.
@ -435,9 +444,9 @@ func TestRecallLimitIsCapped(t *testing.T) {
} }
func TestRememberRejectsEmptyContent(t *testing.T) { func TestRememberRejectsEmptyContent(t *testing.T) {
h, _, projekt := aufbau(t) h, ws, projekt := aufbau(t)
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember",
map[string]any{"slug": "leer", "title": "T", "content": ""}) map[string]any{"slug": "leer", "title": "T", "content": ""})
if !ergebnis.IsError { if !ergebnis.IsError {
@ -451,10 +460,11 @@ func TestRememberRejectsEmptyContent(t *testing.T) {
// Ein Agent in einer Schleife wird gestoppt, statt bis zum Plattenende zu schreiben. // Ein Agent in einer Schleife wird gestoppt, statt bis zum Plattenende zu schreiben.
func TestWriteBudgetStopsRunawayAgent(t *testing.T) { func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
const grenze = 3 const grenze = 3
h, db, projekt := aufbauMitBudget(t, grenze) h, ws, projekt := aufbauMitBudget(t, grenze)
db := ws.Store
for i := 0; i < grenze; i++ { for i := 0; i < grenze; i++ {
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt", "slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt",
}) })
if ergebnis.IsError { if ergebnis.IsError {
@ -462,7 +472,7 @@ func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
} }
} }
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "eintrag-zuviel", "title": "Zu viel", "content": "Inhalt", "slug": "eintrag-zuviel", "title": "Zu viel", "content": "Inhalt",
}) })
if !ergebnis.IsError { if !ergebnis.IsError {
@ -488,14 +498,14 @@ func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
// gedeckelt ist. Ein erschöpftes Budget darf das Gedächtnis nicht unlesbar machen — // gedeckelt ist. Ein erschöpftes Budget darf das Gedächtnis nicht unlesbar machen —
// sonst wäre der Agent nicht gebremst, sondern blind. // sonst wäre der Agent nicht gebremst, sondern blind.
func TestWriteBudgetLeavesReadingAlone(t *testing.T) { func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
h, _, projekt := aufbauMitBudget(t, 1) h, ws, projekt := aufbauMitBudget(t, 1)
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "vorhanden", "title": "Vorhanden", "content": "Inhalt", "slug": "vorhanden", "title": "Vorhanden", "content": "Inhalt",
}) })
// Budget ist jetzt aufgebraucht. // Budget ist jetzt aufgebraucht.
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "noch-einer", "title": "Noch einer", "content": "Inhalt", "slug": "noch-einer", "title": "Noch einer", "content": "Inhalt",
}); !ergebnis.IsError { }); !ergebnis.IsError {
t.Fatal("Vorbedingung verfehlt: das Budget ist noch nicht erschöpft") t.Fatal("Vorbedingung verfehlt: das Budget ist noch nicht erschöpft")
@ -510,7 +520,7 @@ func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
args["slug"] = "vorhanden" args["slug"] = "vorhanden"
} }
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, werkzeug, args); ergebnis.IsError { if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, werkzeug, args); ergebnis.IsError {
t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", werkzeug, text(ergebnis)) t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", werkzeug, text(ergebnis))
} }
} }
@ -519,7 +529,8 @@ func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
// TestWriteBudgetIsPerProject prüft die Mandantentrennung: Ein Projekt, das sein // TestWriteBudgetIsPerProject prüft die Mandantentrennung: Ein Projekt, das sein
// Budget verbraucht, darf kein anderes mitbremsen. // Budget verbraucht, darf kein anderes mitbremsen.
func TestWriteBudgetIsPerProject(t *testing.T) { func TestWriteBudgetIsPerProject(t *testing.T) {
h, db, projekt := aufbauMitBudget(t, 1) h, ws, projekt := aufbauMitBudget(t, 1)
db := ws.Store
ctx := context.Background() ctx := context.Background()
zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "zweites", "Zweites Projekt", "") zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "zweites", "Zweites Projekt", "")
@ -527,14 +538,14 @@ func TestWriteBudgetIsPerProject(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"}) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "B", "content": "Inhalt", "slug": "b", "title": "B", "content": "Inhalt",
}); !ergebnis.IsError { }); !ergebnis.IsError {
t.Fatal("Vorbedingung verfehlt: das erste Projekt ist noch nicht am Limit") t.Fatal("Vorbedingung verfehlt: das erste Projekt ist noch nicht am Limit")
} }
if ergebnis := ruf(t, h, zweites, auth.RoleWrite, "remember", map[string]any{ if ergebnis := ruf(t, h, ws, zweites, auth.RoleWrite, "remember", map[string]any{
"slug": "c", "title": "C", "content": "Inhalt", "slug": "c", "title": "C", "content": "Inhalt",
}); ergebnis.IsError { }); ergebnis.IsError {
t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(ergebnis)) t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(ergebnis))
@ -545,17 +556,214 @@ func TestWriteBudgetIsPerProject(t *testing.T) {
// Projekts den Standard schlägt — und zwar die aus der Sitzung, damit eine // Projekts den Standard schlägt — und zwar die aus der Sitzung, damit eine
// Änderung in der Verwaltung sofort wirkt. // Änderung in der Verwaltung sofort wirkt.
func TestWriteBudgetUsesProjectLimit(t *testing.T) { func TestWriteBudgetUsesProjectLimit(t *testing.T) {
h, _, projekt := aufbauMitBudget(t, 100) h, ws, projekt := aufbauMitBudget(t, 100)
// Dasselbe Projekt, aber mit einer eigenen, engeren Grenze — so, wie es // Dasselbe Projekt, aber mit einer eigenen, engeren Grenze — so, wie es
// web.APIAuth nach einer Änderung in der Verwaltung aus der Datenbank läse. // web.APIAuth nach einer Änderung in der Verwaltung aus der Datenbank läse.
projekt.WriteLimitHourly = 1 projekt.WriteLimitHourly = 1
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"}) ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{ if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "B", "content": "Inhalt", "slug": "b", "title": "B", "content": "Inhalt",
}); !ergebnis.IsError { }); !ergebnis.IsError {
t.Error("die eigene Obergrenze des Projekts wurde nicht angewandt") t.Error("die eigene Obergrenze des Projekts wurde nicht angewandt")
} }
} }
// TestRememberProtokolliertAngelegtOderAktualisiert: Das Protokoll muss
// unterscheiden, ob ein Schreibvorgang neu angelegt oder aktualisiert hat —
// daran lässt sich ablesen, ob Agenten vorhandene Einträge fortschreiben oder
// Dubletten erzeugen (AUSBAUSTUFEN.md 4.6).
func TestRememberProtokolliertAngelegtOderAktualisiert(t *testing.T) {
h, ws, projekt := aufbau(t)
for _, inhalt := range []string{"Erste Erkenntnis", "Zweite Erkenntnis"} {
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": inhalt,
})
}
ereignisse, err := ws.Store.ListAudit(context.Background(), store.AuditFilter{ProjectID: projekt.ID})
if err != nil {
t.Fatal(err)
}
var details []string
for _, e := range ereignisse {
if e.Action == store.ActionEntryWrite {
details = append(details, e.Detail)
}
}
// ListAudit liefert die neuesten zuerst.
erwartet := []string{store.DetailEntryUpdated, store.DetailEntryCreated}
if fmt.Sprint(details) != fmt.Sprint(erwartet) {
t.Errorf("Details = %v, erwartet %v", details, erwartet)
}
}
// ── Papierkorb ───────────────────────────────────────────────────────────────
// TestForgetMovesToTrash: "forget" löscht nicht mehr endgültig. Für den Agenten
// ist der Eintrag weg — get, recall und list sehen ihn nicht mehr —, in der
// Datenbank liegt er aber im Papierkorb, samt Name des Zugangs.
func TestForgetMovesToTrash(t *testing.T) {
h, ws, projekt := aufbau(t)
ctx := context.Background()
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "Wir deployen mit Blaugrün",
})
antwort := ruf(t, h, ws, projekt, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"})
if antwort.IsError {
t.Fatalf("forget: %s", text(antwort))
}
if !strings.Contains(text(antwort), "Papierkorb") {
t.Errorf("die Antwort erwähnt den Papierkorb nicht: %s", text(antwort))
}
if r := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "deployment"}); !r.IsError {
t.Error("get findet den gelöschten Eintrag noch")
}
if r := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "Blaugrün"}); strings.Contains(text(r), "deployment") {
t.Errorf("recall findet den gelöschten Eintrag noch: %s", text(r))
}
if r := ruf(t, h, ws, projekt, auth.RoleRead, "list", map[string]any{}); strings.Contains(text(r), "deployment") {
t.Errorf("list zeigt den gelöschten Eintrag noch: %s", text(r))
}
papierkorb, _ := ws.Store.ListTrash(ctx, projekt.ID, "")
if len(papierkorb) != 1 || papierkorb[0].DeletedBy != "test-agent" {
t.Errorf("Papierkorb = %+v, erwartet „deployment“, gelöscht von test-agent", papierkorb)
}
// Ein zweites forget auf denselben Bezeichner ist ein Fehler — aus Sicht
// des Agenten gibt es den Eintrag nicht mehr.
if r := ruf(t, h, ws, projekt, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"}); !r.IsError {
t.Error("zweites forget meldete keinen Fehler")
}
}
// TestRememberRevivesFromTrash: Schreibt ein Agent unter einem Bezeichner, der
// im Papierkorb liegt, wird der Eintrag zurückgeholt und überschrieben — und
// der Agent erfährt das in der Antwort.
func TestRememberRevivesFromTrash(t *testing.T) {
h, ws, projekt := aufbau(t)
ctx := context.Background()
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "alt",
})
ruf(t, h, ws, projekt, auth.RoleWrite, "forget", map[string]any{"slug": "deployment"})
antwort := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment", "title": "Deployment", "content": "neu",
})
if antwort.IsError {
t.Fatalf("remember: %s", text(antwort))
}
if !strings.Contains(text(antwort), "Papierkorb") {
t.Errorf("die Antwort sagt nicht, dass ein gelöschter Eintrag zurückgeholt wurde: %s", text(antwort))
}
if strings.Contains(text(antwort), "angelegt") {
t.Errorf("die Antwort behauptet, der Eintrag sei neu angelegt: %s", text(antwort))
}
e, err := ws.Store.EntryBySlug(ctx, projekt.ID, "deployment")
if err != nil || e.Content != "neu" || e.Version != 2 {
t.Errorf("nach remember: %+v, %v", e, err)
}
ereignisse, _ := ws.Store.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
gefunden := false
for _, ev := range ereignisse {
if ev.Action == store.ActionEntryRestore && ev.EntrySlug == "deployment" {
gefunden = true
}
}
if !gefunden {
t.Error("das Protokoll vermerkt das Zurückholen nicht")
}
}
// ── Verwandte Einträge und Alter (AUSBAUSTUFEN.md 3.7, 3.5) ──────────────────
func TestRelatedFindsNeighbours(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "release", "title": "Release-Prozess", "content": "Vorher [[checkliste]] durchgehen.",
})
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "checkliste", "title": "Checkliste", "content": "Siehe auch [[rollback]].",
})
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "rollback", "title": "Rollback", "content": "Zurück auf die letzte Fassung.",
})
// Auch ein Lesezugang darf das Umfeld abfragen.
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release"})
if ergebnis.IsError {
t.Fatalf("Fehler: %s", text(ergebnis))
}
antwort := text(ergebnis)
for _, erwartet := range []string{`"checkliste"`, `"rollback"`, `"distance":2`, `"via":"checkliste"`} {
if !strings.Contains(antwort, erwartet) {
t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort)
}
}
nurDirekt := text(ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release", "distance": 1}))
if strings.Contains(nurDirekt, "rollback") {
t.Errorf("distance 1 lieferte die zweite Reihe: %s", nurDirekt)
}
}
func TestRelatedUnknownSlugExplains(t *testing.T) {
h, ws, projekt := aufbau(t)
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "gibtesnicht"})
if !ergebnis.IsError || !strings.Contains(text(ergebnis), "recall") {
t.Errorf("unbekannter Bezeichner nicht hilfreich gemeldet: %s", text(ergebnis))
}
}
// TestStaleEntriesAreMarked prüft, dass alte Einträge in recall, get und list
// als "stale" erscheinen und frische nicht.
func TestStaleEntriesAreMarked(t *testing.T) {
h, ws, projekt := aufbau(t)
ctx := context.Background()
vorZweiJahren := time.Now().AddDate(-2, 0, 0)
if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "altes-deployment", Title: "Deployment alt",
Content: "Deployment per FTP", Source: store.SourceHuman, UpdatedAt: vorZweiJahren,
}); err != nil {
t.Fatal(err)
}
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "neues-deployment", "title": "Deployment neu", "content": "Deployment per Pipeline",
})
recall := text(ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "Deployment"}))
if strings.Count(recall, `"stale":true`) != 1 {
t.Errorf("recall: genau ein Treffer sollte stale sein: %s", recall)
}
if !strings.Contains(recall, "seit über einem Jahr") {
t.Errorf("recall: Hinweis auf veraltete Treffer fehlt: %s", recall)
}
alt := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "altes-deployment"}))
if !strings.Contains(alt, `"stale":true`) || !strings.Contains(alt, "prüfen") {
t.Errorf("get: alter Eintrag nicht markiert: %s", alt)
}
neu := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "neues-deployment"}))
if strings.Contains(neu, "stale") {
t.Errorf("get: frischer Eintrag als stale markiert: %s", neu)
}
liste := text(ruf(t, h, ws, projekt, auth.RoleRead, "list", nil))
if strings.Count(liste, `"stale":true`) != 1 {
t.Errorf("list: genau ein Eintrag sollte stale sein: %s", liste)
}
}

View file

@ -30,7 +30,6 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth" "munin/internal/auth"
"munin/internal/search"
"munin/internal/store" "munin/internal/store"
"munin/internal/web" "munin/internal/web"
) )
@ -62,6 +61,14 @@ Zum Bezeichner (slug):
Verweise: Verweise:
Im Inhalt verweist [[bezeichner]] auf einen anderen Eintrag. Verwandtes so zu Im Inhalt verweist [[bezeichner]] auf einen anderen Eintrag. Verwandtes so zu
verknüpfen ist ausdrücklich erwünscht — es hilft beim späteren Wiederfinden. verknüpfen ist ausdrücklich erwünscht — es hilft beim späteren Wiederfinden.
"related" liefert zu einem Eintrag die verknüpften Einträge, auch solche, die
"recall" nicht findet, weil kein Suchwort passt.
Alter von Wissen:
Einträge mit "stale": true sind seit über einem Jahr unverändert. Sie können
noch stimmen, sollten aber nicht ungeprüft als aktueller Stand weitergegeben
werden. Wer weiß, dass sich etwas geändert hat, aktualisiert den Eintrag mit
"remember".
Gleichzeitiges Schreiben: Gleichzeitiges Schreiben:
"get" liefert eine Feldnummer "version". Wird sie bei "remember" mitgegeben, "get" liefert eine Feldnummer "version". Wird sie bei "remember" mitgegeben,
@ -70,10 +77,14 @@ Gleichzeitiges Schreiben:
// Handler nimmt MCP-Anfragen entgegen und leitet sie an den Server des jeweiligen // Handler nimmt MCP-Anfragen entgegen und leitet sie an den Server des jeweiligen
// Projekts weiter. // Projekts weiter.
//
// Anders als früher hält der Handler keine feste Datenbank mehr: web.APIAuth
// hat den Key bereits einem Workspace zugeordnet und dessen Store samt
// Suchindex im Context abgelegt (siehe web.WorkspaceFromContext). Jedes
// Werkzeug holt sich beides von dort — ein Handler kann so Anfragen für
// beliebig viele Workspaces gleichzeitig bedienen, statt an eine Datei
// gebunden zu sein.
type Handler struct { type Handler struct {
store *store.Store
search *search.Registry
// budget deckelt die schreibenden Aufrufe je Projekt. Darf nil sein — dann // budget deckelt die schreibenden Aufrufe je Projekt. Darf nil sein — dann
// findet keine Begrenzung statt (praktisch für Tests, die damit nichts zu // findet keine Begrenzung statt (praktisch für Tests, die damit nichts zu
// tun haben). // tun haben).
@ -87,10 +98,8 @@ type Handler struct {
// New erstellt den MCP-Handler. Die Server je Projekt entstehen beim ersten // New erstellt den MCP-Handler. Die Server je Projekt entstehen beim ersten
// Zugriff (siehe serverFor). // Zugriff (siehe serverFor).
func New(s *store.Store, idx *search.Registry, budget *auth.WriteBudget) *Handler { func New(budget *auth.WriteBudget) *Handler {
h := &Handler{ h := &Handler{
store: s,
search: idx,
budget: budget, budget: budget,
servers: make(map[string]*mcp.Server), servers: make(map[string]*mcp.Server),
} }
@ -156,6 +165,7 @@ type recallMatch struct {
Source string `json:"source" jsonschema:"human oder agent"` Source string `json:"source" jsonschema:"human oder agent"`
Curated bool `json:"curated" jsonschema:"true, wenn ein Mensch den Eintrag geprüft hat"` Curated bool `json:"curated" jsonschema:"true, wenn ein Mensch den Eintrag geprüft hat"`
Updated string `json:"updated_at" jsonschema:"Zeitpunkt der letzten Änderung"` Updated string `json:"updated_at" jsonschema:"Zeitpunkt der letzten Änderung"`
Stale bool `json:"stale,omitempty" jsonschema:"true, wenn der Eintrag seit über einem Jahr unverändert ist — vor Verwendung prüfen"`
} }
type recallOutput struct { type recallOutput struct {
@ -180,6 +190,8 @@ type getOutput struct {
Updated string `json:"updated_at"` Updated string `json:"updated_at"`
LinksTo []string `json:"links_to" jsonschema:"Bezeichner, auf die dieser Eintrag verweist"` LinksTo []string `json:"links_to" jsonschema:"Bezeichner, auf die dieser Eintrag verweist"`
LinkedBy []string `json:"linked_by" jsonschema:"Bezeichner, die auf diesen Eintrag verweisen"` LinkedBy []string `json:"linked_by" jsonschema:"Bezeichner, die auf diesen Eintrag verweisen"`
Stale bool `json:"stale,omitempty" jsonschema:"true, wenn der Eintrag seit über einem Jahr unverändert ist"`
Hint string `json:"hint,omitempty" jsonschema:"Hinweis zum Umgang mit dem Eintrag"`
} }
type rememberInput struct { type rememberInput struct {
@ -218,6 +230,7 @@ type listItem struct {
Source string `json:"source"` Source string `json:"source"`
Curated bool `json:"curated"` Curated bool `json:"curated"`
Updated string `json:"updated_at"` Updated string `json:"updated_at"`
Stale bool `json:"stale,omitempty"`
} }
type listOutput struct { type listOutput struct {
@ -225,3 +238,25 @@ type listOutput struct {
Total int `json:"total" jsonschema:"Gesamtzahl der Einträge im Projekt"` Total int `json:"total" jsonschema:"Gesamtzahl der Einträge im Projekt"`
Tags []string `json:"tags" jsonschema:"Alle im Projekt vergebenen Schlagworte"` Tags []string `json:"tags" jsonschema:"Alle im Projekt vergebenen Schlagworte"`
} }
type relatedInput struct {
Slug string `json:"slug" jsonschema:"Bezeichner des Eintrags, dessen Umfeld gesucht wird"`
Distance int `json:"distance,omitempty" jsonschema:"Optional: 1 = nur direkt verknüpfte Einträge, 2 = auch deren Nachbarn (Standard 2)"`
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl (Standard 20, Maximum 50)"`
}
type relatedItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Distance int `json:"distance" jsonschema:"1 = direkt verknüpft, 2 = über einen Zwischeneintrag"`
Via string `json:"via" jsonschema:"Eintrag, über den dieser gefunden wurde"`
Relation string `json:"relation" jsonschema:"links_to: via verweist hierhin; linked_by: dieser Eintrag verweist auf via"`
Updated string `json:"updated_at"`
Stale bool `json:"stale,omitempty"`
}
type relatedOutput struct {
Results []relatedItem `json:"results" jsonschema:"Verwandte Einträge, nächste zuerst"`
Total int `json:"total"`
Hint string `json:"hint,omitempty"`
}

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log" "log"
"strings" "strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
@ -13,8 +14,23 @@ import (
"munin/internal/entry" "munin/internal/entry"
"munin/internal/store" "munin/internal/store"
"munin/internal/web" "munin/internal/web"
"munin/internal/workspace"
) )
// workspaceFrom holt den Workspace (Datenbank + Suchindex) des aktuellen
// Aufrufs aus dem Context.
//
// web.APIAuth hat ihn dort abgelegt, nachdem es den Bearer-Key aufgelöst hat
// (siehe internal/web/middleware.go). Fehlt er, ist der Server falsch
// verdrahtet — MCP-Aufrufe laufen nie ohne durchlaufenes APIAuth hier an.
func workspaceFrom(ctx context.Context) (*workspace.Workspace, error) {
ws, ok := web.WorkspaceFromContext(ctx)
if !ok {
return nil, errors.New("kein Workspace aufgelöst")
}
return ws, nil
}
// Grenzen für die Werkzeuge. // Grenzen für die Werkzeuge.
// //
// Die Obergrenzen schützen nicht den Server, sondern den Client: Eine Antwort mit // Die Obergrenzen schützen nicht den Server, sondern den Client: Eine Antwort mit
@ -25,8 +41,22 @@ const (
recallMaxLimit = 50 recallMaxLimit = 50
listDefaultLimit = 50 listDefaultLimit = 50
listMaxLimit = 200 listMaxLimit = 200
relatedDefaultLimit = 20
relatedMaxLimit = 50
relatedDefaultDistance = 2
// relatedMaxDistance ist bewusst klein. Schon in der dritten Reihe hängt
// in einem gut verknüpften Projekt fast alles mit allem zusammen, und die
// Antwort sagt nichts mehr über das Thema aus.
relatedMaxDistance = 2
) )
// staleHint steht bei einem veralteten Eintrag in der Antwort von "get". Er
// sagt dem Modell, was es tun soll, nicht nur, was der Fall ist.
const staleHint = "Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt, " +
"und bei der Antwort erwähnen, dass der Stand alt ist. Ist bekannt, was sich geändert hat: mit \"remember\" aktualisieren."
// buildServer erstellt den MCP-Server eines Projekts. // buildServer erstellt den MCP-Server eines Projekts.
// //
// Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht // Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht
@ -42,6 +72,7 @@ func (h *Handler) buildServer(project store.Project) *mcp.Server {
h.addList(server, project) h.addList(server, project)
h.addRemember(server, project) h.addRemember(server, project)
h.addForget(server, project) h.addForget(server, project)
h.addRelated(server, project)
return server return server
} }
@ -115,6 +146,11 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
"sowohl vor dem Beantworten einer Frage als auch vor dem Schreiben, um Dubletten zu vermeiden.", "sowohl vor dem Beantworten einer Frage als auch vor dem Schreiben, um Dubletten zu vermeiden.",
}, },
func(ctx context.Context, _ *mcp.CallToolRequest, in recallInput) (*mcp.CallToolResult, recallOutput, error) { func(ctx context.Context, _ *mcp.CallToolRequest, in recallInput) (*mcp.CallToolResult, recallOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, recallOutput{}, err
}
if strings.TrimSpace(in.Query) == "" { if strings.TrimSpace(in.Query) == "" {
return nil, recallOutput{}, errors.New("query darf nicht leer sein") return nil, recallOutput{}, errors.New("query darf nicht leer sein")
} }
@ -127,7 +163,7 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
limit = recallMaxLimit limit = recallMaxLimit
} }
idx, err := h.search.For(ctx, project.ID) idx, err := ws.Search.For(ctx, project.ID)
if err != nil { if err != nil {
return nil, recallOutput{}, err return nil, recallOutput{}, err
} }
@ -137,13 +173,16 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
treffer := idx.Search(in.Query, limit*3) treffer := idx.Search(in.Query, limit*3)
gesucht := store.NormalizeTags(in.Tags) gesucht := store.NormalizeTags(in.Tags)
jetzt := time.Now()
veraltet := 0
ergebnisse := make([]recallMatch, 0, limit) ergebnisse := make([]recallMatch, 0, limit)
for _, t := range treffer { for _, t := range treffer {
if len(ergebnisse) >= limit { if len(ergebnisse) >= limit {
break break
} }
e, err := h.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
} }
@ -155,6 +194,11 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
continue continue
} }
alt := e.Stale(jetzt)
if alt {
veraltet++
}
ergebnisse = append(ergebnisse, recallMatch{ ergebnisse = append(ergebnisse, recallMatch{
Slug: e.Slug, Slug: e.Slug,
Title: e.Title, Title: e.Title,
@ -163,6 +207,7 @@ func (h *Handler) addRecall(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: alt,
}) })
} }
@ -172,6 +217,14 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
"oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen." "oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen."
} else { } else {
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen." out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
// Veraltete Treffer bleiben in der Liste und rutschen auch
// nicht nach hinten: Ein alter Eintrag kann der einzige zum
// Thema sein. Der Hinweis sorgt dafür, dass das Modell das
// Feld "stale" nicht übersieht.
if veraltet > 0 {
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)
}
} }
return nil, out, nil return nil, out, nil
}, },
@ -206,7 +259,12 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
"Die gelieferte \"version\" bei einem späteren \"remember\" mitgeben.", "Die gelieferte \"version\" bei einem späteren \"remember\" mitgeben.",
}, },
func(ctx context.Context, _ *mcp.CallToolRequest, in getInput) (*mcp.CallToolResult, getOutput, error) { func(ctx context.Context, _ *mcp.CallToolRequest, in getInput) (*mcp.CallToolResult, getOutput, error) {
e, err := h.store.EntryBySlug(ctx, project.ID, in.Slug) ws, err := workspaceFrom(ctx)
if err != nil {
return nil, getOutput{}, err
}
e, err := ws.Store.EntryBySlug(ctx, project.ID, in.Slug)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
return nil, getOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug) return nil, getOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug)
} }
@ -214,7 +272,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
return nil, getOutput{}, err return nil, getOutput{}, err
} }
rueck, err := h.store.Backlinks(ctx, project.ID, in.Slug) rueck, err := ws.Store.Backlinks(ctx, project.ID, in.Slug)
if err != nil { if err != nil {
return nil, getOutput{}, err return nil, getOutput{}, err
} }
@ -224,7 +282,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
linkedBy = append(linkedBy, b.Slug) linkedBy = append(linkedBy, b.Slug)
} }
return nil, getOutput{ out := getOutput{
Slug: e.Slug, Slug: e.Slug,
Title: e.Title, Title: e.Title,
Content: e.Content, Content: e.Content,
@ -236,7 +294,12 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
Updated: e.UpdatedAt.Format("2006-01-02 15:04"), Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
LinksTo: store.ExtractLinks(e.Content), LinksTo: store.ExtractLinks(e.Content),
LinkedBy: linkedBy, LinkedBy: linkedBy,
}, nil }
if e.Stale(time.Now()) {
out.Stale = true
out.Hint = staleHint
}
return nil, out, nil
}, },
) )
} }
@ -251,6 +314,11 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
"und alle vergebenen Schlagworte. Nützlich, wenn unklar ist, wonach zu suchen wäre.", "und alle vergebenen Schlagworte. Nützlich, wenn unklar ist, wonach zu suchen wäre.",
}, },
func(ctx context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, listOutput, error) { func(ctx context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, listOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, listOutput{}, err
}
limit := in.Limit limit := in.Limit
if limit <= 0 { if limit <= 0 {
limit = listDefaultLimit limit = listDefaultLimit
@ -259,7 +327,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
limit = listMaxLimit limit = listMaxLimit
} }
eintraege, err := h.store.ListEntries(ctx, project.ID, store.EntryFilter{ eintraege, 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,
@ -268,16 +336,17 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
return nil, listOutput{}, err return nil, listOutput{}, err
} }
gesamt, err := h.store.CountEntries(ctx, project.ID) gesamt, err := ws.Store.CountEntries(ctx, project.ID)
if err != nil { if err != nil {
return nil, listOutput{}, err return nil, listOutput{}, err
} }
tags, err := h.store.ListTags(ctx, project.ID) tags, err := ws.Store.ListTags(ctx, project.ID)
if err != nil { if err != nil {
return nil, listOutput{}, err return nil, listOutput{}, err
} }
jetzt := time.Now()
items := make([]listItem, 0, len(eintraege)) items := make([]listItem, 0, len(eintraege))
for _, e := range eintraege { for _, e := range eintraege {
items = append(items, listItem{ items = append(items, listItem{
@ -287,6 +356,7 @@ 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),
}) })
} }
@ -311,6 +381,10 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
"VOR dem Aufruf immer \"recall\" verwenden, um einen passenden vorhandenen Eintrag zu finden.", "VOR dem Aufruf immer \"recall\" verwenden, um einen passenden vorhandenen Eintrag zu finden.",
}, },
func(ctx context.Context, _ *mcp.CallToolRequest, in rememberInput) (*mcp.CallToolResult, rememberOutput, error) { func(ctx context.Context, _ *mcp.CallToolRequest, in rememberInput) (*mcp.CallToolResult, rememberOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, rememberOutput{}, err
}
if err := h.requireWrite(ctx); err != nil { if err := h.requireWrite(ctx); err != nil {
return nil, rememberOutput{}, err return nil, rememberOutput{}, err
} }
@ -333,14 +407,7 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
titel = entry.TitleFromContent(in.Content) titel = entry.TitleFromContent(in.Content)
} }
// Vorher nachsehen, ob es den Eintrag schon gibt — nur um in der geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{
// Antwort "angelegt" von "aktualisiert" unterscheiden zu können. Für
// das Modell ist dieser Unterschied wichtig: Es soll merken, wenn es
// unabsichtlich etwas überschrieben hat.
_, err := h.store.EntryBySlug(ctx, project.ID, slug)
istNeu := errors.Is(err, store.ErrNotFound)
geschrieben, err := h.store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, ProjectID: project.ID,
Slug: slug, Slug: slug,
Title: titel, Title: titel,
@ -360,17 +427,39 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
return nil, rememberOutput{}, err return nil, rememberOutput{}, err
} }
h.search.Update(project.ID, geschrieben) ws.Search.Update(project.ID, geschrieben)
sess, _ := web.SessionFromContext(ctx) sess, _ := web.SessionFromContext(ctx)
h.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.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP, Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP,
Detail: store.EntryWriteDetail(geschrieben),
}) })
// "angelegt" oder "aktualisiert"? Für das Modell ist dieser
// Unterschied wichtig: Es soll merken, wenn es unabsichtlich etwas
// überschrieben hat. Maßgeblich ist die Fassung nach dem Schreiben
// (PutEntry vergibt beim Anlegen immer 1) — eine Nachfrage vorher
// könnte bei zwei gleichzeitigen Schreibern danebenliegen, und sie
// sähe einen Eintrag im Papierkorb nicht.
istNeu := geschrieben.Version == 1
meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version) meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version)
if istNeu { switch {
case istNeu:
meldung = fmt.Sprintf("Eintrag %q angelegt.", slug) meldung = fmt.Sprintf("Eintrag %q angelegt.", slug)
case geschrieben.RestoredFromTrash:
// Der Bezeichner gehörte einem gelöschten Eintrag. Das Modell
// konnte ihn vorher mit recall/get nicht sehen — es soll aber
// wissen, dass es gerade etwas Gelöschtes wiederbelebt hat.
meldung = fmt.Sprintf("Unter %q lag ein gelöschter Eintrag im Papierkorb. Er wurde zurückgeholt "+
"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)
ws.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceMCP,
Detail: "durch Schreiben unter demselben Bezeichner",
})
} }
return nil, rememberOutput{ return nil, rememberOutput{
@ -389,15 +478,26 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) {
mcp.AddTool(server, mcp.AddTool(server,
&mcp.Tool{ &mcp.Tool{
Name: "forget", Name: "forget",
Description: "Löscht einen Eintrag endgültig. Nur verwenden, wenn Wissen nachweislich " + // Die Beschreibung sagt ehrlich, dass Löschen nicht endgültig ist —
"falsch oder überholt ist — Veraltetes lässt sich meist besser mit \"remember\" richtigstellen.", // verschweigt aber nicht, dass es trotzdem Arbeit für Menschen
// erzeugt. Ein Agent soll nicht leichtfertiger löschen, nur weil es
// einen Papierkorb gibt. Wiederherstellen kann er selbst nicht: Das
// ist Absicht (siehe AUSBAUSTUFEN.md 3.9).
Description: fmt.Sprintf("Löscht einen Eintrag: Er verschwindet sofort aus allen Antworten und liegt "+
"%d Tage im Papierkorb, wo nur ein Mensch ihn zurückholen kann. Nur verwenden, wenn Wissen "+
"nachweislich falsch oder überholt ist — Veraltetes lässt sich meist besser mit \"remember\" richtigstellen.",
store.TrashRetentionDays),
}, },
func(ctx context.Context, _ *mcp.CallToolRequest, in forgetInput) (*mcp.CallToolResult, forgetOutput, error) { func(ctx context.Context, _ *mcp.CallToolRequest, in forgetInput) (*mcp.CallToolResult, forgetOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, forgetOutput{}, err
}
if err := h.requireWrite(ctx); err != nil { if err := h.requireWrite(ctx); err != nil {
return nil, forgetOutput{}, err return nil, forgetOutput{}, err
} }
err := h.store.DeleteEntry(ctx, project.ID, in.Slug) err = ws.Store.MoveEntryToTrash(ctx, project.ID, in.Slug, actorName(ctx))
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
return nil, forgetOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q", in.Slug) return nil, forgetOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q", in.Slug)
} }
@ -405,15 +505,85 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) {
return nil, forgetOutput{}, err return nil, forgetOutput{}, err
} }
h.search.Delete(project.ID, in.Slug) ws.Search.Delete(project.ID, in.Slug)
sess, _ := web.SessionFromContext(ctx) sess, _ := web.SessionFromContext(ctx)
h.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.ActionEntryDelete, EntrySlug: in.Slug, Source: store.SourceMCP, Action: store.ActionEntryDelete, EntrySlug: in.Slug, Source: store.SourceMCP,
Detail: "in den Papierkorb",
}) })
return nil, forgetOutput{Message: fmt.Sprintf("Eintrag %q gelöscht.", in.Slug)}, nil return nil, forgetOutput{Message: fmt.Sprintf(
"Eintrag %q gelöscht. Er liegt %d Tage im Papierkorb; ein Mensch kann ihn dort wiederherstellen.",
in.Slug, store.TrashRetentionDays)}, nil
},
)
}
// ── related ──────────────────────────────────────────────────────────────────
func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "related",
Description: "Liefert die Einträge, die über [[Verweise]] mit einem Eintrag verknüpft sind — " +
"direkt und (Standard) auch über einen Zwischeneintrag. Findet Zusammenhänge, " +
"die \"recall\" nicht findet, weil kein Suchwort passt. Nützlich, nachdem \"get\" " +
"einen passenden Eintrag geliefert hat und mehr Kontext gebraucht wird.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in relatedInput) (*mcp.CallToolResult, relatedOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, relatedOutput{}, err
}
abstand := in.Distance
if abstand <= 0 {
abstand = relatedDefaultDistance
}
if abstand > relatedMaxDistance {
abstand = relatedMaxDistance
}
limit := in.Limit
if limit <= 0 {
limit = relatedDefaultLimit
}
if limit > relatedMaxLimit {
limit = relatedMaxLimit
}
verwandte, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, abstand, limit)
if errors.Is(err, store.ErrNotFound) {
return nil, relatedOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug)
}
if err != nil {
return nil, relatedOutput{}, err
}
jetzt := time.Now()
items := make([]relatedItem, 0, len(verwandte))
for _, v := range verwandte {
items = append(items, relatedItem{
Slug: v.Slug,
Title: v.Title,
Distance: v.Distance,
Via: v.Via,
Relation: v.Relation,
Updated: v.UpdatedAt.Format("2006-01-02 15:04"),
Stale: store.StaleSince(v.UpdatedAt, jetzt),
})
}
out := relatedOutput{Results: items, Total: len(items)}
if len(items) == 0 {
out.Hint = "Dieser Eintrag ist mit keinem anderen verknüpft. Mit \"recall\" nach verwandten " +
"Themen suchen. Findet sich etwas, lohnt es sich, die Einträge mit [[bezeichner]] zu verknüpfen."
} else {
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
}
return nil, out, nil
}, },
) )
} }

View file

@ -11,7 +11,21 @@ import (
// nicht an Tippfehlern scheitern. // nicht an Tippfehlern scheitern.
const ( const (
ActionEntryWrite = "entry_write" ActionEntryWrite = "entry_write"
// ActionEntryDelete heißt seit dem Papierkorb (migrations/005): "in den
// Papierkorb gelegt". Der Name bleibt, damit ältere Protokollzeilen und
// neue gleich ausgewertet werden — auch vorher stand er für den Wunsch,
// einen Eintrag loszuwerden.
ActionEntryDelete = "entry_delete" ActionEntryDelete = "entry_delete"
ActionEntryRestore = "entry_restore" // aus dem Papierkorb zurückgeholt
ActionEntryPurge = "entry_purge" // endgültig gelöscht, nicht rückgängig zu machen
// ActionEntryImport steht einmal je Import über die Weboberfläche, mit der
// Zusammenfassung als Detail (siehe internal/importer). Die einzelnen
// Einträge tauchen bewusst nicht als entry_write auf, damit ein
// einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt.
ActionEntryImport = "entry_import"
ActionLoginSuccess = "login_success" ActionLoginSuccess = "login_success"
ActionAuthFailure = "auth_failure" ActionAuthFailure = "auth_failure"
ActionProjectCreated = "project_created" ActionProjectCreated = "project_created"
@ -21,6 +35,44 @@ const (
ActionKeyRevoked = "key_revoked" ActionKeyRevoked = "key_revoked"
) )
// Feste Werte für das Feld Detail bei ActionEntryWrite. Feste Wörter statt
// freiem Text, damit sich später zählen lässt, wie viele Schreibvorgänge neu
// angelegt und wie viele aktualisiert haben (AUSBAUSTUFEN.md 4.6). Die
// Sammelbearbeitung schreibt eigenen Text ("Sammelbearbeitung: …") — sie
// ändert immer nur vorhandene Einträge. Dasselbe gilt für die
// Wiederherstellung einer alten Fassung (EntryRestoreDetail).
const (
DetailEntryCreated = "angelegt"
DetailEntryUpdated = "aktualisiert"
)
// EntryRestoreDetail beschreibt im Protokoll, dass ein Schreibvorgang eine
// alte Fassung zurückgeholt hat — und welche.
//
// Die Zeichenkette beginnt immer mit "wiederhergestellt", damit sie sich später
// für die Nutzungsübersicht (AUSBAUSTUFEN.md 4.6) genauso zählen lässt wie die
// festen Werte oben, etwa mit `detail LIKE 'wiederhergestellt%'`. Eine
// Wiederherstellung ist fachlich immer eine Aktualisierung: Der Eintrag
// existiert ja schon, sonst gäbe es keine alte Fassung.
func EntryRestoreDetail(vonFassung int) string {
// fmt.Sprintf setzt die Zahl in den Text ein — %d steht für eine ganze Zahl.
return fmt.Sprintf("wiederhergestellt: Fassung %d", vonFassung)
}
// EntryWriteDetail sagt anhand des gespeicherten Eintrags, ob der
// Schreibvorgang ihn angelegt oder aktualisiert hat.
//
// Maßgeblich ist die Fassung NACH dem Schreiben: PutEntry vergibt beim
// Anlegen immer Fassung 1 und zählt bei jeder Änderung hoch. Anders als eine
// Nachfrage vor dem Schreiben kann das nicht danebenliegen, wenn zwei
// gleichzeitig denselben Bezeichner schreiben.
func EntryWriteDetail(e Entry) string {
if e.Version == 1 {
return DetailEntryCreated
}
return DetailEntryUpdated
}
// Herkunft eines protokollierten Vorgangs. // Herkunft eines protokollierten Vorgangs.
const ( const (
SourceWeb = "web" SourceWeb = "web"

224
internal/store/control.go Normal file
View file

@ -0,0 +1,224 @@
package store
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
)
// controlMigrationsFS enthält das Schema der zentralen Kontroll-Datenbank
// (siehe migrations_control/001_init.sql und AUSBAUSTUFEN.md 4.4).
//
//go:embed migrations_control/*.sql
var controlMigrationsFS embed.FS
// ControlStore verwaltet die zentrale Kontroll-Datenbank: die Liste der
// Workspaces (Abteilungen) und den Weg von einem Key-Hash zu seinem Workspace.
//
// Alles andere — Projekte, Einträge, Keys im Volltext, Protokoll — lebt in der
// jeweiligen Workspace-Datei (Store, siehe store.go). Ein eigener Typ statt
// eines zweiten Satzes Methoden auf Store, damit nie aus Versehen eine
// Workspace-Abfrage gegen die falsche Datei läuft.
type ControlStore struct {
lesen *sql.DB
schreiben *sql.DB
}
// OpenControl öffnet die Kontroll-Datenbank und bringt ihr Schema auf den
// aktuellen Stand.
func OpenControl(path string) (*ControlStore, error) {
lesen, schreiben, err := openPool(path, controlMigrationsFS, "migrations_control")
if err != nil {
return nil, err
}
return &ControlStore{lesen: lesen, schreiben: schreiben}, nil
}
// Close schließt beide Verbindungspools.
func (c *ControlStore) Close() error {
fehlerLesen := c.lesen.Close()
if err := c.schreiben.Close(); err != nil {
return err
}
return fehlerLesen
}
// ── Workspaces ───────────────────────────────────────────────────────────────
// EnsureDefaultWorkspace legt beim allerersten Start eines Deployments den
// ersten Workspace an und gibt ihn zurück. Gibt es bereits einen oder mehrere,
// wird der älteste verwendet.
//
// Gedacht für den Fall, dass noch keine Workspace-Verwaltung existiert (WP8):
// ein frischer Server soll ohne manuellen Zwischenschritt lauffähig sein.
// Sobald es eine Oberfläche zum Anlegen weiterer Workspaces gibt, verliert
// diese Funktion an Bedeutung, bleibt aber für den ersten Start richtig.
func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string) (Workspace, error) {
var w Workspace
var createdAt string
err := c.lesen.QueryRowContext(ctx,
`SELECT id, name, created_at FROM workspaces ORDER BY created_at LIMIT 1`,
).Scan(&w.ID, &w.Name, &createdAt)
if err == nil {
w.CreatedAt = parseTime(createdAt)
return w, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return Workspace{}, fmt.Errorf("Workspace konnte nicht gelesen werden: %w", err)
}
return c.CreateWorkspace(ctx, name)
}
// CreateWorkspace legt einen neuen Workspace an.
func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Workspace, error) {
w := Workspace{ID: NewID(), Name: name}
jetzt := NowString()
if _, err := c.schreiben.ExecContext(ctx,
`INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)`,
w.ID, w.Name, jetzt,
); err != nil {
return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err)
}
w.CreatedAt = parseTime(jetzt)
return w, nil
}
// ListWorkspaces gibt alle Workspaces zurück, älteste zuerst.
func (c *ControlStore) ListWorkspaces(ctx context.Context) ([]Workspace, error) {
rows, err := c.lesen.QueryContext(ctx,
`SELECT id, name, created_at FROM workspaces ORDER BY created_at`,
)
if err != nil {
return nil, fmt.Errorf("Workspaces konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var ws []Workspace
for rows.Next() {
var w Workspace
var createdAt string
if err := rows.Scan(&w.ID, &w.Name, &createdAt); err != nil {
return nil, err
}
w.CreatedAt = parseTime(createdAt)
ws = append(ws, w)
}
return ws, rows.Err()
}
// WorkspaceByID liest einen Workspace anhand seiner ID.
func (c *ControlStore) WorkspaceByID(ctx context.Context, id string) (Workspace, error) {
var w Workspace
var createdAt string
err := c.lesen.QueryRowContext(ctx,
`SELECT id, name, created_at FROM workspaces WHERE id = ?`, id,
).Scan(&w.ID, &w.Name, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return Workspace{}, ErrNotFound
}
if err != nil {
return Workspace{}, fmt.Errorf("Workspace konnte nicht gelesen werden: %w", err)
}
w.CreatedAt = parseTime(createdAt)
return w, nil
}
// UpdateWorkspace benennt einen Workspace um.
func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) error {
res, err := c.schreiben.ExecContext(ctx,
`UPDATE workspaces SET name = ? WHERE id = ?`, name, id,
)
if err != nil {
return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err)
}
betroffen, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err)
}
if betroffen == 0 {
return ErrNotFound
}
return nil
}
// DeleteWorkspace entfernt einen Workspace aus der Kontroll-Datenbank.
//
// Löscht nur die Zeile hier — die zugehörigen `key_index`-Einträge fallen
// über den Fremdschlüssel (ON DELETE CASCADE) automatisch mit weg. Die
// eigentliche Workspace-Datei mit allen Projekten und Einträgen bleibt davon
// unberührt; sie zu entfernen ist Sache von workspace.Manager.Delete — erst
// wenn beides passiert ist, ist der Workspace wirklich weg (siehe
// handleWorkspaceDelete in internal/web/handlers_workspaces.go).
func (c *ControlStore) DeleteWorkspace(ctx context.Context, id string) error {
res, err := c.schreiben.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err)
}
betroffen, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err)
}
if betroffen == 0 {
return ErrNotFound
}
return nil
}
// ── Key-Routing ──────────────────────────────────────────────────────────────
// IndexKey trägt einen Key-Hash in den Routing-Index ein. Aufzurufen im
// selben Zug wie Store.CreateAPIKey auf der jeweiligen Workspace-Datei — ohne
// diesen Eintrag kann der Key künftig nicht mehr seinem Workspace zugeordnet
// werden, bevor dessen Datei bekannt ist.
func (c *ControlStore) IndexKey(ctx context.Context, hash, workspaceID string) error {
_, err := c.schreiben.ExecContext(ctx,
`INSERT INTO key_index (hash, workspace_id, created_at) VALUES (?, ?, ?)`,
hash, workspaceID, NowString(),
)
if err != nil {
return fmt.Errorf("Key konnte nicht im Routing-Index vermerkt werden: %w", err)
}
return nil
}
// RemoveKeyIndex entfernt einen Key-Hash aus dem Routing-Index. Aufzurufen im
// selben Zug wie Store.RevokeAPIKey.
func (c *ControlStore) RemoveKeyIndex(ctx context.Context, hash string) error {
_, err := c.schreiben.ExecContext(ctx,
`DELETE FROM key_index WHERE hash = ?`, hash,
)
if err != nil {
return fmt.Errorf("Key konnte nicht aus dem Routing-Index entfernt werden: %w", err)
}
return nil
}
// ResolveKeyWorkspace schlägt nach, zu welchem Workspace ein Key-Hash gehört.
//
// Das ist der erste Schritt jeder Key-Auflösung, sobald mehrere
// Workspace-Dateien gleichzeitig offen sein können (siehe AUSBAUSTUFEN.md 4.4,
// WP4/WP5): erst hier lässt sich feststellen, welche Datei überhaupt
// angefasst werden muss, bevor Store.ResolveKeyHash dort wie gewohnt läuft.
func (c *ControlStore) ResolveKeyWorkspace(ctx context.Context, hash string) (workspaceID string, err error) {
err = c.lesen.QueryRowContext(ctx,
`SELECT workspace_id FROM key_index WHERE hash = ?`, hash,
).Scan(&workspaceID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("Workspace konnte nicht zum Key aufgelöst werden: %w", err)
}
return workspaceID, nil
}

View file

@ -0,0 +1,100 @@
package store
import (
"context"
"path/filepath"
"testing"
)
// testControl öffnet eine frische Kontroll-Datenbank in einem Testverzeichnis.
func testControl(t *testing.T) *ControlStore {
t.Helper()
c, err := OpenControl(filepath.Join(t.TempDir(), "control.db"))
if err != nil {
t.Fatalf("Kontroll-Datenbank konnte nicht geöffnet werden: %v", err)
}
t.Cleanup(func() { c.Close() })
return c
}
func TestEnsureDefaultWorkspaceIsStable(t *testing.T) {
c := testControl(t)
ctx := context.Background()
erst, err := c.EnsureDefaultWorkspace(ctx, "Intern")
if err != nil {
t.Fatal(err)
}
zweit, err := c.EnsureDefaultWorkspace(ctx, "Anderer Name")
if err != nil {
t.Fatal(err)
}
if erst.ID != zweit.ID {
t.Error("EnsureDefaultWorkspace hat einen zweiten Workspace angelegt statt den bestehenden zu nehmen")
}
}
func TestKeyIndexRouting(t *testing.T) {
c := testControl(t)
ctx := context.Background()
ws, err := c.CreateWorkspace(ctx, "Team A")
if err != nil {
t.Fatal(err)
}
if err := c.IndexKey(ctx, "hash-1", ws.ID); err != nil {
t.Fatal(err)
}
gefunden, err := c.ResolveKeyWorkspace(ctx, "hash-1")
if err != nil {
t.Fatal(err)
}
if gefunden != ws.ID {
t.Errorf("ResolveKeyWorkspace = %q, erwartet %q", gefunden, ws.ID)
}
if err := c.RemoveKeyIndex(ctx, "hash-1"); err != nil {
t.Fatal(err)
}
if _, err := c.ResolveKeyWorkspace(ctx, "hash-1"); err != ErrNotFound {
t.Errorf("nach RemoveKeyIndex: err = %v, erwartet ErrNotFound", err)
}
}
func TestListWorkspaces(t *testing.T) {
c := testControl(t)
ctx := context.Background()
a, err := c.CreateWorkspace(ctx, "Team A")
if err != nil {
t.Fatal(err)
}
b, err := c.CreateWorkspace(ctx, "Team B")
if err != nil {
t.Fatal(err)
}
ws, err := c.ListWorkspaces(ctx)
if err != nil {
t.Fatal(err)
}
if len(ws) != 2 || ws[0].ID != a.ID || ws[1].ID != b.ID {
t.Errorf("ListWorkspaces = %+v, erwartet [%q, %q] in Anlegereihenfolge", ws, a.ID, b.ID)
}
gelesen, err := c.WorkspaceByID(ctx, a.ID)
if err != nil {
t.Fatal(err)
}
if gelesen.Name != "Team A" {
t.Errorf("WorkspaceByID.Name = %q, erwartet %q", gelesen.Name, "Team A")
}
if _, err := c.WorkspaceByID(ctx, "unbekannt"); err != ErrNotFound {
t.Errorf("unbekannte ID: err = %v, erwartet ErrNotFound", err)
}
}

View file

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"time"
) )
// EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag. // EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag.
@ -32,8 +33,31 @@ type EntryWrite struct {
// mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass // mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass
// sich etwas geändert hat, und kann neu lesen. // sich etwas geändert hat, und kann neu lesen.
ExpectedVersion int ExpectedVersion int
// CreatedAt und UpdatedAt geben beim ANLEGEN eines Eintrags die
// Zeitstempel vor. Leer bedeutet „jetzt“, und das ist der Normalfall.
//
// Gebraucht wird das nur beim Import (internal/importer): Wer eine
// Sicherung einspielt, will das ursprüngliche Änderungsdatum behalten.
// Sonst sähe jeder wiederhergestellte Eintrag brandneu aus, und der
// Vermerk für veraltetes Wissen (Entry.Stale) liefe ins Leere.
//
// Beim Ändern eines vorhandenen Eintrags werden beide Felder ignoriert.
// Eine Änderung passiert jetzt, und der Verlauf soll das ehrlich zeigen.
CreatedAt time.Time
UpdatedAt time.Time
} }
// nichtImPapierkorb gehört in JEDE lesende Abfrage auf entries (siehe
// migrations/005_papierkorb.sql). Als Konstante, damit sich alle Stellen mit
// einer Suche finden lassen — und damit eine neue Abfrage ohne diese Bedingung
// beim Lesen auffällt. TestPapierkorbUnsichtbar (papierkorb_test.go) prüft
// jeden lesenden Weg.
//
// Wo die Tabelle in einer Verknüpfung den Kurznamen "e" trägt, steht die
// Bedingung ausgeschrieben als "e.deleted_at IS NULL".
const nichtImPapierkorb = `deleted_at IS NULL`
// 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`
@ -105,12 +129,23 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
err := s.tx(ctx, func(tx *sql.Tx) error { err := s.tx(ctx, func(tx *sql.Tx) 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
// Bezeichner weiter. Wer ihn neu beschreibt, holt ihn zurück (siehe
// unten) — statt an einer UNIQUE-Verletzung zu scheitern oder
// stillschweigend einen zweiten Eintrag daneben zu legen.
//
// sql.NullString kann, anders als ein normaler string, auch NULL
// aufnehmen: .Valid ist dann false. Genau das unterscheidet hier
// "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
err := tx.QueryRowContext(ctx, err := tx.QueryRowContext(ctx,
`SELECT id, version, created_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) ).Scan(&id, &version, &createdAt, &geloeschtAm)
imPapierkorb := geloeschtAm.Valid
switch { switch {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
@ -126,6 +161,12 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
id = NewID() id = NewID()
version = 1 version = 1
createdAt = jetzt createdAt = jetzt
if !w.CreatedAt.IsZero() {
createdAt = formatTime(w.CreatedAt)
}
if !w.UpdatedAt.IsZero() {
jetzt = 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)
@ -141,14 +182,36 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
default: default:
// Vorhandener Eintrag. // Vorhandener Eintrag.
//
// Liegt er im Papierkorb und der Schreiber gibt eine gelesene
// Fassung an, hat er einen Stand bearbeitet, den inzwischen jemand
// gelöscht hat — etwa ein offenes Formular im Browser, während ein
// Agent "forget" aufrief. Das ist ein Konflikt, genau wie beim
// zwischenzeitlich geänderten Eintrag: Die Löschung soll nicht
// unbemerkt rückgängig gemacht werden.
if imPapierkorb && w.ExpectedVersion > 0 {
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 {
return fmt.Errorf("%w (gelesen: %d, aktuell: %d)", ErrConflict, w.ExpectedVersion, version) return fmt.Errorf("%w (gelesen: %d, aktuell: %d)", ErrConflict, w.ExpectedVersion, version)
} }
// Die bisherige Fassung sichern, bevor sie überschrieben wird —
// die Schlagworte in entry_tags gehören noch zum alten Stand,
// replaceTags läuft erst weiter unten. Bei einem Eintrag aus dem
// Papierkorb ist das der gelöschte Stand: Er geht also auch beim
// Zurückholen durch Überschreiben nicht verloren.
if err := snapshotEntryVersion(ctx, tx, w.ProjectID, id); err != nil {
return err
}
version++ version++
// deleted_at = NULL holt einen Eintrag aus dem Papierkorb zurück.
// Für einen normalen Eintrag ändert es nichts — er war ja schon NULL.
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`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 = ''
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, jetzt, id,
); err != nil { ); err != nil {
@ -179,6 +242,8 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
Version: version, Version: version,
CreatedAt: parseTime(createdAt), CreatedAt: parseTime(createdAt),
UpdatedAt: parseTime(jetzt), UpdatedAt: parseTime(jetzt),
RestoredFromTrash: imPapierkorb,
} }
return nil return nil
}) })
@ -189,6 +254,114 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
return ergebnis, nil return ergebnis, nil
} }
// snapshotEntryVersion sichert den aktuellen Stand eines Eintrags in
// entry_versions, bevor er überschrieben wird — die einzige Stelle, an der
// diese Tabelle beschrieben wird (siehe migrations/004_aenderungshistorie.sql).
//
// Läuft immer als Teil der Transaktion, die auch die eigentliche Änderung
// vornimmt: Entweder werden alte Fassung und neue Fassung beide geschrieben,
// oder keine von beiden.
func snapshotEntryVersion(ctx context.Context, tx *sql.Tx, projectID, entryID string) error {
var version int
var title, content, source, author string
var curated int
err := tx.QueryRowContext(ctx,
`SELECT version, title, content, source, author, curated FROM entries WHERE id = ?`, entryID,
).Scan(&version, &title, &content, &source, &author, &curated)
if err != nil {
return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err)
}
rows, err := tx.QueryContext(ctx, `SELECT tag FROM entry_tags WHERE entry_id = ? ORDER BY tag`, entryID)
if err != nil {
return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err)
}
var tags []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
rows.Close()
return err
}
tags = append(tags, tag)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_versions (id, entry_id, project_id, version, title, content, tags, author, source, curated, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
NewID(), entryID, projectID, version, title, content, strings.Join(tags, ","), author, source, curated, NowString(),
); err != nil {
return fmt.Errorf("Fassung konnte nicht gesichert werden: %w", err)
}
return nil
}
// scanEntryVersion liest eine Zeile aus entry_versions.
func scanEntryVersion(scan func(...any) error) (EntryVersion, error) {
var v EntryVersion
var tags string
var curated int
var createdAt string
if err := scan(&v.ID, &v.EntryID, &v.Version, &v.Title, &v.Content, &tags,
&v.Author, &v.Source, &curated, &createdAt); err != nil {
return EntryVersion{}, err
}
if tags != "" {
v.Tags = strings.Split(tags, ",")
}
v.Curated = curated == 1
v.CreatedAt = parseTime(createdAt)
return v, nil
}
const entryVersionColumns = `id, entry_id, version, title, content, tags, author, source, curated, created_at`
// EntryVersions gibt die abgelösten Fassungen eines Eintrags zurück, neueste
// zuerst. Die aktuelle Fassung steht nicht darin — die liefert EntryBySlug.
func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersion, error) {
rows, err := s.lesen.QueryContext(ctx,
`SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? ORDER BY version DESC`,
entryID,
)
if err != nil {
return nil, fmt.Errorf("Änderungshistorie konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var versionen []EntryVersion
for rows.Next() {
v, err := scanEntryVersion(rows.Scan)
if err != nil {
return nil, err
}
versionen = append(versionen, v)
}
return versionen, rows.Err()
}
// EntryVersionByNumber liest eine einzelne abgelöste Fassung.
func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, version int) (EntryVersion, error) {
row := s.lesen.QueryRowContext(ctx,
`SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? AND version = ?`,
entryID, version,
)
v, err := scanEntryVersion(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return EntryVersion{}, ErrNotFound
}
if err != nil {
return EntryVersion{}, fmt.Errorf("Fassung konnte nicht gelesen werden: %w", err)
}
return v, nil
}
// 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
@ -229,7 +402,7 @@ func replaceLinks(ctx context.Context, tx *sql.Tx, projectID, entryID string, li
// 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.lesen.QueryRowContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ?`, `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
projectID, slug, projectID, slug,
) )
@ -267,7 +440,7 @@ 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 = ?` abfrage := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + nichtImPapierkorb
werte := []any{projectID} werte := []any{projectID}
if f.Tag != "" { if f.Tag != "" {
@ -305,10 +478,12 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter
} }
// 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
// Suchindex beim Start und für den Export. // Suchindex beim Start und für den Export. Einträge im Papierkorb gehören
// nicht dazu: Sie sollen weder gefunden werden noch im Export wieder
// 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.lesen.QueryContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? ORDER BY slug`, `SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+nichtImPapierkorb+` ORDER BY slug`,
projectID, projectID,
) )
if err != nil { if err != nil {
@ -331,22 +506,11 @@ func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, erro
return eintraege, s.attachTagsToSlice(ctx, eintraege) return eintraege, s.attachTagsToSlice(ctx, eintraege)
} }
// DeleteEntry löscht einen Eintrag samt Schlagworten und Verweisen (per CASCADE). // CountEntries zählt die Einträge eines Projekts (ohne Papierkorb).
func (s *Store) DeleteEntry(ctx context.Context, projectID, slug string) error {
res, err := s.schreiben.ExecContext(ctx,
`DELETE FROM entries WHERE project_id = ? AND slug = ?`, projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag konnte nicht gelöscht werden: %w", err)
}
return requireOneRow(res)
}
// CountEntries zählt die Einträge eines Projekts.
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.lesen.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ?`, projectID, `SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, 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)
@ -363,13 +527,20 @@ func (s *Store) ListTags(ctx context.Context, projectID string) ([]TagCount, err
// hängt jetzt an der Zahl der Schlagworte, nicht an der Zahl der Einträge. // hängt jetzt an der Zahl der Schlagworte, nicht an der Zahl der Einträge.
// Siehe migrations/002 — diese Abfrage läuft bei jedem Seitenaufruf, weil // Siehe migrations/002 — diese Abfrage läuft bei jedem Seitenaufruf, weil
// die Schlagworte in der Seitenleiste stehen. // die Schlagworte in der Seitenleiste stehen.
//
// Die Schlagworte gelöschter Einträge bleiben in entry_tags stehen — sie
// werden beim Wiederherstellen gebraucht. Damit sie hier nicht mitgezählt
// werden, schließt die Unterabfrage die Einträge im Papierkorb aus. Die
// läuft über den kleinen Teilindex idx_entries_papierkorb (migrations/005)
// und kostet deshalb praktisch nichts, egal wie groß das Projekt ist.
rows, err := s.lesen.QueryContext(ctx, rows, err := s.lesen.QueryContext(ctx,
`SELECT tag, COUNT(*) AS anzahl `SELECT tag, COUNT(*) AS anzahl
FROM entry_tags FROM entry_tags
WHERE project_id = ? WHERE project_id = ?
AND entry_id NOT IN (SELECT id FROM entries WHERE project_id = ? AND deleted_at IS NOT NULL)
GROUP BY tag GROUP BY tag
ORDER BY anzahl DESC, tag`, ORDER BY anzahl DESC, tag`,
projectID, projectID, projectID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err) return nil, fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
@ -443,6 +614,121 @@ func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error {
return rows.Err() return rows.Err()
} }
// BulkUpdateTags fügt einer Auswahl von Einträgen Schlagworte hinzu und/oder
// entfernt welche — eine Sammelbearbeitung, ohne jeden Eintrag einzeln öffnen
// zu müssen.
//
// Läuft in einer Transaktion, damit entweder alle betroffenen Einträge
// aktualisiert werden oder keiner. IDs, die nicht zu projectID gehören, werden
// stillschweigend übersprungen — sonst könnte eine untergeschobene ID über
// dieses eine Formular Einträge eines fremden Projekts verändern.
//
// Gibt die geänderten Einträge zurück, damit der Aufrufer den Suchindex
// nachziehen kann (siehe search.Registry.Update) — ein zweiter Lesezugriff
// nach der Transaktion wäre unnötig.
func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, add, remove []string) ([]Entry, error) {
add = NormalizeTags(add)
remove = NormalizeTags(remove)
entferneMenge := make(map[string]bool, len(remove))
for _, t := range remove {
entferneMenge[t] = true
}
var geaendert []Entry
jetzt := NowString()
err := s.tx(ctx, func(tx *sql.Tx) error {
for _, id := range entryIDs {
var vorhandenID string
err := tx.QueryRowContext(ctx,
`SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb, id, projectID,
).Scan(&vorhandenID)
if errors.Is(err, sql.ErrNoRows) {
continue // gehört nicht zu diesem Projekt, existiert nicht mehr oder liegt im Papierkorb
}
if err != nil {
return fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
}
rows, err := tx.QueryContext(ctx, `SELECT tag FROM entry_tags WHERE entry_id = ? ORDER BY tag`, id)
if err != nil {
return fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
}
var bisherige []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
rows.Close()
return err
}
bisherige = append(bisherige, tag)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
neu := NormalizeTags(append(bisherige, add...))
gefiltert := neu[:0]
for _, t := range neu {
if !entferneMenge[t] {
gefiltert = append(gefiltert, t)
}
}
// Nur schreiben, wenn sich wirklich etwas ändert — sonst würde ein
// Sammelaufruf über eine gemischte Auswahl auch die Einträge
// "berühren", bei denen add/remove gar nichts bewirkt haben.
if stringSlicesEqual(bisherige, gefiltert) {
continue
}
// Wie in PutEntry: die alte Fassung sichern, bevor die
// Schlagworte ersetzt werden — entry_tags trägt an dieser
// Stelle noch den vorherigen Stand.
if err := snapshotEntryVersion(ctx, tx, projectID, id); err != nil {
return err
}
if err := replaceTags(ctx, tx, projectID, id, gefiltert); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE entries SET version = version + 1, updated_at = ? WHERE id = ?`, jetzt, id,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht aktualisiert werden: %w", err)
}
row := tx.QueryRowContext(ctx, `SELECT `+entryColumns+` FROM entries WHERE id = ?`, id)
e, err := scanEntry(row.Scan)
if err != nil {
return fmt.Errorf("Eintrag konnte nicht neu gelesen werden: %w", err)
}
e.Tags = gefiltert
geaendert = append(geaendert, e)
}
return nil
})
if err != nil {
return nil, err
}
return geaendert, nil
}
// stringSlicesEqual vergleicht zwei (bereits sortierte) Zeichenketten-Listen.
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// ── Verweise ───────────────────────────────────────────────────────────────── // ── Verweise ─────────────────────────────────────────────────────────────────
// Backlink ist ein Eintrag, der auf einen anderen verweist. // Backlink ist ein Eintrag, der auf einen anderen verweist.
@ -469,7 +755,7 @@ func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backli
`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
WHERE l.project_id = ? AND l.to_slug = ?`, WHERE l.project_id = ? AND l.to_slug = ? AND e.deleted_at IS NULL`,
projectID, slug, projectID, slug,
) )
if err != nil { if err != nil {
@ -493,6 +779,85 @@ func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backli
return links, nil return links, nil
} }
// GraphNode ist ein Eintrag in der Graph-Ansicht der Verweise.
type GraphNode struct {
Slug string
Title string
Exists bool // false = Ziel eines Verweises, aber noch kein Eintrag dazu angelegt
}
// GraphEdge ist ein einzelner Verweis zwischen zwei Bezeichnern.
type GraphEdge struct {
From string
To string
}
// LinkGraph gibt alle Einträge eines Projekts als Knoten und alle
// [[…]]-Verweise dazwischen als Kanten zurück — die Grundlage der
// Graph-Ansicht (siehe internal/web/handlers_entries.go).
//
// Verweise auf einen Bezeichner, zu dem es (noch) keinen Eintrag gibt,
// erzeugen trotzdem einen Knoten — mit Exists = false. Das macht in der
// Graph-Ansicht sichtbar, wohin schon verwiesen wird, was aber noch fehlt,
// statt solche Verweise stillschweigend zu unterschlagen.
func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, []GraphEdge, error) {
entryRows, err := s.lesen.QueryContext(ctx,
`SELECT slug, title FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID,
)
if err != nil {
return nil, nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
knoten := make(map[string]*GraphNode)
for entryRows.Next() {
var slug, title string
if err := entryRows.Scan(&slug, &title); err != nil {
entryRows.Close()
return nil, nil, err
}
knoten[slug] = &GraphNode{Slug: slug, Title: title, Exists: true}
}
if err := entryRows.Err(); err != nil {
entryRows.Close()
return nil, nil, err
}
entryRows.Close()
linkRows, err := s.lesen.QueryContext(ctx,
`SELECT e.slug, l.to_slug
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
WHERE l.project_id = ? AND e.deleted_at IS NULL`,
projectID,
)
if err != nil {
return nil, nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err)
}
defer linkRows.Close()
var kanten []GraphEdge
for linkRows.Next() {
var von, nach string
if err := linkRows.Scan(&von, &nach); err != nil {
return nil, nil, err
}
if _, ok := knoten[nach]; !ok {
knoten[nach] = &GraphNode{Slug: nach, Title: nach, Exists: false}
}
kanten = append(kanten, GraphEdge{From: von, To: nach})
}
if err := linkRows.Err(); err != nil {
return nil, nil, err
}
knotenListe := make([]GraphNode, 0, len(knoten))
for _, k := range knoten {
knotenListe = append(knotenListe, *k)
}
sort.Slice(knotenListe, func(i, j int) bool { return knotenListe[i].Slug < knotenListe[j].Slug })
return knotenListe, kanten, nil
}
// boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung. // boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung.
func boolToInt(b bool) int { func boolToInt(b bool) int {
if b { if b {

View file

@ -11,20 +11,20 @@
-- * Kein SQLite-spezifisches SQL (kein AUTOINCREMENT, kein INSERT OR REPLACE, -- * Kein SQLite-spezifisches SQL (kein AUTOINCREMENT, kein INSERT OR REPLACE,
-- keine datetime()-Funktionen), damit der Umstieg offen bleibt. -- keine datetime()-Funktionen), damit der Umstieg offen bleibt.
-- Ein Workspace ist die oberste Klammer. Vorerst gibt es genau einen; die Spalte -- Diese Datei enthält genau einen Workspace (eine Abteilung). Die
-- existiert von Anfang an, damit später Abteilungstrennung möglich ist, ohne -- Workspace-Verwaltung selbst (Tabelle "workspaces") lebt in einer eigenen,
-- jede Tabelle anfassen zu müssen. -- zentralen Kontroll-Datenbank — siehe internal/store/migrations_control und
CREATE TABLE workspaces ( -- AUSBAUSTUFEN.md 4.4. "workspace_id" steht hier trotzdem an jeder Zeile, damit
id TEXT PRIMARY KEY, -- der Bezug beim Lesen einer einzelnen Datei erhalten bleibt, auch wenn er
name TEXT NOT NULL, -- innerhalb der Datei immer denselben Wert hat. Es gibt bewusst keinen
created_at TEXT NOT NULL -- Fremdschlüssel auf eine "workspaces"-Tabelle — die existiert in dieser Datei
); -- nicht.
-- Ein Projekt ist die Einheit der Abschottung: eigene Einträge, eigene Keys, -- Ein Projekt ist die Einheit der Abschottung: eigene Einträge, eigene Keys,
-- eigener MCP-Server. Ein Agent, der an Projekt A arbeitet, sieht Projekt B nicht. -- eigener MCP-Server. Ein Agent, der an Projekt A arbeitet, sieht Projekt B nicht.
CREATE TABLE projects ( CREATE TABLE projects (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL,
slug TEXT NOT NULL, slug TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',

View file

@ -0,0 +1,43 @@
-- Änderungshistorie: die überschriebene Fassung eines Eintrags geht nicht
-- mehr verloren.
--
-- ── Warum? ───────────────────────────────────────────────────────────────────
--
-- entries.version zählt bei jeder Änderung hoch, aber der alte Inhalt wird
-- nirgends aufgehoben. Überschreibt ein Agent etwas Richtiges mit etwas
-- Falschem — oder ein Mensch verwechselt sich beim Bearbeiten —, ist das
-- Richtige weg, ohne Wiederherstellungsweg.
--
-- ── Aufbau ───────────────────────────────────────────────────────────────────
--
-- entries bleibt die aktuelle Fassung. entry_versions hält die SUPERSEDED
-- Fassungen: bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht
-- beim erstmaligen Anlegen), wird der bisherige Stand hier abgelegt, bevor er
-- überschrieben wird. version trägt dabei die Nummer, die die Fassung VOR der
-- Änderung hatte — Fassung 1 ist also das, was durch das Schreiben zu Fassung 2
-- wurde.
--
-- Schlagworte stehen hier bewusst als kommagetrennter Text statt in einer
-- eigenen Tabelle wie bei entries/entry_tags: Eine historische Fassung wird nie
-- gefiltert oder durchsucht, sie wird nur ganz gelesen — eine Nebentabelle
-- brächte dafür keinen Nutzen, nur eine weitere Stelle, die beim Schreiben
-- mitgepflegt werden müsste.
CREATE TABLE entry_versions (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
project_id TEXT NOT NULL,
version INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
curated INTEGER NOT NULL DEFAULT 0,
-- Zeitpunkt, zu dem diese Fassung abgelöst wurde — nicht, wann sie
-- entstand (das wüssten wir hier nicht mehr, ohne die Kette rückwärts
-- aufzurollen; für die Historie reicht "abgelöst am").
created_at TEXT NOT NULL,
UNIQUE (entry_id, version)
);
CREATE INDEX idx_entry_versions_entry ON entry_versions (entry_id, version);

View file

@ -0,0 +1,41 @@
-- Papierkorb: Löschen ist nicht mehr sofort endgültig (AUSBAUSTUFEN.md 3.9).
--
-- ── Warum? ───────────────────────────────────────────────────────────────────
--
-- Seit der Änderungshistorie (004) lässt sich jedes Überschreiben reparieren.
-- Löschen nicht: DELETE entfernte den Eintrag samt aller Fassungen (ON DELETE
-- CASCADE). Damit war "forget" der schlimmste Fall, den ein fehlgeleiteter
-- Agent anrichten konnte.
--
-- ── Aufbau ───────────────────────────────────────────────────────────────────
--
-- Löschen setzt jetzt nur noch deleted_at (Zeitpunkt) und deleted_by (Name des
-- Zugangs). Die Zeile bleibt mit Schlagworten, Verweisen und Verlauf stehen.
-- Endgültig entfernt wird sie erst, wenn ein Mensch den Papierkorb leert oder
-- die tägliche Aufgabe sie nach 30 Tagen abräumt — dann greift das bekannte
-- ON DELETE CASCADE.
--
-- deleted_at ist NULL für jeden normalen Eintrag. NULL statt eines leeren
-- Textes, weil "nicht gelöscht" kein Zeitpunkt ist — und weil sich
-- "deleted_at IS NULL" in jeder Abfrage eindeutig liest.
--
-- Jede lesende Abfrage auf entries braucht ab jetzt diese Bedingung. In Go
-- steht sie als Konstante (store.nichtImPapierkorb), damit sie sich leicht
-- finden lässt; ein Test prüft jeden lesenden Weg.
--
-- Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt
-- (UNIQUE (project_id, slug) gilt weiter). Wer denselben Bezeichner neu
-- schreibt, holt den Eintrag zurück und überschreibt ihn — der Stand aus dem
-- Papierkorb wandert dabei in den Verlauf (siehe PutEntry).
ALTER TABLE entries ADD COLUMN deleted_at TEXT;
ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT '';
-- Ein Teilindex nur über die Einträge im Papierkorb. Er bleibt winzig, denn
-- dort liegt fast nie etwas, und bedient die drei Fragen, die nur den
-- Papierkorb betreffen: "was liegt im Papierkorb dieses Projekts" (Seite),
-- "was ist älter als die Frist" (tägliches Aufräumen) und "welche
-- Schlagworte gehören zu gelöschten Einträgen" (ListTags).
--
-- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg
-- dorthin bleibt offen.
CREATE INDEX idx_entries_papierkorb ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL;

View file

@ -0,0 +1,33 @@
-- Schema der zentralen Kontroll-Datenbank.
--
-- Diese Datei ist bewusst von den Workspace-Dateien getrennt (siehe
-- internal/store/migrations) und enthält nur, was gebraucht wird, BEVOR der
-- Workspace bekannt ist: die Liste der Workspaces selbst, und den Weg von
-- einem Key-Hash zu seinem Workspace.
--
-- Alles andere (Projekte, Einträge, Keys im Volltext, Protokoll) lebt in der
-- jeweiligen Workspace-Datei — siehe AUSBAUSTUFEN.md 4.4.
-- Ein Workspace ist eine Abteilung. Jede hat ihre eigene Datenbankdatei; diese
-- Tabelle ist nur das Verzeichnis darüber.
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- key_index bildet Key-Hash auf Workspace ab — der einzige Grund, warum diese
-- Datenbank vor dem Öffnen einer Workspace-Datei befragt werden muss.
--
-- Die Zeile hier ist ein Index, keine Wahrheit über den Key selbst: Name,
-- Rolle, Projekt und der Zeitpunkt der letzten Nutzung stehen ausschließlich
-- in der Workspace-Datei. Wird ein Key dort angelegt oder zurückgezogen, muss
-- diese Tabelle im selben Zug nachgezogen werden (siehe ControlStore.IndexKey
-- und ControlStore.RemoveKeyIndex).
CREATE TABLE key_index (
hash TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE INDEX idx_key_index_workspace ON key_index (workspace_id);

View file

@ -66,6 +66,33 @@ func (k APIKey) Revoked() bool {
return !k.RevokedAt.IsZero() return !k.RevokedAt.IsZero()
} }
// UnusedAfter ist die Zeitspanne, nach der ein aktiver Key ohne Benutzung als
// „ungenutzt“ gilt (siehe AUSBAUSTUFEN.md 4.7).
const UnusedAfter = 90 * 24 * time.Hour
// Unused sagt, ob ein aktiver Key seit mehr als UnusedAfter nicht benutzt wurde.
//
// Wurde der Key noch nie benutzt, zählt stattdessen das Anlegedatum. Sonst
// stünde jeder frisch angelegte Key sofort als „ungenutzt“ da, obwohl sein
// Besitzer ihn vielleicht erst morgen einrichtet. Nach 90 Tagen ohne einen
// einzigen Aufruf ist dagegen klar: Den braucht niemand.
//
// Zurückgezogene Keys sind nie „ungenutzt“, denn sie sind ohnehin kein offener
// Zugang mehr.
//
// jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen
// Zeitpunkt vorgeben können.
func (k APIKey) Unused(jetzt time.Time) bool {
if k.Revoked() {
return false
}
zuletzt := k.LastUsedAt
if zuletzt.IsZero() {
zuletzt = k.CreatedAt
}
return jetzt.Sub(zuletzt) > UnusedAfter
}
// Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben. // Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben.
type Entry struct { type Entry struct {
ID string ID string
@ -80,6 +107,67 @@ type Entry struct {
Version int Version int
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
// RestoredFromTrash setzt nur PutEntry: true, wenn der Schreibvorgang
// einen Eintrag aus dem Papierkorb zurückgeholt und überschrieben hat.
// Aufrufer sagen das dem Schreiber — ein Agent soll merken, dass er einen
// gelöschten Eintrag wiederbelebt hat. Beim Lesen ist der Wert immer false.
RestoredFromTrash bool
}
// StaleAfter ist die Zeitspanne ohne Änderung, nach der ein Eintrag als
// „möglicherweise veraltet“ gilt (siehe AUSBAUSTUFEN.md 3.5).
//
// Ein Jahr ist ein Kompromiss: Ein Zugangsverfahren kann schon nach Wochen
// überholt sein, eine Grundsatzentscheidung nach Jahren noch stimmen. Eine
// Frist je Art des Wissens wäre genauer, ist aber der größere Schritt. Bis
// dahin gilt eine Grenze für alle, und sie ist bewusst eher lang: Ein Vermerk,
// der an jedem zweiten Eintrag steht, liest bald niemand mehr.
const StaleAfter = 365 * 24 * time.Hour
// Stale sagt, ob ein Eintrag seit mehr als StaleAfter nicht geändert wurde.
//
// Das heißt nicht, dass er falsch ist, sondern nur, dass ihn lange niemand
// angefasst hat. Deshalb wird er auch nicht versteckt oder schlechter
// gewichtet. Wer ihn liest, soll aber wissen, dass er ihn vor dem Verwenden
// prüfen sollte.
//
// Auch geprüfte Einträge (Curated) können veralten. Das Häkchen sagt nur, dass
// ein Mensch den Eintrag irgendwann bestätigt hat, nicht wann.
func (e Entry) Stale(jetzt time.Time) bool {
return StaleSince(e.UpdatedAt, jetzt)
}
// StaleSince ist dieselbe Regel für Stellen, die nur den Änderungszeitpunkt
// kennen, aber keinen ganzen Entry, etwa verwandte Einträge (RelatedEntry).
func StaleSince(geaendert, jetzt time.Time) bool {
return !geaendert.IsZero() && jetzt.Sub(geaendert) > StaleAfter
}
// TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite
// braucht: wer ihn wann gelöscht hat, ohne den ganzen Inhalt.
type TrashedEntry struct {
ID string
Slug string
Title string
Version int
DeletedAt time.Time
DeletedBy string // Name des Zugangs, der gelöscht hat
}
// EntryVersion ist eine abgelöste Fassung eines Eintrags — der Stand, der bei
// einem Schreibvorgang überschrieben wurde (siehe migrations/004).
type EntryVersion struct {
ID string
EntryID string
Version int // Fassungsnummer, die diese Zeile VOR der Ablösung trug
Title string
Content string
Tags []string
Author string
Source string
Curated bool
CreatedAt time.Time // Zeitpunkt der Ablösung, nicht der Entstehung
} }
// Herkunft eines Eintrags. // Herkunft eines Eintrags.

314
internal/store/nutzung.go Normal file
View file

@ -0,0 +1,314 @@
package store
// Nutzungsübersicht je Projekt (AUSBAUSTUFEN.md 4.6).
//
// Die Frage dahinter: Benutzen die Teams Munin wirklich — und benutzen ihre
// Agenten es so, wie es gedacht ist? Ein gutes Zeichen ist, wenn der Anteil
// der Aktualisierungen gegenüber Neuanlagen wächst: Dann finden Agenten
// vorhandenes Wissen und schreiben es fort, statt Dubletten daneben zu legen.
//
// Alle Zahlen kommen aus Daten, die ohnehin da sind — dem Protokoll
// (audit_events) und den Einträgen. Es gibt keine eigene Zähltabelle, die
// mitgepflegt werden müsste und auseinanderlaufen könnte.
import (
"context"
"database/sql"
"fmt"
"time"
)
// UsageWindow ist der Zeitraum, über den die Schreibvorgänge gezählt werden.
// Kürzer als die Aufbewahrungsfrist des Protokolls (90 Tage), sonst wären die
// ältesten Tage schon teilweise weggeräumt und die Zahlen zu niedrig.
const UsageWindow = 30 * 24 * time.Hour
// UsageWindowDays ist derselbe Zeitraum in Tagen, für Texte in der Oberfläche.
const UsageWindowDays = int(UsageWindow / (24 * time.Hour))
// WriteCounts zählt Schreibvorgänge eines Zeitraums nach Art.
type WriteCounts struct {
Created int // neu angelegt
Updated int // bestehenden Eintrag geändert (auch Wiederherstellen, Sammelbearbeitung)
Deleted int // in den Papierkorb gelegt
// Unknown sind Schreibvorgänge ohne Angabe, ob angelegt oder aktualisiert.
// Das Protokoll unterscheidet das erst seit 2026-09-24; ältere Zeilen
// landen hier. Nach 30 Tagen ist dieser Wert von selbst immer 0.
Unknown int
}
// Writes ist die Zahl aller Schreibvorgänge ohne Löschungen.
func (w WriteCounts) Writes() int {
return w.Created + w.Updated + w.Unknown
}
// add zählt zwei Werte zusammen — für die Summe aus Menschen und Agenten.
func (w WriteCounts) add(o WriteCounts) WriteCounts {
return WriteCounts{
Created: w.Created + o.Created,
Updated: w.Updated + o.Updated,
Deleted: w.Deleted + o.Deleted,
Unknown: w.Unknown + o.Unknown,
}
}
// UsageSummary sind die Kennzahlen eines Projekts.
type UsageSummary struct {
// Schreibvorgänge im Zeitraum UsageWindow, getrennt nach Weg: Die
// Weboberfläche benutzen Menschen, MCP benutzen Agenten. (Ein Mensch, der
// selbst über MCP schreibt, zählt als Agent — aus Sicht von Munin ist er
// in dem Moment auch einer.)
Human WriteCounts
Agent WriteCounts
// Bestand, ohne Papierkorb.
Entries int
Curated int
Untagged int
LastUpdatedAt time.Time // jüngste Änderung eines Eintrags; Nullzeitpunkt = keine Einträge
}
// Total ist die Summe aus Menschen und Agenten.
func (u UsageSummary) Total() WriteCounts {
return u.Human.add(u.Agent)
}
// CuratedPercent ist der Anteil kuratierter Einträge in Prozent (0–100).
func (u UsageSummary) CuratedPercent() int {
return prozent(u.Curated, u.Entries)
}
// UpdatePercent ist der Anteil der Aktualisierungen an allen Schreibvorgängen
// mit bekannter Art, in Prozent. -1 heißt: Es gab keine solchen
// Schreibvorgänge, ein Anteil ergibt dann keinen Sinn.
func (u UsageSummary) UpdatePercent() int {
t := u.Total()
if t.Created+t.Updated == 0 {
return -1
}
return prozent(t.Updated, t.Created+t.Updated)
}
// prozent rechnet teil/ganz in ganze Prozent um, kaufmännisch gerundet.
// Ganzzahlig statt mit Kommazahlen: "37 %" reicht für diese Übersicht, und so
// gibt es keine Rundungsfragen in der Anzeige.
func prozent(teil, ganz int) int {
if ganz == 0 {
return 0
}
// Die Hälfte des Nenners dazuzählen, bevor ganzzahlig geteilt wird —
// das ist Runden statt Abschneiden.
return (teil*100 + ganz/2) / ganz
}
// UsageSummaries liefert die Kennzahlen je Projekt, als map von Projekt-ID
// auf Werte. projectID schränkt auf ein Projekt ein; leer heißt: alle Projekte
// dieser Workspace-Datei (für die Admin-Übersicht, in zwei Abfragen statt
// zwei je Projekt).
//
// Projekte ganz ohne Einträge und ohne Schreibvorgänge fehlen in der map. Der
// Aufrufer bekommt für sie beim Nachschlagen den Nullwert — also überall 0,
// und genau das stimmt ja auch.
func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time.Time) (map[string]UsageSummary, error) {
ergebnis := map[string]UsageSummary{}
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// ── 1. Schreibvorgänge aus dem Protokoll ────────────────────────────────
//
// Eine Abfrage für alle vier Zahlen: CASE ... THEN 1 ELSE 0 macht aus jeder
// Zeile eine 1 oder 0 je Spalte, SUM zählt sie. Das Feld detail sagt, ob
// angelegt oder aktualisiert wurde (siehe DetailEntryCreated in audit.go).
// Alles, was nicht "angelegt" und nicht leer ist, ist eine Aktualisierung:
// "aktualisiert", "wiederhergestellt: …", "Sammelbearbeitung: …".
abfrage := `SELECT project_id, source,
SUM(CASE WHEN action = ? AND detail = ? THEN 1 ELSE 0 END),
SUM(CASE WHEN action = ? AND detail <> ? AND detail <> '' THEN 1 ELSE 0 END),
SUM(CASE WHEN action = ? AND detail = '' THEN 1 ELSE 0 END),
SUM(CASE WHEN action = ? THEN 1 ELSE 0 END)
FROM audit_events
WHERE ts >= ? AND action IN (?, ?)`
werte := []any{
ActionEntryWrite, DetailEntryCreated,
ActionEntryWrite, DetailEntryCreated,
ActionEntryWrite,
ActionEntryDelete,
seit, ActionEntryWrite, ActionEntryDelete,
}
if projectID != "" {
abfrage += ` AND project_id = ?`
werte = append(werte, projectID)
}
abfrage += ` GROUP BY project_id, source`
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
if err != nil {
return nil, fmt.Errorf("Nutzung konnte nicht gelesen werden: %w", err)
}
for rows.Next() {
var projekt, quelle string
var z WriteCounts
if err := rows.Scan(&projekt, &quelle, &z.Created, &z.Updated, &z.Unknown, &z.Deleted); err != nil {
rows.Close()
return nil, err
}
// Werte aus einer map sind in Go Kopien: erst herausholen, ändern,
// dann wieder hineinschreiben.
u := ergebnis[projekt]
switch quelle {
case SourceWeb:
u.Human = u.Human.add(z)
case SourceMCP:
u.Agent = u.Agent.add(z)
}
ergebnis[projekt] = u
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
// ── 2. Bestand aus den Einträgen ────────────────────────────────────────
//
// NOT EXISTS fragt je Eintrag, ob es in entry_tags gar keine Zeile für ihn
// gibt. Das läuft über den Primärschlüssel (entry_id, tag) und ist damit
// ein kurzer Blick in den Index je Eintrag.
abfrage = `SELECT project_id, COUNT(*), COALESCE(SUM(curated), 0),
SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) THEN 1 ELSE 0 END),
MAX(updated_at)
FROM entries e
WHERE e.deleted_at IS NULL`
werte = nil
if projectID != "" {
abfrage += ` AND project_id = ?`
werte = append(werte, projectID)
}
abfrage += ` GROUP BY project_id`
rows, err = s.lesen.QueryContext(ctx, abfrage, werte...)
if err != nil {
return nil, fmt.Errorf("Bestand konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var projekt string
var zuletzt sql.NullString
var anzahl, kuratiert, ohneSchlagwort int
if err := rows.Scan(&projekt, &anzahl, &kuratiert, &ohneSchlagwort, &zuletzt); err != nil {
return nil, err
}
u := ergebnis[projekt]
u.Entries, u.Curated, u.Untagged = anzahl, kuratiert, ohneSchlagwort
if zuletzt.Valid {
u.LastUpdatedAt = parseTime(zuletzt.String)
}
ergebnis[projekt] = u
}
return ergebnis, rows.Err()
}
// EntryRef ist ein knapper Verweis auf einen Eintrag: Bezeichner und Titel.
type EntryRef struct {
Slug string
Title string
}
// UntaggedEntries gibt Einträge ohne Schlagwort zurück, zuletzt geänderte
// zuerst, höchstens limit Stück.
func (s *Store) UntaggedEntries(ctx context.Context, projectID string, limit int) ([]EntryRef, error) {
rows, err := s.lesen.QueryContext(ctx,
`SELECT slug, title FROM entries e
WHERE project_id = ? AND e.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id)
ORDER BY updated_at DESC
LIMIT ?`,
projectID, limit,
)
if err != nil {
return nil, fmt.Errorf("Einträge ohne Schlagwort konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var liste []EntryRef
for rows.Next() {
var e EntryRef
if err := rows.Scan(&e.Slug, &e.Title); err != nil {
return nil, err
}
liste = append(liste, e)
}
return liste, rows.Err()
}
// MissingLink ist ein Bezeichner, auf den verwiesen wird, zu dem es aber
// keinen Eintrag gibt — mit der Zahl der Einträge, die darauf verweisen.
type MissingLink struct {
Slug string
Count int
}
// MissingLinks gibt alle [[…]]-Verweise ins Leere zurück, meistgenannte zuerst.
//
// Ein Verweis ins Leere ist nicht unbedingt ein Fehler: Oft steht er für
// Wissen, das jemand für wichtig hielt, aber noch niemand aufgeschrieben hat.
// Die meistgenannten sind deshalb gute Kandidaten für den nächsten Eintrag.
//
// Verweise AUS gelöschten Einträgen zählen nicht; Verweise AUF gelöschte
// Einträge dagegen schon — für alle Leser ist das Ziel ja weg.
func (s *Store) MissingLinks(ctx context.Context, projectID string) ([]MissingLink, error) {
rows, err := s.lesen.QueryContext(ctx,
`SELECT l.to_slug, COUNT(*) AS anzahl
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
WHERE l.project_id = ? AND e.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM entries z
WHERE z.project_id = l.project_id AND z.slug = l.to_slug AND z.deleted_at IS NULL)
GROUP BY l.to_slug
ORDER BY anzahl DESC, l.to_slug`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var liste []MissingLink
for rows.Next() {
var m MissingLink
if err := rows.Scan(&m.Slug, &m.Count); err != nil {
return nil, err
}
liste = append(liste, m)
}
return liste, rows.Err()
}
// RecentlyUsedKeys gibt die zuletzt benutzten, nicht zurückgezogenen Keys
// eines Projekts zurück, höchstens limit Stück. Nie benutzte Keys fehlen —
// die gehören in eine andere Frage (AUSBAUSTUFEN.md 4.7).
func (s *Store) RecentlyUsedKeys(ctx context.Context, projectID string, limit int) ([]APIKey, error) {
rows, err := s.lesen.QueryContext(ctx,
`SELECT `+keyColumns+` FROM api_keys
WHERE project_id = ? AND revoked_at = '' AND last_used_at <> ''
ORDER BY last_used_at DESC
LIMIT ?`,
projectID, limit,
)
if err != nil {
return nil, fmt.Errorf("Keys konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var keys []APIKey
for rows.Next() {
k, err := scanKey(rows.Scan)
if err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}

View file

@ -0,0 +1,150 @@
package store
import (
"context"
"testing"
"time"
)
// TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und
// Löschungen werden getrennt nach Mensch (Web) und Agent (MCP) gezählt — und
// nur innerhalb des Zeitfensters.
func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
protokoll := func(quelle, aktion, detail string) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: aktion, Source: quelle, Detail: detail})
}
protokoll(SourceMCP, ActionEntryWrite, DetailEntryCreated)
protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
protokoll(SourceMCP, ActionEntryDelete, "in den Papierkorb")
protokoll(SourceWeb, ActionEntryWrite, DetailEntryCreated)
protokoll(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung
protokoll(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso
protokoll(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung
protokoll(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht
// Ein Schreibvorgang außerhalb des Fensters darf nicht mitzählen.
alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
`INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, '', 'x', ?, '', ?, '', ?)`,
NewID(), alt, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil {
t.Fatal(err)
}
alle, err := s.UsageSummaries(ctx, "", time.Now())
if err != nil {
t.Fatal(err)
}
u := alle[p.ID]
if (u.Agent != WriteCounts{Created: 1, Updated: 2, Deleted: 1}) {
t.Errorf("Agent = %+v", u.Agent)
}
if (u.Human != WriteCounts{Created: 1, Updated: 2, Unknown: 1}) {
t.Errorf("Mensch = %+v", u.Human)
}
// 4 Aktualisierungen von 6 bekannten Schreibvorgängen = 67 %.
if got := u.UpdatePercent(); got != 67 {
t.Errorf("UpdatePercent = %d, erwartet 67", got)
}
// Eingeschränkt auf das Projekt kommt dasselbe heraus.
einzeln, _ := s.UsageSummaries(ctx, p.ID, time.Now())
if einzeln[p.ID] != u {
t.Errorf("mit Projektfilter: %+v, ohne: %+v", einzeln[p.ID], u)
}
}
// TestUsageSummariesBestand: Einträge, kuratierte und solche ohne Schlagwort —
// ohne Papierkorb.
func TestUsageSummariesBestand(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Tags: []string{"x"}, Curated: true, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Source: SourceAgent})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Source: SourceAgent})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Curated: true, Source: SourceAgent})
s.MoveEntryToTrash(ctx, p.ID, "weg", "tom")
alle, _ := s.UsageSummaries(ctx, "", time.Now())
u := alle[p.ID]
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",
u.Entries, u.Curated, u.Untagged)
}
if u.CuratedPercent() != 33 {
t.Errorf("CuratedPercent = %d, erwartet 33", u.CuratedPercent())
}
if u.LastUpdatedAt.IsZero() {
t.Error("LastUpdatedAt fehlt")
}
// Keine Schreibvorgänge im Protokoll → kein sinnvoller Anteil.
if u.UpdatePercent() != -1 {
t.Errorf("UpdatePercent ohne Schreibvorgänge = %d, erwartet -1", u.UpdatePercent())
}
ohne, _ := s.UntaggedEntries(ctx, p.ID, 10)
if len(ohne) != 2 {
t.Errorf("UntaggedEntries = %v, erwartet b und c", ohne)
}
}
// TestMissingLinks: Verweise ins Leere, meistgenannte zuerst. Verweise aus
// dem Papierkorb zählen nicht, Verweise auf gelöschte Einträge schon.
func TestMissingLinks(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Content: "[[fehlt]] [[vorhanden]] [[geloescht]]", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Content: "[[fehlt]]", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "vorhanden", Title: "V", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "geloescht", Title: "G", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "im-papierkorb", Title: "P", Content: "[[nur-von-dort]]", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "geloescht", "tom")
s.MoveEntryToTrash(ctx, p.ID, "im-papierkorb", "tom")
liste, err := s.MissingLinks(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
erwartet := []MissingLink{{"fehlt", 2}, {"geloescht", 1}}
if len(liste) != len(erwartet) {
t.Fatalf("MissingLinks = %v, erwartet %v", liste, erwartet)
}
for i := range erwartet {
if liste[i] != erwartet[i] {
t.Errorf("[%d] = %v, erwartet %v", i, liste[i], erwartet[i])
}
}
}
func TestRecentlyUsedKeys(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
benutzt, _ := s.CreateAPIKey(ctx, p.ID, "benutzt", "hash-1", "write")
s.CreateAPIKey(ctx, p.ID, "nie", "hash-2", "write")
weg, _ := s.CreateAPIKey(ctx, p.ID, "zurueckgezogen", "hash-3", "write")
jetzt := NowString()
s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, jetzt, benutzt.ID, weg.ID)
s.RevokeAPIKey(ctx, weg.ID)
keys, err := s.RecentlyUsedKeys(ctx, p.ID, 5)
if err != nil {
t.Fatal(err)
}
if len(keys) != 1 || keys[0].Name != "benutzt" {
t.Errorf("RecentlyUsedKeys = %v, erwartet nur „benutzt“", keys)
}
}

View file

@ -0,0 +1,209 @@
package store
// Papierkorb (AUSBAUSTUFEN.md 3.9, migrations/005_papierkorb.sql).
//
// Der Lebenslauf eines gelöschten Eintrags:
//
// MoveEntryToTrash → deleted_at gesetzt, für alle Lesewege unsichtbar
// RestoreFromTrash → deleted_at wieder NULL, alles wie vorher
// PutEntry → derselbe Bezeichner wird neu geschrieben: zurückgeholt
// und überschrieben, der gelöschte Stand im Verlauf
// PurgeFromTrash → ein Mensch leert den Papierkorb: endgültig weg
// PurgeTrash → die tägliche Aufgabe räumt nach Ablauf der Frist ab
//
// Endgültig löschen können nur die beiden Purge-Funktionen, und beide fassen
// ausschließlich Einträge an, die schon im Papierkorb liegen. Ein normaler
// Eintrag kann also nie in einem Schritt verschwinden.
import (
"context"
"database/sql"
"fmt"
"time"
)
// TrashRetention bestimmt, wie lange ein gelöschter Eintrag im Papierkorb
// liegt, bevor die tägliche Aufgabe ihn endgültig entfernt.
//
// 30 Tage: lang genug, dass ein Fehler auch nach Urlaub oder Sprintende noch
// auffällt, kurz genug, dass versehentlich gespeicherte Geheimnisse nicht
// ewig liegen bleiben (die lassen sich außerdem sofort von Hand endgültig
// löschen). Eine Konstante hier statt in main.go, damit die Oberfläche
// dieselbe Zahl anzeigt, nach der auch aufgeräumt wird.
const TrashRetention = 30 * 24 * time.Hour
// TrashRetentionDays ist dieselbe Frist in Tagen, für Texte in Oberfläche
// und MCP-Beschreibung.
const TrashRetentionDays = int(TrashRetention / (24 * time.Hour))
// MoveEntryToTrash legt einen Eintrag in den Papierkorb.
//
// Inhalt, Schlagworte, Verweise und Verlauf bleiben unverändert stehen, auch
// die Fassungsnummer: Löschen ist keine neue Fassung, sondern eine Markierung
// daneben. geloeschtVon ist der Name des Zugangs, der gelöscht hat — die
// Papierkorb-Seite zeigt ihn, damit man sieht, ob ein Agent aufgeräumt hat.
//
// Liegt der Eintrag schon im Papierkorb (oder gibt es ihn nicht), kommt
// ErrNotFound — aus Sicht des Aufrufers gibt es ihn ja nicht mehr.
func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloeschtVon string) error {
res, err := s.schreiben.ExecContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
NowString(), geloeschtVon, projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err)
}
return requireOneRow(res)
}
// 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
// alles, was ein bestimmter Agent gelöscht hat, auf einen Blick sehen und in
// einem Zug zurückholen.
func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) ([]TrashedEntry, error) {
abfrage := `SELECT id, slug, title, version, deleted_at, deleted_by
FROM entries
WHERE project_id = ? AND deleted_at IS NOT NULL`
werte := []any{projectID}
if geloeschtVon != "" {
abfrage += ` AND deleted_by = ?`
werte = append(werte, geloeschtVon)
}
abfrage += ` ORDER BY deleted_at DESC`
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
if err != nil {
return nil, fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var liste []TrashedEntry
for rows.Next() {
var t TrashedEntry
var geloeschtAm string
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &geloeschtAm, &t.DeletedBy); err != nil {
return nil, err
}
t.DeletedAt = parseTime(geloeschtAm)
liste = append(liste, t)
}
return liste, rows.Err()
}
// RestoreFromTrash holt Einträge aus dem Papierkorb zurück.
//
// Bezeichner, die nicht (mehr) im Papierkorb dieses Projekts liegen, werden
// stillschweigend übersprungen — genauso wie bei BulkUpdateTags: Eine
// untergeschobene Angabe aus einem fremden Projekt verändert nichts.
//
// Zurückgegeben werden die wiederhergestellten Einträge samt Schlagworten,
// damit der Aufrufer sie wieder in den Suchindex aufnehmen kann.
func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []string) ([]Entry, error) {
var zurueck []Entry
err := s.tx(ctx, func(tx *sql.Tx) error {
for _, slug := range slugs {
res, err := tx.ExecContext(ctx,
`UPDATE entries SET deleted_at = NULL, deleted_by = ''
WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`,
projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag %q konnte nicht wiederhergestellt werden: %w", slug, err)
}
if n, _ := res.RowsAffected(); n == 0 {
continue
}
row := tx.QueryRowContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ?`, projectID, slug)
e, err := scanEntry(row.Scan)
if err != nil {
return fmt.Errorf("Eintrag %q konnte nicht neu gelesen werden: %w", slug, err)
}
zurueck = append(zurueck, e)
}
return nil
})
if err != nil {
return nil, err
}
// Die Schlagworte erst nach der Transaktion nachladen: attachTags liest
// über den Lese-Pool, und der sieht die Änderung erst nach dem Commit.
return zurueck, s.attachTagsToSlice(ctx, zurueck)
}
// PurgeFromTrash löscht Einträge aus dem Papierkorb endgültig, samt Verlauf,
// Schlagworten und Verweisen (ON DELETE CASCADE). Das lässt sich nicht
// rückgängig machen.
//
// Die Bedingung "deleted_at IS NOT NULL" ist der Schutz dieser Funktion: Ein
// Eintrag, der nicht im Papierkorb liegt, wird nie angefasst — auch nicht,
// wenn jemand seinen Bezeichner ins Formular schmuggelt.
func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []string) (int, error) {
geloescht := 0
err := s.tx(ctx, func(tx *sql.Tx) error {
for _, slug := range slugs {
res, err := tx.ExecContext(ctx,
`DELETE FROM entries WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`,
projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag %q konnte nicht endgültig gelöscht werden: %w", slug, err)
}
n, _ := res.RowsAffected()
geloescht += int(n)
}
return nil
})
if err != nil {
return 0, err
}
return geloescht, nil
}
// PurgeTrash räumt alle Einträge endgültig ab, die länger als bis aelterAls im
// Papierkorb liegen — über alle Projekte dieser Workspace-Datei. Gedacht für
// die tägliche Aufgabe in main.go, die auch das Protokoll aufräumt.
//
// Gibt die Bezeichner der entfernten Einträge je Projekt zurück, damit der
// Aufrufer jeden Fall im Protokoll vermerken kann.
func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]string, error) {
entfernt := map[string][]string{}
err := s.tx(ctx, func(tx *sql.Tx) error {
// Erst lesen, was gleich verschwindet — nach dem DELETE wäre es weg.
rows, err := tx.QueryContext(ctx,
`SELECT project_id, slug FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls)
if err != nil {
return fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
}
for rows.Next() {
var projektID, slug string
if err := rows.Scan(&projektID, &slug); err != nil {
rows.Close()
return err
}
entfernt[projektID] = append(entfernt[projektID], slug)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if _, err := tx.ExecContext(ctx,
`DELETE FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls,
); err != nil {
return fmt.Errorf("Papierkorb konnte nicht aufgeräumt werden: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return entfernt, nil
}

View file

@ -0,0 +1,296 @@
package store
import (
"context"
"errors"
"testing"
"time"
)
// TestPapierkorbUnsichtbar ist der wichtigste Test des Papierkorbs: Jeder
// lesende Weg muss einen gelöschten Eintrag ausblenden. Kommt später eine
// neue Abfrage auf entries dazu, gehört sie hier mit hinein (siehe
// nichtImPapierkorb in entries.go).
//
// Aufbau: "lebt" verweist auf "weg", "weg" verweist auf "lebt". Beide tragen
// das Schlagwort "gemeinsam", "weg" zusätzlich "nur-weg". Dann wird "weg"
// gelöscht.
func TestPapierkorbUnsichtbar(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
lebt, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "Lebt",
Content: "siehe [[weg]]", Tags: []string{"gemeinsam"}, Source: SourceHuman})
weg, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg",
Content: "siehe [[lebt]]", Tags: []string{"gemeinsam", "nur-weg"}, Source: SourceHuman, Curated: true})
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "agent-1"); err != nil {
t.Fatal(err)
}
// t.Run fasst jeden Weg als eigenen Untertest — schlägt einer fehl, steht
// sein Name in der Ausgabe.
t.Run("EntryBySlug", func(t *testing.T) {
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) {
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
}
})
t.Run("ListEntries", func(t *testing.T) {
for name, filter := range map[string]EntryFilter{
"ohne Filter": {},
"mit Schlagwort": {Tag: "nur-weg"},
"nur kuratierte": {Curated: true},
} {
liste, err := s.ListEntries(ctx, p.ID, filter)
if err != nil {
t.Fatal(err)
}
for _, e := range liste {
if e.Slug == "weg" {
t.Errorf("%s: gelöschter Eintrag in der Liste", name)
}
}
}
})
t.Run("AllEntries (Suchindex, Export)", func(t *testing.T) {
alle, _ := s.AllEntries(ctx, p.ID)
if len(alle) != 1 || alle[0].Slug != "lebt" {
t.Errorf("AllEntries = %v, erwartet nur „lebt“", alle)
}
})
t.Run("CountEntries", func(t *testing.T) {
if n, _ := s.CountEntries(ctx, p.ID); n != 1 {
t.Errorf("%d Einträge gezählt, erwartet 1", n)
}
})
t.Run("ListTags", func(t *testing.T) {
tags, _ := s.ListTags(ctx, p.ID)
for _, tc := range tags {
if tc.Tag == "nur-weg" {
t.Error("Schlagwort, das nur ein gelöschter Eintrag trägt, wird noch angezeigt")
}
if tc.Tag == "gemeinsam" && tc.Count != 1 {
t.Errorf("„gemeinsam“ zählt %d, erwartet 1", tc.Count)
}
}
})
t.Run("Backlinks", func(t *testing.T) {
// "weg" verweist auf "lebt" — dieser Rückverweis muss verschwinden.
links, _ := s.Backlinks(ctx, p.ID, "lebt")
if len(links) != 0 {
t.Errorf("Rückverweise = %v, erwartet keine", links)
}
})
t.Run("LinkGraph", func(t *testing.T) {
knoten, kanten, _ := s.LinkGraph(ctx, p.ID)
for _, k := range knoten {
// "lebt" verweist weiter auf "weg" — der Knoten darf bleiben, aber
// nur als gestrichelter "gibt es nicht".
if k.Slug == "weg" && k.Exists {
t.Error("gelöschter Eintrag erscheint im Graph als vorhanden")
}
}
for _, e := range kanten {
if e.From == "weg" {
t.Error("Verweis eines gelöschten Eintrags erscheint im Graph")
}
}
})
t.Run("BulkUpdateTags", func(t *testing.T) {
geaendert, _ := s.BulkUpdateTags(ctx, p.ID, []string{lebt.ID, weg.ID}, []string{"neu"}, nil)
if len(geaendert) != 1 || geaendert[0].Slug != "lebt" {
t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", geaendert)
}
})
}
func TestListTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
for _, slug := range []string{"a", "b", "c"} {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Source: SourceHuman})
}
s.MoveEntryToTrash(ctx, p.ID, "a", "agent-1")
s.MoveEntryToTrash(ctx, p.ID, "b", "tom")
alle, _ := s.ListTrash(ctx, p.ID, "")
if len(alle) != 2 {
t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(alle))
}
vomAgenten, _ := s.ListTrash(ctx, p.ID, "agent-1")
if len(vomAgenten) != 1 || vomAgenten[0].Slug != "a" || vomAgenten[0].DeletedBy != "agent-1" {
t.Errorf("Filter nach Zugang: %+v", vomAgenten)
}
}
func TestRestoreFromTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
fremd, _ := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "")
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "Inhalt",
Tags: []string{"x"}, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: fremd.ID, Slug: "f", Title: "F", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "tom")
s.MoveEntryToTrash(ctx, fremd.ID, "f", "tom")
// "f" liegt im Papierkorb eines ANDEREN Projekts — darf nicht mit.
zurueck, err := s.RestoreFromTrash(ctx, p.ID, []string{"e", "f", "gibt-es-nicht"})
if err != nil {
t.Fatal(err)
}
if len(zurueck) != 1 || zurueck[0].Slug != "e" {
t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", zurueck)
}
if len(zurueck[0].Tags) != 1 || zurueck[0].Tags[0] != "x" {
t.Errorf("Schlagworte nach Wiederherstellen = %v", zurueck[0].Tags)
}
e, err := s.EntryBySlug(ctx, p.ID, "e")
if err != nil || e.Content != "Inhalt" || e.Version != 1 {
t.Errorf("nach Wiederherstellen: %+v, %v — Inhalt und Fassung müssen unverändert sein", e, err)
}
if liste, _ := s.ListTrash(ctx, fremd.ID, ""); len(liste) != 1 {
t.Error("der Eintrag des fremden Projekts wurde angefasst")
}
}
// TestPutEntryRevivesFromTrash: Wer einen Bezeichner aus dem Papierkorb neu
// beschreibt, holt den Eintrag zurück und überschreibt ihn. Der gelöschte
// Stand bleibt im Verlauf.
func TestPutEntryRevivesFromTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Alt", Content: "gelöscht", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1")
neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "frisch", Source: SourceAgent})
if err != nil {
t.Fatal(err)
}
if !neu.RestoredFromTrash {
t.Error("RestoredFromTrash nicht gesetzt")
}
if neu.Version != 2 {
t.Errorf("Fassung %d, erwartet 2", neu.Version)
}
if _, err := s.EntryBySlug(ctx, p.ID, "e"); err != nil {
t.Errorf("Eintrag nach dem Überschreiben nicht sichtbar: %v", err)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 0 {
t.Error("Eintrag liegt nach dem Überschreiben noch im Papierkorb")
}
alt, err := s.EntryVersionByNumber(ctx, neu.ID, 1)
if err != nil || alt.Content != "gelöscht" {
t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, alt.Content)
}
// Ein normaler Schreibvorgang danach meldet nichts mehr vom Papierkorb.
weiter, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent})
if weiter.RestoredFromTrash {
t.Error("RestoredFromTrash bei einem gewöhnlichen Update gesetzt")
}
}
// TestPutEntryConflictWhenDeletedMeanwhile: Wer einen Stand gelesen hat und
// ihn fortschreiben will, während jemand anderes gelöscht hat, bekommt einen
// Konflikt — die Löschung wird nicht unbemerkt rückgängig gemacht.
func TestPutEntryConflictWhenDeletedMeanwhile(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "1", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1")
_, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "2",
Source: SourceHuman, ExpectedVersion: 1})
if !errors.Is(err, ErrConflict) {
t.Errorf("Fehler = %v, erwartet ErrConflict", err)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 {
t.Error("der Eintrag wurde trotz Konflikt aus dem Papierkorb geholt")
}
}
// TestPurgeFromTrashOnlyTrashed: Endgültig löschen fasst nie einen Eintrag an,
// der nicht im Papierkorb liegt.
func TestPurgeFromTrashOnlyTrashed(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "T", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "weg", "tom")
n, err := s.PurgeFromTrash(ctx, p.ID, []string{"lebt", "weg"})
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("%d endgültig gelöscht, erwartet 1", n)
}
if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil {
t.Error("ein Eintrag außerhalb des Papierkorbs wurde endgültig gelöscht")
}
// Der Bezeichner ist danach wieder frei: Neu anlegen beginnt bei Fassung 1.
neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
if err != nil || neu.Version != 1 || neu.RestoredFromTrash {
t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", neu, err)
}
}
// TestPurgeTrashAfterRetention: Die tägliche Aufgabe entfernt nur, was länger
// als die Frist im Papierkorb liegt.
func TestPurgeTrashAfterRetention(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
for _, slug := range []string{"alt", "frisch", "lebt"} {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Source: SourceHuman})
}
s.MoveEntryToTrash(ctx, p.ID, "alt", "tom")
s.MoveEntryToTrash(ctx, p.ID, "frisch", "tom")
// "alt" künstlich vor 31 Tage zurückdatieren — die Tests sollen nicht
// einen Monat warten.
vorEinemMonat := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
`UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, vorEinemMonat); err != nil {
t.Fatal(err)
}
grenze := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano)
entfernt, err := s.PurgeTrash(ctx, grenze)
if err != nil {
t.Fatal(err)
}
if len(entfernt[p.ID]) != 1 || entfernt[p.ID][0] != "alt" {
t.Errorf("entfernt: %v, erwartet nur „alt“", entfernt)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 || liste[0].Slug != "frisch" {
t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", liste)
}
if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil {
t.Error("ein normaler Eintrag wurde beim Aufräumen entfernt")
}
}

View file

@ -61,44 +61,11 @@ func Slugify(text string) string {
return slug return slug
} }
// ── Workspace ────────────────────────────────────────────────────────────────
// EnsureWorkspace legt beim ersten Start den Standard-Workspace an und gibt ihn
// zurück. Existiert bereits einer, wird dieser verwendet.
//
// Solange es nur einen gibt, braucht niemand ihn zu verwalten — die Struktur ist
// aber vorhanden, falls später nach Abteilungen getrennt werden soll.
func (s *Store) EnsureWorkspace(ctx context.Context, name string) (Workspace, error) {
var w Workspace
var createdAt string
err := s.lesen.QueryRowContext(ctx,
`SELECT id, name, created_at FROM workspaces ORDER BY created_at LIMIT 1`,
).Scan(&w.ID, &w.Name, &createdAt)
if err == nil {
w.CreatedAt = parseTime(createdAt)
return w, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return Workspace{}, fmt.Errorf("Workspace konnte nicht gelesen werden: %w", err)
}
w = Workspace{ID: NewID(), Name: name}
jetzt := NowString()
if _, err := s.schreiben.ExecContext(ctx,
`INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)`,
w.ID, w.Name, jetzt,
); err != nil {
return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err)
}
w.CreatedAt = parseTime(jetzt)
return w, nil
}
// ── Projekte ───────────────────────────────────────────────────────────────── // ── Projekte ─────────────────────────────────────────────────────────────────
//
// Die Workspace-Verwaltung selbst (EnsureDefaultWorkspace, CreateWorkspace, …)
// lebt in control.go — sie arbeitet auf der zentralen Kontroll-Datenbank, nicht
// auf dieser Workspace-Datei.
// CreateProject legt ein neues Projekt an. // CreateProject legt ein neues Projekt an.
func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, description string) (Project, error) { func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, description string) (Project, error) {

270
internal/store/related.go Normal file
View file

@ -0,0 +1,270 @@
package store
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// Richtung eines Verweises aus Sicht des Eintrags, über den ein verwandter
// Eintrag gefunden wurde (siehe RelatedEntry.Relation).
const (
RelationLinksTo = "links_to" // der Vorgänger verweist auf diesen Eintrag
RelationLinkedBy = "linked_by" // dieser Eintrag verweist auf den Vorgänger
)
// RelatedEntry ist ein Nachbar eines Eintrags im Verweisgraphen.
type RelatedEntry struct {
Slug string
Title string
UpdatedAt time.Time
// Distance zählt die Verweise bis zum Ausgangseintrag: 1 = direkt
// verknüpft, 2 = über einen Zwischeneintrag.
Distance int
// Via ist der Eintrag, über den dieser gefunden wurde. Bei Distance 1 ist
// das der Ausgangseintrag selbst, bei Distance 2 der Zwischeneintrag.
Via string
// Relation sagt, in welche Richtung der Verweis zwischen Via und diesem
// Eintrag zeigt: RelationLinksTo oder RelationLinkedBy.
Relation string
}
// RelatedEntries liefert die Nachbarn eines Eintrags im Verweisgraphen, bis zu
// maxDistance Schritte weit und höchstens limit Stück (siehe AUSBAUSTUFEN.md
// 3.7).
//
// # Wozu, wenn "get" schon links_to und linked_by liefert?
//
// "get" kennt nur die direkten Nachbarn, und das nur als nackte Bezeichner.
// Hier kommen Titel und Änderungsdatum dazu, und auf Wunsch auch die zweite
// Reihe: Was hängt an den Einträgen, die mit meinem Thema verknüpft sind? Für
// einen Agenten ist das ein zweiter Weg zum Kontext, neben der Suche. Er
// findet Einträge, die kein Suchwort gemeinsam haben, aber von einem Menschen
// bewusst verknüpft wurden.
//
// # Vorgehen
//
// Breitensuche, eine Ebene nach der anderen: erst alle direkten Nachbarn, dann
// deren Nachbarn. Die Richtung der Verweise spielt für die Suche keine Rolle.
// Wer auf mich verweist, ist genauso verwandt wie das, worauf ich verweise.
// Näheres steht vor Fernerem, innerhalb einer Ebene nach Titel sortiert.
//
// Verweise auf Bezeichner ohne Eintrag und Einträge im Papierkorb tauchen nicht
// auf. Für einen Agenten, der Kontext sucht, gibt es dort nichts zu lesen.
//
// Gibt es den Ausgangseintrag nicht, ist das ErrNotFound.
func (s *Store) RelatedEntries(ctx context.Context, projectID, slug string, maxDistance, limit int) ([]RelatedEntry, error) {
// Den Ausgangseintrag selbst prüfen. Sonst bekäme ein Tippfehler im
// Bezeichner eine leere Liste zurück, und der Agent hielte das Thema für
// unverknüpft statt für nicht vorhanden.
var vorhanden int
if err := s.lesen.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
projectID, slug,
).Scan(&vorhanden); err != nil {
return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
}
if vorhanden == 0 {
return nil, ErrNotFound
}
// besucht verhindert, dass ein Eintrag zweimal auftaucht, etwa wenn A auf
// B verweist und B zurück auf A. Der Ausgangseintrag zählt von Anfang an
// als besucht, er soll nicht als sein eigener Nachbar erscheinen.
besucht := map[string]bool{slug: true}
ebene := []string{slug}
var ergebnis []RelatedEntry
for abstand := 1; abstand <= maxDistance && len(ebene) > 0 && len(ergebnis) < limit; abstand++ {
kanten, err := s.nachbarn(ctx, projectID, ebene)
if err != nil {
return nil, err
}
// Je neu entdecktem Bezeichner nur die erste Kante merken. Welcher
// Vorgänger „gewinnt“, ist egal, aber es muss derselbe sein, egal in
// welcher Reihenfolge die Datenbank die Zeilen liefert. Deshalb sind
// die Kanten vorher sortiert (siehe nachbarn).
neu := make(map[string]RelatedEntry)
var neueSlugs []string
for _, k := range kanten {
if besucht[k.nach] {
continue
}
if _, schonDa := neu[k.nach]; schonDa {
continue
}
neu[k.nach] = RelatedEntry{Slug: k.nach, Distance: abstand, Via: k.von, Relation: k.relation}
neueSlugs = append(neueSlugs, k.nach)
}
// Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne
// Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in
// der Antwort.
details, err := s.eintragsKoepfe(ctx, projectID, neueSlugs)
if err != nil {
return nil, err
}
var dieseEbene []RelatedEntry
ebene = ebene[:0]
for _, nachbar := range neueSlugs {
besucht[nachbar] = true
kopf, ok := details[nachbar]
if !ok {
continue
}
r := neu[nachbar]
r.Title = kopf.Title
r.UpdatedAt = kopf.UpdatedAt
dieseEbene = append(dieseEbene, r)
ebene = append(ebene, nachbar)
}
sort.Slice(dieseEbene, func(i, j int) bool { return dieseEbene[i].Title < dieseEbene[j].Title })
ergebnis = append(ergebnis, dieseEbene...)
}
if len(ergebnis) > limit {
ergebnis = ergebnis[:limit]
}
return ergebnis, nil
}
// kante ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus.
type kante struct {
von, nach string
relation string
}
// nachbarn liefert alle Verweise, die einen der Bezeichner in slugs berühren,
// in beide Richtungen.
//
// Zwei Abfragen statt einer mit OR: So nutzt jede ihren eigenen Index. Die
// ausgehenden laufen über den Primärschlüssel (from_entry_id, to_slug), die
// eingehenden über idx_entry_links_projekt (project_id, to_slug).
func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) ([]kante, error) {
platz, args := inListe(projectID, slugs)
var kanten []kante
// Ausgehend: Einträge der Ebene verweisen auf etwas.
rows, err := s.lesen.QueryContext(ctx,
`SELECT e.slug, l.to_slug
FROM entries e
JOIN entry_links l ON l.from_entry_id = e.id
WHERE e.project_id = ? AND e.slug IN (`+platz+`) AND e.deleted_at IS NULL`,
args...,
)
if err != nil {
return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err)
}
for rows.Next() {
var k kante
if err := rows.Scan(&k.von, &k.nach); err != nil {
rows.Close()
return nil, err
}
k.relation = RelationLinksTo
kanten = append(kanten, k)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
// Eingehend: Etwas verweist auf Einträge der Ebene. Der verweisende
// Eintrag ist hier der Nachbar, deshalb stehen die Spalten andersherum.
rows, err = s.lesen.QueryContext(ctx,
`SELECT l.to_slug, e.slug
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
WHERE l.project_id = ? AND l.to_slug IN (`+platz+`) AND e.deleted_at IS NULL`,
args...,
)
if err != nil {
return nil, fmt.Errorf("Rückverweise konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var k kante
if err := rows.Scan(&k.von, &k.nach); err != nil {
return nil, err
}
k.relation = RelationLinkedBy
kanten = append(kanten, k)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Feste Reihenfolge, damit dasselbe Projekt immer dieselbe Antwort gibt
// (siehe RelatedEntries).
sort.Slice(kanten, func(i, j int) bool {
if kanten[i].nach != kanten[j].nach {
return kanten[i].nach < kanten[j].nach
}
if kanten[i].von != kanten[j].von {
return kanten[i].von < kanten[j].von
}
return kanten[i].relation < kanten[j].relation
})
return kanten, nil
}
// eintragsKopf ist das Wenige, was RelatedEntries über einen Nachbarn braucht.
type eintragsKopf struct {
Title string
UpdatedAt time.Time
}
// eintragsKoepfe lädt Titel und Änderungsdatum zu einer Liste von Bezeichnern.
// Was nicht (mehr) existiert oder im Papierkorb liegt, fehlt in der Antwort.
func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []string) (map[string]eintragsKopf, error) {
koepfe := make(map[string]eintragsKopf, len(slugs))
if len(slugs) == 0 {
return koepfe, nil
}
platz, args := inListe(projectID, slugs)
rows, err := s.lesen.QueryContext(ctx,
`SELECT slug, title, updated_at FROM entries
WHERE project_id = ? AND slug IN (`+platz+`) AND `+nichtImPapierkorb,
args...,
)
if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var slug, title, geaendert string
if err := rows.Scan(&slug, &title, &geaendert); err != nil {
return nil, err
}
koepfe[slug] = eintragsKopf{Title: title, UpdatedAt: parseTime(geaendert)}
}
return koepfe, rows.Err()
}
// inListe baut die Platzhalter für "IN (?, ?, …)" und die passenden
// Argumente, mit der Projekt-ID vorneweg.
//
// Die Werte werden nie in den SQL-Text geschrieben, sondern immer als
// Parameter übergeben. Nur so ist ausgeschlossen, dass ein Bezeichner die
// Abfrage verändert (SQL-Injection).
func inListe(projectID string, werte []string) (string, []any) {
args := make([]any, 0, len(werte)+1)
args = append(args, projectID)
for _, w := range werte {
args = append(args, w)
}
return strings.TrimSuffix(strings.Repeat("?, ", len(werte)), ", "), args
}

View file

@ -0,0 +1,197 @@
package store
import (
"context"
"errors"
"testing"
"time"
)
// schreibe legt einen Eintrag an und bricht den Test bei einem Fehler ab.
func schreibe(t *testing.T, s *Store, projectID, slug, content string) {
t.Helper()
if _, err := s.PutEntry(context.Background(), EntryWrite{
ProjectID: projectID, Slug: slug, Title: "Titel " + slug, Content: content,
Source: SourceHuman, Author: "test",
}); err != nil {
t.Fatalf("Eintrag %q: %v", slug, err)
}
}
// TestRelatedEntries prüft Nachbarn im Verweisgraphen:
//
// start → a → fern
// b → start
// start → fehlt (Verweis ohne Eintrag)
// c (unverknüpft)
func TestRelatedEntries(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
schreibe(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]")
schreibe(t, s, p.ID, "a", "weiter bei [[fern]]")
schreibe(t, s, p.ID, "b", "hängt an [[start]]")
schreibe(t, s, p.ID, "fern", "Ende")
schreibe(t, s, p.ID, "c", "allein")
verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
if err != nil {
t.Fatal(err)
}
gefunden := make(map[string]RelatedEntry)
for _, v := range verwandte {
gefunden[v.Slug] = v
}
erwartet := map[string]struct {
abstand int
via string
relation string
}{
"a": {1, "start", RelationLinksTo},
"b": {1, "start", RelationLinkedBy},
"fern": {2, "a", RelationLinksTo},
}
for slug, e := range erwartet {
v, ok := gefunden[slug]
if !ok {
t.Errorf("%q fehlt", slug)
continue
}
if v.Distance != e.abstand || v.Via != e.via || v.Relation != e.relation {
t.Errorf("%q: Abstand %d über %q (%s), erwartet %d über %q (%s)",
slug, v.Distance, v.Via, v.Relation, e.abstand, e.via, e.relation)
}
if v.Title != "Titel "+slug || v.UpdatedAt.IsZero() {
t.Errorf("%q: Titel oder Änderungsdatum fehlen: %+v", slug, v)
}
}
for _, slug := range []string{"start", "fehlt", "c"} {
if _, ok := gefunden[slug]; ok {
t.Errorf("%q darf nicht unter den verwandten Einträgen stehen", slug)
}
}
// Näheres vor Fernerem.
if verwandte[len(verwandte)-1].Slug != "fern" {
t.Errorf("die zweite Reihe steht nicht am Ende: %+v", verwandte)
}
// Mit Abstand 1 nur die direkten Nachbarn.
direkt, err := s.RelatedEntries(ctx, p.ID, "start", 1, 20)
if err != nil {
t.Fatal(err)
}
if len(direkt) != 2 {
t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direkt), direkt)
}
// Die Obergrenze gilt.
begrenzt, err := s.RelatedEntries(ctx, p.ID, "start", 2, 1)
if err != nil {
t.Fatal(err)
}
if len(begrenzt) != 1 {
t.Errorf("Limit 1 lieferte %d Einträge", len(begrenzt))
}
}
// TestRelatedEntriesSkipsTrash prüft, dass gelöschte Einträge weder als
// Nachbar erscheinen noch als Brücke in die zweite Reihe dienen.
func TestRelatedEntriesSkipsTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
schreibe(t, s, p.ID, "start", "siehe [[weg]]")
schreibe(t, s, p.ID, "weg", "siehe [[dahinter]]")
schreibe(t, s, p.ID, "dahinter", "Ende")
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "test"); err != nil {
t.Fatal(err)
}
verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
if err != nil {
t.Fatal(err)
}
if len(verwandte) != 0 {
t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", verwandte)
}
// Ein Ausgangseintrag im Papierkorb gilt als nicht vorhanden.
if _, err := s.RelatedEntries(ctx, p.ID, "weg", 2, 20); !errors.Is(err, ErrNotFound) {
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
}
}
func TestRelatedEntriesUnknownSlug(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
if _, err := s.RelatedEntries(context.Background(), p.ID, "gibtesnicht", 2, 20); !errors.Is(err, ErrNotFound) {
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
}
}
// TestEntryStale prüft die Grenze für veraltetes Wissen (AUSBAUSTUFEN.md 3.5).
func TestEntryStale(t *testing.T) {
jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
faelle := []struct {
name string
geaendert time.Time
erwartet bool
}{
{"gestern", jetzt.AddDate(0, 0, -1), false},
{"vor elf Monaten", jetzt.AddDate(0, -11, 0), false},
{"vor zwei Jahren", jetzt.AddDate(-2, 0, 0), true},
{"ohne Datum", time.Time{}, false},
}
for _, f := range faelle {
t.Run(f.name, func(t *testing.T) {
if got := (Entry{UpdatedAt: f.geaendert}).Stale(jetzt); got != f.erwartet {
t.Errorf("Stale = %v, erwartet %v", got, f.erwartet)
}
})
}
}
// TestPutEntryKeepsImportedTimestamps prüft, dass vorgegebene Zeitstempel
// beim Anlegen übernommen und beim Ändern ignoriert werden.
func TestPutEntryKeepsImportedTimestamps(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
alt := time.Date(2023, 3, 11, 9, 0, 0, 0, time.UTC)
e, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "x",
Source: SourceHuman, CreatedAt: alt, UpdatedAt: alt,
})
if err != nil {
t.Fatal(err)
}
gelesen, _ := s.EntryBySlug(ctx, p.ID, "alt")
if !gelesen.CreatedAt.Equal(alt) || !gelesen.UpdatedAt.Equal(alt) || !e.UpdatedAt.Equal(alt) {
t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", gelesen.CreatedAt, gelesen.UpdatedAt)
}
// Eine Änderung passiert jetzt, egal was mitgegeben wird.
if _, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "neu",
Source: SourceHuman, UpdatedAt: alt,
}); err != nil {
t.Fatal(err)
}
gelesen, _ = s.EntryBySlug(ctx, p.ID, "alt")
if gelesen.UpdatedAt.Equal(alt) {
t.Error("beim Ändern wurde der vorgegebene Zeitstempel übernommen")
}
if !gelesen.CreatedAt.Equal(alt) {
t.Error("beim Ändern ging das Anlegedatum verloren")
}
}

View file

@ -35,10 +35,14 @@ import (
_ "modernc.org/sqlite" // meldet sich als Treiber "sqlite" an _ "modernc.org/sqlite" // meldet sich als Treiber "sqlite" an
) )
// migrationsFS enthält die .sql-Dateien, die das Schema aufbauen. // migrationsFS enthält die .sql-Dateien, die das Schema einer Workspace-Datei
// aufbauen (Projekte, Einträge, Keys, Protokoll — siehe internal/store/migrations).
// Durch das Einbetten wandern sie mit ins Binary — beim Start ist kein // Durch das Einbetten wandern sie mit ins Binary — beim Start ist kein
// zusätzlicher Ordner nötig. // zusätzlicher Ordner nötig.
// //
// Das Schema der zentralen Kontroll-Datenbank (Workspaces, Key-Routing) liegt
// getrennt in migrations_control — siehe control.go.
//
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsFS embed.FS var migrationsFS embed.FS
@ -67,9 +71,24 @@ type Store struct {
schreiben *sql.DB // genau eine Verbindung — alles, was verändert schreiben *sql.DB // genau eine Verbindung — alles, was verändert
} }
// Open öffnet die Datenbankdatei, setzt die nötigen Einstellungen und bringt das // Open öffnet die Workspace-Datenbankdatei, setzt die nötigen Einstellungen und
// Schema auf den aktuellen Stand. // bringt das Schema auf den aktuellen Stand.
//
// Jede Workspace-Datei bekommt dieselbe Behandlung — unabhängig davon, ob es
// sich um die einzige Abteilung handelt oder eine von vierzig.
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
lesen, schreiben, err := openPool(path, migrationsFS, "migrations")
if err != nil {
return nil, err
}
return &Store{lesen: lesen, schreiben: schreiben}, nil
}
// openPool öffnet eine Datenbankdatei mit getrenntem Lese- und Schreibpool und
// bringt ihr Schema auf den aktuellen Stand. Verwendet von Open
// (Workspace-Dateien) und OpenControl (Kontroll-Datenbank) — beide brauchen
// exakt dieselben Einstellungen, nur unterschiedliche Dateien und Migrationen.
func openPool(path string, migrationsFS embed.FS, migrationsDir string) (lesen, schreiben *sql.DB, err error) {
// 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:
// //
@ -95,40 +114,39 @@ func Open(path string) (*Store, error) {
// statt erst beim ersten Schreibbefehl. Ohne das kann eine Transaktion // statt erst beim ersten Schreibbefehl. Ohne das kann eine Transaktion
// mitten drin auf die Sperre laufen — und dort hilft busy_timeout nicht mehr, // mitten drin auf die Sperre laufen — und dort hilft busy_timeout nicht mehr,
// 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 die 296 Schreibvorgänge oben. // abzubrechen. Genau daran scheiterten 296 von 320 gleichzeitigen
schreiben, err := sql.Open("sqlite", dsn+"&_txlock=immediate") // Schreibvorgängen in einer früheren Messung (siehe AUSBAUSTUFEN.md).
schreiben, err = sql.Open("sqlite", dsn+"&_txlock=immediate")
if err != nil { if err != nil {
return nil, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err) return nil, nil, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err)
} }
schreiben.SetMaxOpenConns(1) schreiben.SetMaxOpenConns(1)
if err := schreiben.Ping(); err != nil { if err := schreiben.Ping(); err != nil {
schreiben.Close() schreiben.Close()
return nil, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err) return nil, nil, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err)
} }
s := &Store{schreiben: schreiben} if err := runMigrations(schreiben, migrationsFS, migrationsDir); err != nil {
if err := s.migrate(); err != nil {
schreiben.Close() schreiben.Close()
return nil, err return nil, nil, 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.
lesen, err := sql.Open("sqlite", dsn) lesen, err = sql.Open("sqlite", dsn)
if err != nil { if err != nil {
schreiben.Close() schreiben.Close()
return nil, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err) return nil, nil, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err)
} }
lesen.SetMaxOpenConns(maxLeseverbindungen) lesen.SetMaxOpenConns(maxLeseverbindungen)
if err := lesen.Ping(); err != nil { if err := lesen.Ping(); err != nil {
schreiben.Close() schreiben.Close()
lesen.Close() lesen.Close()
return nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err) return nil, nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err)
} }
s.lesen = lesen return lesen, schreiben, nil
return s, nil
} }
// maxLeseverbindungen begrenzt die gleichzeitigen Lesezugriffe. // maxLeseverbindungen begrenzt die gleichzeitigen Lesezugriffe.
@ -147,20 +165,25 @@ func (s *Store) Close() error {
return fehlerLesen return fehlerLesen
} }
// migrate wendet alle noch nicht ausgeführten Migrationen an. // runMigrations wendet alle noch nicht ausgeführten Migrationen aus fsys/dir
// über die gegebene Schreibverbindung an.
// //
// # Wie das funktioniert // # Wie das funktioniert
// //
// Die Dateien in migrations/ sind durchnummeriert (001_…, 002_…). Eine Tabelle // Die Dateien sind durchnummeriert (001_…, 002_…). Eine Tabelle merkt sich,
// merkt sich, welche davon schon gelaufen sind. Beim Start wird die Liste // welche davon schon gelaufen sind. Beim Start wird die Liste durchgegangen
// durchgegangen und alles Fehlende ausgeführt — in einer Transaktion pro Datei, // und alles Fehlende ausgeführt — in einer Transaktion pro Datei, damit eine
// damit eine halb angewandte Migration nicht möglich ist. // halb angewandte Migration nicht möglich ist.
// //
// Das sind bewusst nur ein paar Zeilen statt einer Bibliothek: Für ein Schema // Das sind bewusst nur ein paar Zeilen statt einer Bibliothek: Für Schemata
// dieser Größe ist der Mechanismus leicht zu überblicken, und wer ihn versteht, // dieser Größe ist der Mechanismus leicht zu überblicken, und wer ihn
// versteht auch, was beim Start mit seiner Datenbank passiert. // versteht, versteht auch, was beim Start mit seiner Datenbank passiert.
func (s *Store) migrate() error { //
_, err := s.schreiben.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( // Jede Datenbankdatei (jede Workspace-Datei ebenso wie die Kontroll-Datenbank)
// führt ihre eigene schema_migrations-Tabelle — sie kennt nur die Migrationen
// aus dem für sie zuständigen Verzeichnis.
func runMigrations(schreiben *sql.DB, fsys embed.FS, dir string) error {
_, err := schreiben.Exec(`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
)`) )`)
@ -168,12 +191,12 @@ func (s *Store) migrate() error {
return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err) return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err)
} }
angewandt, err := s.appliedMigrations() angewandt, err := appliedMigrations(schreiben)
if err != nil { if err != nil {
return err return err
} }
dateien, err := migrationFiles() dateien, err := migrationFiles(fsys, dir)
if err != nil { if err != nil {
return err return err
} }
@ -183,12 +206,12 @@ func (s *Store) migrate() error {
continue continue
} }
inhalt, err := migrationsFS.ReadFile("migrations/" + name) inhalt, 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 := s.applyMigration(name, string(inhalt)); err != nil { if err := applyMigration(schreiben, name, string(inhalt)); err != nil {
return err return err
} }
} }
@ -197,8 +220,8 @@ func (s *Store) migrate() error {
} }
// appliedMigrations liest die Namen der bereits ausgeführten Migrationen. // appliedMigrations liest die Namen der bereits ausgeführten Migrationen.
func (s *Store) appliedMigrations() (map[string]bool, error) { func appliedMigrations(schreiben *sql.DB) (map[string]bool, error) {
rows, err := s.schreiben.Query(`SELECT name FROM schema_migrations`) rows, err := schreiben.Query(`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)
} }
@ -216,8 +239,8 @@ func (s *Store) appliedMigrations() (map[string]bool, error) {
} }
// migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge. // migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge.
func migrationFiles() ([]string, error) { func migrationFiles(fsys embed.FS, dir string) ([]string, error) {
eintraege, err := migrationsFS.ReadDir("migrations") eintraege, 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)
} }
@ -236,8 +259,8 @@ func migrationFiles() ([]string, error) {
} }
// applyMigration führt eine einzelne Migration in einer Transaktion aus. // applyMigration führt eine einzelne Migration in einer Transaktion aus.
func (s *Store) applyMigration(name, sqlText string) error { func applyMigration(schreiben *sql.DB, name, sqlText string) error {
tx, err := s.schreiben.Begin() tx, err := schreiben.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)
} }

View file

@ -28,15 +28,21 @@ func testStore(t *testing.T) *Store {
return s return s
} }
// testWorkspace liefert eine Workspace-ID für Tests.
//
// Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr (die lebt in der
// zentralen Kontroll-Datenbank, siehe control.go) — für Tests reicht deshalb
// eine beliebige ID, ohne dass eine echte ControlStore-Instanz nötig wäre.
func testWorkspace() Workspace {
return Workspace{ID: NewID(), Name: "Test"}
}
// testProject legt Workspace und Projekt an — die Grundlage fast aller Tests. // testProject legt Workspace und Projekt an — die Grundlage fast aller Tests.
func testProject(t *testing.T, s *Store) Project { func testProject(t *testing.T, s *Store) Project {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
ws, err := s.EnsureWorkspace(ctx, "Test") ws := testWorkspace()
if err != nil {
t.Fatalf("Workspace: %v", err)
}
p, err := s.CreateProject(ctx, ws.ID, "test", "Testprojekt", "") p, err := s.CreateProject(ctx, ws.ID, "test", "Testprojekt", "")
if err != nil { if err != nil {
t.Fatalf("Projekt: %v", err) t.Fatalf("Projekt: %v", err)
@ -80,7 +86,7 @@ func TestMigrationBestandsdaten(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
ctx := context.Background() ctx := context.Background()
ws, _ := s.EnsureWorkspace(ctx, "M") ws := testWorkspace()
p, _ := s.CreateProject(ctx, ws.ID, "alt", "Alt", "") p, _ := s.CreateProject(ctx, ws.ID, "alt", "Alt", "")
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel", s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel",
Content: "x", Tags: []string{"ops"}, Source: SourceHuman}) Content: "x", Tags: []string{"ops"}, Source: SourceHuman})
@ -214,29 +220,14 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
} }
// ── Workspace und Projekte ─────────────────────────────────────────────────── // ── Workspace und Projekte ───────────────────────────────────────────────────
//
func TestEnsureWorkspaceIsStable(t *testing.T) { // Die Stabilität von EnsureDefaultWorkspace wird in control_test.go geprüft —
s := testStore(t) // die Funktion gehört zur zentralen Kontroll-Datenbank, nicht zu dieser Datei.
ctx := context.Background()
erst, err := s.EnsureWorkspace(ctx, "Intern")
if err != nil {
t.Fatal(err)
}
zweit, err := s.EnsureWorkspace(ctx, "Anderer Name")
if err != nil {
t.Fatal(err)
}
if erst.ID != zweit.ID {
t.Error("EnsureWorkspace hat einen zweiten Workspace angelegt statt den bestehenden zu nehmen")
}
}
func TestCreateProjectRejectsBadSlug(t *testing.T) { func TestCreateProjectRejectsBadSlug(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
ws, _ := s.EnsureWorkspace(ctx, "Test") ws := testWorkspace()
for _, slug := range []string{"", "Groß", "mit leer", "../raus", "a/b", strings.Repeat("x", 65)} { for _, slug := range []string{"", "Groß", "mit leer", "../raus", "a/b", strings.Repeat("x", 65)} {
if _, err := s.CreateProject(ctx, ws.ID, slug, "X", ""); err == nil { if _, err := s.CreateProject(ctx, ws.ID, slug, "X", ""); err == nil {
@ -248,7 +239,7 @@ func TestCreateProjectRejectsBadSlug(t *testing.T) {
func TestCreateProjectRejectsDuplicateSlug(t *testing.T) { func TestCreateProjectRejectsDuplicateSlug(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
ws, _ := s.EnsureWorkspace(ctx, "Test") ws := testWorkspace()
if _, err := s.CreateProject(ctx, ws.ID, "doppelt", "Erst", ""); err != nil { if _, err := s.CreateProject(ctx, ws.ID, "doppelt", "Erst", ""); err != nil {
t.Fatal(err) t.Fatal(err)
@ -427,6 +418,33 @@ func TestRevokedKeyIsGone(t *testing.T) {
} }
} }
// TestAPIKeyUnused prüft, wann ein Key als „ungenutzt“ gilt (AUSBAUSTUFEN.md 4.7).
func TestAPIKeyUnused(t *testing.T) {
jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) }
faelle := []struct {
name string
key APIKey
erwartet bool
}{
{"gestern benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(1)}, false},
{"vor 91 Tagen benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(91)}, true},
{"genau 90 Tage", APIKey{CreatedAt: tage(400), LastUsedAt: tage(90)}, false},
{"nie benutzt, frisch angelegt", APIKey{CreatedAt: tage(3)}, false},
{"nie benutzt, lange angelegt", APIKey{CreatedAt: tage(120)}, true},
{"zurückgezogen", APIKey{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false},
}
for _, f := range faelle {
t.Run(f.name, func(t *testing.T) {
if got := f.key.Unused(jetzt); got != f.erwartet {
t.Errorf("Unused = %v, erwartet %v", got, f.erwartet)
}
})
}
}
func TestCreateAPIKeyRequiresName(t *testing.T) { func TestCreateAPIKeyRequiresName(t *testing.T) {
s := testStore(t) s := testStore(t)
p := testProject(t, s) p := testProject(t, s)
@ -558,7 +576,7 @@ func TestPutEntryValidation(t *testing.T) {
func TestEntriesAreProjectScoped(t *testing.T) { func TestEntriesAreProjectScoped(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
ws, _ := s.EnsureWorkspace(ctx, "Test") ws := testWorkspace()
a, _ := s.CreateProject(ctx, ws.ID, "projekt-a", "A", "") a, _ := s.CreateProject(ctx, ws.ID, "projekt-a", "A", "")
b, _ := s.CreateProject(ctx, ws.ID, "projekt-b", "B", "") b, _ := s.CreateProject(ctx, ws.ID, "projekt-b", "B", "")
@ -626,20 +644,20 @@ func TestListTagsCounts(t *testing.T) {
} }
} }
func TestDeleteEntry(t *testing.T) { func TestMoveEntryToTrash(t *testing.T) {
s := testStore(t) s := testStore(t)
ctx := context.Background() ctx := context.Background()
p := testProject(t, s) p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman}) s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
if err := s.DeleteEntry(ctx, p.ID, "weg"); err != nil { if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) { if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) {
t.Error("der Eintrag existiert noch") t.Error("der Eintrag ist nach dem Löschen noch sichtbar")
} }
if err := s.DeleteEntry(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) { if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); !errors.Is(err, ErrNotFound) {
t.Error("zweimaliges Löschen meldete keinen Fehler") t.Error("zweimaliges Löschen meldete keinen Fehler")
} }
} }
@ -806,3 +824,258 @@ func TestNormalizeTags(t *testing.T) {
t.Errorf("NormalizeTags = %v, erwartet [docs ops]", got) t.Errorf("NormalizeTags = %v, erwartet [docs ops]", got)
} }
} }
func TestBulkUpdateTags(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
a, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A",
Tags: []string{"alt"}, Source: SourceHuman})
b, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B",
Tags: []string{"alt", "bleibt"}, Source: SourceHuman})
// Dritter Eintrag bleibt unangetastet — zur Probe, dass BulkUpdateTags nur
// trifft, wer ausgewählt wurde.
c, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C",
Tags: []string{"alt"}, Source: SourceHuman})
geaendert, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"})
if err != nil {
t.Fatal(err)
}
if len(geaendert) != 2 {
t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(geaendert))
}
neuA, _ := s.EntryBySlug(ctx, p.ID, "a")
if !stringSlicesEqual(neuA.Tags, []string{"neu"}) {
t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags)
}
if neuA.Version != 2 {
t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", neuA.Version)
}
neuB, _ := s.EntryBySlug(ctx, p.ID, "b")
if !stringSlicesEqual(neuB.Tags, []string{"bleibt", "neu"}) {
t.Errorf("Tags von B = %v, erwartet [bleibt neu]", neuB.Tags)
}
unveraendertC, _ := s.EntryBySlug(ctx, p.ID, "c")
if !stringSlicesEqual(unveraendertC.Tags, []string{"alt"}) {
t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unveraendertC.Tags)
}
if unveraendertC.Version != c.Version {
t.Error("Version von C wurde erhöht, obwohl C nicht ausgewählt war")
}
}
func TestBulkUpdateTagsIgnoresForeignProjectIDs(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ws := testWorkspace()
a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "")
b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "")
fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "fremd", Title: "Fremd",
Tags: []string{"x"}, Source: SourceHuman})
// Aufruf mit dem Projekt A, aber einer ID aus Projekt B — darf nichts
// bewirken, sonst könnte ein untergeschobenes Formularfeld projektübergreifend
// Einträge verändern.
geaendert, err := s.BulkUpdateTags(ctx, a.ID, []string{fremd.ID}, []string{"neu"}, nil)
if err != nil {
t.Fatal(err)
}
if len(geaendert) != 0 {
t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(geaendert))
}
unveraendert, _ := s.EntryBySlug(ctx, b.ID, "fremd")
if !stringSlicesEqual(unveraendert.Tags, []string{"x"}) {
t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unveraendert.Tags)
}
}
// ── Änderungshistorie ────────────────────────────────────────────────────────
func TestPutEntryRecordsHistory(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
erste, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Erste Fassung",
Content: "Inhalt 1", Tags: []string{"a"}, Source: SourceHuman, Author: "tom"})
if err != nil {
t.Fatal(err)
}
// Beim erstmaligen Anlegen gibt es noch nichts zu sichern.
historie, err := s.EntryVersions(ctx, erste.ID)
if err != nil {
t.Fatal(err)
}
if len(historie) != 0 {
t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(historie))
}
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Zweite Fassung",
Content: "Inhalt 2", Tags: []string{"b"}, Source: SourceHuman, Author: "tom"}); err != nil {
t.Fatal(err)
}
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Dritte Fassung",
Content: "Inhalt 3", Tags: []string{"c"}, Source: SourceHuman, Author: "tom"}); err != nil {
t.Fatal(err)
}
historie, err = s.EntryVersions(ctx, erste.ID)
if err != nil {
t.Fatal(err)
}
if len(historie) != 2 {
t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(historie))
}
// Neueste zuerst.
if historie[0].Version != 2 || historie[0].Title != "Zweite Fassung" {
t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", historie[0].Version, historie[0].Title)
}
if historie[1].Version != 1 || historie[1].Title != "Erste Fassung" {
t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", historie[1].Version, historie[1].Title)
}
if historie[1].Content != "Inhalt 1" || !stringSlicesEqual(historie[1].Tags, []string{"a"}) {
t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", historie[1].Content, historie[1].Tags)
}
fassung1, err := s.EntryVersionByNumber(ctx, erste.ID, 1)
if err != nil {
t.Fatal(err)
}
if fassung1.Content != "Inhalt 1" {
t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", fassung1.Content)
}
if _, err := s.EntryVersionByNumber(ctx, erste.ID, 99); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannte Fassung: err = %v, erwartet ErrNotFound", err)
}
}
func TestBulkUpdateTagsRecordsHistory(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
e, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T",
Content: "Inhalt", Tags: []string{"alt"}, Source: SourceHuman})
if _, err := s.BulkUpdateTags(ctx, p.ID, []string{e.ID}, []string{"neu"}, []string{"alt"}); err != nil {
t.Fatal(err)
}
historie, err := s.EntryVersions(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
if len(historie) != 1 {
t.Fatalf("%d Fassungen, erwartet 1", len(historie))
}
if !stringSlicesEqual(historie[0].Tags, []string{"alt"}) {
t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", historie[0].Tags)
}
}
// TestTrashKeepsHistoryPurgeRemovesIt: Der Papierkorb behält den Verlauf,
// erst endgültiges Löschen entfernt ihn (CASCADE).
func TestTrashKeepsHistoryPurgeRemovesIt(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
e, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "1", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "2", Source: SourceHuman})
if err := s.MoveEntryToTrash(ctx, p.ID, "e", "tom"); err != nil {
t.Fatal(err)
}
if historie, _ := s.EntryVersions(ctx, e.ID); len(historie) != 1 {
t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(historie))
}
if n, err := s.PurgeFromTrash(ctx, p.ID, []string{"e"}); err != nil || n != 1 {
t.Fatalf("PurgeFromTrash: %d, %v", n, err)
}
historie, err := s.EntryVersions(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
if len(historie) != 0 {
t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(historie))
}
}
// ── Graph ────────────────────────────────────────────────────────────────────
func TestLinkGraph(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A",
Content: "verweist auf [[b]] und [[fehlt]]", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B",
Content: "verweist zurück auf [[a]]", Source: SourceHuman})
// Eintrag ohne jeden Verweis — muss trotzdem als Knoten auftauchen.
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C",
Content: "allein", Source: SourceHuman})
knoten, kanten, err := s.LinkGraph(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if len(knoten) != 4 {
t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(knoten))
}
nachSlug := make(map[string]GraphNode, len(knoten))
for _, k := range knoten {
nachSlug[k.Slug] = k
}
if !nachSlug["a"].Exists || !nachSlug["b"].Exists || !nachSlug["c"].Exists {
t.Error("vorhandene Einträge sollten Exists = true tragen")
}
fehlt, ok := nachSlug["fehlt"]
if !ok {
t.Fatal("Verweis auf einen nicht existierenden Bezeichner erzeugte keinen Knoten")
}
if fehlt.Exists {
t.Error("Exists = true für einen Bezeichner ohne Eintrag")
}
if len(kanten) != 3 {
t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(kanten))
}
}
func TestLinkGraphIsProjectScoped(t *testing.T) {
s := testStore(t)
ctx := context.Background()
ws := testWorkspace()
a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "")
b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "")
s.PutEntry(ctx, EntryWrite{ProjectID: a.ID, Slug: "geheim", Title: "Geheim",
Content: "nur in A", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "anderes", Title: "Anderes",
Content: "nur in B", Source: SourceHuman})
knoten, _, err := s.LinkGraph(ctx, a.ID)
if err != nil {
t.Fatal(err)
}
if len(knoten) != 1 || knoten[0].Slug != "geheim" {
t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", knoten)
}
}

View file

@ -8,6 +8,7 @@ import (
"munin/internal/auth" "munin/internal/auth"
"munin/internal/store" "munin/internal/store"
"munin/internal/workspace"
) )
// Session beschreibt, wer gerade zugreift. // Session beschreibt, wer gerade zugreift.
@ -15,12 +16,17 @@ import (
// Es gibt zwei Ausprägungen: // Es gibt zwei Ausprägungen:
// //
// - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf // - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf
// jedes Projekt zugreifen. Key und Project sind leer. // jedes Projekt eines gewählten Workspace zugreifen. Key und Project sind
// leer. WorkspaceID ist leer, solange noch kein Workspace gewählt wurde —
// eine solche Sitzung darf ausschließlich die Workspace-Auswahl aufrufen
// (siehe requireWorkspace in middleware.go).
// - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem // - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem
// Projekt gehört. Was er darf, sagt Role. // Projekt in genau einem Workspace gehört. Was er darf, sagt Role.
type Session struct { type Session struct {
Subject string // für die CSRF-Token-Berechnung Subject string // für die CSRF-Token-Berechnung
IsPlatformAdmin bool IsPlatformAdmin bool
WorkspaceID string
WorkspaceName string // nur beim Plattform-Admin gefüllt, für den Umschalter in der Kopfleiste
Key store.APIKey Key store.APIKey
Project store.Project Project store.Project
Role auth.Role Role auth.Role
@ -67,6 +73,7 @@ const (
sessionContextKey contextKey = iota sessionContextKey contextKey = iota
projectContextKey projectContextKey
aktorContextKey aktorContextKey
workspaceContextKey
) )
// aktorHalter ist ein veränderbarer Platz im Context, in den die // aktorHalter ist ein veränderbarer Platz im Context, in den die
@ -127,6 +134,37 @@ func ProjectFrom(r *http.Request) (store.Project, bool) {
return p, ok return p, ok
} }
// withWorkspace legt den aufgelösten Workspace (Datenbank + Suchindex) der
// aktuellen Sitzung im Request-Context ab.
//
// Das übernimmt requireSession bzw. APIAuth — für Sitzungen ohne gewählten
// Workspace (Plattform-Admin vor der Workspace-Auswahl) bleibt der Context
// ohne Eintrag, siehe WorkspaceFrom.
func withWorkspace(ctx context.Context, w *workspace.Workspace) context.Context {
return context.WithValue(ctx, workspaceContextKey, w)
}
// WorkspaceFrom liest den Workspace der aktuellen Anfrage.
// Ohne durchlaufene Auth-Middleware oder ohne gewählten Workspace ist ok false.
func WorkspaceFrom(r *http.Request) (*workspace.Workspace, bool) {
return WorkspaceFromContext(r.Context())
}
// WorkspaceFromContext liest den Workspace direkt aus einem Context.
//
// Wie bei SessionFromContext: Die MCP-Werkzeuge bekommen vom SDK nur den
// Context, keinen *http.Request.
func WorkspaceFromContext(ctx context.Context) (*workspace.Workspace, bool) {
w, ok := ctx.Value(workspaceContextKey).(*workspace.Workspace)
return w, ok
}
// WithWorkspaceForTest legt einen Workspace im Context ab.
// Siehe WithSessionForTest — derselbe Grund, derselbe Umweg.
func WithWorkspaceForTest(ctx context.Context, w *workspace.Workspace) context.Context {
return withWorkspace(ctx, w)
}
// SessionFromContext liest die Sitzung direkt aus einem Context. // SessionFromContext liest die Sitzung direkt aus einem Context.
// //
// Die MCP-Werkzeuge bekommen vom SDK nur den Context übergeben, keinen // Die MCP-Werkzeuge bekommen vom SDK nur den Context übergeben, keinen

View file

@ -3,10 +3,12 @@ package web
import ( import (
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"munin/internal/auth" "munin/internal/auth"
"munin/internal/store" "munin/internal/store"
@ -22,6 +24,11 @@ type projectListItem struct {
Project store.Project Project store.Project
Entries int Entries int
// Usage sind die Kennzahlen der Nutzungsübersicht (AUSBAUSTUFEN.md 4.6),
// hier als Spalten: So fällt dem Plattform-Admin auf, welches Team Munin
// gar nicht benutzt.
Usage store.UsageSummary
// Limit ist die geltende Obergrenze für schreibende MCP-Aufrufe je Stunde, // Limit ist die geltende Obergrenze für schreibende MCP-Aufrufe je Stunde,
// Frei der Rest im laufenden Fenster. Limit 0 heißt "unbegrenzt". // Frei der Rest im laufenden Fenster. Limit 0 heißt "unbegrenzt".
// //
@ -39,7 +46,9 @@ type projectListItem struct {
// handleProjectList zeigt alle Projekte — nur für den Plattform-Admin. // handleProjectList zeigt alle Projekte — nur für den Plattform-Admin.
func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
projekte, err := s.store.ListProjects(r.Context(), s.workspaceID) ws, sess := s.workspaceAndSession(r)
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
if err != nil { if err != nil {
s.serverError(w, "Projekte konnten nicht geladen werden", err) s.serverError(w, "Projekte konnten nicht geladen werden", err)
return return
@ -61,6 +70,8 @@ func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
// handleProjectCreate legt ein neues Projekt an. // handleProjectCreate legt ein neues Projekt an.
func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
slug := strings.TrimSpace(r.FormValue("slug")) slug := strings.TrimSpace(r.FormValue("slug"))
beschreibung := strings.TrimSpace(r.FormValue("description")) beschreibung := strings.TrimSpace(r.FormValue("description"))
@ -69,13 +80,13 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
slug = store.Slugify(name) slug = store.Slugify(name)
} }
projekt, err := s.store.CreateProject(r.Context(), s.workspaceID, slug, name, beschreibung) projekt, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, beschreibung)
if err != nil { if err != nil {
s.renderProjectListWithError(w, r, err.Error()) s.renderProjectListWithError(w, r, err.Error())
return return
} }
s.store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: projekt.ID, Actor: "platform-admin", ProjectID: projekt.ID, Actor: "platform-admin",
Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
}) })
@ -87,9 +98,10 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
// handleProjectDelete löscht ein Projekt samt Einträgen und Keys. // handleProjectDelete löscht ein Projekt samt Einträgen und Keys.
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
ws, _ := s.workspaceAndSession(r)
id := r.FormValue("id") id := r.FormValue("id")
projekt, err := s.store.ProjectByID(r.Context(), id) projekt, err := ws.Store.ProjectByID(r.Context(), id)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -108,13 +120,13 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.store.DeleteProject(r.Context(), id); err != nil { if err := ws.Store.DeleteProject(r.Context(), id); err != nil {
s.serverError(w, "Projekt konnte nicht gelöscht werden", err) s.serverError(w, "Projekt konnte nicht gelöscht werden", err)
return return
} }
s.search.Forget(id) ws.Search.Forget(id)
s.store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: id, Actor: "platform-admin", ProjectID: id, Actor: "platform-admin",
Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r), Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: projekt.Slug, Detail: projekt.Slug,
@ -125,7 +137,9 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
// renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung. // renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung.
func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, meldung string) { func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, meldung string) {
projekte, err := s.store.ListProjects(r.Context(), s.workspaceID) ws, sess := s.workspaceAndSession(r)
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
if err != nil { if err != nil {
s.serverError(w, "Projekte konnten nicht geladen werden", err) s.serverError(w, "Projekte konnten nicht geladen werden", err)
return return
@ -143,23 +157,30 @@ func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Reque
}) })
} }
// projectListItems reichert die Projekte mit Eintragszahl und Schreibbudget an. // projectListItems reichert die Projekte mit Eintragszahl, Nutzung und
// Schreibbudget an.
// //
// Eine eigene Funktion, weil die Übersicht an zwei Stellen gerendert wird — mit // Eine eigene Funktion, weil die Übersicht an zwei Stellen gerendert wird — mit
// und ohne Fehlermeldung. Ohne sie liefen beide Fassungen früher oder später // und ohne Fehlermeldung. Ohne sie liefen beide Fassungen früher oder später
// auseinander. // auseinander.
func (s *Server) projectListItems(r *http.Request, projekte []store.Project) ([]projectListItem, error) { func (s *Server) projectListItems(r *http.Request, projekte []store.Project) ([]projectListItem, error) {
ws, _ := s.workspaceAndSession(r)
items := make([]projectListItem, 0, len(projekte)) items := make([]projectListItem, 0, len(projekte))
for _, p := range projekte { // Die Kennzahlen aller Projekte in einem Rutsch (zwei Abfragen), statt
anzahl, err := s.store.CountEntries(r.Context(), p.ID) // je Projekt einzeln nachzufragen — bei 40 Projekten wären das sonst 80.
// Die Eintragszahl steckt mit darin.
nutzung, err := ws.Store.UsageSummaries(r.Context(), "", time.Now())
if err != nil { if err != nil {
return items, err return items, err
} }
for _, p := range projekte {
u := nutzung[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch
item := projectListItem{ item := projectListItem{
Project: p, Project: p,
Entries: anzahl, Entries: u.Entries,
Usage: u,
EigenesLimit: p.WriteLimitHourly, EigenesLimit: p.WriteLimitHourly,
} }
if s.writeBudget != nil { if s.writeBudget != nil {
@ -194,6 +215,8 @@ func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) {
// handleProjectEditSave speichert Name, Beschreibung und Obergrenze. // handleProjectEditSave speichert Name, Beschreibung und Obergrenze.
func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
ws, _ := s.workspaceAndSession(r)
projekt, ok := s.projectFromSlug(w, r) projekt, ok := s.projectFromSlug(w, r)
if !ok { if !ok {
return return
@ -221,7 +244,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{ if err := ws.Store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{
Name: name, Name: name,
Description: beschreibung, Description: beschreibung,
WriteLimitHourly: limit, WriteLimitHourly: limit,
@ -234,7 +257,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
// DASS jemand etwas geändert hat. Gerade bei der Obergrenze ist das der // DASS jemand etwas geändert hat. Gerade bei der Obergrenze ist das der
// interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine // interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine
// Betriebsentscheidung. // Betriebsentscheidung.
s.store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: projekt.ID, Actor: "platform-admin", ProjectID: projekt.ID, Actor: "platform-admin",
Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r), Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: strings.Join(aenderungen, "; "), Detail: strings.Join(aenderungen, "; "),
@ -270,7 +293,9 @@ func projektAenderungen(alt store.Project, name, beschreibung string, limit int)
// schon nur der Plattform-Admin unterwegs (siehe Routes), und der Bezeichner // schon nur der Plattform-Admin unterwegs (siehe Routes), und der Bezeichner
// steht unter einem anderen Platzhalter. // steht unter einem anderen Platzhalter.
func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) { func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) {
projekt, err := s.store.ProjectBySlug(r.Context(), s.workspaceID, r.PathValue("slug")) ws, sess := s.workspaceAndSession(r)
projekt, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, r.PathValue("slug"))
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return store.Project{}, false return store.Project{}, false
@ -312,6 +337,21 @@ type keyListData struct {
// er hier genau einmal angezeigt. // er hier genau einmal angezeigt.
NewKey string NewKey string
NewKeyName string NewKeyName string
// ServerURL ist die Adresse dieses Servers für die fertigen
// Konfigurationsblöcke, etwa "https://munin.example.org". Dieselbe Funktion
// wie in der Hilfe (serverURL in handlers_hilfe.go) — gerade hier zählt es,
// weil der Key nur dieses eine Mal zu sehen ist und der Block ohne
// Nacharbeit kopierbar sein soll.
ServerURL string
// Jetzt ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro
// Seitenaufruf festgehalten, damit alle Zeilen mit demselben Wert rechnen.
Jetzt time.Time
// Ungenutzt zählt die aktiven Keys, die seit über 90 Tagen niemand benutzt
// hat. Ist die Zahl größer als null, steht über der Tabelle ein Hinweis.
Ungenutzt int
} }
// handleKeyList zeigt die Keys eines Projekts. // handleKeyList zeigt die Keys eines Projekts.
@ -321,8 +361,8 @@ func (s *Server) handleKeyList(w http.ResponseWriter, r *http.Request) {
// handleKeyCreate erzeugt einen neuen Key und zeigt ihn genau einmal an. // handleKeyCreate erzeugt einen neuen Key und zeigt ihn genau einmal an.
func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
project, _ := ProjectFrom(r) project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
rolle := auth.Role(r.FormValue("role")) rolle := auth.Role(r.FormValue("role"))
@ -338,13 +378,23 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
erzeugt, err := s.store.CreateAPIKey(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle)) erzeugt, err := ws.Store.CreateAPIKey(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle))
if err != nil { if err != nil {
s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", err.Error()) s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", err.Error())
return return
} }
s.store.LogAudit(r.Context(), store.AuditEvent{ // Der Routing-Index muss im selben Zug nachgezogen werden — sonst lässt sich
// der Key künftig nicht mehr seinem Workspace zuordnen (siehe
// AUSBAUSTUFEN.md 4.4). Schlägt das fehl, bleibt der Key in dieser
// Workspace-Datei trotzdem gültig; nur das schnelle Routing über mehrere
// Workspaces hinweg wäre für ihn kaputt. Deshalb kein Abbruch, nur ein
// Log-Eintrag zum Nachziehen.
if err := s.control.IndexKey(r.Context(), erzeugt.Hash, sess.WorkspaceID); err != nil {
log.Printf("Key %s konnte nicht im Routing-Index vermerkt werden: %v", erzeugt.ID, err)
}
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(), ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(),
Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: erzeugt.Name + " (" + string(rolle) + ")", Detail: erzeugt.Name + " (" + string(rolle) + ")",
@ -357,25 +407,29 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
// handleKeyRevoke zieht einen Key zurück. // handleKeyRevoke zieht einen Key zurück.
func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
project, _ := ProjectFrom(r) project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
id := r.FormValue("id") id := r.FormValue("id")
// Nachsehen, ob der Key wirklich zu diesem Projekt gehört. Ohne diese // Nachsehen, ob der Key wirklich zu diesem Projekt gehört. Ohne diese
// Prüfung könnte ein Projekt-Admin über eine untergeschobene ID den Key eines // Prüfung könnte ein Projekt-Admin über eine untergeschobene ID den Key eines
// fremden Projekts sperren. // fremden Projekts sperren.
key, err := s.store.APIKeyByID(r.Context(), id) key, err := ws.Store.APIKeyByID(r.Context(), id)
if err != nil || key.ProjectID != project.ID { if err != nil || key.ProjectID != project.ID {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if err := s.store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) { if err := ws.Store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) {
s.serverError(w, "Key konnte nicht zurückgezogen werden", err) s.serverError(w, "Key konnte nicht zurückgezogen werden", err)
return return
} }
s.store.LogAudit(r.Context(), store.AuditEvent{ if err := s.control.RemoveKeyIndex(r.Context(), key.Hash); err != nil {
log.Printf("Key %s konnte nicht aus dem Routing-Index entfernt werden: %v", id, err)
}
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: id, Actor: sess.ActorName(), ProjectID: project.ID, APIKeyID: id, Actor: sess.ActorName(),
Action: store.ActionKeyRevoked, Source: store.SourceWeb, IP: auth.ClientIP(r), Action: store.ActionKeyRevoked, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: key.Name, Detail: key.Name,
@ -386,14 +440,22 @@ func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) {
// renderKeyList zeigt die Key-Verwaltung mit optionaler Meldung. // renderKeyList zeigt die Key-Verwaltung mit optionaler Meldung.
func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status int, daten keyListData, hinweis, fehler string) { func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status int, daten keyListData, hinweis, fehler string) {
ws, _ := s.workspaceAndSession(r)
project, _ := ProjectFrom(r) project, _ := ProjectFrom(r)
keys, err := s.store.ListAPIKeys(r.Context(), project.ID) keys, err := ws.Store.ListAPIKeys(r.Context(), project.ID)
if err != nil { if err != nil {
s.serverError(w, "Keys konnten nicht geladen werden", err) s.serverError(w, "Keys konnten nicht geladen werden", err)
return return
} }
daten.Keys = keys daten.Keys = keys
daten.ServerURL = serverURL(r)
daten.Jetzt = time.Now()
for _, k := range keys {
if k.Unused(daten.Jetzt) {
daten.Ungenutzt++
}
}
s.render(w, r, "keys.html", status, pageData{ s.render(w, r, "keys.html", status, pageData{
Title: "Zugänge – " + project.Name, Title: "Zugänge – " + project.Name,
@ -427,9 +489,10 @@ func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) {
// renderAudit lädt Ereignisse samt Klarnamen und rendert die Tabelle. // renderAudit lädt Ereignisse samt Klarnamen und rendert die Tabelle.
func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter store.AuditFilter, global bool, titel string) { func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter store.AuditFilter, global bool, titel string) {
ws, sess := s.workspaceAndSession(r)
ctx := r.Context() ctx := r.Context()
ereignisse, err := s.store.ListAudit(ctx, filter) ereignisse, err := ws.Store.ListAudit(ctx, filter)
if err != nil { if err != nil {
s.serverError(w, "Protokoll konnte nicht geladen werden", err) s.serverError(w, "Protokoll konnte nicht geladen werden", err)
return return
@ -437,7 +500,7 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor
// IDs in lesbare Namen übersetzen — in der Tabelle sollen keine rohen // IDs in lesbare Namen übersetzen — in der Tabelle sollen keine rohen
// Kennungen stehen. // Kennungen stehen.
projektNamen, err := s.store.ProjectNames(ctx, s.workspaceID) projektNamen, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID)
if err != nil { if err != nil {
s.serverError(w, "Projektnamen konnten nicht geladen werden", err) s.serverError(w, "Projektnamen konnten nicht geladen werden", err)
return return
@ -448,7 +511,7 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor
projektIDs = append(projektIDs, id) projektIDs = append(projektIDs, id)
} }
keyNamen, err := s.store.KeyNames(ctx, projektIDs) keyNamen, err := ws.Store.KeyNames(ctx, projektIDs)
if err != nil { if err != nil {
s.serverError(w, "Key-Namen konnten nicht geladen werden", err) s.serverError(w, "Key-Namen konnten nicht geladen werden", err)
return return
@ -473,13 +536,28 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor
// Der Export läuft ohnehin täglich (siehe main.go). Der Knopf hier ist für den // Der Export läuft ohnehin täglich (siehe main.go). Der Knopf hier ist für den
// Fall gedacht, dass jemand unmittelbar vor einer Sicherung oder nach einer // Fall gedacht, dass jemand unmittelbar vor einer Sicherung oder nach einer
// größeren Änderung einen aktuellen Stand haben möchte. // größeren Änderung einen aktuellen Stand haben möchte.
//
// Exportiert wird nur der gerade gewählte Workspace — derselbe, dessen
// Projekte auf der Seite mit dem Knopf stehen. Der tägliche Lauf in main.go
// übernimmt alle.
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
if s.exporter == nil { if s.exporter == nil {
http.Error(w, "Der Export ist nicht eingerichtet (EXPORT_DIR fehlt).", http.StatusNotFound) http.Error(w, "Der Export ist nicht eingerichtet (EXPORT_DIR fehlt).", http.StatusNotFound)
return return
} }
stats, err := s.exporter.ExportAll(r.Context(), s.workspaceID) ws, sess := s.workspaceAndSession(r)
// Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung: Er
// landet in _workspace.md, und nach einem Umbenennen soll dort der neue
// stehen, nicht der beim Anmelden gemerkte.
eintrag, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID)
if err != nil {
s.serverError(w, "Workspace konnte nicht gelesen werden", err)
return
}
stats, err := s.exporter.ExportWorkspace(r.Context(), ws.Store, eintrag)
if err != nil { if err != nil {
s.serverError(w, "Export fehlgeschlagen", err) s.serverError(w, "Export fehlgeschlagen", err)
return return

View file

@ -2,6 +2,7 @@ package web
import ( import (
"fmt" "fmt"
"log"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@ -15,6 +16,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
sess, _ := SessionFrom(r) sess, _ := SessionFrom(r)
if sess.IsPlatformAdmin { if sess.IsPlatformAdmin {
if sess.WorkspaceID == "" {
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther) http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
return return
} }
@ -44,9 +49,9 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
} }
s.limiter.RecordFailure(ip) s.limiter.RecordFailure(ip)
s.store.LogAudit(r.Context(), store.AuditEvent{ // Ein ungültiger Key oder Klartext lässt sich keinem Workspace zuordnen
Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceWeb, IP: ip, // — siehe der entsprechende Kommentar in APIAuth (middleware.go).
}) log.Printf("Ungültiger Anmeldeversuch von %s", ip)
s.render(w, r, "login.html", http.StatusUnauthorized, pageData{ s.render(w, r, "login.html", http.StatusUnauthorized, pageData{
Title: "Anmelden", Title: "Anmelden",
@ -61,6 +66,11 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
} }
// resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück. // resolveLoginKey prüft einen Key beim Anmelden und gibt das Session-Subject zurück.
//
// Der Plattform-Admin bekommt hier noch keinen Workspace zugewiesen — das
// Subject ist zunächst nur "platform". Erst über /admin/workspaces wird ein
// Workspace gewählt und das Subject um dessen ID ergänzt (siehe
// handleWorkspaceSelect).
func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) { func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
if key == "" { if key == "" {
return "", false return "", false
@ -70,10 +80,7 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
// die Hashes, damit auch hier keine Zeichenkette Zeichen für Zeichen // die Hashes, damit auch hier keine Zeichenkette Zeichen für Zeichen
// verglichen wird und die Antwortzeit nichts verrät. // verglichen wird und die Antwortzeit nichts verrät.
if auth.HashKey(key) == s.adminTokenHash { if auth.HashKey(key) == s.adminTokenHash {
s.store.LogAudit(r.Context(), store.AuditEvent{ log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r))
Actor: "platform-admin", Action: store.ActionLoginSuccess,
Source: store.SourceWeb, IP: auth.ClientIP(r),
})
return auth.SubjectPlatformAdmin, true return auth.SubjectPlatformAdmin, true
} }
@ -81,17 +88,27 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
return "", false return "", false
} }
resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key)) hash := auth.HashKey(key)
workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash)
if err != nil {
return "", false
}
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return "", false
}
resolved, err := ws.Store.ResolveKeyHash(r.Context(), hash)
if err != nil { if err != nil {
return "", false return "", false
} }
s.store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: resolved.Project.ID, APIKeyID: resolved.Key.ID, Actor: resolved.Key.Name, ProjectID: resolved.Project.ID, APIKeyID: resolved.Key.ID, Actor: resolved.Key.Name,
Action: store.ActionLoginSuccess, Source: store.SourceWeb, IP: auth.ClientIP(r), Action: store.ActionLoginSuccess, Source: store.SourceWeb, IP: auth.ClientIP(r),
}) })
return auth.SubjectKeyPrefix + resolved.Key.ID, true return auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, true
} }
// handleLogout löscht den Session-Cookie. // handleLogout löscht den Session-Cookie.

View file

@ -1,11 +1,17 @@
package web package web
import ( import (
"encoding/json"
"errors" "errors"
"fmt"
"html/template" "html/template"
"log"
"net/http" "net/http"
"net/url"
"sort"
"strconv" "strconv"
"strings" "strings"
"time"
"munin/internal/entry" "munin/internal/entry"
"munin/internal/store" "munin/internal/store"
@ -29,6 +35,7 @@ type entryListItem struct {
// handleEntryList zeigt die Einträge eines Projekts — gefiltert, gesucht oder // handleEntryList zeigt die Einträge eines Projekts — gefiltert, gesucht oder
// einfach nach letzter Änderung. // einfach nach letzter Änderung.
func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r) project, _ := ProjectFrom(r)
ctx := r.Context() ctx := r.Context()
@ -48,11 +55,11 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
return return
} }
if daten.Tags, err = s.store.ListTags(ctx, project.ID); err != nil { if daten.Tags, err = ws.Store.ListTags(ctx, project.ID); err != nil {
s.serverError(w, "Schlagworte konnten nicht geladen werden", err) s.serverError(w, "Schlagworte konnten nicht geladen werden", err)
return return
} }
if daten.Total, err = s.store.CountEntries(ctx, project.ID); err != nil { if daten.Total, err = ws.Store.CountEntries(ctx, project.ID); err != nil {
s.serverError(w, "Einträge konnten nicht gezählt werden", err) s.serverError(w, "Einträge konnten nicht gezählt werden", err)
return return
} }
@ -61,15 +68,17 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
Title: project.Name, Title: project.Name,
Bereich: "eintraege", Bereich: "eintraege",
AktivesTag: tag, AktivesTag: tag,
Flash: r.URL.Query().Get("hinweis"),
Data: daten, Data: daten,
}) })
} }
// searchEntries führt eine Volltextsuche durch und lädt die Treffer nach. // searchEntries führt eine Volltextsuche durch und lädt die Treffer nach.
func (s *Server) searchEntries(r *http.Request, project store.Project, query string) ([]entryListItem, error) { func (s *Server) searchEntries(r *http.Request, project store.Project, query string) ([]entryListItem, error) {
ws, _ := WorkspaceFrom(r)
ctx := r.Context() ctx := r.Context()
idx, err := s.search.For(ctx, project.ID) idx, err := ws.Search.For(ctx, project.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -78,7 +87,7 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str
items := make([]entryListItem, 0, len(treffer)) items := make([]entryListItem, 0, len(treffer))
for _, t := range treffer { for _, t := range treffer {
e, err := s.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) {
// Der Index kann einen Eintrag kennen, den es nicht mehr gibt — // Der Index kann einen Eintrag kennen, den es nicht mehr gibt —
// dann wird er hier stillschweigend übersprungen. // dann wird er hier stillschweigend übersprungen.
@ -98,7 +107,8 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str
// listEntries lädt die Einträge ohne Suche, zuletzt geänderte zuerst. // listEntries lädt die Einträge ohne Suche, zuletzt geänderte zuerst.
func (s *Server) listEntries(r *http.Request, project store.Project, tag string) ([]entryListItem, error) { func (s *Server) listEntries(r *http.Request, project store.Project, tag string) ([]entryListItem, error) {
eintraege, err := s.store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100}) ws, _ := WorkspaceFrom(r)
eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -118,14 +128,20 @@ type entryViewData struct {
Entry store.Entry Entry store.Entry
HTML template.HTML HTML template.HTML
Backlinks []store.Backlink Backlinks []store.Backlink
// Stale: seit über einem Jahr unverändert (siehe store.Entry.Stale). Die
// Seite zeigt dann einen Vermerk, damit Menschen den Eintrag genauso
// vorsichtig lesen wie ein Agent, dem MCP dasselbe sagt.
Stale bool
} }
// handleEntryView zeigt einen einzelnen Eintrag. // handleEntryView zeigt einen einzelnen Eintrag.
func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r) project, _ := ProjectFrom(r)
slug := r.PathValue("slug") slug := r.PathValue("slug")
e, err := s.store.EntryBySlug(r.Context(), project.ID, slug) e, err := ws.Store.EntryBySlug(r.Context(), project.ID, slug)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -142,7 +158,7 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
return return
} }
backlinks, err := s.store.Backlinks(r.Context(), project.ID, slug) backlinks, err := ws.Store.Backlinks(r.Context(), project.ID, slug)
if err != nil { if err != nil {
s.serverError(w, "Rückverweise konnten nicht geladen werden", err) s.serverError(w, "Rückverweise konnten nicht geladen werden", err)
return return
@ -151,7 +167,11 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "entry.html", http.StatusOK, pageData{ s.render(w, r, "entry.html", http.StatusOK, pageData{
Title: e.Title, Title: e.Title,
Bereich: "eintraege", Bereich: "eintraege",
Data: entryViewData{Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks}, Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung
Data: entryViewData{
Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks,
Stale: e.Stale(time.Now()),
},
}) })
} }
@ -163,19 +183,29 @@ type entryEditData struct {
} }
// handleEntryNew zeigt das Formular für einen neuen Eintrag. // handleEntryNew zeigt das Formular für einen neuen Eintrag.
//
// Mit ?slug=… ist der Bezeichner vorbelegt — so führt die Nutzungsseite von
// einem Verweis ins Leere direkt zum passenden neuen Eintrag. Ein ungültiger
// Wert wird ignoriert statt ins Formular übernommen.
func (s *Server) handleEntryNew(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEntryNew(w http.ResponseWriter, r *http.Request) {
var vorbelegt store.Entry
if slug := r.URL.Query().Get("slug"); store.ValidSlug(slug) {
vorbelegt.Slug = slug
}
s.render(w, r, "edit.html", http.StatusOK, pageData{ s.render(w, r, "edit.html", http.StatusOK, pageData{
Title: "Neuer Eintrag", Title: "Neuer Eintrag",
Bereich: "eintraege", Bereich: "eintraege",
Data: entryEditData{IsNew: true}, Data: entryEditData{IsNew: true, Entry: vorbelegt},
}) })
} }
// handleEntryEdit zeigt das Formular für einen bestehenden Eintrag. // handleEntryEdit zeigt das Formular für einen bestehenden Eintrag.
func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r) project, _ := ProjectFrom(r)
e, err := s.store.EntryBySlug(r.Context(), project.ID, r.PathValue("slug")) e, err := ws.Store.EntryBySlug(r.Context(), project.ID, r.PathValue("slug"))
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -192,8 +222,88 @@ func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) {
}) })
} }
// suggestLimit begrenzt die Vorschläge der Autovervollständigung — mehr
// würde die Liste unübersichtlich machen, ohne beim Tippen zu helfen.
const suggestLimit = 8
// suggestItem ist ein Vorschlag der Autovervollständigung für [[…]]-Verweise.
type suggestItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
}
// handleEntrySuggest liefert Bezeichner-Vorschläge fürs Tippen von
// [[…]]-Verweisen im Bearbeitungsformular (siehe static/autocomplete.js).
//
// Ohne Suchbegriff kommen die zuletzt geänderten Einträge — meist das, was
// gerade im Sinn ist. Mit Suchbegriff läuft dieselbe Volltextsuche wie bei
// "recall" (MCP) und der Projektsuche: ein eigener Präfix-Abgleich auf dem
// Bezeichner wäre eine zweite Suchlogik, die mit der Zeit von der ersten
// abweicht.
func (s *Server) handleEntrySuggest(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
query := strings.TrimSpace(r.URL.Query().Get("q"))
// Nicht nil, damit die Antwort immer ein JSON-Array ist ([] statt null) —
// das JavaScript ruft direkt .forEach darauf auf.
vorschlaege := []suggestItem{}
if query == "" {
eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Limit: suggestLimit})
if err != nil {
s.serverError(w, "Vorschläge konnten nicht geladen werden", err)
return
}
for _, e := range eintraege {
vorschlaege = append(vorschlaege, suggestItem{Slug: e.Slug, Title: e.Title})
}
} else {
idx, err := ws.Search.For(r.Context(), project.ID)
if err != nil {
s.serverError(w, "Vorschläge konnten nicht geladen werden", err)
return
}
for _, treffer := range idx.Search(query, suggestLimit) {
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, treffer.DocID)
if err != nil {
continue // Index kann einen Eintrag kennen, den es nicht mehr gibt
}
vorschlaege = append(vorschlaege, suggestItem{Slug: e.Slug, Title: e.Title})
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(vorschlaege); err != nil {
log.Printf("Vorschläge konnten nicht gesendet werden: %v", err)
}
}
// handleEntryPreview rendert eingereichten Markdown-Text als HTML — für die
// Vorschau im Bearbeitungsformular (siehe static/preview.js).
//
// Nimmt bewusst nur den Inhalt entgegen, keinen ganzen Eintrag: Es wird
// nichts gespeichert, nur derselbe Renderer aufgerufen, den auch die
// Detailansicht benutzt (entry.RenderHTML) — Vorschau und späteres Ergebnis
// können dadurch nicht auseinanderlaufen. Ein leerer oder fehlerhafter Inhalt
// ist hier kein Fehlerfall: goldmark rendert auch unfertigen Markdown-Text,
// den jemand gerade erst zu tippen begonnen hat.
func (s *Server) handleEntryPreview(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
html, err := entry.RenderHTML(r.FormValue("content"), "/p/"+project.Slug+"/e/")
if err != nil {
s.serverError(w, "Vorschau konnte nicht erzeugt werden", err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
// handleEntrySave speichert einen Eintrag aus dem Formular. // handleEntrySave speichert einen Eintrag aus dem Formular.
func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r) project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r) sess, _ := SessionFrom(r)
@ -211,7 +321,7 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
// Hinweis statt eines stillen Überschreibens. // Hinweis statt eines stillen Überschreibens.
version, _ := strconv.Atoi(r.FormValue("version")) version, _ := strconv.Atoi(r.FormValue("version"))
geschrieben, err := s.store.PutEntry(r.Context(), store.EntryWrite{ geschrieben, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{
ProjectID: project.ID, ProjectID: project.ID,
Slug: slug, Slug: slug,
Title: titel, Title: titel,
@ -243,33 +353,440 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
return return
} }
s.search.Update(project.ID, geschrieben) ws.Search.Update(project.ID, geschrieben)
s.store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb, Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb,
Detail: store.EntryWriteDetail(geschrieben),
}) })
http.Redirect(w, r, "/p/"+project.Slug+"/e/"+slug, http.StatusSeeOther) ziel := "/p/" + project.Slug + "/e/" + slug
if geschrieben.RestoredFromTrash {
// Der Bezeichner gehörte einem Eintrag im Papierkorb. PutEntry hat ihn
// zurückgeholt und überschrieben — das soll im Protokoll stehen und
// der Mensch soll es erfahren, sonst wundert er sich über "Fassung 4"
// bei einem vermeintlich neuen Eintrag.
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceWeb,
Detail: "durch Speichern unter demselben Bezeichner",
})
ziel += "?hinweis=" + url.QueryEscape(
"Unter diesem Bezeichner lag ein Eintrag im Papierkorb. Er wurde zurückgeholt und überschrieben; der gelöschte Stand steht im Verlauf.")
} }
// handleEntryDelete löscht einen Eintrag. http.Redirect(w, r, ziel, http.StatusSeeOther)
}
// handleEntryDelete legt einen Eintrag in den Papierkorb (AUSBAUSTUFEN.md 3.9).
// Endgültig gelöscht wird nur über die Papierkorb-Seite oder nach Ablauf der
// Frist.
func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r) project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r) sess, _ := SessionFrom(r)
slug := r.PathValue("slug") slug := r.PathValue("slug")
if err := s.store.DeleteEntry(r.Context(), project.ID, slug); err != nil && !errors.Is(err, store.ErrNotFound) { err := ws.Store.MoveEntryToTrash(r.Context(), project.ID, slug, sess.ActorName())
if errors.Is(err, store.ErrNotFound) {
// Schon weg (etwa doppelt abgeschickt) — kein Fehler, einfach zur Liste.
http.Redirect(w, r, "/p/"+project.Slug+"/", http.StatusSeeOther)
return
}
if err != nil {
s.serverError(w, "Eintrag konnte nicht gelöscht werden", err) s.serverError(w, "Eintrag konnte nicht gelöscht werden", err)
return return
} }
s.search.Delete(project.ID, slug) ws.Search.Delete(project.ID, slug)
s.store.LogAudit(r.Context(), store.AuditEvent{ ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb, Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb,
Detail: "in den Papierkorb",
}) })
http.Redirect(w, r, "/p/"+project.Slug+"/", http.StatusSeeOther) meldung := fmt.Sprintf("„%s“ liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.",
slug, store.TrashRetentionDays)
http.Redirect(w, r, "/p/"+project.Slug+"/?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
}
// handleEntryBulkTags fügt einer Auswahl von Einträgen Schlagworte hinzu
// und/oder entfernt welche — die Sammelbearbeitung aus der Übersichtsseite.
//
// Die Auswahl kommt als mehrere gleichnamige Formularfelder "ids" — das
// HTML-Formular in entries.html legt für jeden angehakten Eintrag ein eigenes
// verstecktes Feld an.
func (s *Server) handleEntryBulkTags(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
if err := r.ParseForm(); err != nil {
s.serverError(w, "Formular konnte nicht gelesen werden", err)
return
}
ziel := "/p/" + project.Slug + "/"
if tag := r.FormValue("tag"); tag != "" {
ziel += "?tag=" + url.QueryEscape(tag)
}
ids := r.Form["ids"]
add := splitTags(r.FormValue("add_tags"))
remove := splitTags(r.FormValue("remove_tags"))
if len(ids) == 0 || (len(add) == 0 && len(remove) == 0) {
http.Redirect(w, r, ziel, http.StatusSeeOther)
return
}
geaendert, err := ws.Store.BulkUpdateTags(r.Context(), project.ID, ids, add, remove)
if err != nil {
s.serverError(w, "Schlagworte konnten nicht geändert werden", err)
return
}
for _, e := range geaendert {
ws.Search.Update(project.ID, e)
}
if len(geaendert) > 0 {
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, Source: store.SourceWeb,
Detail: fmt.Sprintf("Sammelbearbeitung: %d Einträge, +%v -%v", len(geaendert), add, remove),
})
}
meldung := fmt.Sprintf("%d Eintrag/Einträge aktualisiert", len(geaendert))
trenner := "?"
if strings.Contains(ziel, "?") {
trenner = "&"
}
http.Redirect(w, r, ziel+trenner+"hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
}
// ── Änderungshistorie ────────────────────────────────────────────────────────
// entryVersionListData sind die Daten der Verlaufsseite eines Eintrags.
type entryVersionListData struct {
Entry store.Entry
Versions []store.EntryVersion
}
// handleEntryVersions zeigt den Verlauf eines Eintrags — jede abgelöste
// Fassung mit Zeitpunkt und Zugang, verlinkt zum Vergleich mit ihrer
// Nachfolgerin.
func (s *Server) handleEntryVersions(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
slug := r.PathValue("slug")
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, slug)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Eintrag konnte nicht geladen werden", err)
return
}
s.renderEntryVersions(w, r, http.StatusOK, e, "")
}
// renderEntryVersions zeigt die Verlaufsseite, auf Wunsch mit Fehlermeldung.
// Eine eigene Funktion, weil auch die Wiederherstellung bei einem Konflikt
// hierher zurückführt (siehe handleEntryRestore).
func (s *Server) renderEntryVersions(w http.ResponseWriter, r *http.Request, status int, e store.Entry, fehler string) {
ws, _ := WorkspaceFrom(r)
versionen, err := ws.Store.EntryVersions(r.Context(), e.ID)
if err != nil {
s.serverError(w, "Änderungshistorie konnte nicht geladen werden", err)
return
}
s.render(w, r, "entry-versions.html", status, pageData{
Title: "Verlauf – " + e.Title,
Bereich: "eintraege",
Error: fehler,
Data: entryVersionListData{Entry: e, Versions: versionen},
})
}
// handleEntryRestore holt eine abgelöste Fassung zurück (AUSBAUSTUFEN.md 5.1).
//
// # Kein eigener Speicherweg
//
// Technisch ist das ein ganz gewöhnliches PutEntry mit Titel, Inhalt und
// Schlagworten der alten Fassung. Das hat zwei Vorteile:
//
// 1. Der aktuelle Stand wird dabei — wie bei jeder Änderung — selbst zu einer
// abgelösten Fassung. Es geht also nichts verloren; auch eine
// Wiederherstellung lässt sich wiederherstellen.
// 2. Suchindex, Verweise und Schlagworte werden auf demselben Weg nachgezogen
// wie beim normalen Speichern. Es gibt keine zweite Stelle, die man bei
// künftigen Änderungen vergessen könnte.
//
// Die Markierung "kuratiert" bleibt, wie sie aktuell ist: Sie ist eine Aussage
// über den Eintrag, nicht über einen bestimmten Textstand.
//
// # Schutz vor Überschreiben
//
// Das Formular schickt die Fassungsnummer mit, die beim Anzeigen aktuell war
// ("current_version"). Hat in der Zwischenzeit jemand — etwa ein Agent — den
// Eintrag geändert, schlägt PutEntry mit ErrConflict fehl, statt diese
// Änderung stillschweigend zu verdrängen.
func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
slug := r.PathValue("slug")
// Die Fassungsnummer steht im Pfad. strconv.Atoi wandelt Text in eine Zahl;
// alles, was keine positive Zahl ist, kann es als Fassung nicht geben.
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil || version < 1 {
http.NotFound(w, r)
return
}
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, slug)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Eintrag konnte nicht geladen werden", err)
return
}
// EntryVersionByNumber sucht über die Eintrags-ID. Eine Fassung eines
// fremden Eintrags (oder fremden Projekts) ist darüber nicht erreichbar.
alt, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Fassung konnte nicht geladen werden", err)
return
}
// Fehlt das Feld (oder ist es kaputt), bleibt erwartet bei 0 — dann würde
// PutEntry ohne Konfliktprüfung schreiben. Das wollen wir hier nicht: Wer
// eine alte Fassung zurückholt, soll dabei nie unbemerkt eine neuere
// Änderung eines anderen überschreiben. Deshalb lieber ablehnen.
erwartet, err := strconv.Atoi(r.FormValue("current_version"))
if err != nil || erwartet < 1 {
http.Error(w, "Fassungsangabe fehlt", http.StatusBadRequest)
return
}
geschrieben, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{
ProjectID: project.ID,
Slug: e.Slug,
Title: alt.Title,
Content: alt.Content,
Tags: alt.Tags,
Source: store.SourceHuman,
Author: sess.ActorName(),
Curated: e.Curated,
ExpectedVersion: erwartet,
})
if errors.Is(err, store.ErrConflict) {
// Den Eintrag frisch laden, damit die Verlaufsseite die Änderung zeigt,
// die uns zuvorgekommen ist. Klappt das nicht, reicht auch der alte Stand.
if frisch, ladeFehler := ws.Store.EntryBySlug(r.Context(), project.ID, slug); ladeFehler == nil {
e = frisch
}
s.renderEntryVersions(w, r, http.StatusConflict, e,
"Der Eintrag wurde inzwischen geändert. Bitte den Verlauf prüfen und dann erneut wiederherstellen.")
return
}
if err != nil {
s.serverError(w, "Fassung konnte nicht wiederhergestellt werden", err)
return
}
ws.Search.Update(project.ID, geschrieben)
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb,
Detail: store.EntryRestoreDetail(alt.Version),
})
meldung := fmt.Sprintf("Fassung %d wiederhergestellt — jetzt Fassung %d.", alt.Version, geschrieben.Version)
http.Redirect(w, r, "/p/"+project.Slug+"/e/"+e.Slug+"?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
}
// entryDiffData sind die Daten der Vergleichsseite zweier Fassungen.
type entryDiffData struct {
Entry store.Entry
OldVersion int
OldTitle string
OldAuthor string
OldTime time.Time
OldTags []string
NewVersion int
NewTitle string
NewAuthor string
NewTime time.Time
NewTags []string
NewIsCurrent bool // true, wenn "neu" die aktuell gültige Fassung ist
Lines []entry.DiffLine
}
// handleEntryDiff vergleicht eine abgelöste Fassung mit ihrer Nachfolgerin —
// entweder der nächsten abgelösten Fassung oder, falls es keine mehr gibt,
// der aktuellen.
func (s *Server) handleEntryDiff(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
slug := r.PathValue("slug")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil || version < 1 {
http.NotFound(w, r)
return
}
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, slug)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Eintrag konnte nicht geladen werden", err)
return
}
alt, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Fassung konnte nicht geladen werden", err)
return
}
daten := entryDiffData{
Entry: e,
OldVersion: alt.Version, OldTitle: alt.Title, OldAuthor: alt.Author, OldTime: alt.CreatedAt, OldTags: alt.Tags,
}
neu, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version+1)
switch {
case err == nil:
daten.NewVersion, daten.NewTitle, daten.NewAuthor = neu.Version, neu.Title, neu.Author
daten.NewTime, daten.NewTags = neu.CreatedAt, neu.Tags
daten.Lines = entry.DiffLines(alt.Content, neu.Content)
case errors.Is(err, store.ErrNotFound):
// Keine weitere abgelöste Fassung mehr — die Nachfolgerin ist die
// aktuell gültige.
daten.NewVersion, daten.NewTitle, daten.NewAuthor = e.Version, e.Title, e.Author
daten.NewTime, daten.NewTags, daten.NewIsCurrent = e.UpdatedAt, e.Tags, true
daten.Lines = entry.DiffLines(alt.Content, e.Content)
default:
s.serverError(w, "Fassung konnte nicht geladen werden", err)
return
}
s.render(w, r, "entry-diff.html", http.StatusOK, pageData{
Title: "Vergleich – " + e.Title,
Bereich: "eintraege",
Data: daten,
})
}
// ── Graph ────────────────────────────────────────────────────────────────────
// graphNodeLimit begrenzt, wie viele Knoten die Graph-Ansicht überhaupt an
// den Browser schickt.
//
// Ohne Grenze würde ein sehr großes Projekt zwei Probleme gleichzeitig
// bekommen: Das Layout im Browser läuft mit jedem Knotenpaar (siehe
// static/graph.js — die Abstoßung zwischen allen Knoten ist O(n²)), und die
// Darstellung wird ab einigen hundert Punkten ohnehin unlesbar. Bei
// Überschreitung bleiben die am stärksten verknüpften Knoten übrig — die
// tragen das meiste zur Struktur bei.
const graphNodeLimit = 150
type graphNode struct {
Slug string `json:"slug"`
Title string `json:"title"`
Exists bool `json:"exists"`
Grad int `json:"degree"`
}
type graphEdge struct {
From string `json:"from"`
To string `json:"to"`
}
type graphResponse struct {
Nodes []graphNode `json:"nodes"`
Edges []graphEdge `json:"edges"`
Truncated bool `json:"truncated"`
Total int `json:"total"`
}
// handleEntryGraph zeigt die Graph-Seite — nur das Gerüst, die Daten holt
// static/graph.js selbst über handleEntryGraphData nach.
func (s *Server) handleEntryGraph(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
s.render(w, r, "graph.html", http.StatusOK, pageData{
Title: "Graph – " + project.Name,
Bereich: "graph",
})
}
// handleEntryGraphData liefert Knoten und Kanten der Graph-Ansicht als JSON.
func (s *Server) handleEntryGraphData(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
knoten, kanten, err := ws.Store.LinkGraph(r.Context(), project.ID)
if err != nil {
s.serverError(w, "Graph konnte nicht geladen werden", err)
return
}
grad := make(map[string]int, len(knoten))
for _, k := range kanten {
grad[k.From]++
grad[k.To]++
}
resp := graphResponse{Nodes: []graphNode{}, Edges: []graphEdge{}, Total: len(knoten)}
behalten := knoten
if len(knoten) > graphNodeLimit {
resp.Truncated = true
sort.Slice(knoten, func(i, j int) bool { return grad[knoten[i].Slug] > grad[knoten[j].Slug] })
behalten = knoten[:graphNodeLimit]
}
erlaubt := make(map[string]bool, len(behalten))
for _, k := range behalten {
erlaubt[k.Slug] = true
resp.Nodes = append(resp.Nodes, graphNode{Slug: k.Slug, Title: k.Title, Exists: k.Exists, Grad: grad[k.Slug]})
}
for _, e := range kanten {
if erlaubt[e.From] && erlaubt[e.To] {
resp.Edges = append(resp.Edges, graphEdge{From: e.From, To: e.To})
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("Graph-Daten konnten nicht gesendet werden: %v", err)
}
} }
// splitTags zerlegt die Schlagwort-Eingabe an Kommas. // splitTags zerlegt die Schlagwort-Eingabe an Kommas.

View file

@ -0,0 +1,119 @@
package web
import (
"html/template"
"net/http"
"regexp"
"strings"
"munin/internal/auth"
"munin/internal/hilfe"
)
// hilfeData sind die Daten einer Hilfeseite.
type hilfeData struct {
Seite hilfe.Seite // die angezeigte Seite
Seiten []hilfe.Seite // alle Seiten, für die Navigation
HTML template.HTML // der gerenderte Inhalt
}
// handleHilfe zeigt eine Seite der eingebauten Onboarding-Dokumentation.
//
// Erreichbar für jede angemeldete Sitzung, unabhängig von Rolle und Projekt:
// Die Hilfe enthält nichts, was nicht jeder Nutzer wissen darf. Auch ein
// Plattform-Admin, der noch keinen Workspace gewählt hat, kommt hierher.
//
// /hilfe ohne Seitenangabe zeigt die Startseite. Deshalb kommt der Slug aus
// r.PathValue und fällt auf "start" zurück, wenn er leer ist.
func (s *Server) handleHilfe(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("seite")
if slug == "" {
slug = "start"
}
sess, _ := SessionFrom(r)
// Eine Admin-Seite antwortet für alle anderen mit 404, genau wie eine
// Seite, die es nicht gibt — sie steht für sie ja auch nirgends verlinkt.
seite, ok := hilfe.Finde(slug)
if !ok || (seite.NurAdmin && !sess.IsPlatformAdmin) {
http.NotFound(w, r)
return
}
markdown, err := seite.Markdown()
if err != nil {
s.serverError(w, "Hilfeseite konnte nicht geladen werden", err)
return
}
// Platzhalter ersetzen, damit die Beispiele direkt kopierbar sind: mit der
// Adresse dieses Servers und dem Bezeichner des eigenen Projekts statt
// mit "DEIN-SERVER". Das passiert vor dem Rendern — goldmark sieht nur
// noch fertigen Text und maskiert ihn wie jeden anderen.
projektSlug := "mein-projekt"
if sess.Project.Slug != "" {
projektSlug = sess.Project.Slug
}
markdown = strings.NewReplacer(
"{{SERVER}}", serverURL(r),
"{{PROJEKT}}", projektSlug,
).Replace(markdown)
inhalt, err := hilfe.Render(markdown)
if err != nil {
s.serverError(w, "Hilfeseite konnte nicht dargestellt werden", err)
return
}
data := pageData{
Title: seite.Titel,
Bereich: "hilfe",
Data: hilfeData{
Seite: seite,
Seiten: hilfe.Sichtbar(sess.IsPlatformAdmin),
HTML: template.HTML(inhalt),
},
}
// Ein Projekt-Key soll auch auf der Hilfe seine gewohnte Seitenleiste
// sehen (Einträge, Graph, Schlagworte). render() übernimmt das Projekt
// sonst nur aus der URL, und /hilfe hat keins.
if !sess.IsPlatformAdmin {
data.Project = sess.Project
}
s.render(w, r, "hilfe.html", http.StatusOK, data)
}
// gueltigerHost erlaubt nur, was in einem Hostnamen mit Port vorkommen kann:
// Buchstaben, Ziffern, Punkt, Bindestrich, Doppelpunkt und die eckigen
// Klammern einer IPv6-Adresse.
var gueltigerHost = regexp.MustCompile(`^[A-Za-z0-9.\-:\[\]]+$`)
// serverURL errät die Adresse, unter der dieser Server von außen erreichbar
// ist — für die Beispiele in der Hilfe.
//
// # Warum nur "erraten"?
//
// Der Host-Header kommt vom Browser und damit vom Nutzer selbst. Das ist hier
// unbedenklich: Das Ergebnis landet nur in der Seite, die derselbe Nutzer
// gerade abruft, und wird nirgends gespeichert. Trotzdem wird der Wert
// geprüft, und bei allem Ungewöhnlichen steht ein Platzhalter da.
//
// Das Schema ist schwerer zu erkennen: Hinter einem Reverse-Proxy, der TLS
// beendet, kommt die Anfrage hier als http an. Deshalb gilt: https, außer bei
// localhost ohne TLS — dieselbe Regel wie beim Secure-Flag der Cookies, und
// deshalb auch dieselbe Funktion (auth.IsLocalhost).
func serverURL(r *http.Request) string {
host := r.Host
if host == "" || !gueltigerHost.MatchString(host) {
return "https://DEIN-SERVER"
}
schema := "https"
if r.TLS == nil && auth.IsLocalhost(r) {
schema = "http"
}
return schema + "://" + host
}

View file

@ -0,0 +1,142 @@
package web
// Import von Markdown-Dateien in ein Projekt (AUSBAUSTUFEN.md 3.2).
//
// # Warum über die Weboberfläche und nicht als Kommandozeilenbefehl?
//
// Der Suchindex liegt im Arbeitsspeicher des laufenden Servers (siehe
// internal/search). Ein zweiter Prozess, der direkt in die Datenbank
// schreibt, könnte ihn nicht nachziehen, und die importierten Einträge blieben
// bis zum nächsten Neustart für recall unsichtbar. Hier läuft der Import im
// Server selbst und aktualisiert den Index im selben Zug.
//
// Dazu kommt: So können Projekt-Admins ihre vorhandene Dokumentation selbst
// einspielen, ohne auf den Plattform-Admin zu warten.
//
// # Rechte
//
// Nur mit der Rolle admin, wie die Zugänge. Ein Import schreibt womöglich
// tausende Einträge auf einmal, und wer ihn im Modus „Überschreiben“ startet,
// ändert Einträge, die andere geschrieben haben.
import (
"errors"
"io"
"net/http"
"munin/internal/auth"
"munin/internal/importer"
"munin/internal/store"
)
// importData sind die Daten der Import-Seite.
type importData struct {
// Bericht ist nach einem Lauf gesetzt, sonst nil.
Bericht *importer.Bericht
// Vorschau: Der Bericht zeigt nur, was passieren würde.
Vorschau bool
// Modus ist die zuletzt gewählte Einstellung, damit das Formular sie nach
// einer Vorschau beibehält.
Modus importer.Modus
MaxDateien int
}
// handleImportForm zeigt das Formular zum Hochladen.
func (s *Server) handleImportForm(w http.ResponseWriter, r *http.Request) {
s.renderImport(w, r, http.StatusOK, importData{Modus: importer.ModusUeberspringen}, "", "")
}
// handleImport nimmt ein ZIP-Archiv entgegen und importiert es, oder zeigt
// bei gesetzter Vorschau nur, was passieren würde.
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
project, _ := ProjectFrom(r)
daten := importData{
Modus: importer.Modus(r.FormValue("modus")),
Vorschau: r.FormValue("vorschau") == "1",
}
if daten.Modus != importer.ModusUeberschreiben {
daten.Modus = importer.ModusUeberspringen
}
// r.FormFile liest das hochgeladene Feld "archiv". Die Größe ist schon
// begrenzt (LimitBodies in middleware.go), ReadAll kann also nicht mehr
// als das Import-Limit in den Speicher holen.
datei, _, err := r.FormFile("archiv")
if err != nil {
var zuGross *http.MaxBytesError
if errors.As(err, &zuGross) {
s.renderImport(w, r, http.StatusRequestEntityTooLarge, daten, "",
"Das Archiv ist zu groß. Bitte in mehrere kleinere aufteilen.")
return
}
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Bitte ein ZIP-Archiv auswählen.")
return
}
defer datei.Close()
inhalt, err := io.ReadAll(datei)
if err != nil {
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Das Archiv konnte nicht gelesen werden.")
return
}
dateien, err := importer.LeseZip(inhalt)
if err != nil {
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Import nicht möglich: "+err.Error()+".")
return
}
bericht, err := importer.Importiere(r.Context(), ws.Store, project.ID, dateien, importer.Optionen{
Modus: daten.Modus,
Vorschau: daten.Vorschau,
Autor: sess.ActorName(),
})
// Auch nach einem Abbruch sind die bis dahin geschriebenen Einträge da.
// Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den
// Fehler melden.
for _, e := range bericht.Geschrieben {
ws.Search.Update(project.ID, e)
}
if !daten.Vorschau && len(bericht.Geschrieben) > 0 {
// Ein Protokolleintrag für den ganzen Import statt einer je Datei.
// Sonst verschwände alles andere im Protokoll unter tausend Zeilen.
// Und die Nutzungsübersicht (4.6) soll zählen, was die Teams im
// Alltag schreiben, nicht einen einmaligen Import.
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryImport, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: bericht.Zusammenfassung(),
})
}
if err != nil {
s.serverError(w, "Import abgebrochen nach "+bericht.Zusammenfassung(), err)
return
}
daten.Bericht = &bericht
hinweis := "Import abgeschlossen: " + bericht.Zusammenfassung() + "."
if daten.Vorschau {
hinweis = "Vorschau, noch nichts geschrieben: " + bericht.Zusammenfassung() +
". Zum Übernehmen dasselbe Archiv ohne Häkchen bei „Nur Vorschau“ erneut hochladen."
}
s.renderImport(w, r, http.StatusOK, daten, hinweis, "")
}
// renderImport zeigt die Import-Seite mit optionaler Meldung.
func (s *Server) renderImport(w http.ResponseWriter, r *http.Request, status int, daten importData, hinweis, fehler string) {
project, _ := ProjectFrom(r)
daten.MaxDateien = importer.MaxDateien
s.render(w, r, "import.html", status, pageData{
Title: "Import – " + project.Name,
Bereich: "import",
Flash: hinweis,
Error: fehler,
Data: daten,
})
}

View file

@ -0,0 +1,86 @@
package web
// Nutzungsübersicht je Projekt (AUSBAUSTUFEN.md 4.6).
//
// Für alle mit Zugang zum Projekt, auch mit Leserecht: Die Seite zeigt nur
// Zahlen und Namen von Zugängen, und die stehen ohnehin schon im Protokoll,
// das jede Rolle sehen darf.
import (
"net/http"
"time"
"munin/internal/store"
)
// Wie viele Einträge die Listen auf der Seite höchstens zeigen. Die Seite
// soll einen Überblick geben, keine vollständige Aufstellung — wer mehr
// braucht, findet sie über die Eintragsliste oder den Graph.
const (
nutzungListenLaenge = 15
nutzungKeysLaenge = 5
)
// usageData sind die Daten der Nutzungsseite.
type usageData struct {
Summary store.UsageSummary
WindowDays int
Untagged []store.EntryRef
Missing []store.MissingLink
MissingMore int // wie viele Verweise ins Leere über die Liste hinaus
Keys []store.APIKey
}
// handleProjectUsage zeigt die Nutzungsübersicht eines Projekts.
func (s *Server) handleProjectUsage(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
ctx := r.Context()
alle, err := ws.Store.UsageSummaries(ctx, project.ID, time.Now())
if err != nil {
s.serverError(w, "Nutzung konnte nicht geladen werden", err)
return
}
ohneSchlagwort, err := ws.Store.UntaggedEntries(ctx, project.ID, nutzungListenLaenge)
if err != nil {
s.serverError(w, "Einträge ohne Schlagwort konnten nicht geladen werden", err)
return
}
insLeere, err := ws.Store.MissingLinks(ctx, project.ID)
if err != nil {
s.serverError(w, "Verweise konnten nicht geladen werden", err)
return
}
keys, err := ws.Store.RecentlyUsedKeys(ctx, project.ID, nutzungKeysLaenge)
if err != nil {
s.serverError(w, "Zugänge konnten nicht geladen werden", err)
return
}
daten := usageData{
// Ein Projekt ohne Einträge und Schreibvorgänge fehlt in der map —
// dann liefert alle[project.ID] den Nullwert, also überall 0.
Summary: alle[project.ID],
WindowDays: store.UsageWindowDays,
Untagged: ohneSchlagwort,
Missing: insLeere,
Keys: keys,
}
// Die Verweise ins Leere kommen vollständig (die Gesamtzahl ist selbst
// eine Aussage), angezeigt werden nur die meistgenannten.
if len(daten.Missing) > nutzungListenLaenge {
daten.MissingMore = len(daten.Missing) - nutzungListenLaenge
daten.Missing = daten.Missing[:nutzungListenLaenge]
}
s.render(w, r, "nutzung.html", http.StatusOK, pageData{
Title: "Nutzung – " + project.Name,
Bereich: "nutzung",
Data: daten,
})
}

View file

@ -0,0 +1,183 @@
package web
// Papierkorb je Projekt (AUSBAUSTUFEN.md 3.9).
//
// Alle drei Wege verlangen Schreibrecht, auch das bloße Ansehen. Grund: Ein
// Eintrag landet oft genau deshalb im Papierkorb, weil er etwas enthielt, das
// dort nicht hingehört — ein versehentlich abgelegtes Passwort etwa. Wer nur
// lesen darf, soll das nicht über den Papierkorb doch noch zu sehen bekommen.
//
// Wiederherstellen und endgültig Löschen gibt es bewusst NUR hier, nicht über
// MCP: Ein fehlgeleiteter Agent soll seinen Schaden nicht selbst "vollenden"
// können, indem er nach dem Löschen auch noch den Papierkorb leert.
import (
"fmt"
"net/http"
"net/url"
"munin/internal/store"
)
// trashData sind die Daten der Papierkorb-Seite.
type trashData struct {
Entries []store.TrashedEntry
// Von ist der Filter "nur, was dieser Zugang gelöscht hat" — leer, wenn
// alles angezeigt wird.
Von string
// Frist in Tagen, für den Hinweistext.
RetentionDays int
}
// handleTrashList zeigt den Papierkorb eines Projekts.
func (s *Server) handleTrashList(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
von := r.URL.Query().Get("von")
eintraege, err := ws.Store.ListTrash(r.Context(), project.ID, von)
if err != nil {
s.serverError(w, "Papierkorb konnte nicht geladen werden", err)
return
}
s.render(w, r, "papierkorb.html", http.StatusOK, pageData{
Title: "Papierkorb – " + project.Name,
Bereich: "papierkorb",
Flash: r.URL.Query().Get("hinweis"),
Data: trashData{Entries: eintraege, Von: von, RetentionDays: store.TrashRetentionDays},
})
}
// papierkorbAuswahl liest die ausgewählten Bezeichner aus dem Formular.
//
// Das Formular schickt für jeden angehakten Eintrag ein eigenes Feld "slugs"
// — mehrere Felder mit demselben Namen. r.Form["slugs"] liefert sie alle als
// Liste; r.FormValue("slugs") gäbe nur das erste zurück.
func papierkorbAuswahl(r *http.Request) ([]string, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
// Doppelte Angaben (etwa ein Kästchen plus derselbe Eintrag über "alle
// wiederherstellen") nur einmal behandeln — sonst stünde der Vorgang
// zweimal im Protokoll. Die map merkt sich, was schon übernommen wurde.
gesehen := map[string]bool{}
var slugs []string
for _, slug := range r.Form["slugs"] {
if slug == "" || gesehen[slug] {
continue
}
gesehen[slug] = true
slugs = append(slugs, slug)
}
return slugs, nil
}
// zurueckZumPapierkorb leitet mit einer Meldung auf die Papierkorb-Seite
// zurück — und behält dabei einen gesetzten Filter bei.
func zurueckZumPapierkorb(w http.ResponseWriter, r *http.Request, projektSlug, meldung string) {
// url.Values baut eine Query ("a=1&b=2") und maskiert dabei jeden Wert
// korrekt — sicherer als Zeichenketten von Hand zusammenzusetzen.
werte := url.Values{}
if von := r.FormValue("von"); von != "" {
werte.Set("von", von)
}
if meldung != "" {
werte.Set("hinweis", meldung)
}
ziel := "/p/" + projektSlug + "/trash"
if len(werte) > 0 {
ziel += "?" + werte.Encode()
}
http.Redirect(w, r, ziel, http.StatusSeeOther)
}
// handleTrashRestore holt die ausgewählten Einträge zurück.
func (s *Server) handleTrashRestore(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
slugs, err := papierkorbAuswahl(r)
if err != nil {
s.serverError(w, "Formular konnte nicht gelesen werden", err)
return
}
if len(slugs) == 0 {
zurueckZumPapierkorb(w, r, project.Slug, "Nichts ausgewählt.")
return
}
zurueck, err := ws.Store.RestoreFromTrash(r.Context(), project.ID, slugs)
if err != nil {
s.serverError(w, "Einträge konnten nicht wiederhergestellt werden", err)
return
}
// Jeder zurückgeholte Eintrag kommt wieder in den Suchindex und bekommt
// eine eigene Protokollzeile — so lässt sich im Protokoll nach einem
// einzelnen Bezeichner filtern und sein ganzer Weg nachvollziehen.
for _, e := range zurueck {
ws.Search.Update(project.ID, e)
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryRestore, EntrySlug: e.Slug, Source: store.SourceWeb,
Detail: "aus dem Papierkorb",
})
}
zurueckZumPapierkorb(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge wiederhergestellt.", len(zurueck)))
}
// handleTrashPurge löscht die ausgewählten Einträge endgültig.
func (s *Server) handleTrashPurge(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
slugs, err := papierkorbAuswahl(r)
if err != nil {
s.serverError(w, "Formular konnte nicht gelesen werden", err)
return
}
if len(slugs) == 0 {
zurueckZumPapierkorb(w, r, project.Slug, "Nichts ausgewählt.")
return
}
// Vorher festhalten, welche Bezeichner wirklich im Papierkorb liegen —
// nur die werden gelöscht und nur die gehören ins Protokoll. Eine
// untergeschobene Angabe taucht so auch dort nicht auf.
imPapierkorb, err := ws.Store.ListTrash(r.Context(), project.ID, "")
if err != nil {
s.serverError(w, "Papierkorb konnte nicht geladen werden", err)
return
}
vorhanden := make(map[string]bool, len(imPapierkorb))
for _, t := range imPapierkorb {
vorhanden[t.Slug] = true
}
anzahl, err := ws.Store.PurgeFromTrash(r.Context(), project.ID, slugs)
if err != nil {
s.serverError(w, "Einträge konnten nicht endgültig gelöscht werden", err)
return
}
for _, slug := range slugs {
if !vorhanden[slug] {
continue
}
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryPurge, EntrySlug: slug, Source: store.SourceWeb,
Detail: "von Hand aus dem Papierkorb",
})
}
zurueckZumPapierkorb(w, r, project.Slug, fmt.Sprintf("%d Eintrag/Einträge endgültig gelöscht.", anzahl))
}

View file

@ -0,0 +1,243 @@
package web
import (
"errors"
"log"
"net/http"
"net/url"
"strings"
"munin/internal/store"
)
// workspaceListData sind die Daten der Workspace-Auswahl.
type workspaceListData struct {
Workspaces []workspaceItem
// Current ist die ID des gerade gewählten Workspace, falls einer gewählt
// ist — für die Hervorhebung in der Liste.
Current string
}
type workspaceItem struct {
ID string
Name string
CreatedAt string
Projects int
}
// handleWorkspaceList zeigt alle Workspaces zur Auswahl.
//
// Erreichbar auch ohne gewählten Workspace — genau dafür ist die Seite da:
// Eine frische Plattform-Admin-Sitzung landet hier, bevor sie überhaupt eine
// Projektseite aufrufen kann (siehe requireProject in middleware.go).
func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) {
s.renderWorkspaceList(w, r, http.StatusOK, "", "")
}
// renderWorkspaceList lädt alle Workspaces und rendert die Auswahlseite.
func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, status int, hinweis, fehler string) {
sess, _ := SessionFrom(r)
workspaces, err := s.control.ListWorkspaces(r.Context())
if err != nil {
s.serverError(w, "Workspaces konnten nicht geladen werden", err)
return
}
// Neueste zuerst — genau umgekehrt zu ListWorkspaces, das für
// EnsureDefaultWorkspace die älteste zuerst braucht.
items := make([]workspaceItem, len(workspaces))
for i, ws := range workspaces {
items[len(workspaces)-1-i] = workspaceItem{
ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"),
Projects: s.projectCount(r, ws.ID),
}
}
s.render(w, r, "workspaces.html", status, pageData{
Title: "Workspaces",
Bereich: "workspaces",
Flash: hinweis,
Error: fehler,
Data: workspaceListData{Workspaces: items, Current: sess.WorkspaceID},
})
}
// handleWorkspaceCreate legt einen neuen Workspace an und wählt ihn gleich —
// wer eine Abteilung anlegt, will unmittelbar darin ihr erstes Projekt anlegen,
// nicht erst zurück zur Auswahl.
func (s *Server) handleWorkspaceCreate(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
s.renderWorkspaceList(w, r, http.StatusBadRequest, "", "Der Name darf nicht leer sein.")
return
}
ws, err := s.control.CreateWorkspace(r.Context(), name)
if err != nil {
s.serverError(w, "Workspace konnte nicht angelegt werden", err)
return
}
sess, _ := SessionFrom(r)
log.Printf("Workspace %q (%s) angelegt von %s", ws.Name, ws.ID, sess.ActorName())
s.setSessionCookie(w, r, "platform:"+ws.ID)
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
}
// ── Bearbeiten und Löschen ───────────────────────────────────────────────────
// workspaceEditData sind die Daten der Bearbeitungsseite eines Workspace.
type workspaceEditData struct {
Workspace workspaceItem
Projects int
}
// handleWorkspaceEditForm zeigt das Formular zum Umbenennen und die Gefahrenzone.
func (s *Server) handleWorkspaceEditForm(w http.ResponseWriter, r *http.Request) {
ws, ok := s.workspaceFromPath(w, r)
if !ok {
return
}
s.renderWorkspaceEdit(w, r, http.StatusOK, ws, "", "")
}
// handleWorkspaceEditSave benennt einen Workspace um.
func (s *Server) handleWorkspaceEditSave(w http.ResponseWriter, r *http.Request) {
ws, ok := s.workspaceFromPath(w, r)
if !ok {
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, "", "Der Name darf nicht leer sein.")
return
}
if err := s.control.UpdateWorkspace(r.Context(), ws.ID, name); err != nil {
s.serverError(w, "Workspace konnte nicht umbenannt werden", err)
return
}
sess, _ := SessionFrom(r)
log.Printf("Workspace %s umbenannt in %q von %s", ws.ID, name, sess.ActorName())
http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape(name+" gespeichert"), http.StatusSeeOther)
}
// handleWorkspaceDelete löscht einen Workspace samt aller seiner Projekte,
// Einträge und Zugänge unwiederbringlich.
//
// Erst die Kontroll-Datenbank (Workspace-Zeile, Routing-Index), dann die Datei
// selbst — siehe der Kommentar an workspace.Manager.Delete. Betraf die
// Löschung den Workspace der eigenen Sitzung, wird die Sitzung auf "kein
// Workspace gewählt" zurückgesetzt statt den Admin einfach auszusperren.
func (s *Server) handleWorkspaceDelete(w http.ResponseWriter, r *http.Request) {
ws, ok := s.workspaceFromID(w, r, r.FormValue("id"))
if !ok {
return
}
if r.FormValue("confirm") != ws.Name {
s.renderWorkspaceEdit(w, r, http.StatusBadRequest, ws, "",
"Zum Löschen muss der Workspace-Name \""+ws.Name+"\" zur Bestätigung eingegeben werden.")
return
}
if err := s.control.DeleteWorkspace(r.Context(), ws.ID); err != nil {
s.serverError(w, "Workspace konnte nicht gelöscht werden", err)
return
}
// Die Datei ist danach überflüssiger Ballast, kein Sicherheitsproblem mehr
// — die Kontroll-Datenbank kennt den Workspace nicht mehr, also kann ihn
// auch niemand mehr erreichen. Ein Fehler hier darf die Antwort deshalb
// nicht mehr aufhalten, nur im Log stehen.
if err := s.workspaces.Delete(ws.ID); err != nil {
log.Printf("Workspace-Datei %s konnte nicht gelöscht werden: %v", ws.ID, err)
}
sess, _ := SessionFrom(r)
log.Printf("Workspace %q (%s) gelöscht von %s", ws.Name, ws.ID, sess.ActorName())
if sess.WorkspaceID == ws.ID {
s.setSessionCookie(w, r, "platform")
}
http.Redirect(w, r, "/admin/workspaces?hinweis="+url.QueryEscape("Workspace gelöscht"), http.StatusSeeOther)
}
// projectCount zählt die Projekte eines Workspace.
//
// Dafür muss die Datei geöffnet werden (oder liegt schon im Cache des
// Managers) — bei 10–40 Workspaces auf einer Verwaltungsseite unproblematisch,
// siehe AUSBAUSTUFEN.md 4.4. Ein Fehler zeigt sich als 0 statt eine ganze
// Übersichtsseite scheitern zu lassen; das Ergebnis ist ohnehin nur eine
// Zusatzinformation, kein Sicherheitsmerkmal.
func (s *Server) projectCount(r *http.Request, workspaceID string) int {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
log.Printf("Projekte von Workspace %s konnten nicht gezählt werden: %v", workspaceID, err)
return 0
}
projekte, err := ws.Store.ListProjects(r.Context(), workspaceID)
if err != nil {
log.Printf("Projekte von Workspace %s konnten nicht gezählt werden: %v", workspaceID, err)
return 0
}
return len(projekte)
}
// renderWorkspaceEdit lädt die Projektzahl und rendert die Bearbeitungsseite.
func (s *Server) renderWorkspaceEdit(w http.ResponseWriter, r *http.Request, status int,
ws workspaceItem, flash, fehler string) {
s.render(w, r, "workspace-edit.html", status, pageData{
Title: ws.Name,
Bereich: "workspaces",
Flash: flash,
Error: fehler,
Data: workspaceEditData{Workspace: ws, Projects: s.projectCount(r, ws.ID)},
})
}
// workspaceFromPath löst die Workspace-ID aus der URL auf ({id}/edit-Routen).
func (s *Server) workspaceFromPath(w http.ResponseWriter, r *http.Request) (workspaceItem, bool) {
return s.workspaceFromID(w, r, r.PathValue("id"))
}
// workspaceFromID lädt einen Workspace anhand seiner ID — gemeinsame Logik für
// die pfadbasierten Bearbeitungsrouten und die formularbasierte Löschroute
// (die folgt bewusst demselben Muster wie /admin/projects/delete).
func (s *Server) workspaceFromID(w http.ResponseWriter, r *http.Request, id string) (workspaceItem, bool) {
ws, err := s.control.WorkspaceByID(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return workspaceItem{}, false
}
if err != nil {
s.serverError(w, "Workspace konnte nicht geladen werden", err)
return workspaceItem{}, false
}
return workspaceItem{ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02")}, true
}
// handleWorkspaceSelect wechselt den Workspace der aktuellen Plattform-Admin-Sitzung.
//
// Technisch ist das eine neue Anmeldung mit demselben Subject-Schema wie beim
// Login (siehe resolveLoginKey in handlers_auth.go), nur ausgelöst durch einen
// Klick statt durch eine erneute Eingabe des ADMIN_TOKEN.
func (s *Server) handleWorkspaceSelect(w http.ResponseWriter, r *http.Request) {
id := r.FormValue("id")
if _, err := s.control.WorkspaceByID(r.Context(), id); err != nil {
http.NotFound(w, r)
return
}
s.setSessionCookie(w, r, "platform:"+id)
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
}

194
internal/web/import_test.go Normal file
View file

@ -0,0 +1,194 @@
package web
import (
"archive/zip"
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"munin/internal/auth"
"munin/internal/store"
)
// importAnfrage baut einen Upload wie das Formular der Import-Seite.
func importAnfrage(t *testing.T, cookie *http.Cookie, csrf string, dateien map[string]string, felder map[string]string) *http.Request {
t.Helper()
var archiv bytes.Buffer
zw := zip.NewWriter(&archiv)
for pfad, inhalt := range dateien {
f, _ := zw.Create(pfad)
f.Write([]byte(inhalt))
}
zw.Close()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
mw.WriteField(auth.CSRFFieldName, csrf)
for name, wert := range felder {
mw.WriteField(name, wert)
}
teil, _ := mw.CreateFormFile("archiv", "import.zip")
teil.Write(archiv.Bytes())
mw.Close()
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body)
r.Header.Set("Content-Type", mw.FormDataContentType())
r.AddCookie(cookie)
return r
}
// adminMitToken meldet einen Admin-Key an und berechnet sein CSRF-Token so,
// wie es das Template tut.
func adminMitToken(t *testing.T, srv *Server, db *store.Store, projekt store.Project, h http.Handler) (*http.Cookie, string) {
t.Helper()
key := erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
subject := auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID
return cookie, auth.NewSigner([]byte(testSecret)).CSRFToken(subject)
}
func TestImportPreviewThenApply(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie, csrf := adminMitToken(t, srv, db, projekt, h)
ctx := context.Background()
// Suchindex vorher aufbauen. Der Import muss ihn nachziehen, sonst fände
// die Suche die neuen Einträge erst nach einem Neustart.
suche := httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil)
suche.AddCookie(cookie)
h.ServeHTTP(httptest.NewRecorder(), suche)
dateien := map[string]string{
"doku/verkehr.md": "# Verkehr\n\nAm Zebrastreifen anhalten.",
"doku/_index.md": "Übersicht",
}
// Vorschau: nichts geschrieben.
w := httptest.NewRecorder()
h.ServeHTTP(w, importAnfrage(t, cookie, csrf, dateien, map[string]string{"vorschau": "1", "modus": "skip"}))
if w.Code != http.StatusOK {
t.Fatalf("Vorschau: Status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Vorschau") || !strings.Contains(w.Body.String(), "verkehr") {
t.Error("die Vorschau zeigt die Datei nicht")
}
if _, err := db.EntryBySlug(ctx, projekt.ID, "verkehr"); err == nil {
t.Fatal("die Vorschau hat geschrieben")
}
// Übernehmen.
w = httptest.NewRecorder()
h.ServeHTTP(w, importAnfrage(t, cookie, csrf, dateien, map[string]string{"modus": "skip"}))
if w.Code != http.StatusOK {
t.Fatalf("Import: Status %d: %s", w.Code, w.Body.String())
}
e, err := db.EntryBySlug(ctx, projekt.ID, "verkehr")
if err != nil {
t.Fatalf("Eintrag fehlt nach dem Import: %v", err)
}
if e.Author != "verwalterin" || e.Title != "Verkehr" {
t.Errorf("Autor %q, Titel %q", e.Author, e.Title)
}
suche = httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil)
suche.AddCookie(cookie)
w = httptest.NewRecorder()
h.ServeHTTP(w, suche)
if !strings.Contains(w.Body.String(), "/e/verkehr") {
t.Error("die Suche findet den importierten Eintrag nicht — Suchindex nicht nachgezogen")
}
// Genau ein Protokolleintrag für den ganzen Import.
ereignisse, err := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID, Limit: 50})
if err != nil {
t.Fatal(err)
}
anzahl := 0
for _, ev := range ereignisse {
if ev.Action == store.ActionEntryImport {
anzahl++
}
}
if anzahl != 1 {
t.Errorf("%d Import-Einträge im Protokoll, erwartet 1", anzahl)
}
}
func TestImportNeedsAdmin(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "schreiber", auth.RoleWrite)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/import", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
}
func TestImportRejectsNonZip(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie, csrf := adminMitToken(t, srv, db, projekt, h)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
mw.WriteField(auth.CSRFFieldName, csrf)
teil, _ := mw.CreateFormFile("archiv", "kaputt.zip")
teil.Write([]byte("das ist kein zip"))
mw.Close()
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body)
r.Header.Set("Content-Type", mw.FormDataContentType())
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "ZIP") {
t.Errorf("Status %d, Antwort ohne verständliche Meldung", w.Code)
}
}
// TestLimitBodiesPerRoute prüft, dass /mcp und der Import ihre eigene,
// größere Grenze bekommen und alles andere die kleine. Früher lag ein
// einziges Limit über allem und schnitt MCP auf 1 MB ab.
func TestLimitBodiesPerRoute(t *testing.T) {
var gelesen int64
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gelesen, _ = io.Copy(io.Discard, r.Body)
})
h := LimitBodies(inner, BodyLimits{Web: 100, API: 1000, Import: 5000})
faelle := []struct {
methode, pfad string
erwartet int64
}{
{http.MethodPost, "/p/x/save", 100},
{http.MethodPost, "/mcp", 1000},
{http.MethodPost, "/p/x/import", 5000},
{http.MethodGet, "/p/x/import", 100},
{http.MethodPost, "/p/x/e/import", 100}, // ein Eintrag namens "import", nicht die Import-Seite
}
for _, f := range faelle {
gelesen = 0
h.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(f.methode, f.pfad, strings.NewReader(strings.Repeat("x", 10000))))
if gelesen != f.erwartet {
t.Errorf("%s %s: %d Bytes gelesen, erwartet %d", f.methode, f.pfad, gelesen, f.erwartet)
}
}
}

View file

@ -11,6 +11,7 @@ import (
"munin/internal/auth" "munin/internal/auth"
"munin/internal/store" "munin/internal/store"
"munin/internal/workspace"
) )
// requireSession prüft den Session-Cookie und legt die Sitzung im Context ab. // requireSession prüft den Session-Cookie und legt die Sitzung im Context ab.
@ -48,11 +49,36 @@ func (s *Server) requireSession(next http.Handler) http.Handler {
return return
} }
next.ServeHTTP(w, r.WithContext(withSession(r.Context(), sess))) ctx := withSession(r.Context(), sess)
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl)
// gibt es nichts zu öffnen — solche Sitzungen dürfen ausschließlich die
// Workspace-Auswahl aufrufen, siehe requireWorkspacePicked.
if sess.WorkspaceID != "" {
ws, err := s.workspaces.Get(sess.WorkspaceID)
if err != nil {
s.serverError(w, "Workspace konnte nicht geöffnet werden", err)
return
}
ctx = withWorkspace(ctx, ws)
}
next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
// sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung. // sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung.
//
// Ein Subject hat eine von drei Formen:
//
// - "platform" — Plattform-Admin, noch kein Workspace gewählt.
// - "platform:<workspace-id>" — Plattform-Admin, mit gewähltem Workspace.
// - "key:<workspace-id>:<key-id>" — Projekt-Zugang, immer an genau einen
// Workspace gebunden (den seines Keys).
//
// Die Workspace-ID steht direkt im (signierten, also fälschungssicheren)
// Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank
// der richtigen Workspace-Datei zuordnen.
func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) { func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) {
if subject == auth.SubjectPlatformAdmin { if subject == auth.SubjectPlatformAdmin {
return Session{ return Session{
@ -62,23 +88,47 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b
}, true }, true
} }
keyID, ok := auth.KeyIDFromSubject(subject) if rest, ok := strings.CutPrefix(subject, auth.SubjectPlatformAdmin+":"); ok {
ws, err := s.control.WorkspaceByID(r.Context(), rest)
if err != nil {
return Session{}, false
}
return Session{
Subject: subject,
IsPlatformAdmin: true,
WorkspaceID: rest,
WorkspaceName: ws.Name,
Role: auth.RoleAdmin,
}, true
}
rest, ok := auth.KeyIDFromSubject(subject)
if !ok {
return Session{}, false
}
workspaceID, keyID, ok := strings.Cut(rest, ":")
if !ok { if !ok {
return Session{}, false return Session{}, false
} }
resolved, err := s.store.APIKeyByID(r.Context(), keyID) ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return Session{}, false
}
resolved, err := ws.Store.APIKeyByID(r.Context(), keyID)
if err != nil || resolved.Revoked() { if err != nil || resolved.Revoked() {
return Session{}, false return Session{}, false
} }
project, err := s.store.ProjectByID(r.Context(), resolved.ProjectID) project, err := ws.Store.ProjectByID(r.Context(), resolved.ProjectID)
if err != nil { if err != nil {
return Session{}, false return Session{}, false
} }
return Session{ return Session{
Subject: subject, Subject: subject,
WorkspaceID: workspaceID,
Key: resolved, Key: resolved,
Project: project, Project: project,
Role: auth.Role(resolved.Role), Role: auth.Role(resolved.Role),
@ -129,8 +179,16 @@ func (s *Server) requireProject(next http.Handler) http.Handler {
return return
} }
ws, ok := WorkspaceFrom(r)
if !ok {
// Plattform-Admin ohne gewählten Workspace: Projektseiten gibt es
// erst, nachdem einer gewählt wurde.
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
return
}
slug := r.PathValue("project") slug := r.PathValue("project")
project, err := s.store.ProjectBySlug(r.Context(), s.workspaceID, slug) project, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, slug)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -152,6 +210,22 @@ func (s *Server) requireProject(next http.Handler) http.Handler {
}) })
} }
// requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch.
//
// Betrifft nur den Plattform-Admin: Eine Projekt-Sitzung hat ihren Workspace
// immer schon über ihren Key (siehe sessionFromSubject). Der Plattform-Admin
// dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er
// verwalten will — dafür ist /admin/workspaces da.
func (s *Server) requireWorkspace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := WorkspaceFrom(r); !ok {
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
// requirePlatformAdmin lässt nur den Plattform-Admin durch. // requirePlatformAdmin lässt nur den Plattform-Admin durch.
func (s *Server) requirePlatformAdmin(next http.Handler) http.Handler { func (s *Server) requirePlatformAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -267,6 +341,48 @@ func LimitBody(next http.Handler, maxBytes int64) http.Handler {
}) })
} }
// BodyLimits sind die Höchstgrößen eines Request-Bodys je Art von Route.
type BodyLimits struct {
Web int64 // Formulare der Weboberfläche
API int64 // MCP-Aufrufe unter /mcp
Import int64 // Hochladen eines ZIP-Archivs (siehe handlers_import.go)
}
// LimitBodies begrenzt den Body wie LimitBody, wählt die Grenze aber nach der
// Route.
//
// # Warum eine Stelle statt LimitBody je Route?
//
// Zwei ineinander geschachtelte MaxBytesReader begrenzen immer auf den
// KLEINEREN Wert. Ein großzügiges Limit an einer einzelnen Route unter einem
// strengen Limit für alles wirkt deshalb nie. Genau so war MCP früher
// verdrahtet: MAX_API_BODY_BYTES stand auf 4 MB, das äußere Limit von 1 MB
// schnitt trotzdem ab. Hier wird die Grenze einmal außen gewählt, bevor
// irgendetwas anderes den Body anfasst.
func LimitBodies(next http.Handler, limits BodyLimits) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
grenze := limits.Web
switch {
case r.URL.Path == "/mcp":
grenze = limits.API
case r.Method == http.MethodPost && istImportPfad(r.URL.Path):
grenze = limits.Import
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, grenze)
}
next.ServeHTTP(w, r)
})
}
// istImportPfad erkennt "/p/{projekt}/import". Der Mux hat zu diesem
// Zeitpunkt noch nicht entschieden, welche Route passt, deshalb wird der Pfad
// hier von Hand zerlegt: genau drei Teile, der erste "p", der letzte "import".
func istImportPfad(pfad string) bool {
teile := strings.Split(strings.Trim(pfad, "/"), "/")
return len(teile) == 3 && teile[0] == "p" && teile[2] == "import"
}
// ── API-Zugang (MCP) ───────────────────────────────────────────────────────── // ── API-Zugang (MCP) ─────────────────────────────────────────────────────────
// APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im // APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im
@ -288,17 +404,30 @@ func (s *Server) APIAuth(next http.Handler) http.Handler {
} }
if auth.LooksLikeKey(key) { if auth.LooksLikeKey(key) {
resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key)) hash := auth.HashKey(key)
// Erster Schritt: welcher Workspace? Erst danach lässt sich die
// richtige Datei überhaupt ansprechen (siehe ControlStore in
// internal/store/control.go und AUSBAUSTUFEN.md 4.4).
workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash)
if err == nil {
var ws *workspace.Workspace
ws, err = s.workspaces.Get(workspaceID)
if err == nil {
var resolved store.ResolvedKey
resolved, err = ws.Store.ResolveKeyHash(r.Context(), hash)
if err == nil { if err == nil {
s.limiter.Reset(ip) s.limiter.Reset(ip)
// Nebenbei vermerken, dass der Key benutzt wurde — damit man in // Nebenbei vermerken, dass der Key benutzt wurde — damit man
// der Verwaltung sieht, welche Keys noch aktiv sind. Ein Fehler // in der Verwaltung sieht, welche Keys noch aktiv sind. Ein
// dabei ist unerheblich und darf den Request nicht scheitern lassen. // Fehler dabei ist unerheblich und darf den Request nicht
_ = s.store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt) // scheitern lassen.
_ = ws.Store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt)
sess := Session{ sess := Session{
Subject: auth.SubjectKeyPrefix + resolved.Key.ID, Subject: auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID,
WorkspaceID: workspaceID,
Key: resolved.Key, Key: resolved.Key,
Project: resolved.Project, Project: resolved.Project,
Role: auth.Role(resolved.Key.Role), Role: auth.Role(resolved.Key.Role),
@ -306,10 +435,13 @@ func (s *Server) APIAuth(next http.Handler) http.Handler {
ctx := withSession(r.Context(), sess) ctx := withSession(r.Context(), sess)
ctx = withProject(ctx, resolved.Project) ctx = withProject(ctx, resolved.Project)
ctx = withWorkspace(ctx, ws)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
return return
} }
} }
}
}
if allowed, retryAfter := s.limiter.Allow(ip); !allowed { if allowed, retryAfter := s.limiter.Allow(ip); !allowed {
sekunden := int(retryAfter.Seconds()) + 1 sekunden := int(retryAfter.Seconds()) + 1
@ -320,9 +452,12 @@ func (s *Server) APIAuth(next http.Handler) http.Handler {
} }
s.limiter.RecordFailure(ip) s.limiter.RecordFailure(ip)
s.store.LogAudit(r.Context(), store.AuditEvent{
Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceMCP, IP: ip, // Ein ungültiger Key lässt sich keinem Workspace zuordnen — es gibt
}) // also keine passende Datei für einen Audit-Eintrag. Das Protokoll
// verzeichnet nur Vorgänge innerhalb eines Workspace; ein anonymer
// Fehlversuch gehört ins allgemeine Server-Log.
log.Printf("Ungültiger API-Key von %s", ip)
http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized) http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized)
}) })

View file

@ -9,8 +9,8 @@ import (
"munin/internal/auth" "munin/internal/auth"
"munin/internal/export" "munin/internal/export"
"munin/internal/search"
"munin/internal/store" "munin/internal/store"
"munin/internal/workspace"
) )
// Server bündelt alles, was die Handler brauchen. // Server bündelt alles, was die Handler brauchen.
@ -19,13 +19,12 @@ import (
// worauf sie zugreift, und im Test lässt sich ein Server mit anderen // worauf sie zugreift, und im Test lässt sich ein Server mit anderen
// Bestandteilen zusammensetzen. // Bestandteilen zusammensetzen.
type Server struct { type Server struct {
store *store.Store workspaces *workspace.Manager
search *search.Registry control *store.ControlStore
signer *auth.Signer signer *auth.Signer
cookies auth.CookiePolicy cookies auth.CookiePolicy
limiter *auth.RateLimiter limiter *auth.RateLimiter
templates *template.Template templates *template.Template
workspaceID string
// exporter ist nil, wenn EXPORT_DIR nicht gesetzt ist — dann blendet die // exporter ist nil, wenn EXPORT_DIR nicht gesetzt ist — dann blendet die
// Oberfläche den Knopf aus, statt eine Funktion anzubieten, die nichts tut. // Oberfläche den Knopf aus, statt eine Funktion anzubieten, die nichts tut.
@ -44,13 +43,12 @@ type Server struct {
// Config sammelt die Angaben zum Bauen eines Servers. // Config sammelt die Angaben zum Bauen eines Servers.
type Config struct { type Config struct {
Store *store.Store Workspaces *workspace.Manager
Search *search.Registry Control *store.ControlStore
Signer *auth.Signer Signer *auth.Signer
Cookies auth.CookiePolicy Cookies auth.CookiePolicy
Limiter *auth.RateLimiter Limiter *auth.RateLimiter
Templates fs.FS Templates fs.FS
WorkspaceID string
AdminToken string AdminToken string
Exporter *export.Exporter Exporter *export.Exporter
WriteBudget *auth.WriteBudget WriteBudget *auth.WriteBudget
@ -66,13 +64,12 @@ func NewServer(cfg Config) (*Server, error) {
} }
return &Server{ return &Server{
store: cfg.Store, workspaces: cfg.Workspaces,
search: cfg.Search, control: cfg.Control,
signer: cfg.Signer, signer: cfg.Signer,
cookies: cfg.Cookies, cookies: cfg.Cookies,
limiter: cfg.Limiter, limiter: cfg.Limiter,
templates: tmpl, templates: tmpl,
workspaceID: cfg.WorkspaceID,
exporter: cfg.Exporter, exporter: cfg.Exporter,
writeBudget: cfg.WriteBudget, writeBudget: cfg.WriteBudget,
adminTokenHash: auth.HashKey(cfg.AdminToken), adminTokenHash: auth.HashKey(cfg.AdminToken),
@ -114,30 +111,69 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
mux.Handle("GET /{$}", geschuetzt(s.handleHome)) mux.Handle("GET /{$}", geschuetzt(s.handleHome))
// Plattform-Verwaltung. // Eingebaute Hilfe — für jede Sitzung, auch ohne gewählten Workspace.
mux.Handle("GET /admin/projects", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectList))) // "/hilfe" zeigt die Startseite, "/hilfe/{seite}" eine bestimmte Seite.
mux.Handle("POST /admin/projects/create", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectCreate))) mux.Handle("GET /hilfe", geschuetzt(s.handleHilfe))
mux.Handle("POST /admin/projects/delete", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectDelete))) mux.Handle("GET /hilfe/{seite}", geschuetzt(s.handleHilfe))
// Workspace-Auswahl des Plattform-Admins — erreichbar auch ohne bereits
// gewählten Workspace, siehe handleWorkspaceList.
mux.Handle("GET /admin/workspaces", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceList)))
mux.Handle("POST /admin/workspaces/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceCreate)))
mux.Handle("POST /admin/workspaces/select", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceSelect)))
mux.Handle("POST /admin/workspaces/delete", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceDelete)))
// Wie bei /admin/projects/{slug}/edit: eigener Pfad statt {id} direkt unter
// /admin/workspaces, sonst wäre er mit "create"/"select"/"delete" mehrdeutig.
mux.Handle("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm)))
mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave)))
// Plattform-Verwaltung. requireWorkspace stellt sicher, dass der
// Plattform-Admin einen Workspace gewählt hat, bevor er dessen Projekte
// verwaltet — siehe requireWorkspace in middleware.go.
imWorkspace := func(h http.HandlerFunc) http.Handler {
return s.requireSession(s.requireWorkspace(s.requirePlatformAdminFunc(h)))
}
mux.Handle("GET /admin/projects", imWorkspace(s.handleProjectList))
mux.Handle("POST /admin/projects/create", imWorkspace(s.handleProjectCreate))
mux.Handle("POST /admin/projects/delete", imWorkspace(s.handleProjectDelete))
// Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst // Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst
// wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen // wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen
// Projekt-Bezeichner. // Projekt-Bezeichner.
mux.Handle("GET /admin/projects/{slug}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectEditForm))) mux.Handle("GET /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditForm))
mux.Handle("POST /admin/projects/{slug}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectEditSave))) mux.Handle("POST /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditSave))
mux.Handle("GET /admin/audit", geschuetzt(s.requirePlatformAdminFunc(s.handleGlobalAudit))) mux.Handle("GET /admin/audit", imWorkspace(s.handleGlobalAudit))
mux.Handle("POST /admin/export", geschuetzt(s.requirePlatformAdminFunc(s.handleExport))) mux.Handle("POST /admin/export", imWorkspace(s.handleExport))
// Projektseiten. // Projektseiten.
mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList)) mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList))
mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew)) mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew))
mux.Handle("POST /p/{project}/preview", mitSchreibrecht(s.handleEntryPreview))
mux.Handle("GET /p/{project}/entries/suggest", mitSchreibrecht(s.handleEntrySuggest))
mux.Handle("GET /p/{project}/e/{slug}", imProjekt(s.handleEntryView)) mux.Handle("GET /p/{project}/e/{slug}", imProjekt(s.handleEntryView))
mux.Handle("GET /p/{project}/e/{slug}/versions", imProjekt(s.handleEntryVersions))
mux.Handle("GET /p/{project}/e/{slug}/versions/{version}", imProjekt(s.handleEntryDiff))
mux.Handle("POST /p/{project}/e/{slug}/versions/{version}/restore", mitSchreibrecht(s.handleEntryRestore))
mux.Handle("GET /p/{project}/graph", imProjekt(s.handleEntryGraph))
mux.Handle("GET /p/{project}/graph/data", imProjekt(s.handleEntryGraphData))
mux.Handle("GET /p/{project}/e/{slug}/edit", mitSchreibrecht(s.handleEntryEdit)) mux.Handle("GET /p/{project}/e/{slug}/edit", mitSchreibrecht(s.handleEntryEdit))
mux.Handle("POST /p/{project}/e/{slug}/save", mitSchreibrecht(s.handleEntrySave)) mux.Handle("POST /p/{project}/e/{slug}/save", mitSchreibrecht(s.handleEntrySave))
mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete)) mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete))
mux.Handle("POST /p/{project}/save", mitSchreibrecht(s.handleEntrySave)) mux.Handle("POST /p/{project}/save", mitSchreibrecht(s.handleEntrySave))
mux.Handle("POST /p/{project}/entries/bulk-tags", mitSchreibrecht(s.handleEntryBulkTags))
// Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_papierkorb.go.
mux.Handle("GET /p/{project}/trash", mitSchreibrecht(s.handleTrashList))
mux.Handle("POST /p/{project}/trash/restore", mitSchreibrecht(s.handleTrashRestore))
mux.Handle("POST /p/{project}/trash/purge", mitSchreibrecht(s.handleTrashPurge))
mux.Handle("GET /p/{project}/audit", imProjekt(s.handleProjectAudit)) mux.Handle("GET /p/{project}/audit", imProjekt(s.handleProjectAudit))
mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage))
// Key-Verwaltung. // Key-Verwaltung.
mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList))))) mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList)))))
// Import: dieselbe Rolle wie die Key-Verwaltung, siehe handlers_import.go.
// Die größere Body-Grenze für POST setzt LimitBodies (middleware.go).
mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImportForm)))))
mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImport)))))
mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate))))) mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate)))))
mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke))))) mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke)))))
} }
@ -166,7 +202,7 @@ type pageData struct {
ExportEnabled bool ExportEnabled bool
// Bereich markiert den aktiven Punkt in der Seitenleiste: // Bereich markiert den aktiven Punkt in der Seitenleiste:
// "eintraege", "zugaenge", "protokoll" oder "projekte". // "eintraege", "zugaenge", "protokoll", "projekte" oder "hilfe".
Bereich string Bereich string
// Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit // Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit
@ -232,8 +268,15 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, sta
func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) navData { func (s *Server) buildNav(r *http.Request, sess Session, project store.Project) navData {
var nav navData var nav navData
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl) gibt
// es weder Projekte noch Schlagworte zu zeigen.
ws, ok := WorkspaceFrom(r)
if !ok {
return nav
}
if sess.IsPlatformAdmin { if sess.IsPlatformAdmin {
projekte, err := s.store.ListProjects(r.Context(), s.workspaceID) projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
if err != nil { if err != nil {
log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err) log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err)
} else { } else {
@ -242,7 +285,7 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project)
} }
if project.ID != "" { if project.ID != "" {
tags, err := s.store.ListTags(r.Context(), project.ID) tags, err := ws.Store.ListTags(r.Context(), project.ID)
if err != nil { if err != nil {
log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err) log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err)
} else { } else {
@ -253,6 +296,16 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project)
return nav return nav
} }
// workspaceAndSession liest Workspace und Sitzung der aktuellen Anfrage.
//
// Nur für Handler gedacht, die über requireWorkspace oder requireProject
// erreicht werden — dort ist immer beides gesetzt (siehe middleware.go).
func (s *Server) workspaceAndSession(r *http.Request) (*workspace.Workspace, Session) {
ws, _ := WorkspaceFrom(r)
sess, _ := SessionFrom(r)
return ws, sess
}
// serverError protokolliert einen unerwarteten Fehler und meldet dem Aufrufer // serverError protokolliert einen unerwarteten Fehler und meldet dem Aufrufer
// eine allgemeine Störung. // eine allgemeine Störung.
// //

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,105 @@
// Package workspace verwaltet die offenen Workspace-Dateien eines laufenden
// Prozesses.
//
// Jede Abteilung hat ihre eigene SQLite-Datei (siehe AUSBAUSTUFEN.md 4.4). Ein
// Manager öffnet sie bei Bedarf und hält sie offen, statt bei jedem Request
// neu zu öffnen — das Öffnen (inklusive Migrationsprüfung) kostet spürbar mehr
// als ein Map-Zugriff.
package workspace
import (
"fmt"
"os"
"path/filepath"
"sync"
"munin/internal/search"
"munin/internal/store"
)
// Workspace bündelt, was zu einer offenen Workspace-Datei gehört: die
// Datenbank und ihren Suchindex. Beide sind an dieselbe Datei gebunden und
// werden deshalb gemeinsam verwaltet — ein Registry, der aus Versehen an der
// falschen Datenbank hängt, wäre ein stiller Fehler.
type Workspace struct {
ID string
Store *store.Store
Search *search.Registry
}
// Manager öffnet Workspace-Dateien bei Bedarf und hält sie offen.
//
// Bei 10–40 Workspaces (siehe AUSBAUSTUFEN.md 4.4) ist das unproblematisch —
// Verdrängung selten gebrauchter Dateien würde erst bei einer deutlich
// höheren Zahl nötig, siehe AUSBAUSTUFEN.md 1.2.
type Manager struct {
dir string // Ordner mit einer Datei je Workspace, z.B. "<DATA_DIR>/workspaces"
mu sync.Mutex
open map[string]*Workspace
}
// NewManager legt einen Manager an. dir muss bereits existieren — das
// übernimmt main.go beim Start.
func NewManager(dir string) *Manager {
return &Manager{dir: dir, open: make(map[string]*Workspace)}
}
// Get liefert den Workspace zu einer ID und öffnet ihn beim ersten Zugriff.
func (m *Manager) Get(workspaceID string) (*Workspace, error) {
m.mu.Lock()
defer m.mu.Unlock()
if w, ok := m.open[workspaceID]; ok {
return w, nil
}
s, err := store.Open(filepath.Join(m.dir, workspaceID+".db"))
if err != nil {
return nil, fmt.Errorf("Workspace %s konnte nicht geöffnet werden: %w", workspaceID, err)
}
w := &Workspace{ID: workspaceID, Store: s, Search: search.NewRegistry(s)}
m.open[workspaceID] = w
return w, nil
}
// Delete schließt die Workspace-Datei, falls offen, und entfernt sie
// unwiederbringlich von der Platte — samt WAL/SHM-Nebendateien, die SQLite im
// laufenden Betrieb danebenlegt.
//
// Ruft nur die Datei ab — die Kontroll-Datenbank (workspaces, key_index) muss
// getrennt bereinigt werden, siehe ControlStore.DeleteWorkspace. Reihenfolge
// ist wichtig: Erst dort löschen, dann hier — sonst verweist die
// Kontroll-Datenbank kurzzeitig auf eine Datei, die es nicht mehr gibt.
func (m *Manager) Delete(workspaceID string) error {
m.mu.Lock()
if w, ok := m.open[workspaceID]; ok {
w.Store.Close()
delete(m.open, workspaceID)
}
m.mu.Unlock()
pfad := filepath.Join(m.dir, workspaceID+".db")
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
}
// Close schließt alle offenen Workspace-Dateien.
func (m *Manager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
var erster error
for id, w := range m.open {
if err := w.Store.Close(); err != nil && erster == nil {
erster = err
}
delete(m.open, id)
}
return erster
}

View file

@ -0,0 +1,82 @@
package workspace
import (
"context"
"testing"
"munin/internal/store"
)
func TestManagerGetCachesAndOpens(t *testing.T) {
m := NewManager(t.TempDir())
t.Cleanup(func() { m.Close() })
id := store.NewID()
w1, err := m.Get(id)
if err != nil {
t.Fatal(err)
}
if w1.ID != id {
t.Errorf("ID = %q, erwartet %q", w1.ID, id)
}
// Ein zweiter Zugriff auf dieselbe ID muss dieselbe, bereits offene
// Instanz liefern — sonst würde jede Anfrage die Datei neu öffnen und
// den Suchindex neu aufbauen.
w2, err := m.Get(id)
if err != nil {
t.Fatal(err)
}
if w1 != w2 {
t.Error("zweiter Get(id) hat eine neue Instanz geöffnet statt die gecachte zu liefern")
}
// Grundfunktion: Der gelieferte Store lässt sich tatsächlich benutzen.
if _, err := w1.Store.CreateProject(context.Background(), id, "test", "Test", ""); err != nil {
t.Fatal(err)
}
}
func TestManagerGetOpensSeparateFilesPerWorkspace(t *testing.T) {
m := NewManager(t.TempDir())
t.Cleanup(func() { m.Close() })
a, err := m.Get(store.NewID())
if err != nil {
t.Fatal(err)
}
b, err := m.Get(store.NewID())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := a.Store.CreateProject(ctx, a.ID, "in-a", "In A", ""); err != nil {
t.Fatal(err)
}
// Strukturelle Trennung: Ein Projekt aus Workspace A darf in Workspace B
// nicht auffindbar sein — sie sind unterschiedliche Dateien.
if _, err := b.Store.ProjectBySlug(ctx, b.ID, "in-a"); err == nil {
t.Error("Projekt aus Workspace A war über Workspace B erreichbar")
}
}
func TestManagerCloseClosesAllOpened(t *testing.T) {
m := NewManager(t.TempDir())
w, err := m.Get(store.NewID())
if err != nil {
t.Fatal(err)
}
if err := m.Close(); err != nil {
t.Fatal(err)
}
// Nach dem Schließen darf die Datenbank nicht mehr benutzbar sein.
if _, err := w.Store.CreateProject(context.Background(), w.ID, "zu-spaet", "Zu spät", ""); err == nil {
t.Error("Store nahm nach Manager.Close() noch Schreibvorgänge an")
}
}

209
main.go
View file

@ -14,7 +14,9 @@ package main
import ( import (
"context" "context"
"embed" "embed"
"errors"
"flag" "flag"
"fmt"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
@ -30,9 +32,9 @@ import (
"munin/internal/auth" "munin/internal/auth"
"munin/internal/export" "munin/internal/export"
"munin/internal/mcpserver" "munin/internal/mcpserver"
"munin/internal/search"
"munin/internal/store" "munin/internal/store"
"munin/internal/web" "munin/internal/web"
"munin/internal/workspace"
) )
// //go:embed bettet Dateien beim Kompilieren ins Binary ein. Nach dem Bauen sind // //go:embed bettet Dateien beim Kompilieren ins Binary ein. Nach dem Bauen sind
@ -56,6 +58,12 @@ const (
// maxAPIBodyBytes gilt für MCP-Aufrufe. // maxAPIBodyBytes gilt für MCP-Aufrufe.
defaultMaxAPIBodyBytes = 4 << 20 // 4 MB defaultMaxAPIBodyBytes = 4 << 20 // 4 MB
// maxImportBodyBytes gilt für das Hochladen eines ZIP-Archivs zum Import
// (AUSBAUSTUFEN.md 3.2). Ein exportiertes Projekt mit einigen tausend
// Einträgen passt gepackt bequem hinein. Was ausgepackt daraus werden darf,
// begrenzt der Importer selbst (siehe internal/importer).
maxImportBodyBytes = 20 << 20 // 20 MB
) )
// ── Server-Timeouts ────────────────────────────────────────────────────────── // ── Server-Timeouts ──────────────────────────────────────────────────────────
@ -120,38 +128,54 @@ func main() {
cfg := readConfig() cfg := readConfig()
// ── Datenbank ──────────────────────────────────────────────────────────── // ── Datenbank ────────────────────────────────────────────────────────────
if err := os.MkdirAll(filepath.Dir(cfg.dbPath), 0750); err != nil { //
// Aufbau: eine zentrale Kontroll-Datenbank (Workspaces, Key-Routing) und je
// Workspace eine eigene Datei mit Projekten, Einträgen und Keys. Eine
// Abteilung kann so strukturell nie auf die Datei einer anderen zugreifen —
// siehe AUSBAUSTUFEN.md 4.4.
workspacesDir := filepath.Join(cfg.dataDir, "workspaces")
if err := os.MkdirAll(workspacesDir, 0750); err != nil {
log.Fatal("Datenverzeichnis konnte nicht angelegt werden: ", err) log.Fatal("Datenverzeichnis konnte nicht angelegt werden: ", err)
} }
db, err := store.Open(cfg.dbPath) control, err := store.OpenControl(filepath.Join(cfg.dataDir, "control.db"))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
defer db.Close() defer control.Close()
// workspaces öffnet und cached die Workspace-Dateien bei Bedarf — jede
// Abteilung bekommt so ihre eigene Datenbank samt Suchindex, siehe
// internal/workspace.
workspaces := workspace.NewManager(workspacesDir)
defer workspaces.Close()
ctx := context.Background() ctx := context.Background()
workspace, err := db.EnsureWorkspace(ctx, "Intern") // Beim allerersten Start gibt es noch keinen Workspace — dann wird einer
if err != nil { // angelegt, damit der Server ohne Zwischenschritt benutzbar ist.
if _, err := control.EnsureDefaultWorkspace(ctx, "Intern"); err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Der Export ist abgeschaltet, solange EXPORT_DIR nicht gesetzt ist. // Der Export ist abgeschaltet, solange EXPORT_DIR nicht gesetzt ist.
var exporter *export.Exporter var exporter *export.Exporter
if cfg.exportDir != "" { if cfg.exportDir != "" {
exporter = export.New(db, cfg.exportDir) exporter = export.New(cfg.exportDir)
} }
if *nurExport { if *nurExport {
if exporter == nil { if exporter == nil {
log.Fatal("EXPORT_DIR ist nicht gesetzt — ohne Zielverzeichnis gibt es nichts zu exportieren.") log.Fatal("EXPORT_DIR ist nicht gesetzt — ohne Zielverzeichnis gibt es nichts zu exportieren.")
} }
stats, err := exporter.ExportAll(ctx, workspace.ID) stats, err := exportiereAlle(ctx, exporter, control, workspaces)
if err != nil {
log.Fatal("Export fehlgeschlagen: ", err)
}
log.Printf("Export abgeschlossen: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed) log.Printf("Export abgeschlossen: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
if err != nil {
// Die übrigen Workspaces sind trotzdem exportiert (siehe
// fuerJedenWorkspace). Der Exit-Code muss den Fehler aber melden,
// sonst hält ein Cronjob die Sicherung für vollständig.
log.Fatal("Export unvollständig: ", err)
}
return return
} }
@ -160,16 +184,14 @@ func main() {
cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE")) cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE"))
limiter := auth.NewRateLimiter() limiter := auth.NewRateLimiter()
writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow) writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow)
searchRegistry := search.NewRegistry(db)
webServer, err := web.NewServer(web.Config{ webServer, err := web.NewServer(web.Config{
Store: db, Workspaces: workspaces,
Search: searchRegistry, Control: control,
Signer: signer, Signer: signer,
Cookies: cookies, Cookies: cookies,
Limiter: limiter, Limiter: limiter,
Templates: templatesFS, Templates: templatesFS,
WorkspaceID: workspace.ID,
AdminToken: cfg.adminToken, AdminToken: cfg.adminToken,
Exporter: exporter, Exporter: exporter,
WriteBudget: writeBudget, WriteBudget: writeBudget,
@ -178,7 +200,7 @@ func main() {
log.Fatal("Templates konnten nicht geladen werden: ", err) log.Fatal("Templates konnten nicht geladen werden: ", err)
} }
mcpHandler := mcpserver.New(db, searchRegistry, writeBudget) mcpHandler := mcpserver.New(writeBudget)
// ── Routen ─────────────────────────────────────────────────────────────── // ── Routen ───────────────────────────────────────────────────────────────
staticSub, err := fs.Sub(staticFS, "static") staticSub, err := fs.Sub(staticFS, "static")
@ -191,23 +213,33 @@ func main() {
// Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier // Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier
// kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key. // kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key.
mux.Handle("/mcp", web.LimitBody(webServer.APIAuth(mcpHandler), cfg.maxAPIBody)) mux.Handle("/mcp", webServer.APIAuth(mcpHandler))
// Reihenfolge von außen nach innen: // Reihenfolge von außen nach innen:
// LogRequests erfasst jede Antwort, auch abgewiesene // LogRequests erfasst jede Antwort, auch abgewiesene
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten // SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
// LimitBody deckelt den Body, bevor ein Handler ihn liest // LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit
// einer eigenen Grenze für /mcp und den Import, siehe
// dort, warum das nicht je Route geschehen kann
handler := web.LogRequests( handler := web.LogRequests(
web.SecurityHeaders( web.SecurityHeaders(
web.LimitBody(mux, maxWebBodyBytes), web.LimitBodies(mux, web.BodyLimits{
Web: maxWebBodyBytes,
API: cfg.maxAPIBody,
Import: maxImportBodyBytes,
}),
cfg.enableHSTS, cfg.enableHSTS,
), ),
) )
// ── Wiederkehrende Aufgaben ────────────────────────────────────────────── // ── Wiederkehrende Aufgaben ──────────────────────────────────────────────
go pruneAuditRegularly(db) //
// Beide laufen bei jedem Durchgang über ALLE Workspaces — auch über die,
// die erst nach dem Start angelegt wurden. Deshalb wird die Liste jedes
// Mal frisch aus der Kontroll-Datenbank geholt (siehe fuerJedenWorkspace).
go aufraeumenRegelmaessig(control, workspaces)
if exporter != nil { if exporter != nil {
go exportRegularly(exporter, workspace.ID) go exportRegularly(exporter, control, workspaces)
log.Printf("Markdown-Export aktiv: %s (täglich)", cfg.exportDir) log.Printf("Markdown-Export aktiv: %s (täglich)", cfg.exportDir)
} }
@ -229,9 +261,9 @@ func main() {
defer stop() defer stop()
go func() { go func() {
projekte, _ := db.ListProjects(ctx, workspace.ID) alle, _ := control.ListWorkspaces(ctx)
log.Printf("Munin läuft auf http://localhost:%s (%d Projekte)", cfg.port, len(projekte)) log.Printf("Munin läuft auf http://localhost:%s (%d Workspaces)", cfg.port, len(alle))
log.Printf("Datenbank: %s", cfg.dbPath) log.Printf("Workspaces: %s (Kontrolle: %s)", workspacesDir, filepath.Join(cfg.dataDir, "control.db"))
if !cfg.enableHSTS { if !cfg.enableHSTS {
log.Println("Hinweis: ENABLE_HSTS ist aus — bei Betrieb unter HTTPS auf true setzen.") log.Println("Hinweis: ENABLE_HSTS ist aus — bei Betrieb unter HTTPS auf true setzen.")
} }
@ -261,7 +293,9 @@ func main() {
// config sammelt alle Einstellungen aus der Umgebung. // config sammelt alle Einstellungen aus der Umgebung.
type config struct { type config struct {
dbPath string // dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner
// workspaces/ mit einer Datei je Workspace — siehe AUSBAUSTUFEN.md 4.4.
dataDir string
port string port string
adminToken string adminToken string
sessionSecret string sessionSecret string
@ -281,7 +315,7 @@ type config struct {
// jedes Projekt. Ein erratbarer Wert wäre eine Hintertür. // jedes Projekt. Ein erratbarer Wert wäre eine Hintertür.
func readConfig() config { func readConfig() config {
cfg := config{ cfg := config{
dbPath: envOr("DB_PATH", "data/munin.db"), dataDir: envOr("DATA_DIR", "data"),
port: envOr("PORT", "8080"), port: envOr("PORT", "8080"),
adminToken: os.Getenv("ADMIN_TOKEN"), adminToken: os.Getenv("ADMIN_TOKEN"),
sessionSecret: os.Getenv("SESSION_SECRET"), sessionSecret: os.Getenv("SESSION_SECRET"),
@ -317,16 +351,60 @@ func readConfig() config {
return cfg return cfg
} }
// fuerJedenWorkspace ruft fn für jeden Workspace mit dessen geöffneter Datei
// auf.
//
// Die Liste kommt bei jedem Aufruf frisch aus der Kontroll-Datenbank. Ein
// Workspace, der gestern über die Oberfläche angelegt wurde, ist damit beim
// nächsten Durchgang dabei, ohne dass der Server neu starten muss.
//
// Scheitert fn für einen Workspace, geht es mit den übrigen weiter: Ein
// einzelner kaputter Workspace soll nicht verhindern, dass bei allen anderen
// das Protokoll aufgeräumt oder der Export geschrieben wird. Die Fehler werden
// gesammelt und zusammen zurückgegeben (errors.Join).
func fuerJedenWorkspace(ctx context.Context, control *store.ControlStore, workspaces *workspace.Manager,
fn func(store.Workspace, *workspace.Workspace) error) error {
alle, err := control.ListWorkspaces(ctx)
if err != nil {
return fmt.Errorf("Workspaces konnten nicht gelesen werden: %w", err)
}
var fehler []error
for _, w := range alle {
offen, err := workspaces.Get(w.ID)
if err == nil {
err = fn(w, offen)
}
if err != nil {
fehler = append(fehler, fmt.Errorf("Workspace %q (%s): %w", w.Name, w.ID, err))
}
}
return errors.Join(fehler...)
}
// exportiereAlle exportiert jeden Workspace in seinen eigenen Unterordner und
// zählt die Ergebnisse zusammen.
func exportiereAlle(ctx context.Context, e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) (export.Stats, error) {
var gesamt export.Stats
err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error {
stats, err := e.ExportWorkspace(ctx, offen.Store, w)
gesamt.Written += stats.Written
gesamt.Removed += stats.Removed
return err
})
return gesamt, err
}
// exportRegularly schreibt den Bestand regelmäßig als Markdown-Dateien. // exportRegularly schreibt den Bestand regelmäßig als Markdown-Dateien.
// //
// Einmal direkt beim Start, damit auch ein Server, der nie einen ganzen Tag // Einmal direkt beim Start, damit auch ein Server, der nie einen ganzen Tag
// durchläuft, einen aktuellen Stand hinterlässt. // durchläuft, einen aktuellen Stand hinterlässt.
func exportRegularly(e *export.Exporter, workspaceID string) { func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) {
lauf := func() { lauf := func() {
stats, err := e.ExportAll(context.Background(), workspaceID) stats, err := exportiereAlle(context.Background(), e, control, workspaces)
if err != nil { if err != nil {
log.Printf("Export fehlgeschlagen: %v", err) log.Printf("Export fehlgeschlagen: %v", err)
return
} }
if stats.Removed > 0 { if stats.Removed > 0 {
log.Printf("Export: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed) log.Printf("Export: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
@ -342,29 +420,84 @@ func exportRegularly(e *export.Exporter, workspaceID string) {
} }
} }
// pruneAuditRegularly räumt das Protokoll täglich auf. // aufraeumenRegelmaessig räumt täglich in allen Workspaces auf: das Protokoll
func pruneAuditRegularly(db *store.Store) { // (nach 90 Tagen) und den Papierkorb (nach store.TrashRetention).
//
// Jeder Workspace führt Protokoll und Papierkorb in seiner eigenen Datei. Die
// Fristen gelten also nur dann, wenn wirklich jede Datei aufgeräumt wird — ein
// vergessener Workspace hieße: Adressen, Zugangsnamen und gelöschte Einträge
// bleiben dort unbegrenzt liegen.
func aufraeumenRegelmaessig(control *store.ControlStore, workspaces *workspace.Manager) {
// Einmal direkt beim Start, damit auch ein Server aufräumt, der nie 24 // Einmal direkt beim Start, damit auch ein Server aufräumt, der nie 24
// Stunden am Stück läuft. // Stunden am Stück läuft.
prune(db) pruneAlle(control, workspaces)
papierkorbLeerenAlle(control, workspaces)
ticker := time.NewTicker(24 * time.Hour) ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
prune(db) pruneAlle(control, workspaces)
papierkorbLeerenAlle(control, workspaces)
} }
} }
func prune(db *store.Store) { // papierkorbLeerenAlle entfernt in jedem Workspace die Einträge endgültig, die
// länger als die Frist im Papierkorb liegen (AUSBAUSTUFEN.md 3.9).
//
// Jeder entfernte Eintrag bekommt eine eigene Protokollzeile mit Herkunft
// "system". So lässt sich später auch für einen nach Ablauf verschwundenen
// Eintrag nachvollziehen, wann und warum er weg ist.
func papierkorbLeerenAlle(control *store.ControlStore, workspaces *workspace.Manager) {
ctx := context.Background()
grenze := time.Now().UTC().Add(-store.TrashRetention).Format(time.RFC3339Nano)
err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error {
entfernt, err := offen.Store.PurgeTrash(ctx, grenze)
if err != nil {
return err
}
// entfernt ordnet jeder Projekt-ID die Liste ihrer entfernten
// Bezeichner zu. "range" über eine map liefert Schlüssel und Wert.
anzahl := 0
for projektID, slugs := range entfernt {
for _, slug := range slugs {
offen.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: projektID, Actor: "system",
Action: store.ActionEntryPurge, EntrySlug: slug, Source: store.SourceSystem,
Detail: fmt.Sprintf("nach %d Tagen im Papierkorb", store.TrashRetentionDays),
})
anzahl++
}
}
if anzahl > 0 {
log.Printf("Papierkorb von %q aufgeräumt: %d Einträge endgültig entfernt", w.Name, anzahl)
}
return nil
})
if err != nil {
log.Printf("Papierkorb konnte nicht vollständig aufgeräumt werden: %v", err)
}
}
// pruneAlle räumt einmal das Protokoll jedes Workspace auf.
func pruneAlle(control *store.ControlStore, workspaces *workspace.Manager) {
ctx := context.Background()
grenze := time.Now().UTC().Add(-auditRetention).Format(time.RFC3339Nano) grenze := time.Now().UTC().Add(-auditRetention).Format(time.RFC3339Nano)
n, err := db.PruneAudit(context.Background(), grenze) err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error {
n, err := offen.Store.PruneAudit(ctx, grenze)
if err != nil { if err != nil {
log.Printf("Protokoll konnte nicht aufgeräumt werden: %v", err) return err
return
} }
if n > 0 { if n > 0 {
log.Printf("Protokoll aufgeräumt: %d Einträge älter als %d Tage entfernt", n, int(auditRetention.Hours()/24)) log.Printf("Protokoll von %q aufgeräumt: %d Einträge älter als %d Tage entfernt",
w.Name, n, int(auditRetention.Hours()/24))
}
return nil
})
if err != nil {
log.Printf("Protokoll konnte nicht vollständig aufgeräumt werden: %v", err)
} }
} }

123
main_test.go Normal file
View file

@ -0,0 +1,123 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"munin/internal/export"
"munin/internal/store"
"munin/internal/workspace"
)
// aufbauZweiWorkspaces legt eine Kontroll-Datenbank mit zwei Workspaces an,
// jeder mit einem Projekt "plattform" und einem Eintrag.
func aufbauZweiWorkspaces(t *testing.T) (*store.ControlStore, *workspace.Manager, []store.Workspace) {
t.Helper()
ctx := context.Background()
dir := t.TempDir()
control, err := store.OpenControl(filepath.Join(dir, "control.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { control.Close() })
workspaces := workspace.NewManager(dir)
t.Cleanup(func() { workspaces.Close() })
var alle []store.Workspace
for _, name := range []string{"Intern", "Vertrieb"} {
w, err := control.CreateWorkspace(ctx, name)
if err != nil {
t.Fatal(err)
}
offen, err := workspaces.Get(w.ID)
if err != nil {
t.Fatal(err)
}
projekt, err := offen.Store.CreateProject(ctx, w.ID, "plattform", "Plattform", "")
if err != nil {
t.Fatal(err)
}
if _, err := offen.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "wissen", Title: "Wissen",
Content: "aus " + name, Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
alle = append(alle, w)
}
return control, workspaces, alle
}
// TestFuerJedenWorkspaceErreichtAlle: Die wiederkehrenden Aufgaben liefen
// früher nur für den ältesten Workspace. Jetzt muss jeder drankommen.
func TestFuerJedenWorkspaceErreichtAlle(t *testing.T) {
control, workspaces, alle := aufbauZweiWorkspaces(t)
gesehen := map[string]bool{}
err := fuerJedenWorkspace(context.Background(), control, workspaces,
func(w store.Workspace, _ *workspace.Workspace) error {
gesehen[w.ID] = true
return nil
})
if err != nil {
t.Fatal(err)
}
for _, w := range alle {
if !gesehen[w.ID] {
t.Errorf("Workspace %q wurde übersprungen", w.Name)
}
}
}
// TestFuerJedenWorkspaceMachtNachFehlerWeiter: Ein kaputter Workspace darf die
// übrigen nicht aufhalten, der Fehler muss aber beim Aufrufer ankommen.
func TestFuerJedenWorkspaceMachtNachFehlerWeiter(t *testing.T) {
control, workspaces, alle := aufbauZweiWorkspaces(t)
kaputt := errors.New("kaputt")
var aufrufe int
err := fuerJedenWorkspace(context.Background(), control, workspaces,
func(w store.Workspace, _ *workspace.Workspace) error {
aufrufe++
if w.ID == alle[0].ID {
return kaputt
}
return nil
})
if aufrufe != len(alle) {
t.Errorf("%d Aufrufe, erwartet %d — nach dem Fehler wurde abgebrochen", aufrufe, len(alle))
}
if !errors.Is(err, kaputt) {
t.Errorf("Fehler ging verloren: %v", err)
}
}
// TestExportiereAlleSchreibtJedenWorkspace prüft den Weg von -export und dem
// täglichen Lauf: jeder Workspace in seinem eigenen Ordner, obwohl beide ein
// Projekt "plattform" haben.
func TestExportiereAlleSchreibtJedenWorkspace(t *testing.T) {
control, workspaces, alle := aufbauZweiWorkspaces(t)
ziel := t.TempDir()
if _, err := exportiereAlle(context.Background(), export.New(ziel), control, workspaces); err != nil {
t.Fatal(err)
}
for _, w := range alle {
inhalt, err := os.ReadFile(filepath.Join(ziel, w.ID, "plattform", "wissen.md"))
if err != nil {
t.Errorf("Workspace %q fehlt im Export: %v", w.Name, err)
continue
}
if want := "aus " + w.Name; !strings.Contains(string(inhalt), want) {
t.Errorf("Workspace %q: %q fehlt im Inhalt", w.Name, want)
}
}
}

79
scripts/backup.sh Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Sichert die zentrale Kontroll-Datenbank und jede Workspace-Datei einzeln.
#
# sqlite3 .backup ist dafür gebaut, auch während der Server im WAL-Modus
# schreibt (siehe AUSBAUSTUFEN.md 4.4 und README.md "Datensicherung") — eine
# einfache Dateikopie wäre hier riskant.
#
# Jede Sicherung landet in einem eigenen, zeitgestempelten Ordner. Da jeder
# Workspace seine eigene Datei ist, lässt sich später auch nur eine einzelne
# Abteilung gezielt wiederherstellen, ohne die übrigen anzufassen.
#
# ── Aufruf ───────────────────────────────────────────────────────────────────
#
# scripts/backup.sh [Datenverzeichnis] [Sicherungsverzeichnis]
#
# Beide Pfade lassen sich auch über die Umgebungsvariablen DATA_DIR und
# BACKUP_DIR setzen (Vorgaben: "data" und "backups") — praktisch für einen
# Cronjob, der ohnehin feste Pfade mitgibt.
#
# ── Beispiel für Cron ────────────────────────────────────────────────────────
#
# Täglich um 3 Uhr, mit 14 Tagen Aufbewahrung:
#
# 0 3 * * * DATA_DIR=/srv/munin/data BACKUP_DIR=/srv/backups RETENTION_DAYS=14 \
# /srv/munin/scripts/backup.sh >> /var/log/munin-backup.log 2>&1
#
# Ohne RETENTION_DAYS bleibt jede Sicherung liegen — lieber zu viel als
# versehentlich eine noch gebrauchte Sicherung verlieren.
#
# ── Wiederherstellen ─────────────────────────────────────────────────────────
#
# Server stoppen, die gewünschte .db-Datei aus einem Sicherungsordner an ihren
# Platz unter DATA_DIR zurückkopieren, Server wieder starten. Für einen
# einzelnen Workspace reicht dessen Datei unter workspaces/ — die
# Kontroll-Datenbank bleibt davon unberührt.
set -euo pipefail
datenverzeichnis="${1:-${DATA_DIR:-data}}"
sicherungsverzeichnis="${2:-${BACKUP_DIR:-backups}}"
stempel="$(date +%Y-%m-%dT%H-%M-%S)"
ziel="$sicherungsverzeichnis/$stempel"
if ! command -v sqlite3 >/dev/null 2>&1; then
echo "sqlite3 ist nicht installiert — ohne dieses Werkzeug keine sichere Sicherung im laufenden Betrieb." >&2
exit 1
fi
if [ ! -d "$datenverzeichnis" ]; then
echo "Datenverzeichnis $datenverzeichnis existiert nicht." >&2
exit 1
fi
mkdir -p "$ziel/workspaces"
sichern() {
sqlite3 "$1" ".backup '$2'"
}
if [ -f "$datenverzeichnis/control.db" ]; then
sichern "$datenverzeichnis/control.db" "$ziel/control.db"
echo "control.db gesichert"
else
echo "Warnung: $datenverzeichnis/control.db nicht gefunden — übersprungen." >&2
fi
anzahl=0
for datei in "$datenverzeichnis"/workspaces/*.db; do
[ -e "$datei" ] || continue # kein Workspace vorhanden (glob lief leer)
sichern "$datei" "$ziel/workspaces/$(basename "$datei")"
anzahl=$((anzahl + 1))
done
echo "$anzahl Workspace-Datei(en) gesichert nach $ziel"
# Alte Sicherungen aufräumen, falls eine Aufbewahrungsfrist gesetzt ist.
if [ -n "${RETENTION_DAYS:-}" ]; then
find "$sicherungsverzeichnis" -mindepth 1 -maxdepth 1 -type d -mtime "+$RETENTION_DAYS" -print -exec rm -rf {} \;
fi

198
static/autocomplete.js Normal file
View file

@ -0,0 +1,198 @@
// Schlägt beim Tippen von "[[" vorhandene Bezeichner vor.
//
// Bewusst kein an der Schreibmarke ausgerichtetes Einblendfenster — das
// bräuchte einen Spiegel-Trick, um die Pixel-Position im Textfeld
// nachzubauen (eine unsichtbare Kopie des Feldes, die dieselbe Schrift und
// denselben Umbruch trägt, nur um die Koordinate eines Zeichens abzulesen).
// Die Vorschläge stehen stattdessen als Liste direkt unter dem Feld, im
// selben Geist wie die Sammelleiste unter der Eintragsübersicht.
(function () {
"use strict";
var AKTIV = "aktiv";
var timer = null;
document.addEventListener("input", function (event) {
if (!istEditorfeld(event.target)) {
return;
}
clearTimeout(timer);
timer = setTimeout(function () {
aktualisiereVorschlaege(event.target);
}, 150);
});
document.addEventListener("keydown", function (event) {
if (!istEditorfeld(event.target)) {
return;
}
var liste = listeFuer(event.target);
if (!liste || liste.hidden) {
return;
}
switch (event.key) {
case "Escape":
liste.hidden = true;
break;
case "ArrowDown":
event.preventDefault();
bewegeAuswahl(liste, 1);
break;
case "ArrowUp":
event.preventDefault();
bewegeAuswahl(liste, -1);
break;
case "Enter":
case "Tab": {
var gewaehlt = liste.querySelector("li." + AKTIV) || liste.firstElementChild;
if (gewaehlt) {
event.preventDefault();
uebernehmen(event.target, liste, gewaehlt);
}
break;
}
}
});
// mousedown statt click, und mit preventDefault: Sonst verlässt die
// Textarea den Fokus, BEVOR der Klick verarbeitet wird, und die Liste
// wäre durch den Blur-Handler unten schon verschwunden.
document.addEventListener("mousedown", function (event) {
var eintrag = event.target.closest("[data-autovervollstaendigung] li");
if (!eintrag) {
return;
}
event.preventDefault();
var liste = eintrag.closest("[data-autovervollstaendigung]");
var textarea = textareaFuer(liste);
if (textarea) {
uebernehmen(textarea, liste, eintrag);
}
});
// Blur bubbelt nicht — nur über die Capture-Phase zu fangen.
document.addEventListener(
"blur",
function (event) {
if (!istEditorfeld(event.target)) {
return;
}
var liste = listeFuer(event.target);
if (liste) {
liste.hidden = true;
}
},
true,
);
function istEditorfeld(el) {
return !!(el && el.tagName === "TEXTAREA" && el.hasAttribute("data-suggest-url"));
}
function listeFuer(textarea) {
return textarea.parentElement.querySelector("[data-autovervollstaendigung]");
}
function textareaFuer(liste) {
return liste.parentElement.querySelector("textarea[data-suggest-url]");
}
// Sucht rückwärts von der Schreibmarke aus das letzte "[[", das noch
// nicht mit "]]" geschlossen wurde. Kein Treffer heißt: die Schreibmarke
// steht gerade nicht in einem offenen Verweis.
function offeneKlammer(wert, position) {
var start = wert.lastIndexOf("[[", position - 1);
if (start === -1) {
return null;
}
var dazwischen = wert.slice(start + 2, position);
if (dazwischen.indexOf("]]") !== -1 || dazwischen.indexOf("\n") !== -1 || dazwischen.length > 60) {
return null;
}
return { start: start, suche: dazwischen };
}
function aktualisiereVorschlaege(textarea) {
var liste = listeFuer(textarea);
if (!liste) {
return;
}
var klammer = offeneKlammer(textarea.value, textarea.selectionStart);
if (!klammer) {
liste.hidden = true;
return;
}
var url = textarea.getAttribute("data-suggest-url") + "?q=" + encodeURIComponent(klammer.suche);
fetch(url, { headers: { Accept: "application/json" } })
.then(function (antwort) {
return antwort.ok ? antwort.json() : [];
})
.then(function (treffer) {
// Zwischenzeitlich könnte weitergetippt oder das Feld
// verlassen worden sein — dann passt die Antwort nicht mehr
// zur aktuellen Schreibmarke.
var jetzt = offeneKlammer(textarea.value, textarea.selectionStart);
if (!jetzt || jetzt.start !== klammer.start) {
return;
}
zeigeVorschlaege(liste, treffer, klammer.start);
})
.catch(function () {
liste.hidden = true;
});
}
function zeigeVorschlaege(liste, treffer, klammerStart) {
liste.innerHTML = "";
if (!treffer.length) {
liste.hidden = true;
return;
}
treffer.forEach(function (t, i) {
var li = document.createElement("li");
li.className = i === 0 ? AKTIV : "";
li.dataset.slug = t.slug;
li.dataset.klammerStart = String(klammerStart);
var slug = document.createElement("code");
slug.textContent = t.slug;
var titel = document.createElement("span");
titel.textContent = t.title;
li.appendChild(slug);
li.appendChild(titel);
liste.appendChild(li);
});
liste.hidden = false;
}
function bewegeAuswahl(liste, richtung) {
var eintraege = Array.prototype.slice.call(liste.children);
if (!eintraege.length) {
return;
}
var index = eintraege.findIndex(function (e) {
return e.classList.contains(AKTIV);
});
eintraege.forEach(function (e) {
e.classList.remove(AKTIV);
});
var naechster = (index + richtung + eintraege.length) % eintraege.length;
eintraege[naechster].classList.add(AKTIV);
}
// Ersetzt den Text zwischen der offenen Klammer und der Schreibmarke
// durch "bezeichner]]" und setzt die Schreibmarke direkt dahinter.
function uebernehmen(textarea, liste, eintrag) {
var start = parseInt(eintrag.dataset.klammerStart, 10);
var cursor = textarea.selectionStart;
textarea.setRangeText(eintrag.dataset.slug + "]]", start + 2, cursor, "end");
liste.hidden = true;
textarea.focus();
}
})();

View file

@ -18,7 +18,13 @@ document.addEventListener("submit", function (event) {
// closest() sucht vom Element aufwärts nach dem passenden Vorfahren — // closest() sucht vom Element aufwärts nach dem passenden Vorfahren —
// hier reicht das Formular selbst, aber so bleibt es robust. // hier reicht das Formular selbst, aber so bleibt es robust.
var message = form.getAttribute && form.getAttribute("data-confirm"); // event.submitter ist der Knopf, mit dem abgeschickt wurde. Hat ein
// Formular mehrere Knöpfe (etwa im Papierkorb: "wiederherstellen" und
// "endgültig löschen"), kann so jeder Knopf seine eigene Rückfrage
// haben — oder gar keine. Steht am Knopf nichts, gilt die des Formulars.
var knopf = event.submitter;
var message = (knopf && knopf.getAttribute("data-confirm")) ||
(form.getAttribute && form.getAttribute("data-confirm"));
if (!message) { if (!message) {
return; return;
} }

380
static/graph.js Normal file
View file

@ -0,0 +1,380 @@
// Zeichnet den Verweisgraph eines Projekts: Knoten = Einträge, Kanten =
// [[…]]-Verweise.
//
// Das Layout ist eine einfache Kräftesimulation (Abstoßung zwischen allen
// Knotenpaaren, Anziehung entlang der Kanten, eine schwache Zentrierung) —
// kein externes Diagramm-Werkzeug, das an der Content-Security-Policy
// (script-src 'self') vorbeigemusst hätte, und ohne Abhängigkeit, die
// gepflegt werden müsste. Läuft einmal beim Laden komplett durch, nicht
// fortlaufend animiert: Bei der serverseitig gedeckelten Knotenzahl (siehe
// graphNodeLimit in internal/web/handlers_entries.go) ist das in Bruchteilen
// einer Sekunde fertig.
(function () {
"use strict";
var SVG_NS = "http://www.w3.org/2000/svg";
document.querySelectorAll("svg[data-graph]").forEach(function (svg) {
ladeUndZeichne(svg);
});
function ladeUndZeichne(svg) {
var url = svg.getAttribute("data-graph");
var basisUrl = svg.getAttribute("data-graph-base");
var hinweis = document.querySelector("[data-graph-hinweis]");
fetch(url, { headers: { Accept: "application/json" } })
.then(function (antwort) {
if (!antwort.ok) {
throw new Error("Graph konnte nicht geladen werden");
}
return antwort.json();
})
.then(function (daten) {
if (hinweis && daten.truncated) {
hinweis.hidden = false;
hinweis.textContent =
"Zeigt die " + daten.nodes.length + " am stärksten verknüpften von " +
daten.total + " Einträgen.";
}
zeichne(svg, daten.nodes || [], daten.edges || [], basisUrl);
})
.catch(function () {
svg.replaceWith(meldung("Graph konnte nicht geladen werden."));
});
}
function meldung(text) {
var div = document.createElement("div");
div.className = "leer";
var p = document.createElement("p");
p.textContent = text;
div.appendChild(p);
return div;
}
function zeichne(svg, knoten, kanten, basisUrl) {
if (!knoten.length) {
svg.replaceWith(meldung("Noch keine Einträge mit Verweisen."));
return;
}
var breite = svg.clientWidth || 800;
var hoehe = 560;
svg.setAttribute("viewBox", "0 0 " + breite + " " + hoehe);
var indexBySlug = {};
knoten.forEach(function (k, i) {
indexBySlug[k.slug] = i;
});
var kantenIdx = kanten
.map(function (e) {
return [indexBySlug[e.from], indexBySlug[e.to]];
})
.filter(function (p) {
return p[0] !== undefined && p[1] !== undefined;
});
var slugs = knoten.map(function (k) {
return k.slug;
});
var positionen = simuliere(knoten.length, kantenIdx, breite, hoehe, slugs);
var viewport = svgEl("g", {});
svg.appendChild(viewport);
var kantenGruppe = svgEl("g", { class: "graph-kanten" });
var knotenGruppe = svgEl("g", { class: "graph-knoten" });
viewport.appendChild(kantenGruppe);
viewport.appendChild(knotenGruppe);
kantenIdx.forEach(function (paar) {
var von = positionen[paar[0]];
var nach = positionen[paar[1]];
kantenGruppe.appendChild(
svgEl("line", { x1: von.x, y1: von.y, x2: nach.x, y2: nach.y, class: "graph-kante" }),
);
});
knoten.forEach(function (k, i) {
var pos = positionen[i];
var radius = 6 + Math.min(14, Math.sqrt(k.degree || 0) * 3);
var gruppe = svgEl("g", {
class: "graph-knoten-gruppe" + (k.exists ? "" : " graph-knoten-fehlend"),
transform: "translate(" + pos.x.toFixed(1) + "," + pos.y.toFixed(1) + ")",
});
var titelEl = document.createElementNS(SVG_NS, "title");
titelEl.textContent = k.title;
gruppe.appendChild(titelEl);
gruppe.appendChild(svgEl("circle", { r: radius, class: "graph-punkt" }));
var text = svgEl("text", { x: radius + 4, y: 4, class: "graph-beschriftung" });
text.textContent = kuerze(k.title, 28);
gruppe.appendChild(text);
if (k.exists) {
gruppe.classList.add("graph-klickbar");
gruppe.setAttribute("data-slug", k.slug);
}
knotenGruppe.appendChild(gruppe);
});
aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl);
}
// fnv1a bildet aus einer Zeichenkette einen 32-Bit-Hash — hier verwendet,
// um aus den Bezeichnern der Knoten einen reproduzierbaren Startwert für
// die Zufallsfolge unten zu gewinnen. Kein kryptografischer Anspruch,
// nur Verteilung.
function fnv1a(text) {
var hash = 0x811c9dc5;
for (var i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
// mulberry32: ein kleiner, schneller, seed-basierter Zufallsgenerator.
// Math.random() lässt sich nicht mit einem Startwert versehen — jeder
// Aufruf wäre unvermeidbar anders. Mit demselben Seed liefert diese
// Funktion dagegen bei jedem Laden dieselbe Folge, und damit die
// Kräftesimulation unten dasselbe Ergebnis: Wer denselben Eintrag sucht,
// findet ihn beim nächsten Besuch wieder an derselben Stelle im Graphen,
// statt dass sich das Bild bei jedem Laden neu würfelt.
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Klassische Kräftesimulation: Abstoßung zwischen allen Knotenpaaren
// (Coulomb-artig), Anziehung entlang der Kanten (Federgesetz) und eine
// schwache Kraft zur Bildmitte, damit der Graph nicht davonwandert.
//
// O(Knoten²) je Durchlauf — bei der serverseitigen Obergrenze von 150
// Knoten sind das rund 22.000 Paare, mit 250 Durchläufen im
// einstelligen Millionenbereich an Rechenschritten. Das läuft im Browser
// in Bruchteilen einer Sekunde.
//
// slugsFuerSeed bestimmt nur den Startwert der Zufallsfolge (siehe
// mulberry32) — Zwei Aufrufe mit derselben Knotenmenge ergeben dieselben
// Startpositionen und damit, weil die Kräfte selbst keine Zufallszahlen
// mehr ziehen, dasselbe Endlayout. Ändert sich die Knotenmenge, ändert
// sich auch der Seed, und das Layout darf sich neu einpendeln.
function simuliere(n, kantenIdx, breite, hoehe, slugsFuerSeed) {
var zufall = mulberry32(fnv1a(slugsFuerSeed.join("\n")));
var positionen = [];
for (var i = 0; i < n; i++) {
positionen.push({
x: breite / 2 + (zufall() - 0.5) * breite * 0.6,
y: hoehe / 2 + (zufall() - 0.5) * hoehe * 0.6,
vx: 0,
vy: 0,
});
}
var ABSTOSSUNG = 1800;
var FEDER = 0.02;
var ZIELLAENGE = 90;
var DAEMPFUNG = 0.85;
var ITERATIONEN = 250;
for (var iter = 0; iter < ITERATIONEN; iter++) {
for (var a = 0; a < n; a++) {
for (var b = a + 1; b < n; b++) {
var dx = positionen[a].x - positionen[b].x;
var dy = positionen[a].y - positionen[b].y;
var distQ = dx * dx + dy * dy || 0.01;
var dist = Math.sqrt(distQ);
var kraft = ABSTOSSUNG / distQ;
var fx = (dx / dist) * kraft;
var fy = (dy / dist) * kraft;
positionen[a].vx += fx;
positionen[a].vy += fy;
positionen[b].vx -= fx;
positionen[b].vy -= fy;
}
}
kantenIdx.forEach(function (paar) {
var p1 = positionen[paar[0]];
var p2 = positionen[paar[1]];
var dx = p2.x - p1.x;
var dy = p2.y - p1.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
var kraft = (dist - ZIELLAENGE) * FEDER;
var fx = (dx / dist) * kraft;
var fy = (dy / dist) * kraft;
p1.vx += fx;
p1.vy += fy;
p2.vx -= fx;
p2.vy -= fy;
});
for (var k = 0; k < n; k++) {
positionen[k].vx += (breite / 2 - positionen[k].x) * 0.002;
positionen[k].vy += (hoehe / 2 - positionen[k].y) * 0.002;
}
for (var m = 0; m < n; m++) {
positionen[m].vx *= DAEMPFUNG;
positionen[m].vy *= DAEMPFUNG;
positionen[m].x += positionen[m].vx;
positionen[m].y += positionen[m].vy;
}
}
return positionen;
}
// Zoom (Mausrad) und Pan (Ziehen) auf der Transform-Gruppe "viewport".
//
// Der naheliegende Ansatz — Mausbewegung in Bildschirm-Pixeln direkt als
// Verschiebung im viewBox-Koordinatensystem übernehmen — geht nur auf,
// wenn die dargestellte Größe der SVG-Fläche exakt ihrer viewBox
// entspricht. Das stimmt zufällig beim ersten Zeichnen (viewBox-Breite
// kommt von svg.clientWidth), aber nicht mehr nach einer
// Fenstergröße-Änderung (die SVG-Breite ist "100%" und wächst/schrumpft
// mit), und auch sonst nicht zuverlässig (abweichende rem-Basis,
// Browser-Zoom). zuViewBox() rechnet deshalb bei jeder Bewegung über
// getBoundingClientRect() neu um, statt sich auf diese Übereinstimmung
// zu verlassen.
function aktiviereZoomUndPan(svg, viewport, breite, hoehe, basisUrl) {
var massstab = 1;
var x = 0;
var y = 0;
var ziehtGerade = false;
var startPunkt = { x: 0, y: 0 };
var startX = 0;
var startY = 0;
var bewegungsstrecke = 0;
var gedruecktesElement = null;
// Ab dieser Strecke (in viewBox-Einheiten) zählt eine Geste als
// Ziehen statt als Klick.
var ZIEHSCHWELLE = 6;
function anwenden() {
viewport.setAttribute("transform", "translate(" + x + "," + y + ") scale(" + massstab + ")");
}
function zuViewBox(clientX, clientY) {
var rect = svg.getBoundingClientRect();
return {
x: ((clientX - rect.left) / rect.width) * breite,
y: ((clientY - rect.top) / rect.height) * hoehe,
};
}
svg.addEventListener("pointerdown", function (event) {
if (event.button !== 0) {
return;
}
ziehtGerade = true;
bewegungsstrecke = 0;
gedruecktesElement = event.target.closest(".graph-klickbar");
startPunkt = zuViewBox(event.clientX, event.clientY);
startX = x;
startY = y;
svg.classList.add("graph-zieht");
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", function (event) {
if (!ziehtGerade) {
return;
}
var jetzt = zuViewBox(event.clientX, event.clientY);
// Die Verschiebung (translate) der Gruppe lebt im selben
// Koordinatensystem wie die viewBox der äußeren SVG — deshalb
// hier ohne weitere Umrechnung durch massstab: Eine Bewegung um
// 10 viewBox-Einheiten verschiebt den Inhalt um genau 10
// Einheiten, unabhängig vom aktuellen Zoomstand.
//
// Der größte je erreichte Abstand zum Startpunkt zählt, nicht
// die Summe aller Zwischenschritte — sonst würde allein die
// Zahl der pointermove-Ereignisse (auch minimales Zittern der
// Maus feuert mehrere) die Schwelle überschreiten und selbst
// einen stillstehenden Klick als Ziehen werten.
var abstand = Math.abs(jetzt.x - startPunkt.x) + Math.abs(jetzt.y - startPunkt.y);
bewegungsstrecke = Math.max(bewegungsstrecke, abstand);
x = startX + (jetzt.x - startPunkt.x);
y = startY + (jetzt.y - startPunkt.y);
anwenden();
});
function beendeZiehen(event) {
if (!ziehtGerade) {
return;
}
ziehtGerade = false;
svg.classList.remove("graph-zieht");
if (event && svg.hasPointerCapture(event.pointerId)) {
svg.releasePointerCapture(event.pointerId);
}
// Navigation läuft hier statt über einen "click"-Listener auf
// dem Knoten: setPointerCapture (oben) verschiebt das Ziel
// NACHFOLGENDER Zeiger- UND Klick-Ereignisse auf die SVG-Wurzel,
// ganz gleich, worauf ursprünglich gedrückt wurde. Ein
// click-Listener am Knoten selbst würde dadurch nie mehr feuern.
// Stattdessen wird beim Loslassen nachgesehen, ob (a) kaum
// Bewegung stattfand und (b) das Drücken auf einem anklickbaren
// Knoten begann.
if (bewegungsstrecke <= ZIEHSCHWELLE && gedruecktesElement) {
window.location.href = basisUrl + encodeURIComponent(gedruecktesElement.getAttribute("data-slug"));
}
gedruecktesElement = null;
}
svg.addEventListener("pointerup", beendeZiehen);
svg.addEventListener("pointercancel", function () {
gedruecktesElement = null;
beendeZiehen.apply(null, arguments);
});
svg.addEventListener(
"wheel",
function (event) {
event.preventDefault();
var faktor = event.deltaY < 0 ? 1.1 : 0.9;
var neuerMassstab = Math.min(4, Math.max(0.25, massstab * faktor));
if (neuerMassstab === massstab) {
return;
}
// Zoomt zur Cursor-Position statt zur Bildecke: Der Punkt
// unter dem Mauszeiger bleibt an derselben Stelle stehen,
// statt dass der Inhalt beim Zoomen "wegläuft".
var zeiger = zuViewBox(event.clientX, event.clientY);
var verhaeltnis = neuerMassstab / massstab;
x = zeiger.x - (zeiger.x - x) * verhaeltnis;
y = zeiger.y - (zeiger.y - y) * verhaeltnis;
massstab = neuerMassstab;
anwenden();
},
{ passive: false },
);
}
function svgEl(tag, attrs) {
var el = document.createElementNS(SVG_NS, tag);
Object.keys(attrs).forEach(function (name) {
el.setAttribute(name, attrs[name]);
});
return el;
}
function kuerze(text, max) {
if (!text) {
return "";
}
return text.length > max ? text.slice(0, max - 1) + "…" : text;
}
})();

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 120 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 120 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 68 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 120 KiB

86
static/preview.js Normal file
View file

@ -0,0 +1,86 @@
// Schaltet im Bearbeitungsformular zwischen "Schreiben" und "Vorschau" um.
//
// Die Vorschau wird nicht im Browser aus Markdown erzeugt — das bräuchte eine
// zweite Rendering-Implementierung (und eine externe Bibliothek, die die
// Content-Security-Policy zulassen müsste, siehe SecurityHeaders in
// internal/web/middleware.go). Stattdessen schickt ein kurzer Aufruf den
// Inhalt an den Server, der denselben goldmark-Renderer benutzt wie die
// Detailansicht (siehe entry.RenderHTML) — Vorschau und späteres Ergebnis
// können so nie auseinanderlaufen.
//
// Ein Klick auf "Vorschau" lädt neu, kein Live-Rendern beim Tippen: Das hält
// die Zahl der Aufrufe klein und macht unnötig, über Verzögerung
// (Debouncing) nachzudenken.
document.addEventListener("click", function (event) {
var knopf = event.target.closest("[data-editor-tab]");
if (!knopf) {
return;
}
var editor = knopf.closest("[data-editor]");
if (!editor) {
return;
}
var ziel = knopf.getAttribute("data-editor-tab");
editor.querySelectorAll("[data-editor-tab]").forEach(function (b) {
var aktiv = b === knopf;
b.classList.toggle("aktiv", aktiv);
b.setAttribute("aria-selected", aktiv ? "true" : "false");
});
editor.querySelectorAll("[data-editor-panel]").forEach(function (p) {
p.hidden = p.getAttribute("data-editor-panel") !== ziel;
});
if (ziel === "vorschau") {
ladeVorschau(editor);
}
});
function ladeVorschau(editor) {
var textarea = editor.querySelector("textarea[data-editor-panel]");
var vorschau = editor.querySelector("[data-editor-panel='vorschau']");
var form = editor.closest("form");
if (!textarea || !vorschau || !form) {
return;
}
var csrf = form.querySelector("input[name=csrf_token]");
var url = form.getAttribute("data-preview-url");
if (!url) {
return;
}
var body = "content=" + encodeURIComponent(textarea.value);
if (csrf) {
body += "&csrf_token=" + encodeURIComponent(csrf.value);
}
vorschau.textContent = "Wird geladen …";
vorschau.classList.add("vorschau-laedt");
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body,
})
.then(function (antwort) {
if (!antwort.ok) {
throw new Error("Vorschau fehlgeschlagen");
}
return antwort.text();
})
.then(function (html) {
vorschau.classList.remove("vorschau-laedt");
if (html.trim() === "") {
vorschau.innerHTML = '<p class="hilfe">Noch kein Inhalt.</p>';
return;
}
vorschau.innerHTML = html;
})
.catch(function () {
vorschau.classList.remove("vorschau-laedt");
vorschau.textContent = "Vorschau konnte nicht geladen werden.";
});
}

View file

@ -185,6 +185,28 @@ pre code { background: none; border: none; padding: 0; }
} }
.marke-wort:hover { text-decoration: none; } .marke-wort:hover { text-decoration: none; }
/* Workspace-Umschalter in der Kopfleiste: Name des aktuellen Workspace,
* klickbar zur Auswahl-/Verwaltungsseite. Dezent gehalten — die Marke bleibt
* der optische Ankerpunkt, das hier ist Kontext, kein zweites Logo. */
.arbeitsbereich {
display: inline-flex;
align-items: center;
gap: var(--s1);
padding: var(--s1) var(--s2);
border-radius: var(--radius);
color: var(--text-leise);
font-size: var(--t-meta);
font-weight: 500;
}
.arbeitsbereich:hover {
background: var(--flaeche-leis);
color: var(--text);
text-decoration: none;
}
.arbeitsbereich-trenner { color: var(--rahmen-stark); font-weight: 400; }
.arbeitsbereich-name { color: inherit; }
.arbeitsbereich-pfeil { font-size: 0.7em; opacity: 0.6; }
/* Das Gerüst: Seitenleiste links, Inhalt rechts. */ /* Das Gerüst: Seitenleiste links, Inhalt rechts. */
.gerüst { .gerüst {
max-width: var(--breite-seite); max-width: var(--breite-seite);
@ -290,6 +312,34 @@ pre code { background: none; border: none; padding: 0; }
} }
.karte + .karte { margin-top: var(--s4); } .karte + .karte { margin-top: var(--s4); }
/* Kennzahlen (Nutzungsseite): eine Reihe kleiner Kacheln, die auf schmalen
* Bildschirmen umbrechen. auto-fit + minmax verteilt sie ohne Media-Query:
* so viele Spalten, wie mit mindestens 9rem Breite hineinpassen. */
.kennzahlen {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: var(--s3);
}
.kennzahl {
display: flex;
flex-direction: column;
gap: var(--s1);
background: var(--flaeche);
border: 1px solid var(--rahmen);
border-radius: var(--radius-gross);
padding: var(--s4);
}
.kennzahl-wert { font-size: var(--t-h2); font-weight: 600; font-variant-numeric: tabular-nums; }
.kennzahl-name { font-size: var(--t-meta); color: var(--text-leise); }
/* Kurze Listen ohne Aufzählungspunkte, etwa "Verweise ins Leere". */
.kompakt-liste { list-style: none; padding: 0; margin: 0; }
.kompakt-liste li { padding: var(--s1) 0; border-bottom: 1px solid var(--rahmen); }
.kompakt-liste li:last-child { border-bottom: none; }
/* Summenzeile am Ende einer Tabelle. */
table.tabelle tr.summe td { font-weight: 600; border-top: 2px solid var(--rahmen); }
/* Meldungen. */ /* Meldungen. */
.meldung { .meldung {
padding: var(--s3) var(--s4); padding: var(--s3) var(--s4);
@ -337,6 +387,12 @@ a.marke:hover {
border-color: transparent; border-color: transparent;
color: var(--akzent-text); color: var(--akzent-text);
} }
/* Etwas, um das sich jemand kümmern sollte, etwa ein seit Langem ungenutzter Key. */
.marke.warnung {
background: var(--warn-flach);
border-color: transparent;
color: var(--warn);
}
/* Metazeile unter einem Titel: Marken und Kleintext in einer Reihe. */ /* Metazeile unter einem Titel: Marken und Kleintext in einer Reihe. */
.meta { .meta {
@ -403,6 +459,9 @@ button.warnung:hover { background: var(--warn-flach); filter: none; }
.reihe { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; } .reihe { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
form.reihe { display: inline-flex; } form.reihe { display: inline-flex; }
/* In rechtsbündigen Tabellenspalten (z. B. Verlauf) sollen die Aktionen
* ebenfalls rechts stehen — ein Flex-Container ignoriert text-align. */
.rechts > .reihe { justify-content: flex-end; }
/* Formulare. /* Formulare.
* *
@ -440,6 +499,77 @@ textarea {
min-height: 20rem; min-height: 20rem;
} }
/* Reiter "Schreiben"/"Vorschau" über dem Inhaltsfeld. */
.editor-reiter {
display: flex;
gap: var(--s1);
margin-bottom: var(--s2);
border-bottom: 1px solid var(--rahmen);
}
.editor-reiter-knopf {
padding: var(--s2) var(--s3);
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
min-height: 0;
color: var(--text-leise);
font-weight: 500;
font-size: var(--t-meta);
cursor: pointer;
margin-bottom: -1px;
}
.editor-reiter-knopf:hover { color: var(--text); filter: none; }
.editor-reiter-knopf.aktiv {
color: var(--text);
border-bottom-color: var(--akzent);
}
/* Vorschau-Fläche: dieselbe Textdarstellung wie die Detailansicht
* (.eintrag-text), aber als Formularfeld gerahmt, damit der Wechsel
* zwischen Schreiben und Vorschau nicht die Höhe des Formulars springen
* lässt. */
.eintrag-text.vorschau-flaeche {
min-height: 20rem;
max-width: none;
padding: var(--s3);
background: var(--flaeche);
border: 1px solid var(--rahmen-stark);
border-radius: var(--radius);
/* Überschreibt den großzügigen margin-top von .eintrag-text (siehe
* weiter unten) — hier steckt es in einem Formularfeld, nicht unter
* einer Überschrift. Die doppelte Klasse hebt die Reihenfolge im
* Stylesheet auf: diese Regel gewinnt unabhängig davon, wo sie steht. */
margin-top: 0;
}
.vorschau-flaeche.vorschau-laedt { color: var(--text-leise); }
/* Vorschlagsliste der Autovervollständigung für [[…]] — direkt unter dem
* Feld statt an der Schreibmarke ausgerichtet, siehe autocomplete.js. */
.autovervollstaendigung {
list-style: none;
margin: var(--s2) 0 0;
padding: 0;
max-height: 12rem;
overflow-y: auto;
background: var(--flaeche);
border: 1px solid var(--rahmen-stark);
border-radius: var(--radius);
box-shadow: var(--schatten);
}
.autovervollstaendigung li {
display: flex;
align-items: baseline;
gap: var(--s2);
padding: var(--s2) var(--s3);
cursor: pointer;
font-size: var(--t-meta);
}
.autovervollstaendigung li:not(:last-child) { border-bottom: 1px solid var(--rahmen); }
.autovervollstaendigung li span { color: var(--text-leise); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.autovervollstaendigung li.aktiv { background: var(--akzent-flach); }
.autovervollstaendigung li.aktiv span { color: var(--akzent-text); }
/* Feldbreiten nach Inhalt. */ /* Feldbreiten nach Inhalt. */
.feld.kurz { max-width: 18rem; } .feld.kurz { max-width: 18rem; }
.feld.mittel { max-width: 28rem; } .feld.mittel { max-width: 28rem; }
@ -508,8 +638,10 @@ table.tabelle {
padding: var(--s5); padding: var(--s5);
} }
.anmeldeseite .karte { width: 100%; padding: var(--s6); } .anmeldeseite .karte { width: 100%; padding: var(--s6); }
.anmeldeseite h1 { margin-bottom: var(--s2); }
.anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); } .anmeldeseite .hilfe { display: block; margin-bottom: var(--s5); }
/* Logo zentriert über der Anmeldekarte. */
.anmelde-logo { display: flex; justify-content: center; margin-bottom: var(--s4); }
.anmelde-logo img { width: 160px; height: auto; }
/* Werkzeugleiste über der Eintragsliste: Suche und Filter. */ /* Werkzeugleiste über der Eintragsliste: Suche und Filter. */
.werkzeugleiste { .werkzeugleiste {
@ -532,12 +664,63 @@ table.tabelle {
overflow: hidden; overflow: hidden;
} }
.eintraege li { .eintraege li {
display: flex;
align-items: flex-start;
gap: var(--s3);
padding: var(--s4); padding: var(--s4);
border-bottom: 1px solid var(--rahmen); border-bottom: 1px solid var(--rahmen);
} }
.eintraege li:last-child { border-bottom: none; } .eintraege li:last-child { border-bottom: none; }
.eintraege li:hover { background: var(--flaeche-leis); } .eintraege li:hover { background: var(--flaeche-leis); }
/* Auswahlkästchen der Sammelbearbeitung: etwas abgesetzt vom Titel, auf
* dessen erster Zeile ausgerichtet statt an der Oberkante der ganzen Zeile. */
.eintrag-auswahl {
display: flex;
align-items: center;
padding-top: 0.35rem;
}
.eintrag-inhalt { flex: 1; min-width: 0; }
/* Sammelleiste: erscheint unter der Liste, sobald Einträge angehakt sind.
* position: sticky hält sie beim Scrollen einer langen Liste in Sichtweite. */
.sammelleiste {
display: flex;
align-items: center;
gap: var(--s2);
flex-wrap: wrap;
margin-top: var(--s3);
padding: var(--s3) var(--s4);
background: var(--flaeche);
border: 1px solid var(--rahmen);
border-radius: var(--radius-gross);
box-shadow: var(--schatten);
position: sticky;
bottom: var(--s4);
}
/* Papierkorb: "Alle von … wiederherstellen" steht mit etwas Abstand unter der
* Sammelleiste. */
.papierkorb-alle { margin-top: var(--s3); }
/* Für Screenreader lesbar, aber unsichtbar — etwa die Überschrift einer
* Spalte, die nur Kästchen enthält. display:none ginge nicht: Das würde den
* Text auch vor Screenreadern verstecken. */
.unsichtbar {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sammelleiste input[type=text] {
width: auto;
flex: 1 1 12rem;
min-height: 2rem;
padding: var(--s1) var(--s2);
}
.eintrag-titel { .eintrag-titel {
display: block; display: block;
font-size: var(--t-gross); font-size: var(--t-gross);
@ -584,6 +767,12 @@ table.tabelle {
.eintrag-text table { border-collapse: collapse; width: 100%; font-size: var(--t-meta); } .eintrag-text table { border-collapse: collapse; width: 100%; font-size: var(--t-meta); }
.eintrag-text th, .eintrag-text td { border: 1px solid var(--rahmen); padding: var(--s2) var(--s3); } .eintrag-text th, .eintrag-text td { border: 1px solid var(--rahmen); padding: var(--s2) var(--s3); }
.eintrag-text hr { border: none; border-top: 1px solid var(--rahmen); margin: var(--s6) 0; } .eintrag-text hr { border: none; border-top: 1px solid var(--rahmen); margin: var(--s6) 0; }
/* Checklisten (- [ ] / - [x]): Das Kästchen ersetzt den Aufzählungspunkt.
* :has() wählt nur Listenpunkte aus, die direkt ein Kästchen enthalten —
* gewöhnliche Aufzählungen behalten ihren Punkt. */
.eintrag-text li:has(> input[type="checkbox"]) { list-style: none; margin-left: calc(-1 * var(--s4)); }
.eintrag-text li > input[type="checkbox"] { margin: 0 var(--s2) 0 0; vertical-align: middle; }
.eintrag-text del { color: var(--text-leise); }
.rueckverweise { margin-top: var(--s7); } .rueckverweise { margin-top: var(--s7); }
.rueckverweise h2 { font-size: var(--t-meta); color: var(--text-leise); margin-bottom: var(--s3); } .rueckverweise h2 { font-size: var(--t-meta); color: var(--text-leise); margin-bottom: var(--s3); }
@ -611,6 +800,85 @@ table.tabelle {
} }
.key-anleitung pre { margin-top: var(--s3); font-size: var(--t-klein); } .key-anleitung pre { margin-top: var(--s3); font-size: var(--t-klein); }
/* Fassungsvergleich. */
.diff-koepfe {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s4);
margin-bottom: var(--s4);
}
.diff-kopf {
display: flex;
flex-direction: column;
gap: var(--s1);
padding: var(--s3) var(--s4);
background: var(--flaeche);
border: 1px solid var(--rahmen);
border-radius: var(--radius);
}
.diff-huelle {
background: var(--flaeche);
border: 1px solid var(--rahmen);
border-radius: var(--radius-gross);
box-shadow: var(--schatten);
overflow-x: auto;
}
.diff {
margin: 0;
padding: var(--s3) 0;
font-size: var(--t-meta);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.diff span { display: block; padding: 0 var(--s4); }
.diff-hinzugefuegt { background: var(--akzent-flach); color: var(--akzent-text); }
.diff-entfernt { background: var(--warn-flach); color: var(--warn); }
/* Graph-Ansicht der Verweise. */
.graph-huelle {
background: var(--flaeche);
border: 1px solid var(--rahmen);
border-radius: var(--radius-gross);
box-shadow: var(--schatten);
overflow: hidden;
margin-bottom: var(--s4);
}
.graph-huelle svg {
display: block;
width: 100%;
height: 35rem;
cursor: grab;
touch-action: none;
}
.graph-huelle svg.graph-zieht { cursor: grabbing; }
.graph-kante { stroke: var(--rahmen-stark); stroke-width: 1.5; }
.graph-punkt {
fill: var(--akzent-flach);
stroke: var(--akzent);
stroke-width: 1.5;
}
.graph-knoten-fehlend .graph-punkt {
fill: var(--flaeche);
stroke: var(--rahmen-stark);
stroke-dasharray: 3 2;
}
.graph-beschriftung {
font-size: 0.75rem;
fill: var(--text);
dominant-baseline: middle;
/* Beschriftungen sollen das Ziehen des Graphen nicht durch
* Textmarkierung stören. */
user-select: none;
}
.graph-knoten-fehlend .graph-beschriftung { fill: var(--text-leise); }
.graph-klickbar { cursor: pointer; }
.graph-klickbar:hover .graph-punkt { stroke-width: 2.5; }
.graph-klickbar:hover .graph-beschriftung { fill: var(--akzent-text); }
/* Gefahrenzone: Löschvorgänge stehen abgesetzt am Seitenende, nicht zwischen /* Gefahrenzone: Löschvorgänge stehen abgesetzt am Seitenende, nicht zwischen
* den harmlosen Aktionen. Vorher steckte das Löschen eines Projekts samt * den harmlosen Aktionen. Vorher steckte das Löschen eines Projekts samt
* Bestätigungsfeld in einer Tabellenzelle — zu eng und viel zu beiläufig für * Bestätigungsfeld in einer Tabellenzelle — zu eng und viel zu beiläufig für
@ -618,6 +886,58 @@ table.tabelle {
.gefahrenzone { border-color: var(--warn); } .gefahrenzone { border-color: var(--warn); }
.gefahrenzone h2 { color: var(--warn); } .gefahrenzone h2 { color: var(--warn); }
/* Hilfe: Themen als umbrechende Reiterleiste über dem Text, der Text selbst
* wie ein Eintrag in Lesebreite. */
.hilfe-navigation {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
margin-top: var(--s5);
padding-bottom: var(--s4);
border-bottom: 1px solid var(--rahmen);
}
.hilfe-navigation a {
padding: var(--s1) var(--s3);
border: 1px solid var(--rahmen);
border-radius: var(--radius);
color: var(--text-leise);
font-size: var(--t-meta);
}
.hilfe-navigation a:hover { background: var(--flaeche-leis); color: var(--text); text-decoration: none; }
.hilfe-navigation a.aktiv {
background: var(--akzent-flach);
border-color: var(--akzent);
color: var(--akzent-text);
font-weight: 500;
}
.hilfe-text { max-width: var(--breite-lesen); }
/* Das kleine "?" neben Überschriften und Feldern (Partial "hilfelink").
* Bewusst unaufdringlich: ein Kreis in Textgröße, der erst beim Überfahren
* Farbe bekommt. Die Größe in rem (bezogen auf die Grundschrift, nicht auf
* die Umgebung), damit der Kreis neben einer großen h1 genauso klein bleibt
* wie neben einem Formular-Label. */
.hilfe-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
margin-left: var(--s1);
border: 1px solid var(--rahmen);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
color: var(--text-leise);
vertical-align: middle;
}
.hilfe-link:hover {
border-color: var(--akzent);
color: var(--akzent-text);
text-decoration: none;
}
/* ── 6. Schmale Geräte ───────────────────────────────────────────────────── */ /* ── 6. Schmale Geräte ───────────────────────────────────────────────────── */
/* Unterhalb dieser Breite ist für zwei Spalten kein Platz mehr: Die Seitenleiste /* Unterhalb dieser Breite ist für zwei Spalten kein Platz mehr: Die Seitenleiste
@ -675,4 +995,5 @@ table.tabelle {
.tabelle th, .tabelle td { padding: var(--s2) var(--s3); } .tabelle th, .tabelle td { padding: var(--s2) var(--s3); }
.feld.kurz, .feld.mittel, .feld.lang { max-width: none; } .feld.kurz, .feld.mittel, .feld.lang { max-width: none; }
textarea { min-height: 14rem; } textarea { min-height: 14rem; }
.diff-koepfe { grid-template-columns: 1fr; }
} }

View file

@ -2,7 +2,9 @@
<div class="seitenkopf"> <div class="seitenkopf">
<div class="seitenkopf-titel"> <div class="seitenkopf-titel">
<h1>Protokoll</h1> {{/* Das Protokoll über alle Projekte sieht nur der Plattform-Admin — für ihn
gibt es einen eigenen Abschnitt auf seiner Hilfeseite. */}}
<h1>Protokoll {{if .Data.Global}}{{template "hilfelink" "plattform-admin#protokoll-ueber-alle-projekte"}}{{else}}{{template "hilfelink" "zugaenge#protokoll"}}{{end}}</h1>
<span class="hilfe"> <span class="hilfe">
Die jüngsten {{len .Data.Events}} Vorgänge, neueste zuerst. Die jüngsten {{len .Data.Events}} Vorgänge, neueste zuerst.
{{if .Data.Global}}Über alle Projekte hinweg.{{end}} {{if .Data.Global}}Über alle Projekte hinweg.{{end}}

View file

@ -9,7 +9,8 @@
<div class="karte"> <div class="karte">
<form method="POST" class="formular" <form method="POST" class="formular"
action="{{if .Data.IsNew}}/p/{{.Project.Slug}}/save{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/save{{end}}"> action="{{if .Data.IsNew}}/p/{{.Project.Slug}}/save{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/save{{end}}"
data-preview-url="/p/{{.Project.Slug}}/preview">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{/* Die Fassung wandert unverändert durchs Formular und zurück. Hat jemand {{/* Die Fassung wandert unverändert durchs Formular und zurück. Hat jemand
@ -27,7 +28,7 @@
<label for="slug">Bezeichner</label> <label for="slug">Bezeichner</label>
<span class="hilfe">Leer lassen, um ihn aus dem Titel abzuleiten.</span> <span class="hilfe">Leer lassen, um ihn aus dem Titel abzuleiten.</span>
<input type="text" id="slug" name="slug" pattern="[a-z0-9][a-z0-9_-]*" <input type="text" id="slug" name="slug" pattern="[a-z0-9][a-z0-9_-]*"
placeholder="wird-abgeleitet" autocomplete="off"> value="{{.Data.Entry.Slug}}" placeholder="wird-abgeleitet" autocomplete="off">
</div> </div>
{{else}} {{else}}
<input type="hidden" name="slug" value="{{.Data.Entry.Slug}}"> <input type="hidden" name="slug" value="{{.Data.Entry.Slug}}">
@ -39,10 +40,27 @@
<input type="text" id="tags" name="tags" value="{{.Data.TagsIn}}" placeholder="ops, deployment"> <input type="text" id="tags" name="tags" value="{{.Data.TagsIn}}" placeholder="ops, deployment">
</div> </div>
<div class="feld"> <div class="feld" data-editor>
<label for="content">Inhalt</label> <label for="content">Inhalt {{template "hilfelink" "weboberflaeche#markdown-im-inhalt"}}</label>
<span class="hilfe">Markdown. <code>[[bezeichner]]</code> verweist auf einen anderen Eintrag.</span> <span class="hilfe">Markdown. <code>[[bezeichner]]</code> verweist auf einen anderen Eintrag.</span>
<textarea id="content" name="content" rows="24" spellcheck="true">{{.Data.Entry.Content}}</textarea>
{{/* Ohne JavaScript bleibt einfach der erste Reiter aktiv — das
Formular funktioniert dann genau wie vorher, nur ohne
Vorschau-Knopf-Wirkung. Kein <noscript>-Sonderfall nötig. */}}
<div class="editor-reiter" role="tablist">
<button type="button" class="editor-reiter-knopf aktiv"
data-editor-tab="schreiben" role="tab" aria-selected="true">Schreiben</button>
<button type="button" class="editor-reiter-knopf"
data-editor-tab="vorschau" role="tab" aria-selected="false">Vorschau</button>
</div>
<textarea id="content" name="content" rows="24" spellcheck="true"
data-editor-panel="schreiben"
data-suggest-url="/p/{{.Project.Slug}}/entries/suggest">{{.Data.Entry.Content}}</textarea>
{{/* Erscheint nur, während "[[" getippt und noch nicht mit "]]"
geschlossen wurde — siehe static/autocomplete.js. */}}
<ul class="autovervollstaendigung" data-autovervollstaendigung hidden></ul>
<div class="eintrag-text vorschau-flaeche" data-editor-panel="vorschau" hidden></div>
</div> </div>
<div class="feld ankreuz"> <div class="feld ankreuz">

View file

@ -2,7 +2,7 @@
<div class="seitenkopf"> <div class="seitenkopf">
<div class="seitenkopf-titel"> <div class="seitenkopf-titel">
<h1>Einträge</h1> <h1>Einträge {{template "hilfelink" "weboberflaeche#finden"}}</h1>
<span class="hilfe"> <span class="hilfe">
{{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}} {{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}}
</span> </span>
@ -28,10 +28,24 @@
{{end}} {{end}}
{{if .Data.Entries}} {{if .Data.Entries}}
{{$sammelbearbeitung := .Session.CanWrite}}
{{if $sammelbearbeitung}}
<form method="POST" action="/p/{{.Project.Slug}}/entries/bulk-tags" id="sammelform">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if .Data.Tag}}<input type="hidden" name="tag" value="{{.Data.Tag}}">{{end}}
{{end}}
<ul class="eintraege"> <ul class="eintraege">
{{$slug := .Project.Slug}} {{$slug := .Project.Slug}}
{{range .Data.Entries}} {{range .Data.Entries}}
<li> <li>
{{if $sammelbearbeitung}}
<label class="eintrag-auswahl">
<input type="checkbox" name="ids" value="{{.Entry.ID}}" aria-label="{{.Entry.Title}} auswählen">
</label>
{{end}}
<div class="eintrag-inhalt">
<a class="eintrag-titel" href="/p/{{$slug}}/e/{{.Entry.Slug}}">{{.Entry.Title}}</a> <a class="eintrag-titel" href="/p/{{$slug}}/e/{{.Entry.Slug}}">{{.Entry.Title}}</a>
<div class="meta"> <div class="meta">
@ -43,9 +57,20 @@
</div> </div>
{{if .Snippet}}<p class="eintrag-ausschnitt">{{.Snippet}}</p>{{end}} {{if .Snippet}}<p class="eintrag-ausschnitt">{{.Snippet}}</p>{{end}}
</div>
</li> </li>
{{end}} {{end}}
</ul> </ul>
{{if $sammelbearbeitung}}
<div class="sammelleiste">
<span class="hilfe">Ausgewählte Einträge:</span>
<input type="text" name="add_tags" placeholder="Schlagworte hinzufügen, kommagetrennt" autocomplete="off">
<input type="text" name="remove_tags" placeholder="Schlagworte entfernen, kommagetrennt" autocomplete="off">
<button type="submit" class="zweitrangig">Anwenden</button>
</div>
</form>
{{end}}
{{else}} {{else}}
<div class="leer"> <div class="leer">
{{if .Data.Query}} {{if .Data.Query}}
@ -57,6 +82,8 @@
{{else}} {{else}}
<p>Noch keine Einträge.</p> <p>Noch keine Einträge.</p>
<p class="hilfe">Agenten legen sie über MCP an, Menschen über „Neuer Eintrag“.</p> <p class="hilfe">Agenten legen sie über MCP an, Menschen über „Neuer Eintrag“.</p>
<p><a href="/hilfe/mcp-einrichten">Einen Agenten anbinden</a> ·
<a href="/hilfe/mit-agenten-arbeiten">So nutzen Agenten das Gedächtnis wirklich</a></p>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

48
templates/entry-diff.html Normal file
View file

@ -0,0 +1,48 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Vergleich {{template "hilfelink" "weboberflaeche#verlauf-und-vergleich"}}</h1>
<span class="hilfe">
<a href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}">{{.Data.Entry.Title}}</a> ·
<a href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/versions">Verlauf</a>
</span>
</div>
{{/* Wiederherstellen nur mit Schreibrecht. current_version ist die Fassung,
die beim Anzeigen dieser Seite aktuell war — hat sich der Eintrag seitdem
geändert, lehnt der Server ab, statt die neuere Änderung zu verdrängen. */}}
{{if .Session.CanWrite}}
<div class="seitenkopf-aktionen">
<form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/versions/{{.Data.OldVersion}}/restore" class="reihe"
data-confirm="Fassung {{.Data.OldVersion}} wiederherstellen? Der aktuelle Stand bleibt im Verlauf erhalten.">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="current_version" value="{{.Data.Entry.Version}}">
<button type="submit" class="zweitrangig">Fassung {{.Data.OldVersion}} wiederherstellen</button>
</form>
</div>
{{end}}
</div>
<div class="diff-koepfe">
<div class="diff-kopf diff-kopf-alt">
<span class="marke">Fassung {{.Data.OldVersion}}</span>
<span class="hilfe">{{datum .Data.OldTime}}{{if .Data.OldAuthor}} · {{.Data.OldAuthor}}{{end}}</span>
</div>
<div class="diff-kopf diff-kopf-neu">
<span class="marke {{if .Data.NewIsCurrent}}betont{{end}}">
Fassung {{.Data.NewVersion}}{{if .Data.NewIsCurrent}} (aktuell){{end}}
</span>
<span class="hilfe">{{datum .Data.NewTime}}{{if .Data.NewAuthor}} · {{.Data.NewAuthor}}{{end}}</span>
</div>
</div>
{{if ne .Data.OldTitle .Data.NewTitle}}
<p class="hilfe">Titel geändert: „{{.Data.OldTitle}}“ → „{{.Data.NewTitle}}“</p>
{{end}}
<div class="diff-huelle">
<pre class="diff">{{range .Data.Lines}}<span class="diff-{{if eq .Op 1}}entfernt{{else if eq .Op 2}}hinzugefuegt{{else}}gleich{{end}}">{{if eq .Op 1}}-{{else if eq .Op 2}}+{{else}} {{end}} {{.Text}}
</span>{{end}}</pre>
</div>
{{template "fuss" .}}

View file

@ -0,0 +1,67 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Verlauf {{template "hilfelink" "weboberflaeche#verlauf-und-vergleich"}}</h1>
<span class="hilfe">
<a href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}">{{.Data.Entry.Title}}</a> ·
aktuell Fassung {{.Data.Entry.Version}}
</span>
</div>
</div>
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr>
<th class="zahl">Fassung</th>
<th>Zugang</th>
<th>Abgelöst am</th>
<th class="schmal rechts">Aktionen</th>
</tr>
</thead>
<tbody>
<tr>
<td class="zahl">{{.Data.Entry.Version}}</td>
<td>{{if .Data.Entry.Author}}{{.Data.Entry.Author}}{{else}}<span class="hilfe">unbekannt</span>{{end}}</td>
<td class="hilfe"><span class="marke betont">aktuell</span></td>
<td class="schmal rechts"></td>
</tr>
{{$slug := .Project.Slug}}
{{$eslug := .Data.Entry.Slug}}
{{/* Innerhalb von range zeigt der Punkt auf die jeweilige Fassung.
Was von außen gebraucht wird, merken wir uns vorher in Variablen. */}}
{{$aktuell := .Data.Entry.Version}}
{{$darfSchreiben := .Session.CanWrite}}
{{$csrf := .CSRFToken}}
{{range .Data.Versions}}
<tr>
<td class="zahl">{{.Version}}</td>
<td>{{if .Author}}{{.Author}}{{else}}<span class="hilfe">unbekannt</span>{{end}}</td>
<td class="hilfe">{{datum .CreatedAt}}</td>
<td class="schmal rechts">
<div class="reihe">
<a href="/p/{{$slug}}/e/{{$eslug}}/versions/{{.Version}}">Änderungen ansehen</a>
{{if $darfSchreiben}}
<form method="POST" action="/p/{{$slug}}/e/{{$eslug}}/versions/{{.Version}}/restore" class="reihe"
data-confirm="Fassung {{.Version}} wiederherstellen? Der aktuelle Stand bleibt im Verlauf erhalten.">
<input type="hidden" name="csrf_token" value="{{$csrf}}">
<input type="hidden" name="current_version" value="{{$aktuell}}">
<button type="submit" class="zweitrangig">Wiederherstellen</button>
</form>
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if not .Data.Versions}}
<div class="leer">
<p>Für diese Fassung gibt es noch keine Vorgänger.</p>
</div>
{{end}}
{{template "fuss" .}}

View file

@ -8,7 +8,7 @@
<div class="seitenkopf-aktionen"> <div class="seitenkopf-aktionen">
<a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/edit">Bearbeiten</a> <a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/edit">Bearbeiten</a>
<form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/delete" class="reihe" <form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/delete" class="reihe"
data-confirm="Eintrag „{{.Data.Entry.Title}}“ wirklich löschen?"> data-confirm="Eintrag „{{.Data.Entry.Title}}“ in den Papierkorb legen? Er lässt sich dort wiederherstellen.">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="warnung">Löschen</button> <button type="submit" class="warnung">Löschen</button>
</form> </form>
@ -19,11 +19,16 @@
<div class="meta"> <div class="meta">
<code>{{.Data.Entry.Slug}}</code> <code>{{.Data.Entry.Slug}}</code>
{{if .Data.Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}} {{if .Data.Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}}
{{if .Data.Stale}}<span class="marke warnung" title="Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt.">über ein Jahr alt</span>{{end}}
<span class="marke">{{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span> <span class="marke">{{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span>
{{$slug := .Project.Slug}} {{$slug := .Project.Slug}}
{{range .Data.Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}} {{range .Data.Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}}
<span>geändert {{datum .Data.Entry.UpdatedAt}}{{if .Data.Entry.Author}} von {{.Data.Entry.Author}}{{end}}</span> <span>geändert {{datum .Data.Entry.UpdatedAt}}{{if .Data.Entry.Author}} von {{.Data.Entry.Author}}{{end}}</span>
{{if gt .Data.Entry.Version 1}}
<span>· <a href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/versions">Fassung {{.Data.Entry.Version}}, Verlauf ansehen</a></span>
{{else}}
<span>· Fassung {{.Data.Entry.Version}}</span> <span>· Fassung {{.Data.Entry.Version}}</span>
{{end}}
</div> </div>
{{/* .HTML ist bereits gerendertes, sicheres HTML — goldmark lässt kein Roh-HTML {{/* .HTML ist bereits gerendertes, sicheres HTML — goldmark lässt kein Roh-HTML

20
templates/graph.html Normal file
View file

@ -0,0 +1,20 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Graph</h1>
<span class="hilfe">Verweise zwischen den Einträgen dieses Projekts. Gestrichelte Knoten sind
Ziele eines <code>[[…]]</code>-Verweises, zu denen es noch keinen Eintrag gibt.</span>
</div>
</div>
<p class="hilfe" data-graph-hinweis hidden></p>
<div class="graph-huelle">
<svg data-graph="/p/{{.Project.Slug}}/graph/data" data-graph-base="/p/{{.Project.Slug}}/e/"
role="img" aria-label="Verweisgraph"></svg>
</div>
<p class="hilfe">Ziehen zum Verschieben, Mausrad zum Zoomen, Klick auf einen Knoten öffnet den Eintrag.</p>
{{template "fuss" .}}

25
templates/hilfe.html Normal file
View file

@ -0,0 +1,25 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>{{.Data.Seite.Titel}}</h1>
<span class="hilfe">{{.Data.Seite.Kurz}}</span>
</div>
</div>
{{/* Die Hilfeseiten stehen als Reiterleiste über dem Text statt in der
Seitenleiste. Die Seitenleiste bleibt so dieselbe wie auf allen anderen
Seiten, und wer die Hilfe liest, sieht trotzdem auf einen Blick, welche
Themen es gibt. */}}
<nav class="hilfe-navigation" aria-label="Hilfethemen">
{{$aktuell := .Data.Seite.Slug}}
{{range .Data.Seiten}}
<a href="/hilfe/{{.Slug}}" {{if eq $aktuell .Slug}}class="aktiv" aria-current="page"{{end}}>{{.Titel}}</a>
{{end}}
</nav>
<article class="eintrag-text hilfe-text">
{{.Data.HTML}}
</article>
{{template "fuss" .}}

82
templates/import.html Normal file
View file

@ -0,0 +1,82 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Import {{template "hilfelink" "weboberflaeche#import"}}</h1>
<span class="hilfe">Markdown-Dateien als ZIP-Archiv hochladen: einen Export aus Munin
oder vorhandene Dokumentation. Jede Datei wird ein Eintrag.</span>
</div>
</div>
<section class="abschnitt">
<div class="karte">
{{/* enctype="multipart/form-data" ist nötig, damit der Browser die Datei
selbst mitschickt und nicht nur ihren Namen. */}}
<form method="POST" action="/p/{{.Project.Slug}}/import" enctype="multipart/form-data" class="formular">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="feld mittel">
<label for="archiv">ZIP-Archiv</label>
<span class="hilfe">Dateien auf <code>.md</code>, auch in Unterordnern, höchstens
{{.Data.MaxDateien}} Stück. Alles andere wird übergangen.</span>
<input type="file" id="archiv" name="archiv" accept=".zip,application/zip" required>
</div>
<div class="feld kurz">
<label for="modus">Wenn es den Bezeichner schon gibt</label>
<select id="modus" name="modus">
<option value="skip" {{if eq .Data.Modus "skip"}}selected{{end}}>überspringen — Vorhandenes bleibt, wie es ist</option>
<option value="overwrite" {{if eq .Data.Modus "overwrite"}}selected{{end}}>überschreiben — alter Stand wandert in den Verlauf</option>
</select>
</div>
<div class="feld ankreuz">
{{/* Beim ersten Aufruf ist die Vorschau angehakt. Wer eine
Vorschau gesehen hat, bekommt das Formular ohne Haken
zurück: Der nächste Schritt ist dann das Übernehmen. */}}
<input type="checkbox" id="vorschau" name="vorschau" value="1" {{if not .Data.Bericht}}checked{{end}}>
<label for="vorschau">
Nur Vorschau
<span class="hilfe">Zeigt, was passieren würde, ohne etwas zu schreiben.</span>
</label>
</div>
<div class="formular-aktionen">
<button type="submit">Hochladen</button>
</div>
</form>
</div>
</section>
{{with .Data.Bericht}}
<section class="abschnitt">
<h2>{{if $.Data.Vorschau}}Vorschau{{else}}Ergebnis{{end}}</h2>
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr>
<th>Datei</th>
<th>Bezeichner</th>
<th>Titel</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{{range .Zeilen}}
<tr>
<td><code>{{.Pfad}}</code></td>
<td>{{if .Slug}}<code>{{.Slug}}</code>{{end}}</td>
<td>{{.Title}}</td>
<td>
<span class="marke{{if eq .Aktion "Fehler"}} warnung{{else if eq .Aktion "neu"}} betont{{end}}">{{.Aktion}}</span>
{{if .Grund}}<span class="hilfe">{{.Grund}}</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
{{template "fuss" .}}

View file

@ -2,7 +2,7 @@
<div class="seitenkopf"> <div class="seitenkopf">
<div class="seitenkopf-titel"> <div class="seitenkopf-titel">
<h1>Zugänge</h1> <h1>Zugänge {{template "hilfelink" "zugaenge"}}</h1>
<span class="hilfe">Ein eigener Key je Mensch und je Agent. Nur so steht im <span class="hilfe">Ein eigener Key je Mensch und je Agent. Nur so steht im
Protokoll, wer etwas geschrieben hat — und ein einzelner Zugang lässt Protokoll, wer etwas geschrieben hat — und ein einzelner Zugang lässt
sich zurückziehen, ohne alle anderen zu stören.</span> sich zurückziehen, ohne alle anderen zu stören.</span>
@ -24,7 +24,7 @@
<summary>MCP-Client einrichten</summary> <summary>MCP-Client einrichten</summary>
<strong>Claude Code</strong> <strong>Claude Code</strong>
<pre><code>claude mcp add --transport http munin-{{.Project.Slug}} https://DEIN-SERVER/mcp \ <pre><code>claude mcp add --transport http munin-{{.Project.Slug}} {{.Data.ServerURL}}/mcp \
--header "Authorization: Bearer {{.Data.NewKey}}"</code></pre> --header "Authorization: Bearer {{.Data.NewKey}}"</code></pre>
<strong>Cline</strong> <span class="hilfe">(<code>cline_mcp_settings.json</code>)</span> <strong>Cline</strong> <span class="hilfe">(<code>cline_mcp_settings.json</code>)</span>
@ -32,9 +32,9 @@
"mcpServers": { "mcpServers": {
"munin-{{.Project.Slug}}": { "munin-{{.Project.Slug}}": {
"type": "streamableHttp", "type": "streamableHttp",
"url": "https://DEIN-SERVER/mcp", "url": "{{.Data.ServerURL}}/mcp",
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" }, "headers": { "Authorization": "Bearer {{.Data.NewKey}}" },
"autoApprove": ["recall", "get", "list"] "autoApprove": ["recall", "get", "list", "related"]
} }
} }
}</code></pre> }</code></pre>
@ -44,7 +44,7 @@
"mcp": { "mcp": {
"munin-{{.Project.Slug}}": { "munin-{{.Project.Slug}}": {
"type": "remote", "type": "remote",
"url": "https://DEIN-SERVER/mcp", "url": "{{.Data.ServerURL}}/mcp",
"enabled": true, "enabled": true,
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" } "headers": { "Authorization": "Bearer {{.Data.NewKey}}" }
} }
@ -54,6 +54,15 @@
</div> </div>
{{end}} {{end}}
{{if .Data.Ungenutzt}}
<div class="meldung fehler">
{{if eq .Data.Ungenutzt 1}}Ein aktiver Zugang wurde{{else}}{{.Data.Ungenutzt}} aktive Zugänge wurden{{end}}
seit über 90 Tagen nicht benutzt. Jeder davon ist ein offener Zugang zu diesem
Projekt. Wenn niemand mehr weiß, wofür ein Key da ist, ist Zurückziehen meist
die richtige Wahl — ein neuer ist schnell angelegt.
</div>
{{end}}
{{if .Data.Keys}} {{if .Data.Keys}}
<div class="tabelle-huelle"> <div class="tabelle-huelle">
<table class="tabelle"> <table class="tabelle">
@ -68,14 +77,17 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{$csrf := .CSRFToken}}{{$slug := .Project.Slug}} {{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}{{$jetzt := .Data.Jetzt}}
{{range .Data.Keys}} {{range .Data.Keys}}
<tr {{if .Revoked}}class="gedaempft"{{end}}> <tr {{if .Revoked}}class="gedaempft"{{end}}>
<td>{{.Name}}</td> <td>{{.Name}}</td>
<td><code>{{.Role}}</code></td> <td><code>{{.Role}}</code></td>
<td>{{datum .CreatedAt}}</td> <td>{{datum .CreatedAt}}</td>
<td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td> <td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td>
<td>{{if .Revoked}}zurückgezogen{{else}}aktiv{{end}}</td> <td>
{{if .Revoked}}zurückgezogen{{else}}aktiv{{end}}
{{if .Unused $jetzt}}<span class="marke warnung" title="Seit über 90 Tagen nicht benutzt">ungenutzt</span>{{end}}
</td>
<td class="schmal rechts"> <td class="schmal rechts">
{{if not .Revoked}} {{if not .Revoked}}
<form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe" <form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe"

View file

@ -4,8 +4,14 @@
<div class="anmeldeseite"> <div class="anmeldeseite">
<div class="schale schmal"> <div class="schale schmal">
{{/* Das Logo gibt es in zwei Fassungen: mit dunklem Schriftzug für
helle Darstellung und mit weißem Schriftzug für dunkle Darstellung.
<picture> wählt anhand der Systemeinstellung die passende aus. */}}
<picture class="anmelde-logo">
<source srcset="/static/img/munin_logo_dark.svg" media="(prefers-color-scheme: dark)">
<img src="/static/img/munin_logo_transparent.svg" alt="Munin" width="160" height="160">
</picture>
<div class="karte"> <div class="karte">
<h1>Munin</h1>
<span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin <span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin
verwendet sein Admin-Token.</span> verwendet sein Admin-Token.</span>

165
templates/nutzung.html Normal file
View file

@ -0,0 +1,165 @@
{{template "kopf" .}}
{{$slug := .Project.Slug}}
{{$u := .Data.Summary}}
{{$gesamt := $u.Total}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Nutzung {{template "hilfelink" "mit-agenten-arbeiten#pruefen-ob-es-wirkt"}}</h1>
<span class="hilfe">Wird Munin in diesem Projekt benutzt — und schreiben die Agenten
vorhandenes Wissen fort, statt Neues daneben zu legen?</span>
</div>
</div>
{{/* ── Bestand ─────────────────────────────────────────────────────────── */}}
<div class="kennzahlen">
<div class="kennzahl">
<span class="kennzahl-wert">{{$u.Entries}}</span>
<span class="kennzahl-name">Einträge</span>
</div>
<div class="kennzahl">
<span class="kennzahl-wert">{{$u.CuratedPercent}} %</span>
<span class="kennzahl-name">kuratiert ({{$u.Curated}})</span>
</div>
<div class="kennzahl">
<span class="kennzahl-wert">{{$u.Untagged}}</span>
<span class="kennzahl-name">ohne Schlagwort</span>
</div>
<div class="kennzahl">
<span class="kennzahl-wert">{{if $u.LastUpdatedAt.IsZero}}—{{else}}{{datum $u.LastUpdatedAt}}{{end}}</span>
<span class="kennzahl-name">zuletzt geändert</span>
</div>
</div>
{{/* ── Schreibvorgänge ─────────────────────────────────────────────────── */}}
<section class="abschnitt">
<h2>Schreibvorgänge der letzten {{.Data.WindowDays}} Tage</h2>
<span class="hilfe">Menschen schreiben über die Weboberfläche, Agenten über MCP.
Wiederherstellen und Sammelbearbeitung zählen als Aktualisierung.</span>
{{if or $gesamt.Writes $gesamt.Deleted}}
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr>
<th>Wer</th>
<th class="zahl">neu angelegt</th>
<th class="zahl">aktualisiert</th>
<th class="zahl">gelöscht</th>
{{if $gesamt.Unknown}}<th class="zahl">ohne Angabe</th>{{end}}
</tr>
</thead>
<tbody>
<tr>
<td>Agenten</td>
<td class="zahl">{{$u.Agent.Created}}</td>
<td class="zahl">{{$u.Agent.Updated}}</td>
<td class="zahl">{{$u.Agent.Deleted}}</td>
{{if $gesamt.Unknown}}<td class="zahl">{{$u.Agent.Unknown}}</td>{{end}}
</tr>
<tr>
<td>Menschen</td>
<td class="zahl">{{$u.Human.Created}}</td>
<td class="zahl">{{$u.Human.Updated}}</td>
<td class="zahl">{{$u.Human.Deleted}}</td>
{{if $gesamt.Unknown}}<td class="zahl">{{$u.Human.Unknown}}</td>{{end}}
</tr>
<tr class="summe">
<td>Zusammen</td>
<td class="zahl">{{$gesamt.Created}}</td>
<td class="zahl">{{$gesamt.Updated}}</td>
<td class="zahl">{{$gesamt.Deleted}}</td>
{{if $gesamt.Unknown}}<td class="zahl">{{$gesamt.Unknown}}</td>{{end}}
</tr>
</tbody>
</table>
</div>
{{/* Der eine Wert, auf den es ankommt. Keine feste Schwelle "gut/schlecht":
Ein junges Projekt legt naturgemäß vor allem an. Wichtig ist, dass der
Anteil mit der Zeit steigt. */}}
{{if ge $u.UpdatePercent 0}}
<p><strong>{{$u.UpdatePercent}} %</strong> der Schreibvorgänge haben einen vorhandenen
Eintrag fortgeschrieben.
<span class="hilfe">Am Anfang legen Teams vor allem an. Steigt der Anteil mit der Zeit,
finden die Agenten vorhandenes Wissen. Bleibt er dauerhaft niedrig, lohnt ein Blick
auf die Einträge: Entstehen Dubletten unter leicht anderem Bezeichner?</span></p>
{{end}}
{{if $gesamt.Unknown}}
<p class="hilfe">„Ohne Angabe“ sind Schreibvorgänge aus der Zeit, bevor das Protokoll
zwischen Anlegen und Aktualisieren unterschied. Sie fallen nach und nach aus dem Zeitraum.</p>
{{end}}
{{else}}
<div class="leer">
<p>In den letzten {{.Data.WindowDays}} Tagen wurde nichts geschrieben.</p>
<p><a href="/hilfe/mcp-einrichten">Einen Agenten anbinden</a> ·
<a href="/hilfe/mit-agenten-arbeiten">So nutzen Agenten das Gedächtnis wirklich</a></p>
</div>
{{end}}
</section>
{{/* ── Verweise ins Leere ─────────────────────────────────────────────── */}}
{{if .Data.Missing}}
<section class="abschnitt">
<h2>Verweise ins Leere</h2>
<span class="hilfe">Bezeichner, auf die mit <code>[[…]]</code> verwiesen wird, zu denen es
aber keinen Eintrag gibt. Oft Wissen, das jemand für wichtig hielt, aber noch niemand
aufgeschrieben hat — die meistgenannten zuerst.</span>
<ul class="kompakt-liste">
{{range .Data.Missing}}
<li>
<code>{{.Slug}}</code>
<span class="hilfe">· {{.Count}}×</span>
{{if $.Session.CanWrite}}· <a href="/p/{{$slug}}/new?slug={{.Slug}}">anlegen</a>{{end}}
</li>
{{end}}
</ul>
{{if .Data.MissingMore}}<p class="hilfe">… und {{.Data.MissingMore}} weitere. Der
<a href="/p/{{$slug}}/graph">Graph</a> zeigt sie alle gestrichelt.</p>{{end}}
</section>
{{end}}
{{/* ── Ohne Schlagwort ─────────────────────────────────────────────────── */}}
{{if .Data.Untagged}}
<section class="abschnitt">
<h2>Einträge ohne Schlagwort</h2>
<span class="hilfe">Schlagworte helfen beim Filtern, Menschen wie Agenten.
Mit der Sammelbearbeitung in der Eintragsliste lassen sie sich schnell nachtragen.</span>
<ul class="kompakt-liste">
{{range .Data.Untagged}}
<li><a href="/p/{{$slug}}/e/{{.Slug}}">{{.Title}}</a> <code class="hilfe">{{.Slug}}</code></li>
{{end}}
</ul>
{{if gt $u.Untagged (len .Data.Untagged)}}
<p class="hilfe">Die {{len .Data.Untagged}} zuletzt geänderten von {{$u.Untagged}}.</p>
{{end}}
</section>
{{end}}
{{/* ── Zugänge ─────────────────────────────────────────────────────────── */}}
<section class="abschnitt">
<h2>Zuletzt aktive Zugänge</h2>
{{if .Data.Keys}}
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr><th>Zugang</th><th>Rolle</th><th>Zuletzt benutzt</th></tr>
</thead>
<tbody>
{{range .Data.Keys}}
<tr>
<td>{{.Name}}</td>
<td><span class="marke">{{.Role}}</span></td>
<td class="hilfe">{{datum .LastUsedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="hilfe">Noch kein Zugang dieses Projekts wurde benutzt.</p>
{{end}}
</section>
{{template "fuss" .}}

116
templates/papierkorb.html Normal file
View file

@ -0,0 +1,116 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Papierkorb {{template "hilfelink" "weboberflaeche#papierkorb"}}</h1>
<span class="hilfe">Gelöschte Einträge liegen hier {{.Data.RetentionDays}} Tage mitsamt ihrem Verlauf
und werden danach endgültig entfernt. Für Agenten und die Suche sind sie
schon jetzt unsichtbar.</span>
</div>
</div>
{{$slug := .Project.Slug}}
{{$von := .Data.Von}}
{{$csrf := .CSRFToken}}
{{if $von}}
<p class="hilfe">Nur, was „{{$von}}“ gelöscht hat ·
<a href="/p/{{$slug}}/trash">Filter aufheben</a></p>
{{end}}
{{if .Data.Entries}}
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr>
<th class="schmal"><span class="unsichtbar">Auswahl</span></th>
<th>Eintrag</th>
<th>Gelöscht von</th>
<th>Gelöscht am</th>
<th class="schmal rechts">Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Data.Entries}}
<tr>
{{/* Das Kästchen steht in der Tabelle, gehört aber zum
Sammelformular unter der Tabelle: Das Attribut form="…"
verbindet ein Feld mit einem Formular, das woanders auf der
Seite steht. So kann jede Zeile zusätzlich ihre eigenen
kleinen Formulare haben — Formulare ineinander zu
schachteln erlaubt HTML nämlich nicht. */}}
<td class="schmal">
<input type="checkbox" name="slugs" value="{{.Slug}}" form="papierkorb-auswahl"
aria-label="{{.Title}} auswählen">
</td>
<td>
{{.Title}}<br>
<code class="hilfe">{{.Slug}}</code>
<span class="hilfe">· Fassung {{.Version}}</span>
</td>
<td>
{{if .DeletedBy}}
<a href="/p/{{$slug}}/trash?von={{.DeletedBy}}" title="Nur zeigen, was dieser Zugang gelöscht hat">{{.DeletedBy}}</a>
{{else}}<span class="hilfe">unbekannt</span>{{end}}
</td>
<td class="hilfe">{{datum .DeletedAt}}</td>
<td class="schmal rechts">
<div class="reihe">
<form method="POST" action="/p/{{$slug}}/trash/restore" class="reihe">
<input type="hidden" name="csrf_token" value="{{$csrf}}">
<input type="hidden" name="von" value="{{$von}}">
<input type="hidden" name="slugs" value="{{.Slug}}">
<button type="submit" class="zweitrangig">Wiederherstellen</button>
</form>
<form method="POST" action="/p/{{$slug}}/trash/purge" class="reihe"
data-confirm="„{{.Title}}“ endgültig löschen? Das lässt sich nicht rückgängig machen — auch der Verlauf ist dann weg.">
<input type="hidden" name="csrf_token" value="{{$csrf}}">
<input type="hidden" name="von" value="{{$von}}">
<input type="hidden" name="slugs" value="{{.Slug}}">
<button type="submit" class="warnung">Endgültig löschen</button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{/* Sammelformular für die angehakten Kästchen. Zwei Knöpfe, zwei Ziele:
formaction am Knopf überschreibt das action des Formulars. Die Rückfrage
steht nur am gefährlichen Knopf (static/confirm.js liest sie dort). */}}
<form method="POST" action="/p/{{$slug}}/trash/restore" id="papierkorb-auswahl" class="sammelleiste">
<input type="hidden" name="csrf_token" value="{{$csrf}}">
<input type="hidden" name="von" value="{{$von}}">
<span class="hilfe">Ausgewählte Einträge:</span>
<button type="submit" class="zweitrangig">Wiederherstellen</button>
<button type="submit" class="warnung" formaction="/p/{{$slug}}/trash/purge"
data-confirm="Die ausgewählten Einträge endgültig löschen? Das lässt sich nicht rückgängig machen.">Endgültig löschen</button>
</form>
{{/* Ist nach einem Zugang gefiltert, lässt sich alles, was er gelöscht hat, in
einem Zug zurückholen — der typische Fall, wenn ein Agent zu gründlich
aufgeräumt hat. Ohne JavaScript: Das Formular trägt jeden angezeigten
Bezeichner als verstecktes Feld. */}}
{{if $von}}
<form method="POST" action="/p/{{$slug}}/trash/restore" class="reihe papierkorb-alle"
data-confirm="Alle {{len .Data.Entries}} Einträge wiederherstellen, die „{{$von}}“ gelöscht hat?">
<input type="hidden" name="csrf_token" value="{{$csrf}}">
<input type="hidden" name="von" value="{{$von}}">
{{range .Data.Entries}}<input type="hidden" name="slugs" value="{{.Slug}}">{{end}}
<button type="submit">Alle {{len .Data.Entries}} von „{{$von}}“ wiederherstellen</button>
</form>
{{end}}
{{else}}
<div class="leer">
{{if $von}}
<p>„{{$von}}“ hat nichts gelöscht, das noch im Papierkorb liegt.</p>
{{else}}
<p>Der Papierkorb ist leer.</p>
{{end}}
</div>
{{end}}
{{template "fuss" .}}

View file

@ -23,17 +23,35 @@
{{define "html-ende"}} {{define "html-ende"}}
<script src="/static/confirm.js"></script> <script src="/static/confirm.js"></script>
<script src="/static/preview.js"></script>
<script src="/static/autocomplete.js"></script>
<script src="/static/graph.js"></script>
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "kopf"}}{{template "html-anfang" .}} {{define "kopf"}}{{template "html-anfang" .}}
{{/* Schmale Kopfleiste über die ganze Breite: Marke und Abmelden. Alles {{/* Schmale Kopfleiste über die ganze Breite: Marke, Workspace-Umschalter und
Weitere steht in der Seitenleiste. */}} Abmelden. Alles Weitere steht in der Seitenleiste.
Der Umschalter steht bewusst hier statt in der Seitenleiste — ein
Plattform-Admin mit mehreren Abteilungen muss auf jeder Seite sehen,
WELCHEN Workspace er gerade bearbeitet, nicht nur auf der Auswahlseite
selbst. Für einen Projekt-Zugang (immer genau ein Workspace) bleibt der
Umschalter weg — WorkspaceName ist dann leer. */}}
<header class="kopfleiste"> <header class="kopfleiste">
<div class="kopfleiste-innen"> <div class="kopfleiste-innen">
<div class="reihe">
<a class="marke-wort" href="/">Munin</a> <a class="marke-wort" href="/">Munin</a>
{{if .Session.WorkspaceName}}
<a class="arbeitsbereich" href="/admin/workspaces">
<span class="arbeitsbereich-trenner" aria-hidden="true">/</span>
<span class="arbeitsbereich-name">{{.Session.WorkspaceName}}</span>
<span class="arbeitsbereich-pfeil" aria-hidden="true">⌄</span>
</a>
{{end}}
</div>
<form method="POST" action="/logout" class="reihe"> <form method="POST" action="/logout" class="reihe">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="wie-link">Abmelden</button> <button type="submit" class="wie-link">Abmelden</button>
@ -71,10 +89,16 @@
<h2 class="nav-titel">{{.Project.Name}}</h2> <h2 class="nav-titel">{{.Project.Name}}</h2>
<ul class="nav-liste"> <ul class="nav-liste">
<li><a href="/p/{{.Project.Slug}}/" {{if eq .Bereich "eintraege"}}class="aktiv"{{end}}>Einträge</a></li> <li><a href="/p/{{.Project.Slug}}/" {{if eq .Bereich "eintraege"}}class="aktiv"{{end}}>Einträge</a></li>
<li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li>
{{if .Session.CanManageKeys}} {{if .Session.CanManageKeys}}
<li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li> <li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li>
<li><a href="/p/{{.Project.Slug}}/import" {{if eq .Bereich "import"}}class="aktiv"{{end}}>Import</a></li>
{{end}} {{end}}
<li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li>
<li><a href="/p/{{.Project.Slug}}/audit" {{if eq .Bereich "protokoll"}}class="aktiv"{{end}}>Protokoll</a></li> <li><a href="/p/{{.Project.Slug}}/audit" {{if eq .Bereich "protokoll"}}class="aktiv"{{end}}>Protokoll</a></li>
{{if .Session.CanWrite}}
<li><a href="/p/{{.Project.Slug}}/trash" {{if eq .Bereich "papierkorb"}}class="aktiv"{{end}}>Papierkorb</a></li>
{{end}}
</ul> </ul>
</nav> </nav>
@ -101,9 +125,18 @@
<h2 class="nav-titel">Plattform</h2> <h2 class="nav-titel">Plattform</h2>
<ul class="nav-liste"> <ul class="nav-liste">
<li><a href="/admin/audit">Protokoll (alle)</a></li> <li><a href="/admin/audit">Protokoll (alle)</a></li>
<li><a href="/admin/workspaces" {{if eq .Bereich "workspaces"}}class="aktiv"{{end}}>Workspaces</a></li>
</ul> </ul>
</nav> </nav>
{{end}} {{end}}
{{/* Die Hilfe steht für alle ganz unten — gleich, mit welcher Rolle
man angemeldet ist. */}}
<nav class="nav-gruppe">
<ul class="nav-liste">
<li><a href="/hilfe" {{if eq .Bereich "hilfe"}}class="aktiv"{{end}}>Hilfe &amp; Einstieg</a></li>
</ul>
</nav>
</aside> </aside>
<main class="inhalt"> <main class="inhalt">
@ -111,6 +144,20 @@
{{if .Flash}}<div class="meldung hinweis">{{.Flash}}</div>{{end}} {{if .Flash}}<div class="meldung hinweis">{{.Flash}}</div>{{end}}
{{end}} {{end}}
{{/* hilfelink setzt ein kleines "?" neben eine Überschrift oder ein Feld, das
zur passenden Stelle der eingebauten Hilfe führt (AUSBAUSTUFEN.md 5.4).
Aufruf mit dem Ziel unterhalb von /hilfe/, auf Wunsch mit Abschnitt:
{{template "hilfelink" "weboberflaeche#markdown-im-inhalt"}}
Die Abschnittsnamen entstehen aus den Überschriften der Hilfeseiten
(internal/markdown, ankerIDs). Ein Test in web_test.go prüft, dass jedes
Ziel in den Templates wirklich existiert — wer eine Überschrift umbenennt,
merkt es also.
Die Hilfe öffnet in einem neuen Tab: Wer mitten im Bearbeiten nachschlägt,
soll dabei nicht seinen ungespeicherten Text verlieren. rel="noopener"
verhindert, dass der neue Tab auf das Fenster dieser Seite zugreifen kann. */}}
{{define "hilfelink"}}<a class="hilfe-link" href="/hilfe/{{.}}" target="_blank" rel="noopener" title="Hilfe zu diesem Thema (neuer Tab)" aria-label="Hilfe zu diesem Thema (öffnet in neuem Tab)">?</a>{{end}}
{{define "fuss"}} {{define "fuss"}}
</main> </main>
</div> </div>

View file

@ -2,7 +2,7 @@
<div class="seitenkopf"> <div class="seitenkopf">
<div class="seitenkopf-titel"> <div class="seitenkopf-titel">
<h1>Projekte</h1> <h1>Projekte {{template "hilfelink" "plattform-admin#projekte"}}</h1>
<span class="hilfe">Jedes Projekt hat eigene Einträge, eigene Zugänge und einen <span class="hilfe">Jedes Projekt hat eigene Einträge, eigene Zugänge und einen
eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span> eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span>
</div> </div>
@ -26,6 +26,8 @@
<th>Projekt</th> <th>Projekt</th>
<th>Bezeichner</th> <th>Bezeichner</th>
<th class="zahl">Einträge</th> <th class="zahl">Einträge</th>
<th class="zahl">Schreibvorgänge<div class="hilfe">letzte 30 Tage</div></th>
<th>Zuletzt geändert</th>
<th class="zahl">Schreibbudget</th> <th class="zahl">Schreibbudget</th>
<th class="schmal rechts">Verwaltung</th> <th class="schmal rechts">Verwaltung</th>
</tr> </tr>
@ -38,7 +40,23 @@
{{if .Project.Description}}<div class="hilfe">{{.Project.Description}}</div>{{end}} {{if .Project.Description}}<div class="hilfe">{{.Project.Description}}</div>{{end}}
</td> </td>
<td><code>{{.Project.Slug}}</code></td> <td><code>{{.Project.Slug}}</code></td>
<td class="zahl">{{.Entries}}</td> <td class="zahl">
{{.Entries}}
{{if .Entries}}<div class="hilfe">{{.Usage.CuratedPercent}} % kuratiert</div>{{end}}
</td>
{{/* Schreibvorgänge der letzten 30 Tage: Gesamtzahl, darunter die
Aufteilung. "keine" ist das Signal, auf das es hier ankommt —
dieses Team benutzt Munin gerade nicht. */}}
{{$t := .Usage.Total}}
<td class="zahl">
{{if $t.Writes}}
{{$t.Writes}}
<div class="hilfe">{{$t.Created}} neu · {{$t.Updated}} akt. · Agenten {{.Usage.Agent.Writes}}</div>
{{else}}
<span class="marke">keine</span>
{{end}}
</td>
<td class="hilfe">{{if .Usage.LastUpdatedAt.IsZero}}—{{else}}{{datum .Usage.LastUpdatedAt}}{{end}}</td>
<td class="zahl"> <td class="zahl">
{{if .Limit}} {{if .Limit}}
{{.Frei}} / {{.Limit}} {{.Frei}} / {{.Limit}}
@ -51,6 +69,7 @@
</td> </td>
<td class="schmal rechts"> <td class="schmal rechts">
<a href="/admin/projects/{{.Project.Slug}}/edit">Bearbeiten</a> · <a href="/admin/projects/{{.Project.Slug}}/edit">Bearbeiten</a> ·
<a href="/p/{{.Project.Slug}}/usage">Nutzung</a> ·
<a href="/p/{{.Project.Slug}}/keys">Zugänge</a> <a href="/p/{{.Project.Slug}}/keys">Zugänge</a>
</td> </td>
</tr> </tr>

View file

@ -0,0 +1,54 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>{{.Data.Workspace.Name}}</h1>
<span class="hilfe">Angelegt am {{.Data.Workspace.CreatedAt}} · {{.Data.Projects}} Projekt(e)</span>
</div>
<div class="seitenkopf-aktionen">
<a class="knopf zweitrangig" href="/admin/workspaces">Zur Übersicht</a>
</div>
</div>
<div class="karte">
<form method="POST" action="/admin/workspaces/{{.Data.Workspace.ID}}/edit" class="formular">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="feld mittel">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Data.Workspace.Name}}" required autofocus>
</div>
<div class="formular-aktionen">
<button type="submit">Speichern</button>
<a class="knopf zweitrangig" href="/admin/workspaces">Abbrechen</a>
</div>
</form>
</div>
<section class="abschnitt">
<h2>Gefahrenzone</h2>
<span class="hilfe">Das Löschen entfernt alle {{.Data.Projects}} Projekte dieses Workspace samt
ihrer Einträge und Zugänge unwiederbringlich — die Workspace-Datei wird von der Platte
entfernt. Der Markdown-Export bleibt erhalten, sofern einer eingerichtet ist.</span>
<div class="karte gefahrenzone">
<form method="POST" action="/admin/workspaces/delete" class="formular"
data-confirm="Workspace wirklich löschen? Alle Projekte, Einträge und Zugänge gehen unwiederbringlich verloren.">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="id" value="{{.Data.Workspace.ID}}">
<div class="feld kurz">
<label for="confirm">Name zur Bestätigung</label>
<span class="hilfe">Zum Löschen <code>{{.Data.Workspace.Name}}</code> eintippen.</span>
<input type="text" id="confirm" name="confirm" autocomplete="off">
</div>
<div class="formular-aktionen">
<button type="submit" class="warnung">Workspace löschen</button>
</div>
</form>
</div>
</section>
{{template "fuss" .}}

80
templates/workspaces.html Normal file
View file

@ -0,0 +1,80 @@
{{template "kopf" .}}
<div class="seitenkopf">
<div class="seitenkopf-titel">
<h1>Workspaces {{template "hilfelink" "plattform-admin#workspaces"}}</h1>
<span class="hilfe">Jede Abteilung hat ihren eigenen Workspace — eigene Projekte,
eigene Einträge, eigene Datei. Sie sehen sich gegenseitig nicht.</span>
</div>
</div>
{{if .Data.Workspaces}}
<div class="tabelle-huelle">
<table class="tabelle">
<thead>
<tr>
<th>Workspace</th>
<th class="zahl">Projekte</th>
<th>Angelegt</th>
<th class="schmal rechts">Auswahl</th>
<th class="schmal rechts">Verwaltung</th>
</tr>
</thead>
<tbody>
{{$aktuell := .Data.Current}}
{{range .Data.Workspaces}}
<tr>
<td>
{{.Name}}
{{if eq .ID $aktuell}}<span class="marke betont">Aktuell</span>{{end}}
</td>
<td class="zahl">{{.Projects}}</td>
<td class="hilfe">{{.CreatedAt}}</td>
<td class="schmal rechts">
{{if eq .ID $aktuell}}
<a href="/admin/projects" class="knopf zweitrangig">Weiter</a>
{{else}}
<form method="POST" action="/admin/workspaces/select" class="reihe">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="zweitrangig">Öffnen</button>
</form>
{{end}}
</td>
<td class="schmal rechts">
<a href="/admin/workspaces/{{.ID}}/edit">Bearbeiten</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="leer">
<p>Noch kein Workspace angelegt.</p>
<p class="hilfe">Ein Workspace ist die Abschottungsgrenze zwischen Abteilungen — meist einer je Team.</p>
</div>
{{end}}
<section class="abschnitt">
<h2>Neuer Workspace</h2>
<span class="hilfe">Für eine neue Abteilung. Projekte werden danach innerhalb
des Workspace angelegt.</span>
<div class="karte">
<form method="POST" action="/admin/workspaces/create" class="formular">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="feld mittel">
<label for="name">Name</label>
<input type="text" id="name" name="name" required placeholder="z.B. Marketing" autofocus>
</div>
<div class="formular-aktionen">
<button type="submit">Workspace anlegen</button>
</div>
</form>
</div>
</section>
{{template "fuss" .}}