Compare commits
4 commits
f846955e58
...
c4753c5fdb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4753c5fdb | ||
|
|
cf35c3f5db | ||
|
|
c4a1941e7f | ||
|
|
466f1e40af |
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
678
AUSBAUSTUFEN.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
178
README.md
|
|
@ -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/
|
||||||
plattform/
|
mfk3x9a2-1a2b3c4d/ ← Workspace-ID
|
||||||
_index.md
|
_workspace.md ← Name des Workspace
|
||||||
deployment.md
|
plattform/
|
||||||
server-zugang.md
|
_index.md
|
||||||
|
deployment.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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
229
REPOMAP.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
||||||
73
internal/entry/diff_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
|
}
|
||||||
110
internal/hilfe/hilfe_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/hilfe/seiten/begriffe.md
Normal 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.
|
||||||
127
internal/hilfe/seiten/mcp-einrichten.md
Normal 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.
|
||||||
155
internal/hilfe/seiten/mit-agenten-arbeiten.md
Normal 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*.
|
||||||
60
internal/hilfe/seiten/plattform-admin.md
Normal 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.
|
||||||
45
internal/hilfe/seiten/start.md
Normal 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.
|
||||||
183
internal/hilfe/seiten/weboberflaeche.md
Normal 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.
|
||||||
82
internal/hilfe/seiten/zugaenge.md
Normal 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
|
|
@ -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
|
||||||
|
}
|
||||||
254
internal/importer/importer.go
Normal 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))
|
||||||
|
}
|
||||||
319
internal/importer/importer_test.go
Normal 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
|
|
@ -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
|
||||||
|
}
|
||||||
148
internal/markdown/markdown.go
Normal 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
|
||||||
|
}
|
||||||
90
internal/markdown/markdown_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: projekt.ID, Role: string(rolle)},
|
WorkspaceID: ws.ID,
|
||||||
Project: projekt,
|
Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: projekt.ID, Role: string(rolle)},
|
||||||
Role: rolle,
|
Project: projekt,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,22 @@ import (
|
||||||
// Aktionen, die im Protokoll auftauchen. Als Konstanten, damit Auswertungen
|
// Aktionen, die im Protokoll auftauchen. Als Konstanten, damit Auswertungen
|
||||||
// nicht an Tippfehlern scheitern.
|
// nicht an Tippfehlern scheitern.
|
||||||
const (
|
const (
|
||||||
ActionEntryWrite = "entry_write"
|
ActionEntryWrite = "entry_write"
|
||||||
ActionEntryDelete = "entry_delete"
|
|
||||||
|
// 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"
|
||||||
|
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
|
|
@ -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
|
||||||
|
}
|
||||||
100
internal/store/control_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 '',
|
||||||
|
|
|
||||||
43
internal/store/migrations/004_aenderungshistorie.sql
Normal 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);
|
||||||
41
internal/store/migrations/005_papierkorb.sql
Normal 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;
|
||||||
33
internal/store/migrations_control/001_init.sql
Normal 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);
|
||||||
|
|
@ -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
|
|
@ -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()
|
||||||
|
}
|
||||||
150
internal/store/nutzung_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
209
internal/store/papierkorb.go
Normal 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
|
||||||
|
}
|
||||||
296
internal/store/papierkorb_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
||||||
197
internal/store/related_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
if err != nil {
|
// Die Eintragszahl steckt mit darin.
|
||||||
return items, err
|
nutzung, err := ws.Store.UsageSummaries(r.Context(), "", time.Now())
|
||||||
}
|
if err != nil {
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range projekte {
|
||||||
|
u := nutzung[p.ID] // fehlt das Projekt in der map, ist alles 0 — stimmt dann auch
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, ziel, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEntryDelete löscht einen Eintrag.
|
// 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.
|
||||||
|
|
|
||||||
119
internal/web/handlers_hilfe.go
Normal 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
|
||||||
|
}
|
||||||
142
internal/web/handlers_import.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
86
internal/web/handlers_nutzung.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
183
internal/web/handlers_papierkorb.go
Normal 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))
|
||||||
|
}
|
||||||
243
internal/web/handlers_workspaces.go
Normal 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
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,26 +88,50 @@ 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,
|
||||||
Key: resolved,
|
WorkspaceID: workspaceID,
|
||||||
Project: project,
|
Key: resolved,
|
||||||
Role: auth.Role(resolved.Role),
|
Project: project,
|
||||||
|
Role: auth.Role(resolved.Role),
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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,26 +404,42 @@ 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 {
|
if err == nil {
|
||||||
s.limiter.Reset(ip)
|
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 {
|
||||||
|
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,
|
||||||
Key: resolved.Key,
|
WorkspaceID: workspaceID,
|
||||||
Project: resolved.Project,
|
Key: resolved.Key,
|
||||||
Role: auth.Role(resolved.Key.Role),
|
Project: resolved.Project,
|
||||||
|
Role: auth.Role(resolved.Key.Role),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := withSession(r.Context(), sess)
|
||||||
|
ctx = withProject(ctx, resolved.Project)
|
||||||
|
ctx = withWorkspace(ctx, ws)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := withSession(r.Context(), sess)
|
|
||||||
ctx = withProject(ctx, resolved.Project)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
105
internal/workspace/workspace.go
Normal 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
|
||||||
|
}
|
||||||
82
internal/workspace/workspace_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
213
main.go
|
|
@ -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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
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 {
|
if err != nil {
|
||||||
log.Printf("Protokoll konnte nicht aufgeräumt werden: %v", err)
|
log.Printf("Protokoll konnte nicht vollständig aufgeräumt werden: %v", err)
|
||||||
return
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
log.Printf("Protokoll aufgeräumt: %d Einträge älter als %d Tage entfernt", n, int(auditRetention.Hours()/24))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
123
main_test.go
Normal 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
|
|
@ -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
|
|
@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 120 KiB |
86
static/preview.js
Normal 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.";
|
||||||
|
});
|
||||||
|
}
|
||||||
323
static/style.css
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,24 +28,49 @@
|
||||||
{{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>
|
||||||
<a class="eintrag-titel" href="/p/{{$slug}}/e/{{.Entry.Slug}}">{{.Entry.Title}}</a>
|
{{if $sammelbearbeitung}}
|
||||||
|
<label class="eintrag-auswahl">
|
||||||
|
<input type="checkbox" name="ids" value="{{.Entry.ID}}" aria-label="{{.Entry.Title}} auswählen">
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="meta">
|
<div class="eintrag-inhalt">
|
||||||
{{if .Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}}
|
<a class="eintrag-titel" href="/p/{{$slug}}/e/{{.Entry.Slug}}">{{.Entry.Title}}</a>
|
||||||
<span class="marke">{{if eq .Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span>
|
|
||||||
{{range .Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}}
|
<div class="meta">
|
||||||
<span>{{datum .Entry.UpdatedAt}}</span>
|
{{if .Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}}
|
||||||
{{if .Entry.Author}}<span>· {{.Entry.Author}}</span>{{end}}
|
<span class="marke">{{if eq .Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span>
|
||||||
|
{{range .Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}}
|
||||||
|
<span>{{datum .Entry.UpdatedAt}}</span>
|
||||||
|
{{if .Entry.Author}}<span>· {{.Entry.Author}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Snippet}}<p class="eintrag-ausschnitt">{{.Snippet}}</p>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Snippet}}<p class="eintrag-ausschnitt">{{.Snippet}}</p>{{end}}
|
|
||||||
</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
|
|
@ -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" .}}
|
||||||
67
templates/entry-versions.html
Normal 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" .}}
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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" .}}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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" .}}
|
||||||
|
|
@ -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">
|
||||||
<a class="marke-wort" href="/">Munin</a>
|
<div class="reihe">
|
||||||
|
<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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
54
templates/workspace-edit.html
Normal 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
|
|
@ -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" .}}
|
||||||