Compare commits
No commits in common. "c4753c5fdb13b9b178d6b31c06a428334826f609" and "f846955e58b09c88c97bb369e7dfe837da81d753" have entirely different histories.
c4753c5fdb
...
f846955e58
|
|
@ -18,10 +18,8 @@ ADMIN_TOKEN=
|
|||
|
||||
# ── Server ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Datenverzeichnis: enthält die zentrale Kontroll-Datenbank (control.db) und
|
||||
# den Ordner workspaces/ mit einer SQLite-Datei je Workspace (Abteilung).
|
||||
# Wird beim Start angelegt.
|
||||
DATA_DIR=data
|
||||
# Pfad zur SQLite-Datenbank. Der Ordner wird beim Start angelegt.
|
||||
DB_PATH=data/munin.db
|
||||
|
||||
# Port, auf dem der Server lauscht.
|
||||
PORT=8080
|
||||
|
|
|
|||
3
.gitignore
vendored
|
|
@ -4,9 +4,6 @@ munin
|
|||
# Datenbank und alles darunter — gehört nicht ins Repository
|
||||
data/
|
||||
|
||||
# Ziel von scripts/backup.sh, falls lokal ohne eigenes BACKUP_DIR aufgerufen
|
||||
backups/
|
||||
|
||||
# Geheimnisse — NIEMALS einchecken
|
||||
.env
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@
|
|||
Wie man KI-Werkzeuge so einrichtet und anspricht, dass sie den Munin
|
||||
tatsächlich benutzen.
|
||||
|
||||
> Dieselben Inhalte stehen für Nutzer auch in der Weboberfläche unter
|
||||
> `/hilfe/mit-agenten-arbeiten` (Quelle: `internal/hilfe/seiten/`). Wer hier
|
||||
> etwas ändert, sollte dort nachziehen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Verbinden
|
||||
|
|
@ -37,7 +33,6 @@ und im Protokoll.
|
|||
| `recall` | `query`, `tags?`, `limit?` | read |
|
||||
| `get` | `slug` | read |
|
||||
| `list` | `tag?`, `curated?`, `limit?` | read |
|
||||
| `related` | `slug`, `distance?`, `limit?` | read |
|
||||
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write |
|
||||
| `forget` | `slug` | write |
|
||||
|
||||
|
|
@ -63,14 +58,11 @@ eigenen Anwendung:
|
|||
## 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.
|
||||
get, list, 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.
|
||||
|
|
@ -179,7 +171,7 @@ sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Hak
|
|||
|
||||
| Rolle | darf | wofür |
|
||||
|---|---|---|
|
||||
| `read` | recall, get, list, related | Agenten, die nur auskunftsfähig sein sollen |
|
||||
| `read` | recall, get, list | Agenten, die nur auskunftsfähig sein sollen |
|
||||
| `write` | zusätzlich remember, forget | der Normalfall |
|
||||
| `admin` | zusätzlich Zugänge verwalten | Menschen |
|
||||
|
||||
|
|
@ -192,15 +184,8 @@ die Einträge auch durchsieht.
|
|||
|
||||
- **Suche ist wortbasiert.** „wie deployen wir" findet „Release-Prozess" nicht.
|
||||
Im Zweifel `list` für den Überblick.
|
||||
- **`forget` legt in den Papierkorb.** Der Eintrag ist für Agenten sofort
|
||||
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.
|
||||
- **Kein Zurück.** `forget` löscht endgültig, alte Fassungen werden nicht
|
||||
aufgehoben. Veraltetes lieber mit `remember` richtigstellen.
|
||||
- **Was drinsteht, landet im Kontext aller.** Wer schreiben darf, schreibt
|
||||
indirekt in die Prompts der anderen — Herkunft und Autor stehen deshalb an
|
||||
jedem Eintrag.
|
||||
|
|
|
|||
678
AUSBAUSTUFEN.md
|
|
@ -54,12 +54,7 @@ bliebe also erhalten.
|
|||
|
||||
### 1.2 Eine SQLite-Datei je Projekt
|
||||
|
||||
> **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
|
||||
**Stand heute:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen
|
||||
zum Lesen, eine zum Schreiben.
|
||||
|
||||
**Gemessen:**
|
||||
|
|
@ -199,7 +194,7 @@ Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht.
|
|||
|
||||
**Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben.
|
||||
|
||||
### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
|
||||
### 3.2 Importer als Gegenstück zum Export
|
||||
|
||||
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
|
||||
Felder im Kopf, ein Einlesen wäre also möglich.
|
||||
|
|
@ -210,40 +205,6 @@ Erstbefüllung aus vorhandener Dokumentation.
|
|||
**Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert
|
||||
— ü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
|
||||
|
||||
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
|
||||
|
|
@ -290,7 +251,7 @@ Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen
|
|||
|
||||
**Vorbild:** Engrams `mem_suggest_topic_key`.
|
||||
|
||||
### 3.5 Aktualität von Wissen — kleiner Schritt ✅ umgesetzt
|
||||
### 3.5 Aktualität von Wissen
|
||||
|
||||
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
|
||||
|
|
@ -310,46 +271,23 @@ schnell abläuft — ein Zugangsverfahren altert schneller als eine
|
|||
Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein
|
||||
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,
|
||||
mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich
|
||||
ausgebauter als das, was wir bräuchten — aber die Richtung stimmt.
|
||||
|
||||
### 3.6 Änderungshistorie — ✅ umgesetzt
|
||||
### 3.6 Änderungshistorie
|
||||
|
||||
`entries.version` zählt hoch, aber alte Fassungen wurden nicht aufgehoben.
|
||||
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg.
|
||||
`entries.version` zählt hoch, aber alte Fassungen werden nicht aufgehoben.
|
||||
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, ist das Richtige weg.
|
||||
|
||||
**Umgesetzt:** Tabelle `entry_versions`
|
||||
(`migrations/004_aenderungshistorie.sql`). `entries` bleibt die aktuelle
|
||||
Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht
|
||||
beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen
|
||||
Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird.
|
||||
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.
|
||||
**Lösung:** Tabelle `entry_versions` mit dem vorherigen Inhalt bei jedem
|
||||
Schreibvorgang. Kostet Platz, gibt aber ein Zurück — und macht die Frage „wer hat
|
||||
das wann geändert" beantwortbar.
|
||||
|
||||
Die Alternative "Markdown-Export in einem Git-Repository" bleibt weiterhin
|
||||
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.
|
||||
**Alternative ohne Aufwand:** der Markdown-Export in einem Git-Repository. Dann
|
||||
liegt die Historie dort, mit `git log` und `git diff`.
|
||||
|
||||
### 3.7 Verwandte Einträge — ✅ umgesetzt
|
||||
### 3.7 Verwandte Einträge
|
||||
|
||||
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
|
||||
|
|
@ -357,16 +295,6 @@ ein zweiter Weg zum Kontext — neben der Suche.
|
|||
|
||||
**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
|
||||
|
||||
Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in
|
||||
|
|
@ -409,73 +337,6 @@ 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
|
||||
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
|
||||
|
|
@ -527,482 +388,32 @@ Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf
|
|||
**Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an
|
||||
Personen statt an Keys.
|
||||
|
||||
### 4.4 Mehrere Workspaces — ✅ umgesetzt
|
||||
### 4.4 Mehrere Workspaces
|
||||
|
||||
**Ausgangslage:** Die Spalte `workspace_id` stand von Anfang an in allen Tabellen,
|
||||
es gab aber nur einen Workspace und keine Oberfläche dafür. Nötig, sobald
|
||||
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.
|
||||
Die Spalte `workspace_id` steckt von Anfang an in allen Tabellen, es gibt aber nur
|
||||
einen und keine Oberfläche dafür. Nötig, sobald Abteilungen sich gegenseitig
|
||||
nicht sehen sollen.
|
||||
|
||||
### 4.5 Kennzahlen
|
||||
|
||||
Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
|
||||
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
|
||||
|
||||
| | Nutzen | Aufwand |
|
||||
|---|---|---|
|
||||
| ✅ Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) |
|
||||
| ✅ Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel |
|
||||
| ✅ Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch |
|
||||
| ✅ Vergleich zweier Fassungen | setzt 3.6 voraus | mittel |
|
||||
| ✅ 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 |
|
||||
| Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) |
|
||||
| Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel |
|
||||
| Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch |
|
||||
| Vergleich zweier Fassungen | setzt 3.6 voraus | mittel |
|
||||
| Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering |
|
||||
|
||||
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
|
||||
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
|
||||
|
|
@ -1017,39 +428,12 @@ sichtbarer Gewinn:
|
|||
|
||||
Als Nächstes:
|
||||
|
||||
*Stand der Priorisierung: 2026-09-24.*
|
||||
|
||||
**Vor dem zweiten Workspace**
|
||||
|
||||
1. ~~**Offene Reste aus 4.4** (WP12 und WP13): Protokoll aufräumen und Export
|
||||
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.
|
||||
1. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
|
||||
Das Zugriffsprotokoll und `/p/<projekt>/audit` liefern jetzt die Daten dafür:
|
||||
Wie viele `remember`-Aufrufe legen neu an, wie viele aktualisieren?
|
||||
2. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
|
||||
beide ändern den MCP-Vertrag und sollten bewusst entschieden werden, nicht
|
||||
nebenbei.
|
||||
|
||||
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
|
||||
|
|
@ -1057,12 +441,8 @@ 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
|
||||
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.
|
||||
|
||||
**Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken,
|
||||
semantische Suche, Zugriff über ein Projekt hinaus (4.9). Alle vier sind gute
|
||||
Ideen für den Fall, dass ihre Auslöser eintreten — und vier gute Wege, Zeit in
|
||||
Probleme zu stecken, die es noch nicht gibt.
|
||||
semantische Suche. Alle drei sind gute Ideen für den Fall, dass ihre Auslöser
|
||||
eintreten — und drei gute Wege, Zeit in Probleme zu stecken, die es noch nicht gibt.
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ COPY --from=builder /app/munin .
|
|||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV DATA_DIR=/data
|
||||
ENV DB_PATH=/data/munin.db
|
||||
ENV PORT=8080
|
||||
|
||||
# Im Container steht praktisch immer ein Reverse-Proxy mit HTTPS davor.
|
||||
|
|
|
|||
170
README.md
|
|
@ -14,8 +14,8 @@ Unterschied ist ein Feld am Eintrag, kein zweites System.
|
|||
|
||||
```
|
||||
KI-Werkzeug ──MCP──┐
|
||||
├──▶ Workspace ──▶ Projekt ──▶ Einträge
|
||||
Browser ──HTTP─────┘ (eine SQLite-Datei je Workspace)
|
||||
├──▶ Projekt ──▶ Einträge (SQLite)
|
||||
Browser ──HTTP─────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -52,12 +52,9 @@ Dann `http://localhost:8080` öffnen und mit dem `ADMIN_TOKEN` anmelden.
|
|||
|
||||
Der weitere Weg:
|
||||
|
||||
1. **Workspace wählen** — nach der Anmeldung landet der Plattform-Admin unter
|
||||
*Workspaces*. Beim ersten Start legt der Server den Workspace „Intern“ selbst
|
||||
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)
|
||||
1. **Projekt anlegen** unter *Projekte*
|
||||
2. **Zugang erzeugen** — der Key wird genau einmal angezeigt
|
||||
3. **MCP-Client einrichten** (die Anleitung erscheint direkt neben dem neuen Key)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -65,13 +62,11 @@ Der weitere Weg:
|
|||
|
||||
| Begriff | Bedeutung |
|
||||
|---|---|
|
||||
| **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. |
|
||||
| **Projekt** | Die Einheit der Abschottung. Eigene Einträge, eigene Zugänge, eigener MCP-Endpunkt. Projekte sehen sich gegenseitig nicht. |
|
||||
| **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. |
|
||||
| **API-Key** | Zugang zu genau einem Projekt, mit Namen und Rolle. Wird nur als Prüfsumme gespeichert. |
|
||||
| **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. |
|
||||
| **Plattform-Admin** | Der `ADMIN_TOKEN`. Legt Projekte an, kommt an jedes Projekt. |
|
||||
|
||||
### Warum Bezeichner statt Ordner
|
||||
|
||||
|
|
@ -89,7 +84,7 @@ der Detailansicht wie auch in der MCP-Antwort.
|
|||
## MCP einrichten
|
||||
|
||||
Nach dem Erzeugen eines Keys zeigt die Weboberfläche den passenden Abschnitt an.
|
||||
Der Key bestimmt Workspace und Projekt — ein zusätzlicher Parameter ist nicht nötig. Wer mit
|
||||
Der Key bestimmt das Projekt — ein zusätzlicher Parameter ist nicht nötig. Wer mit
|
||||
mehreren Projekten arbeitet, trägt mehrere Server mit je eigenem Key ein.
|
||||
|
||||
Alle Clients sprechen denselben Endpunkt `…/mcp` über Streamable HTTP an. Die
|
||||
|
|
@ -148,13 +143,13 @@ Kommandozeilenfassung liegt dieselbe Datei unter `~/.cline/mcp.json`.
|
|||
"url": "https://munin.intern/mcp",
|
||||
"headers": { "Authorization": "Bearer wk_…" },
|
||||
"disabled": false,
|
||||
"autoApprove": ["recall", "get", "list", "related"]
|
||||
"autoApprove": ["recall", "get", "list"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier
|
||||
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei
|
||||
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.
|
||||
`remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern
|
||||
|
|
@ -208,9 +203,8 @@ Wie man Agenten so anspricht, dass sie den Speicher auch nutzen:
|
|||
| `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. |
|
||||
| `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. |
|
||||
| `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. |
|
||||
| `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). |
|
||||
| `forget` | write | Löscht einen Eintrag. |
|
||||
|
||||
Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das
|
||||
Vorgehen beschreibt — vor allem: *vor* dem Schreiben erst `recall` aufrufen. Das
|
||||
|
|
@ -258,54 +252,14 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
|
|||
| Weg | Zweck |
|
||||
|---|---|
|
||||
| `/login` | Anmeldung mit API-Key oder Admin-Token |
|
||||
| `/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` | Projekte anlegen und auflisten (nur Plattform-Admin) |
|
||||
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
|
||||
| `/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}/` | Einträge durchsuchen, nach Schlagwort filtern |
|
||||
| `/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}/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}/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 |
|
||||
|
||||
### 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
|
||||
|
||||
| Rolle | Darf |
|
||||
|
|
@ -322,7 +276,7 @@ funktioniert auch ohne.
|
|||
|---|---|---|
|
||||
| `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. |
|
||||
| `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 16 Zeichen. |
|
||||
| `DATA_DIR` | `data` | Datenverzeichnis: Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace |
|
||||
| `DB_PATH` | `data/munin.db` | Pfad zur SQLite-Datenbank |
|
||||
| `PORT` | `8080` | Port des Servers |
|
||||
| `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. |
|
||||
|
|
@ -343,7 +297,6 @@ besser als unsicherer Betrieb.
|
|||
|
||||
| Schutz | Wo |
|
||||
|---|---|
|
||||
| Workspaces in getrennten Datenbankdateien | `internal/workspace/workspace.go`, `internal/store/control.go` |
|
||||
| Signierte Sitzungs-Cookies (HMAC-SHA256) | `internal/auth/session.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` |
|
||||
|
|
@ -359,9 +312,6 @@ besser als unsicherer Betrieb.
|
|||
|
||||
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
|
||||
dieses Projekt verdrahtet sind
|
||||
- Jedes Projekt hat einen **eigenen Suchindex** — auch die BM25-Gewichtung wird
|
||||
|
|
@ -397,8 +347,8 @@ trägt Herkunft (`human`/`agent`) und den Namen des verwendeten Zugangs, und jed
|
|||
Projektnamen nicht selbst korrigieren. Solange es wenige Projekte gibt, ist
|
||||
eine Berechtigungsstufe weniger den Umweg wert.
|
||||
- **Kein TLS im Server selbst.** Für HTTPS gehört ein Reverse-Proxy davor.
|
||||
- **Ein Admin-Token für alle Workspaces.** Es gibt keinen Admin je Workspace.
|
||||
Wer den `ADMIN_TOKEN` hat, kommt an jeden Workspace.
|
||||
- **Ein Workspace.** Die Struktur für mehrere ist im Schema angelegt
|
||||
(`workspace_id`), aber nicht in der Oberfläche.
|
||||
- **Das Schreibbudget zählt im Arbeitsspeicher.** Nach einem Neustart steht es
|
||||
wieder bei null. Als Netz gegen eine Schleife reicht das; eine verbindliche
|
||||
Zusage ist es nicht.
|
||||
|
|
@ -439,52 +389,25 @@ gleich alle aussperren.
|
|||
|
||||
### Datensicherung
|
||||
|
||||
**Datenbank.** Jeder Workspace (Abteilung) liegt in einer eigenen Datei unter
|
||||
`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:
|
||||
**Datenbank.** Alles steckt in einer Datei. Weil SQLite im WAL-Modus läuft, sollte
|
||||
sie nicht im laufenden Betrieb kopiert werden:
|
||||
|
||||
```bash
|
||||
DATA_DIR=/pfad/data BACKUP_DIR=/pfad/sicherungen scripts/backup.sh
|
||||
sqlite3 /pfad/munin.db ".backup '/sicherung/wissen-$(date +%F).db'"
|
||||
```
|
||||
|
||||
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
|
||||
aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace,
|
||||
darin ein Ordner je Projekt, eine Datei je Eintrag und eine `_index.md` als
|
||||
Übersicht:
|
||||
täglich als Markdown-Dateien dorthin — ein Ordner je Projekt, eine Datei je
|
||||
Eintrag, dazu eine `_index.md` als Übersicht:
|
||||
|
||||
```
|
||||
export/
|
||||
mfk3x9a2-1a2b3c4d/ ← Workspace-ID
|
||||
_workspace.md ← Name des Workspace
|
||||
plattform/
|
||||
_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,
|
||||
Fassung, Zeitstempel), sodass sich der Bestand daraus auch wieder einlesen ließe.
|
||||
|
||||
|
|
@ -493,16 +416,10 @@ keine Datei an und erzeugt deshalb auch keinen Diff.
|
|||
|
||||
```bash
|
||||
# Auf Knopfdruck: "Jetzt exportieren" unter /admin/projects
|
||||
# (exportiert nur den gerade gewählten Workspace)
|
||||
|
||||
# Für Cron oder ein Sicherungsskript, alle Workspaces:
|
||||
# Für Cron oder ein Sicherungsskript:
|
||||
./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
|
||||
wirken nicht zurück und gehen beim nächsten Lauf verloren.
|
||||
|
||||
|
|
@ -513,15 +430,6 @@ Vorgänge werden 90 Tage aufbewahrt und danach automatisch gelöscht
|
|||
aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die
|
||||
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
|
||||
|
|
@ -539,33 +447,15 @@ gofmt -l . # Formatierung prüfen
|
|||
main.go Konfiguration, Verdrahtung, Serverstart
|
||||
internal/
|
||||
store/ SQLite: Schema, Migrationen, alle Abfragen
|
||||
(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
|
||||
entry/ Fachlogik: Markdown, Verweise, Textausschnitte
|
||||
search/ BM25-Volltextindex, einer je Projekt
|
||||
auth/ Sitzungen, CSRF, Cookies, Keys, Rate-Limiting
|
||||
export/ Markdown-Export als Sicherung
|
||||
web/ Routen, Middleware, Handler
|
||||
mcpserver/ MCP-Werkzeuge
|
||||
hilfe/ Eingebaute Hilfeseiten (Markdown in seiten/)
|
||||
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`
|
||||
kennen `store`, aber `store` kennt keinen von beiden.
|
||||
|
||||
|
|
@ -580,17 +470,9 @@ Datumsfunktionen, keine `INSERT OR REPLACE`), damit ein Umstieg auf PostgreSQL
|
|||
offenbleibt. Die Regeln stehen als Kommentar in
|
||||
`internal/store/migrations/001_init.sql`.
|
||||
|
||||
Es gibt zwei Schemata mit je eigenem Migrationsordner:
|
||||
|
||||
| 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.
|
||||
**Schema ändern:** eine neue Datei `003_….sql` in `migrations/` anlegen. Sie wird
|
||||
beim nächsten Start automatisch angewandt und vermerkt; bestehende Migrationen
|
||||
werden nie verändert.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
229
REPOMAP.md
|
|
@ -6,15 +6,11 @@
|
|||
munin/
|
||||
├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die
|
||||
│ Pflichtangaben (SESSION_SECRET, ADMIN_TOKEN inkl.
|
||||
│ Mindestlänge), öffnet Kontroll-Datenbank und
|
||||
│ Workspace-Verwaltung (legt beim ersten Start den
|
||||
│ Workspace "Intern" an), setzt die
|
||||
│ Mindestlänge), öffnet die Datenbank, setzt die
|
||||
│ Bestandteile zusammen und startet den Server.
|
||||
│ Enthält außerdem die Server-Timeouts (gegen
|
||||
│ Slowloris), die Body-Größenlimits und die
|
||||
│ täglichen Aufgaben (Protokoll aufräumen,
|
||||
│ Export), die über fuerJedenWorkspace alle
|
||||
│ Workspaces erreichen. Tests: main_test.go.
|
||||
│ tägliche Aufräumaufgabe fürs Protokoll.
|
||||
│
|
||||
├── go.mod / go.sum ← Moduldefinition und Prüfsummen der Abhängigkeiten.
|
||||
│
|
||||
|
|
@ -41,18 +37,11 @@ munin/
|
|||
├── internal/
|
||||
│ │
|
||||
│ ├── store/ ── Die einzige Stelle, die SQL kennt ──────────────
|
||||
│ │ ├── store.go ← Öffnet eine Workspace-Datei (Open), setzt die
|
||||
│ │ │ nötigen PRAGMAs (WAL, busy_timeout, foreign_keys)
|
||||
│ │ │ und wendet die Migrationen an. Erklärt auch, warum
|
||||
│ │ │ der Treiber modernc.org/sqlite ist: Er ist reines
|
||||
│ │ │ 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.
|
||||
│ │ ├── store.go ← Öffnet die Datenbank, setzt die nötigen PRAGMAs
|
||||
│ │ │ (WAL, busy_timeout, foreign_keys) und wendet die
|
||||
│ │ │ Migrationen an. Erklärt auch, warum der Treiber
|
||||
│ │ │ modernc.org/sqlite ist: Er ist reines Go und
|
||||
│ │ │ erlaubt damit ein statisches Binary.
|
||||
│ │ │
|
||||
│ │ ├── models.go ← Die Datentypen (Project, APIKey, Entry, …), die
|
||||
│ │ │ Fehlerwerte (ErrNotFound, ErrConflict,
|
||||
|
|
@ -61,8 +50,8 @@ munin/
|
|||
│ │ │ AUTOINCREMENT — das hält den Umstieg auf eine
|
||||
│ │ │ andere Datenbank offen.
|
||||
│ │ │
|
||||
│ │ ├── projects.go ← Projekte: anlegen, lesen, ändern, löschen.
|
||||
│ │ │ Enthält ValidSlug und Slugify sowie
|
||||
│ │ ├── projects.go ← Workspace und Projekte: anlegen, lesen, ändern,
|
||||
│ │ │ löschen. Enthält ValidSlug und Slugify sowie
|
||||
│ │ │ SetProjectWriteLimit für die eigene Obergrenze
|
||||
│ │ │ eines Projekts.
|
||||
│ │ │
|
||||
|
|
@ -77,37 +66,7 @@ munin/
|
|||
│ │ │ Mechanismus gegen Dubletten. Enthält das
|
||||
│ │ │ optimistische Sperren (ExpectedVersion) und das
|
||||
│ │ │ Nachladen der Schlagworte in einer Abfrage statt
|
||||
│ │ │ 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).
|
||||
│ │ │ einer pro Eintrag.
|
||||
│ │ │
|
||||
│ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie
|
||||
│ │ │ beim Rendern. Das ersetzt die frühere
|
||||
|
|
@ -116,15 +75,9 @@ munin/
|
|||
│ │ ├── audit.go ← Protokoll: schreiben, lesen, aufräumen. Die
|
||||
│ │ │ Aufbewahrungsfrist ist nicht nur eine Frage des
|
||||
│ │ │ Plattenplatzes — das Protokoll enthält Adressen
|
||||
│ │ │ und Zugangsnamen. EntryWriteDetail vermerkt bei
|
||||
│ │ │ Schreibvorgängen "angelegt" oder "aktualisiert".
|
||||
│ │ │ und Zugangsnamen.
|
||||
│ │ │
|
||||
│ │ ├── 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
|
||||
│ │ └── migrations/
|
||||
│ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln
|
||||
│ │ │ für portables SQL, an die sich alle weiteren
|
||||
│ │ │ Migrationen halten sollen.
|
||||
|
|
@ -136,26 +89,11 @@ munin/
|
|||
│ │ und bei den Rückverweisen kippte durch ein
|
||||
│ │ ORDER BY die Verknüpfungsreihenfolge
|
||||
│ │ (3,1 ms statt 9 µs).
|
||||
│ │ ├── 003_schreibbudget_je_projekt.sql
|
||||
│ │ └── 003_schreibbudget_je_projekt.sql
|
||||
│ │ ← Spalte write_limit_hourly an projects. Vorgabe 0
|
||||
│ │ heißt "keine eigene Angabe" — dann gilt der
|
||||
│ │ Standard aus WRITE_LIMIT_HOURLY. So muss die
|
||||
│ │ Migration keine Daten anfassen.
|
||||
│ │ ├── 004_aenderungshistorie.sql
|
||||
│ │ ← 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? ────────────────────────────────────
|
||||
│ │ ├── doc.go ← Erklärt die beiden Wege hinein (API-Key im Header,
|
||||
|
|
@ -194,51 +132,19 @@ munin/
|
|||
│ │ Header ignoriert — er ist frei fälschbar.
|
||||
│ │
|
||||
│ ├── entry/ ── Wie sieht ein Eintrag aus? ─────────────────────
|
||||
│ │ ├── entry.go ← Markdown nach HTML (über internal/markdown),
|
||||
│ │ │ Auflösen der [[…]]-Verweise, Textausschnitte für
|
||||
│ │ │ 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.
|
||||
│ │ └── entry.go ← Markdown nach HTML (goldmark, ohne Roh-HTML),
|
||||
│ │ Auflösen der [[…]]-Verweise, Textausschnitte für
|
||||
│ │ Trefferlisten samt sicherer Hervorhebung.
|
||||
│ │
|
||||
│ ├── export/ ── Sicherung als Markdown ─────────────────────────
|
||||
│ │ └── export.go ← Schreibt jeden Workspace als Ordner
|
||||
│ │ (<workspace-id>/, mit _workspace.md) und darin
|
||||
│ │ jedes Projekt als Ordner voller
|
||||
│ │ └── export.go ← Schreibt jedes Projekt als Ordner voller
|
||||
│ │ 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
|
||||
│ │ inhaltliche Änderungen fasst keine Datei an und
|
||||
│ │ erzeugt deshalb auch keinen Diff. Verwaiste
|
||||
│ │ Dateien gelöschter Einträge werden entfernt,
|
||||
│ │ 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 ──────────────────────────────────
|
||||
│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen
|
||||
│ │ │ dreifach, Schlagworte zweifach. Enthält die
|
||||
|
|
@ -251,15 +157,6 @@ munin/
|
|||
│ │ die Abschottung strukturell, und die BM25-Werte
|
||||
│ │ 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 ──────────────────────────
|
||||
│ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die
|
||||
│ │ │ Ablage im Request-Context. CanAccessProject ist
|
||||
|
|
@ -267,10 +164,6 @@ munin/
|
|||
│ │ │
|
||||
│ │ ├── middleware.go ← Sitzungsprüfung (inkl. "gilt der Key noch?"),
|
||||
│ │ │ 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
|
||||
│ │ │ SecurityHeaders und LimitBody sowie APIAuth für
|
||||
│ │ │ den MCP-Endpunkt.
|
||||
|
|
@ -280,34 +173,9 @@ munin/
|
|||
│ │ │ sonst gäbe es bei einem Fehler eine halbe Seite
|
||||
│ │ │ mit Status 200).
|
||||
│ │ │
|
||||
│ │ ├── handlers_auth.go ← Anmelden, Abmelden, Startseite. Der
|
||||
│ │ │ Plattform-Admin ohne gewählten Workspace landet
|
||||
│ │ │ auf /admin/workspaces.
|
||||
│ │ ├── handlers_auth.go ← Anmelden, Abmelden, Startseite.
|
||||
│ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten.
|
||||
│ │ │ 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
|
||||
│ │ └── handlers_admin.go ← Projekte, Zugänge, Protokollansicht. Die
|
||||
│ │ Bearbeitungsseite je Projekt (Name,
|
||||
│ │ Beschreibung, Schreibbudget, Löschen) liegt
|
||||
│ │ hier — bewusst eine Seite je Projekt statt
|
||||
|
|
@ -322,11 +190,9 @@ munin/
|
|||
│ │ Qualität der Antworten, weil er bei jedem Client
|
||||
│ │ im Kontext steht.
|
||||
│ │
|
||||
│ └── tools.go ← Die sechs Werkzeuge: recall, get, list, related,
|
||||
│ remember, forget. Alte Einträge tragen in den
|
||||
│ Antworten "stale": true. Jedes ist eine Closure
|
||||
│ über genau ein Projekt und kann strukturell
|
||||
│ nicht an ein anderes.
|
||||
│ └── tools.go ← Die fünf Werkzeuge: recall, get, list, remember,
|
||||
│ forget. 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
|
||||
│ gemeinsamen Blöcke "kopf" und "fuss", die jede
|
||||
|
|
@ -335,33 +201,13 @@ munin/
|
|||
├── static/
|
||||
│ ├── style.css ← Das gesamte Aussehen, ohne Framework. Farben als
|
||||
│ │ Variablen ganz oben, helle und dunkle Darstellung.
|
||||
│ ├── confirm.js ← Bestätigungsabfrage vor dem Löschen. Liest das
|
||||
│ │ Attribut data-confirm, statt onclick="" im HTML zu
|
||||
│ │ verwenden — Inline-JavaScript verbietet die
|
||||
│ │ 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.
|
||||
│ └── confirm.js ← Bestätigungsabfrage vor dem Löschen. Liest das
|
||||
│ Attribut data-confirm, statt onclick="" im HTML zu
|
||||
│ verwenden — Inline-JavaScript verbietet die
|
||||
│ Content-Security-Policy.
|
||||
│
|
||||
├── scripts/
|
||||
│ └── 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
|
||||
└── data/ ← Datenbank (nicht im Repository, als Volume gemountet)
|
||||
└── munin.db
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
|
@ -374,32 +220,19 @@ werden.
|
|||
| Datei | Schwerpunkt |
|
||||
|---|---|
|
||||
| `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For |
|
||||
| `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/store/store_test.go` | Migrationen, Slug-Prüfung, Abschottung, Dublettenvermeidung, optimistisches Sperren, Verweise, Protokoll |
|
||||
| `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/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/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/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, Rollen, Header |
|
||||
| `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
|
||||
|
||||
```
|
||||
KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall", … } }
|
||||
→ web/middleware.go (SecurityHeaders, LimitBody)
|
||||
→ 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
|
||||
→ web/middleware.go (APIAuth) Key → Prüfsumme → Lookup → Projekt + Rolle
|
||||
in den Context; Fehlversuche in die Sperre
|
||||
→ mcpserver/server.go (ServeHTTP) Projekt aus dem Context → dessen MCP-Server
|
||||
→ mcpserver/tools.go (recall)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func (p CookiePolicy) Secure(r *http.Request) bool {
|
|||
if p.explicit != nil {
|
||||
return *p.explicit
|
||||
}
|
||||
return !IsLocalhost(r)
|
||||
return !isLocalhost(r)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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:
|
||||
// Wer über http://localhost:8080 entwickelt, soll das lockere Verhalten bekommen;
|
||||
// wer über eine echte Domain zugreift, das strenge — auch wenn der Request
|
||||
// zufällig von derselben Maschine kommt.
|
||||
func IsLocalhost(r *http.Request) bool {
|
||||
func isLocalhost(r *http.Request) bool {
|
||||
host := r.Host
|
||||
|
||||
// Den Port abtrennen, falls einer dranhängt ("localhost:8080" → "localhost").
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
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,34 +8,39 @@
|
|||
package entry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"munin/internal/markdown"
|
||||
"github.com/yuin/goldmark"
|
||||
|
||||
"munin/internal/store"
|
||||
)
|
||||
|
||||
// RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML.
|
||||
//
|
||||
// Das eigentliche Umwandeln übernimmt internal/markdown — derselbe Renderer
|
||||
// wie für die Hilfe, damit dort beschriebenes Markdown (Tabellen, Checklisten,
|
||||
// Durchstreichen) im Eintrag genauso aussieht. Dort steht auch der
|
||||
// Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf
|
||||
// verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres
|
||||
// Escaping einsetzt.
|
||||
func RenderHTML(quelle string, linkBase string) (string, error) {
|
||||
// # Sicherheitshinweis
|
||||
//
|
||||
// goldmark lässt in der Standardeinstellung eingebettetes Roh-HTML NICHT durch —
|
||||
// aus <script>alert(1)</script> im Markdown wird ein Kommentar, kein Skript.
|
||||
// Genau darauf verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne
|
||||
// weiteres Escaping einsetzt.
|
||||
//
|
||||
// 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
|
||||
// spät: goldmark hätte die eckigen Klammern dann schon als Text behandelt.
|
||||
//
|
||||
// (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 {
|
||||
vorbereitet := store.ReplaceLinks(markdown, func(slug string) string {
|
||||
return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug)
|
||||
})
|
||||
|
||||
return markdown.Render(vorbereitet)
|
||||
var puffer bytes.Buffer
|
||||
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
|
||||
|
|
|
|||
|
|
@ -38,35 +38,14 @@ import (
|
|||
)
|
||||
|
||||
// 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 {
|
||||
store *store.Store
|
||||
root string
|
||||
}
|
||||
|
||||
// New erstellt einen Exporter, der unter root schreibt.
|
||||
func New(root string) *Exporter {
|
||||
return &Exporter{root: root}
|
||||
func New(s *store.Store, root string) *Exporter {
|
||||
return &Exporter{store: s, root: root}
|
||||
}
|
||||
|
||||
// Stats fasst zusammen, was ein Lauf bewirkt hat.
|
||||
|
|
@ -75,23 +54,14 @@ type Stats struct {
|
|||
Removed int // entfernte Dateien gelöschter Einträge
|
||||
}
|
||||
|
||||
// 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.
|
||||
// ExportProject schreibt alle Einträge eines Projekts nach <root>/<projekt-slug>/.
|
||||
//
|
||||
// Der Ablauf ist bewusst schlicht: alles schreiben, danach aufräumen, was nicht
|
||||
// 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.
|
||||
func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project store.Project) (Stats, error) {
|
||||
func (e *Exporter) ExportProject(ctx context.Context, project store.Project) (Stats, error) {
|
||||
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
|
||||
// store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier
|
||||
// noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts
|
||||
|
|
@ -101,12 +71,12 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st
|
|||
return stats, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug)
|
||||
}
|
||||
|
||||
ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug)
|
||||
ziel := filepath.Join(e.root, project.Slug)
|
||||
if err := os.MkdirAll(ziel, 0750); err != nil {
|
||||
return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
|
||||
}
|
||||
|
||||
eintraege, err := s.AllEntries(ctx, project.ID)
|
||||
eintraege, err := e.store.AllEntries(ctx, project.ID)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
|
@ -144,33 +114,17 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st
|
|||
return stats, nil
|
||||
}
|
||||
|
||||
// ExportWorkspace exportiert alle Projekte eines Workspace. s muss die
|
||||
// Workspace-Datei von ws sein.
|
||||
func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store.Workspace) (Stats, error) {
|
||||
// ExportAll exportiert alle Projekte eines Workspace.
|
||||
func (e *Exporter) ExportAll(ctx context.Context, workspaceID string) (Stats, error) {
|
||||
var gesamt Stats
|
||||
|
||||
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)
|
||||
projekte, err := e.store.ListProjects(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return gesamt, err
|
||||
}
|
||||
|
||||
for _, p := range projekte {
|
||||
stats, err := e.ExportProject(ctx, s, p)
|
||||
stats, err := e.ExportProject(ctx, p)
|
||||
if err != nil {
|
||||
return gesamt, fmt.Errorf("Projekt %q: %w", p.Slug, err)
|
||||
}
|
||||
|
|
@ -184,21 +138,6 @@ func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store
|
|||
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
|
||||
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.
|
||||
//
|
||||
// # Warum der Vergleich?
|
||||
|
|
|
|||
|
|
@ -21,20 +21,17 @@ func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
|
|||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
// Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr — die lebt in
|
||||
// der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für
|
||||
// den Test reicht eine beliebige ID.
|
||||
workspaceID := store.NewID()
|
||||
projekt, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams")
|
||||
ws, err := db.EnsureWorkspace(ctx, "Test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform-Team", "Wissen des Teams")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Der Exporter schreibt unter <root>/<workspace-id>/. Zurückgegeben wird
|
||||
// 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)
|
||||
ziel := t.TempDir()
|
||||
return New(db, ziel), db, projekt, ziel
|
||||
}
|
||||
|
||||
// lies gibt den Inhalt einer exportierten Datei zurück.
|
||||
|
|
@ -60,7 +57,7 @@ func TestExportWritesEntries(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := e.ExportProject(ctx, db, projekt)
|
||||
stats, err := e.ExportProject(ctx, projekt)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
|
|
@ -110,7 +107,7 @@ func TestExportEscapesYAML(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
|
||||
if _, err := e.ExportProject(ctx, projekt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -141,19 +138,18 @@ func TestExportRemovesOrphans(t *testing.T) {
|
|||
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "geht", Title: "Geht",
|
||||
Content: "x", Source: store.SourceHuman})
|
||||
|
||||
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
|
||||
if _, err := e.ExportProject(ctx, projekt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ziel, "plattform", "geht.md")); err != nil {
|
||||
t.Fatal("die Datei wurde gar nicht erst angelegt")
|
||||
}
|
||||
|
||||
// Ein Eintrag im Papierkorb gehört nicht mehr in den Export.
|
||||
if err := db.MoveEntryToTrash(ctx, projekt.ID, "geht", "test"); err != nil {
|
||||
if err := db.DeleteEntry(ctx, projekt.ID, "geht"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stats, err := e.ExportProject(ctx, db, projekt)
|
||||
stats, err := e.ExportProject(ctx, projekt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -171,7 +167,7 @@ func TestExportRemovesOrphans(t *testing.T) {
|
|||
// TestExportLeavesForeignFilesAlone prüft, dass eigene Dateien im Zielordner
|
||||
// nicht angetastet werden.
|
||||
func TestExportLeavesForeignFilesAlone(t *testing.T) {
|
||||
e, db, projekt, ziel := aufbau(t)
|
||||
e, _, projekt, ziel := aufbau(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ordner := filepath.Join(ziel, "plattform")
|
||||
|
|
@ -182,7 +178,7 @@ func TestExportLeavesForeignFilesAlone(t *testing.T) {
|
|||
os.WriteFile(filepath.Join(ordner, "notizen.txt"), []byte("privat"), 0640)
|
||||
os.MkdirAll(filepath.Join(ordner, "anhaenge"), 0750)
|
||||
|
||||
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
|
||||
if _, err := e.ExportProject(ctx, projekt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +199,7 @@ func TestExportIsStable(t *testing.T) {
|
|||
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag",
|
||||
Content: "Inhalt", Source: store.SourceHuman})
|
||||
|
||||
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
|
||||
if _, err := e.ExportProject(ctx, projekt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +209,7 @@ func TestExportIsStable(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
|
||||
if _, err := e.ExportProject(ctx, projekt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +234,7 @@ func TestExportProducesNoDiffWhenUnchanged(t *testing.T) {
|
|||
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag",
|
||||
Content: "Inhalt", Tags: []string{"ops"}, Source: store.SourceHuman})
|
||||
|
||||
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
|
||||
if _, err := e.ExportProject(ctx, projekt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -250,7 +246,7 @@ func TestExportProducesNoDiffWhenUnchanged(t *testing.T) {
|
|||
vorher[d.Name()] = lies(t, ordner, d.Name())
|
||||
}
|
||||
|
||||
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
|
||||
if _, err := e.ExportProject(ctx, projekt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -272,7 +268,7 @@ func TestExportIndex(t *testing.T) {
|
|||
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "Beta",
|
||||
Content: "x", Source: store.SourceAgent})
|
||||
|
||||
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
|
||||
if _, err := e.ExportProject(ctx, projekt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -303,9 +299,8 @@ func TestExportAllProjects(t *testing.T) {
|
|||
e, db, projektA, ziel := aufbau(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Beide Projekte liegen in derselben Workspace-Datei — dieselbe
|
||||
// workspace_id wie projektA reicht, eine eigene Workspace ist nicht nötig.
|
||||
projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "anderes", "Anderes Team", "")
|
||||
ws, _ := db.EnsureWorkspace(ctx, "Test")
|
||||
projektB, err := db.CreateProject(ctx, ws.ID, "anderes", "Anderes Team", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -315,8 +310,7 @@ func TestExportAllProjects(t *testing.T) {
|
|||
db.PutEntry(ctx, store.EntryWrite{ProjectID: projektB.ID, Slug: "b-wissen", Title: "B",
|
||||
Content: "gehört zu B", Source: store.SourceHuman})
|
||||
|
||||
ws := store.Workspace{ID: projektA.WorkspaceID, Name: "Intern"}
|
||||
if _, err := e.ExportWorkspace(ctx, db, ws); err != nil {
|
||||
if _, err := e.ExportAll(ctx, ws.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -334,9 +328,9 @@ func TestExportAllProjects(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestExportEmptyProject(t *testing.T) {
|
||||
e, db, projekt, ziel := aufbau(t)
|
||||
e, _, projekt, ziel := aufbau(t)
|
||||
|
||||
stats, err := e.ExportProject(context.Background(), db, projekt)
|
||||
stats, err := e.ExportProject(context.Background(), projekt)
|
||||
if err != nil {
|
||||
t.Fatalf("ein leeres Projekt führte zu einem Fehler: %v", err)
|
||||
}
|
||||
|
|
@ -348,57 +342,3 @@ func TestExportEmptyProject(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
// Package hilfe enthält die eingebaute Onboarding-Dokumentation der
|
||||
// Weboberfläche: wie man Munin einrichtet, benutzt und was die Begriffe
|
||||
// bedeuten.
|
||||
//
|
||||
// # Warum eingebettet und nicht als Einträge in einem Projekt?
|
||||
//
|
||||
// Die Hilfe beschreibt das Programm selbst. Sie muss also zu genau der Fassung
|
||||
// passen, die gerade läuft — ändert sich eine Funktion, ändert sich im selben
|
||||
// Commit auch ihre Beschreibung. Als Einträge in der Datenbank würde sie
|
||||
// unbemerkt veralten, und in jedem neuen Workspace müsste sie erst jemand
|
||||
// anlegen.
|
||||
//
|
||||
// Die Seiten sind Markdown-Dateien im Ordner seiten/. //go:embed packt sie
|
||||
// beim Kompilieren ins Binary, genau wie templates/ und static/.
|
||||
package hilfe
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
|
||||
"munin/internal/markdown"
|
||||
)
|
||||
|
||||
//go:embed seiten/*.md
|
||||
var seitenFS embed.FS
|
||||
|
||||
// Seite beschreibt eine Hilfeseite.
|
||||
type Seite struct {
|
||||
Slug string // Teil der Adresse: /hilfe/<Slug>
|
||||
Titel string // Überschrift und Name in der Navigation
|
||||
Kurz string // ein Satz für die Übersicht
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
package hilfe
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJedeSeiteHatEineDatei stellt sicher, dass die Liste in hilfe.go und die
|
||||
// Dateien in seiten/ zusammenpassen — in beide Richtungen. Eine Seite ohne
|
||||
// Datei gäbe einen Fehler beim Aufruf, eine Datei ohne Listeneintrag wäre
|
||||
// unerreichbar und würde unbemerkt veralten.
|
||||
func TestJedeSeiteHatEineDatei(t *testing.T) {
|
||||
gelistet := map[string]bool{}
|
||||
for _, s := range Alle() {
|
||||
gelistet[s.Slug] = true
|
||||
if _, err := s.Markdown(); err != nil {
|
||||
t.Errorf("Seite %q: %v", s.Slug, err)
|
||||
}
|
||||
}
|
||||
|
||||
dateien, err := fs.Glob(seitenFS, "seiten/*.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range dateien {
|
||||
slug := strings.TrimSuffix(strings.TrimPrefix(d, "seiten/"), ".md")
|
||||
if !gelistet[slug] {
|
||||
t.Errorf("Datei %s steht nicht in der Seitenliste", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterneLinksFuehrenZuSeiten prüft jeden Link der Form (/hilfe/…) in
|
||||
// allen Seiten. Wird eine Seite umbenannt, fällt ein vergessener Verweis hier
|
||||
// auf statt erst als 404 beim Lesen.
|
||||
func TestInterneLinksFuehrenZuSeiten(t *testing.T) {
|
||||
link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`)
|
||||
|
||||
for _, s := range Alle() {
|
||||
md, err := s.Markdown()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, treffer := range link.FindAllStringSubmatch(md, -1) {
|
||||
if _, ok := Finde(treffer[1]); !ok {
|
||||
t.Errorf("Seite %q verlinkt auf unbekannte Hilfeseite %q", s.Slug, treffer[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindeUnbekannteSeite(t *testing.T) {
|
||||
if _, ok := Finde("gibt-es-nicht"); ok {
|
||||
t.Error("Finde meldet eine Seite, die es nicht gibt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTabellenUndKeinRohHTML(t *testing.T) {
|
||||
html, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n\n<script>alert(1)</script>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(html, "<table>") {
|
||||
t.Error("Tabellen werden nicht als Tabelle dargestellt")
|
||||
}
|
||||
if strings.Contains(html, "<script>") {
|
||||
t.Error("Roh-HTML wird durchgelassen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlleGibtKopie stellt sicher, dass ein Aufrufer die Navigation nicht
|
||||
// für alle anderen verändern kann.
|
||||
func TestAlleGibtKopie(t *testing.T) {
|
||||
a := Alle()
|
||||
a[0].Titel = "verändert"
|
||||
if Alle()[0].Titel == "verändert" {
|
||||
t.Error("Alle gibt die interne Liste statt einer Kopie heraus")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
## 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.
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
Ü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.
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
## 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*.
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
## 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.
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
## 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.
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
// 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))
|
||||
}
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
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,7 +14,6 @@ import (
|
|||
"munin/internal/search"
|
||||
"munin/internal/store"
|
||||
"munin/internal/web"
|
||||
"munin/internal/workspace"
|
||||
)
|
||||
|
||||
// aufbau legt Datenbank, Projekt und Handler an.
|
||||
|
|
@ -22,13 +21,13 @@ import (
|
|||
// Das Schreibbudget ist bewusst großzügig: Die meisten Tests haben mit der
|
||||
// Begrenzung nichts zu tun, sollen aber trotzdem durch denselben Code laufen wie
|
||||
// der Betrieb. Wer die Grenze prüfen will, nimmt aufbauMitBudget.
|
||||
func aufbau(t *testing.T) (*Handler, *workspace.Workspace, store.Project) {
|
||||
func aufbau(t *testing.T) (*Handler, *store.Store, store.Project) {
|
||||
t.Helper()
|
||||
return aufbauMitBudget(t, 1000)
|
||||
}
|
||||
|
||||
// aufbauMitBudget wie aufbau, aber mit einer wählbaren Obergrenze je Stunde.
|
||||
func aufbauMitBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, store.Project) {
|
||||
func aufbauMitBudget(t *testing.T, limit int) (*Handler, *store.Store, store.Project) {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
|
|
@ -38,19 +37,17 @@ func aufbauMitBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, s
|
|||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
// Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr — die lebt in
|
||||
// der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für
|
||||
// den Test reicht eine beliebige ID.
|
||||
workspaceID := store.NewID()
|
||||
projekt, err := db.CreateProject(ctx, workspaceID, "test", "Testprojekt", "")
|
||||
ws, err := db.EnsureWorkspace(ctx, "Test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projekt, err := db.CreateProject(ctx, ws.ID, "test", "Testprojekt", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ws := &workspace.Workspace{ID: workspaceID, Store: db, Search: search.NewRegistry(db)}
|
||||
|
||||
budget := auth.NewWriteBudget(limit, time.Hour)
|
||||
return New(budget), ws, projekt
|
||||
return New(db, search.NewRegistry(db), budget), db, projekt
|
||||
}
|
||||
|
||||
// ruf ruft ein MCP-Werkzeug auf.
|
||||
|
|
@ -59,18 +56,16 @@ func aufbauMitBudget(t *testing.T, limit int) (*Handler, *workspace.Workspace, s
|
|||
// In-Memory-Transportpaar direkt mit dem Server — dieselbe Logik, ohne Netzwerk.
|
||||
// Die Sitzung (Projekt und Rolle) wird im Context mitgegeben, so wie es sonst
|
||||
// web.APIAuth tut.
|
||||
func ruf(t *testing.T, h *Handler, ws *workspace.Workspace, projekt store.Project, rolle auth.Role, werkzeug string, args map[string]any) *mcp.CallToolResult {
|
||||
func ruf(t *testing.T, h *Handler, projekt store.Project, rolle auth.Role, werkzeug string, args map[string]any) *mcp.CallToolResult {
|
||||
t.Helper()
|
||||
|
||||
sess := web.Session{
|
||||
Subject: "key:" + ws.ID + ":test",
|
||||
WorkspaceID: ws.ID,
|
||||
Subject: "key:test",
|
||||
Key: store.APIKey{ID: "test-key", Name: "test-agent", ProjectID: projekt.ID, Role: string(rolle)},
|
||||
Project: projekt,
|
||||
Role: rolle,
|
||||
}
|
||||
ctx := web.WithSessionForTest(context.Background(), sess)
|
||||
ctx = web.WithWorkspaceForTest(ctx, ws)
|
||||
|
||||
clientTransport, serverTransport := mcp.NewInMemoryTransports()
|
||||
|
||||
|
|
@ -143,7 +138,7 @@ func TestToolsAndInstructions(t *testing.T) {
|
|||
vorhanden[tool.Name] = true
|
||||
}
|
||||
|
||||
for _, name := range []string{"recall", "get", "list", "remember", "forget", "related"} {
|
||||
for _, name := range []string{"recall", "get", "list", "remember", "forget"} {
|
||||
if !vorhanden[name] {
|
||||
t.Errorf("Werkzeug %q fehlt", name)
|
||||
}
|
||||
|
|
@ -155,13 +150,12 @@ func TestToolsAndInstructions(t *testing.T) {
|
|||
// TestRememberDeduplicatesBySlug prüft den Kern des Gedächtnisses: Zweimal
|
||||
// dasselbe Thema unter demselben Bezeichner ergibt einen Eintrag, nicht zwei.
|
||||
func TestRememberDeduplicatesBySlug(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
db := ws.Store
|
||||
h, db, projekt := aufbau(t)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "deployment", "title": "Deployment", "content": "Erste Erkenntnis",
|
||||
})
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "deployment", "title": "Deployment", "content": "Zweite, bessere Erkenntnis",
|
||||
})
|
||||
|
||||
|
|
@ -184,10 +178,9 @@ func TestRememberDeduplicatesBySlug(t *testing.T) {
|
|||
|
||||
// TestRememberRecordsProvenance prüft, dass nachvollziehbar bleibt, wer geschrieben hat.
|
||||
func TestRememberRecordsProvenance(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
db := ws.Store
|
||||
h, db, projekt := aufbau(t)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "notiz", "title": "Notiz", "content": "Inhalt",
|
||||
})
|
||||
|
||||
|
|
@ -203,10 +196,9 @@ func TestRememberRecordsProvenance(t *testing.T) {
|
|||
// TestRememberDerivesSlug prüft, dass ein fehlender Bezeichner aus dem Titel
|
||||
// abgeleitet wird — das Modell soll nicht an Formalien scheitern.
|
||||
func TestRememberDerivesSlug(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
db := ws.Store
|
||||
h, db, projekt := aufbau(t)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"title": "Der Deployment-Prozess", "content": "Inhalt",
|
||||
})
|
||||
|
||||
|
|
@ -217,18 +209,18 @@ func TestRememberDerivesSlug(t *testing.T) {
|
|||
|
||||
// TestRememberDetectsConflict prüft das optimistische Sperren.
|
||||
func TestRememberDetectsConflict(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "geteilt", "title": "T", "content": "Fassung 1",
|
||||
})
|
||||
// Agent A schreibt und erhöht damit auf Fassung 2.
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "geteilt", "title": "T", "content": "von A",
|
||||
})
|
||||
|
||||
// Agent B hatte noch Fassung 1 gelesen.
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "geteilt", "title": "T", "content": "von B", "expected_version": 1,
|
||||
})
|
||||
|
||||
|
|
@ -245,8 +237,7 @@ func TestRememberDetectsConflict(t *testing.T) {
|
|||
|
||||
// TestReadRoleCannotWrite prüft, dass ein Nur-Lese-Zugang nichts verändern kann.
|
||||
func TestReadRoleCannotWrite(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
db := ws.Store
|
||||
h, db, projekt := aufbau(t)
|
||||
|
||||
// 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.
|
||||
|
|
@ -257,7 +248,7 @@ func TestReadRoleCannotWrite(t *testing.T) {
|
|||
|
||||
for werkzeug, args := range faelle {
|
||||
t.Run(werkzeug, func(t *testing.T) {
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, werkzeug, args)
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleRead, werkzeug, args)
|
||||
if !ergebnis.IsError {
|
||||
t.Error("der Nur-Lese-Zugang durfte schreiben")
|
||||
}
|
||||
|
|
@ -273,9 +264,9 @@ func TestReadRoleCannotWrite(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestReadRoleCanRead(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "notiz", "title": "Notiz", "content": "Inhalt",
|
||||
})
|
||||
|
||||
|
|
@ -284,7 +275,7 @@ func TestReadRoleCanRead(t *testing.T) {
|
|||
if werkzeug == "recall" {
|
||||
args["query"] = "Notiz"
|
||||
}
|
||||
if ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, werkzeug, args); ergebnis.IsError {
|
||||
if ergebnis := ruf(t, h, projekt, auth.RoleRead, werkzeug, args); ergebnis.IsError {
|
||||
t.Errorf("%s wurde für den Lese-Zugang abgelehnt: %s", werkzeug, text(ergebnis))
|
||||
}
|
||||
}
|
||||
|
|
@ -296,14 +287,14 @@ func TestReadRoleCanRead(t *testing.T) {
|
|||
// "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis,
|
||||
// und das Modell erfährt nie, dass es den Eintrag gäbe.
|
||||
func TestRecallFindsByPrefix(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "deployment-prozess", "title": "Deployment-Prozess",
|
||||
"content": "Wir deployen über GitHub Actions.",
|
||||
})
|
||||
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "deploy"})
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", map[string]any{"query": "deploy"})
|
||||
if ergebnis.IsError {
|
||||
t.Fatalf("recall schlug fehl: %s", text(ergebnis))
|
||||
}
|
||||
|
|
@ -315,25 +306,25 @@ func TestRecallFindsByPrefix(t *testing.T) {
|
|||
// TestRecallEmptyGivesHint prüft, dass ein leeres Ergebnis dem Modell sagt, was
|
||||
// als Nächstes zu tun ist.
|
||||
func TestRecallEmptyGivesHint(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"})
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"})
|
||||
if !strings.Contains(text(ergebnis), "remember") {
|
||||
t.Errorf("kein Hinweis auf das weitere Vorgehen: %s", text(ergebnis))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallFiltersByTag(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "a", "title": "Deployment A", "content": "deployment", "tags": []any{"ops"},
|
||||
})
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"},
|
||||
})
|
||||
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall",
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall",
|
||||
map[string]any{"query": "deployment", "tags": []any{"ops"}})
|
||||
|
||||
if strings.Contains(text(ergebnis), `"slug": "b"`) || strings.Contains(text(ergebnis), `"slug":"b"`) {
|
||||
|
|
@ -344,30 +335,30 @@ func TestRecallFiltersByTag(t *testing.T) {
|
|||
// TestGetReturnsBacklinks prüft die Verweise in beide Richtungen — sie ersetzen
|
||||
// die frühere Ordnerhierarchie als Struktur zwischen Einträgen.
|
||||
func TestGetReturnsBacklinks(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "ziel", "title": "Ziel", "content": "Inhalt",
|
||||
})
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "quelle", "title": "Quelle", "content": "siehe [[ziel]]",
|
||||
})
|
||||
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "ziel"})
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "ziel"})
|
||||
if !strings.Contains(text(ergebnis), "quelle") {
|
||||
t.Errorf("der Rückverweis fehlt: %s", text(ergebnis))
|
||||
}
|
||||
|
||||
andersherum := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "quelle"})
|
||||
andersherum := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "quelle"})
|
||||
if !strings.Contains(text(andersherum), "ziel") {
|
||||
t.Error("der ausgehende Verweis fehlt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUnknownSlugExplains(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"})
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleRead, "get", map[string]any{"slug": "gibtesnicht"})
|
||||
if !ergebnis.IsError {
|
||||
t.Fatal("ein unbekannter Bezeichner wurde nicht als Fehler gemeldet")
|
||||
}
|
||||
|
|
@ -382,38 +373,38 @@ func TestGetUnknownSlugExplains(t *testing.T) {
|
|||
// TestProjectIsolation ist der wichtigste Test: Die Werkzeuge eines Projekts
|
||||
// dürfen unter keinen Umständen an die Daten eines anderen kommen.
|
||||
func TestProjectIsolation(t *testing.T) {
|
||||
h, ws, projektA := aufbau(t)
|
||||
db := ws.Store
|
||||
h, db, projektA := aufbau(t)
|
||||
ctx := context.Background()
|
||||
|
||||
projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "fremd", "Fremdes Projekt", "")
|
||||
ws, _ := db.EnsureWorkspace(ctx, "Test")
|
||||
projektB, err := db.CreateProject(ctx, ws.ID, "fremd", "Fremdes Projekt", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wissen in Projekt B ablegen.
|
||||
ruf(t, h, ws, projektB, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projektB, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "geheim", "title": "Geheim", "content": "Vertrauliches aus Projekt B",
|
||||
})
|
||||
|
||||
// Mit den Werkzeugen von Projekt A danach suchen.
|
||||
suche := ruf(t, h, ws, projektA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"})
|
||||
suche := ruf(t, h, projektA, auth.RoleRead, "recall", map[string]any{"query": "Vertrauliches"})
|
||||
if strings.Contains(text(suche), "Vertrauliches") {
|
||||
t.Error("recall in Projekt A fand Inhalte aus Projekt B")
|
||||
}
|
||||
|
||||
lesen := ruf(t, h, ws, projektA, auth.RoleRead, "get", map[string]any{"slug": "geheim"})
|
||||
lesen := ruf(t, h, projektA, auth.RoleRead, "get", map[string]any{"slug": "geheim"})
|
||||
if !lesen.IsError {
|
||||
t.Error("get in Projekt A lieferte einen Eintrag aus Projekt B")
|
||||
}
|
||||
|
||||
liste := ruf(t, h, ws, projektA, auth.RoleRead, "list", map[string]any{})
|
||||
liste := ruf(t, h, projektA, auth.RoleRead, "list", map[string]any{})
|
||||
if strings.Contains(text(liste), "geheim") {
|
||||
t.Error("list in Projekt A zeigte einen Eintrag aus Projekt B")
|
||||
}
|
||||
|
||||
// Und der Gegentest: Löschen darf ebenfalls nicht durchschlagen.
|
||||
ruf(t, h, ws, projektA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"})
|
||||
ruf(t, h, projektA, auth.RoleWrite, "forget", map[string]any{"slug": "geheim"})
|
||||
if n, _ := db.CountEntries(ctx, projektB.ID); n != 1 {
|
||||
t.Error("forget in Projekt A hat einen Eintrag in Projekt B gelöscht")
|
||||
}
|
||||
|
|
@ -424,17 +415,17 @@ func TestProjectIsolation(t *testing.T) {
|
|||
// TestRecallLimitIsCapped prüft, dass ein Client nicht beliebig viele Treffer
|
||||
// anfordern kann — eine riesige Antwort sprengt das Kontextfenster des Modells.
|
||||
func TestRecallLimitIsCapped(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
for i := 0; i < 60; i++ {
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)),
|
||||
"title": "Eintrag",
|
||||
"content": "gemeinsames Stichwort",
|
||||
})
|
||||
}
|
||||
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall",
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleRead, "recall",
|
||||
map[string]any{"query": "gemeinsames", "limit": 9999})
|
||||
|
||||
// Mehr als recallMaxLimit Treffer dürfen nicht zurückkommen.
|
||||
|
|
@ -444,9 +435,9 @@ func TestRecallLimitIsCapped(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRememberRejectsEmptyContent(t *testing.T) {
|
||||
h, ws, projekt := aufbau(t)
|
||||
h, _, projekt := aufbau(t)
|
||||
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember",
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember",
|
||||
map[string]any{"slug": "leer", "title": "T", "content": ""})
|
||||
|
||||
if !ergebnis.IsError {
|
||||
|
|
@ -460,11 +451,10 @@ func TestRememberRejectsEmptyContent(t *testing.T) {
|
|||
// Ein Agent in einer Schleife wird gestoppt, statt bis zum Plattenende zu schreiben.
|
||||
func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
|
||||
const grenze = 3
|
||||
h, ws, projekt := aufbauMitBudget(t, grenze)
|
||||
db := ws.Store
|
||||
h, db, projekt := aufbauMitBudget(t, grenze)
|
||||
|
||||
for i := 0; i < grenze; i++ {
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt",
|
||||
})
|
||||
if ergebnis.IsError {
|
||||
|
|
@ -472,7 +462,7 @@ func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "eintrag-zuviel", "title": "Zu viel", "content": "Inhalt",
|
||||
})
|
||||
if !ergebnis.IsError {
|
||||
|
|
@ -498,14 +488,14 @@ func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
|
|||
// gedeckelt ist. Ein erschöpftes Budget darf das Gedächtnis nicht unlesbar machen —
|
||||
// sonst wäre der Agent nicht gebremst, sondern blind.
|
||||
func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
|
||||
h, ws, projekt := aufbauMitBudget(t, 1)
|
||||
h, _, projekt := aufbauMitBudget(t, 1)
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "vorhanden", "title": "Vorhanden", "content": "Inhalt",
|
||||
})
|
||||
|
||||
// Budget ist jetzt aufgebraucht.
|
||||
if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "noch-einer", "title": "Noch einer", "content": "Inhalt",
|
||||
}); !ergebnis.IsError {
|
||||
t.Fatal("Vorbedingung verfehlt: das Budget ist noch nicht erschöpft")
|
||||
|
|
@ -520,7 +510,7 @@ func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
|
|||
args["slug"] = "vorhanden"
|
||||
}
|
||||
|
||||
if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, werkzeug, args); ergebnis.IsError {
|
||||
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, werkzeug, args); ergebnis.IsError {
|
||||
t.Errorf("%q wurde trotz erschöpftem Schreibbudget abgelehnt: %s", werkzeug, text(ergebnis))
|
||||
}
|
||||
}
|
||||
|
|
@ -529,8 +519,7 @@ func TestWriteBudgetLeavesReadingAlone(t *testing.T) {
|
|||
// TestWriteBudgetIsPerProject prüft die Mandantentrennung: Ein Projekt, das sein
|
||||
// Budget verbraucht, darf kein anderes mitbremsen.
|
||||
func TestWriteBudgetIsPerProject(t *testing.T) {
|
||||
h, ws, projekt := aufbauMitBudget(t, 1)
|
||||
db := ws.Store
|
||||
h, db, projekt := aufbauMitBudget(t, 1)
|
||||
|
||||
ctx := context.Background()
|
||||
zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "zweites", "Zweites Projekt", "")
|
||||
|
|
@ -538,14 +527,14 @@ func TestWriteBudgetIsPerProject(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
|
||||
if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
ruf(t, h, 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{
|
||||
"slug": "b", "title": "B", "content": "Inhalt",
|
||||
}); !ergebnis.IsError {
|
||||
t.Fatal("Vorbedingung verfehlt: das erste Projekt ist noch nicht am Limit")
|
||||
}
|
||||
|
||||
if ergebnis := ruf(t, h, ws, zweites, auth.RoleWrite, "remember", map[string]any{
|
||||
if ergebnis := ruf(t, h, zweites, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "c", "title": "C", "content": "Inhalt",
|
||||
}); ergebnis.IsError {
|
||||
t.Errorf("das zweite Projekt wurde mitgesperrt: %s", text(ergebnis))
|
||||
|
|
@ -556,214 +545,17 @@ func TestWriteBudgetIsPerProject(t *testing.T) {
|
|||
// Projekts den Standard schlägt — und zwar die aus der Sitzung, damit eine
|
||||
// Änderung in der Verwaltung sofort wirkt.
|
||||
func TestWriteBudgetUsesProjectLimit(t *testing.T) {
|
||||
h, ws, projekt := aufbauMitBudget(t, 100)
|
||||
h, _, projekt := aufbauMitBudget(t, 100)
|
||||
|
||||
// Dasselbe Projekt, aber mit einer eigenen, engeren Grenze — so, wie es
|
||||
// web.APIAuth nach einer Änderung in der Verwaltung aus der Datenbank läse.
|
||||
projekt.WriteLimitHourly = 1
|
||||
|
||||
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
|
||||
ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{"slug": "a", "title": "A", "content": "Inhalt"})
|
||||
|
||||
if ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
if ergebnis := ruf(t, h, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||
"slug": "b", "title": "B", "content": "Inhalt",
|
||||
}); !ergebnis.IsError {
|
||||
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,6 +30,7 @@ import (
|
|||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"munin/internal/auth"
|
||||
"munin/internal/search"
|
||||
"munin/internal/store"
|
||||
"munin/internal/web"
|
||||
)
|
||||
|
|
@ -61,14 +62,6 @@ Zum Bezeichner (slug):
|
|||
Verweise:
|
||||
Im Inhalt verweist [[bezeichner]] auf einen anderen Eintrag. Verwandtes so zu
|
||||
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:
|
||||
"get" liefert eine Feldnummer "version". Wird sie bei "remember" mitgegeben,
|
||||
|
|
@ -77,14 +70,10 @@ Gleichzeitiges Schreiben:
|
|||
|
||||
// Handler nimmt MCP-Anfragen entgegen und leitet sie an den Server des jeweiligen
|
||||
// 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 {
|
||||
store *store.Store
|
||||
search *search.Registry
|
||||
|
||||
// budget deckelt die schreibenden Aufrufe je Projekt. Darf nil sein — dann
|
||||
// findet keine Begrenzung statt (praktisch für Tests, die damit nichts zu
|
||||
// tun haben).
|
||||
|
|
@ -98,8 +87,10 @@ type Handler struct {
|
|||
|
||||
// New erstellt den MCP-Handler. Die Server je Projekt entstehen beim ersten
|
||||
// Zugriff (siehe serverFor).
|
||||
func New(budget *auth.WriteBudget) *Handler {
|
||||
func New(s *store.Store, idx *search.Registry, budget *auth.WriteBudget) *Handler {
|
||||
h := &Handler{
|
||||
store: s,
|
||||
search: idx,
|
||||
budget: budget,
|
||||
servers: make(map[string]*mcp.Server),
|
||||
}
|
||||
|
|
@ -165,7 +156,6 @@ type recallMatch struct {
|
|||
Source string `json:"source" jsonschema:"human oder agent"`
|
||||
Curated bool `json:"curated" jsonschema:"true, wenn ein Mensch den Eintrag geprüft hat"`
|
||||
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 {
|
||||
|
|
@ -190,8 +180,6 @@ type getOutput struct {
|
|||
Updated string `json:"updated_at"`
|
||||
LinksTo []string `json:"links_to" jsonschema:"Bezeichner, auf die dieser Eintrag verweist"`
|
||||
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 {
|
||||
|
|
@ -230,7 +218,6 @@ type listItem struct {
|
|||
Source string `json:"source"`
|
||||
Curated bool `json:"curated"`
|
||||
Updated string `json:"updated_at"`
|
||||
Stale bool `json:"stale,omitempty"`
|
||||
}
|
||||
|
||||
type listOutput struct {
|
||||
|
|
@ -238,25 +225,3 @@ type listOutput struct {
|
|||
Total int `json:"total" jsonschema:"Gesamtzahl der Einträge im Projekt"`
|
||||
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,7 +6,6 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
|
|
@ -14,23 +13,8 @@ import (
|
|||
"munin/internal/entry"
|
||||
"munin/internal/store"
|
||||
"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.
|
||||
//
|
||||
// Die Obergrenzen schützen nicht den Server, sondern den Client: Eine Antwort mit
|
||||
|
|
@ -41,22 +25,8 @@ const (
|
|||
recallMaxLimit = 50
|
||||
listDefaultLimit = 50
|
||||
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.
|
||||
//
|
||||
// Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht
|
||||
|
|
@ -72,7 +42,6 @@ func (h *Handler) buildServer(project store.Project) *mcp.Server {
|
|||
h.addList(server, project)
|
||||
h.addRemember(server, project)
|
||||
h.addForget(server, project)
|
||||
h.addRelated(server, project)
|
||||
|
||||
return server
|
||||
}
|
||||
|
|
@ -146,11 +115,6 @@ 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.",
|
||||
},
|
||||
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) == "" {
|
||||
return nil, recallOutput{}, errors.New("query darf nicht leer sein")
|
||||
}
|
||||
|
|
@ -163,7 +127,7 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
|||
limit = recallMaxLimit
|
||||
}
|
||||
|
||||
idx, err := ws.Search.For(ctx, project.ID)
|
||||
idx, err := h.search.For(ctx, project.ID)
|
||||
if err != nil {
|
||||
return nil, recallOutput{}, err
|
||||
}
|
||||
|
|
@ -173,16 +137,13 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
|||
treffer := idx.Search(in.Query, limit*3)
|
||||
gesucht := store.NormalizeTags(in.Tags)
|
||||
|
||||
jetzt := time.Now()
|
||||
veraltet := 0
|
||||
|
||||
ergebnisse := make([]recallMatch, 0, limit)
|
||||
for _, t := range treffer {
|
||||
if len(ergebnisse) >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID)
|
||||
e, err := h.store.EntryBySlug(ctx, project.ID, t.DocID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
continue // Index kennt einen Eintrag, den es nicht mehr gibt
|
||||
}
|
||||
|
|
@ -194,11 +155,6 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
|||
continue
|
||||
}
|
||||
|
||||
alt := e.Stale(jetzt)
|
||||
if alt {
|
||||
veraltet++
|
||||
}
|
||||
|
||||
ergebnisse = append(ergebnisse, recallMatch{
|
||||
Slug: e.Slug,
|
||||
Title: e.Title,
|
||||
|
|
@ -207,7 +163,6 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
|||
Source: e.Source,
|
||||
Curated: e.Curated,
|
||||
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
Stale: alt,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -217,14 +172,6 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
|||
"oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen."
|
||||
} else {
|
||||
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
|
||||
},
|
||||
|
|
@ -259,12 +206,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
|
|||
"Die gelieferte \"version\" bei einem späteren \"remember\" mitgeben.",
|
||||
},
|
||||
func(ctx context.Context, _ *mcp.CallToolRequest, in getInput) (*mcp.CallToolResult, getOutput, error) {
|
||||
ws, err := workspaceFrom(ctx)
|
||||
if err != nil {
|
||||
return nil, getOutput{}, err
|
||||
}
|
||||
|
||||
e, err := ws.Store.EntryBySlug(ctx, project.ID, in.Slug)
|
||||
e, err := h.store.EntryBySlug(ctx, project.ID, in.Slug)
|
||||
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)
|
||||
}
|
||||
|
|
@ -272,7 +214,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
|
|||
return nil, getOutput{}, err
|
||||
}
|
||||
|
||||
rueck, err := ws.Store.Backlinks(ctx, project.ID, in.Slug)
|
||||
rueck, err := h.store.Backlinks(ctx, project.ID, in.Slug)
|
||||
if err != nil {
|
||||
return nil, getOutput{}, err
|
||||
}
|
||||
|
|
@ -282,7 +224,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
|
|||
linkedBy = append(linkedBy, b.Slug)
|
||||
}
|
||||
|
||||
out := getOutput{
|
||||
return nil, getOutput{
|
||||
Slug: e.Slug,
|
||||
Title: e.Title,
|
||||
Content: e.Content,
|
||||
|
|
@ -294,12 +236,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
|
|||
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
LinksTo: store.ExtractLinks(e.Content),
|
||||
LinkedBy: linkedBy,
|
||||
}
|
||||
if e.Stale(time.Now()) {
|
||||
out.Stale = true
|
||||
out.Hint = staleHint
|
||||
}
|
||||
return nil, out, nil
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -314,11 +251,6 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
|
|||
"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) {
|
||||
ws, err := workspaceFrom(ctx)
|
||||
if err != nil {
|
||||
return nil, listOutput{}, err
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = listDefaultLimit
|
||||
|
|
@ -327,7 +259,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
|
|||
limit = listMaxLimit
|
||||
}
|
||||
|
||||
eintraege, err := ws.Store.ListEntries(ctx, project.ID, store.EntryFilter{
|
||||
eintraege, err := h.store.ListEntries(ctx, project.ID, store.EntryFilter{
|
||||
Tag: in.Tag,
|
||||
Curated: in.Curated,
|
||||
Limit: limit,
|
||||
|
|
@ -336,17 +268,16 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
|
|||
return nil, listOutput{}, err
|
||||
}
|
||||
|
||||
gesamt, err := ws.Store.CountEntries(ctx, project.ID)
|
||||
gesamt, err := h.store.CountEntries(ctx, project.ID)
|
||||
if err != nil {
|
||||
return nil, listOutput{}, err
|
||||
}
|
||||
|
||||
tags, err := ws.Store.ListTags(ctx, project.ID)
|
||||
tags, err := h.store.ListTags(ctx, project.ID)
|
||||
if err != nil {
|
||||
return nil, listOutput{}, err
|
||||
}
|
||||
|
||||
jetzt := time.Now()
|
||||
items := make([]listItem, 0, len(eintraege))
|
||||
for _, e := range eintraege {
|
||||
items = append(items, listItem{
|
||||
|
|
@ -356,7 +287,6 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
|
|||
Source: e.Source,
|
||||
Curated: e.Curated,
|
||||
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
||||
Stale: e.Stale(jetzt),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -381,10 +311,6 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
|
|||
"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) {
|
||||
ws, err := workspaceFrom(ctx)
|
||||
if err != nil {
|
||||
return nil, rememberOutput{}, err
|
||||
}
|
||||
if err := h.requireWrite(ctx); err != nil {
|
||||
return nil, rememberOutput{}, err
|
||||
}
|
||||
|
|
@ -407,7 +333,14 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
|
|||
titel = entry.TitleFromContent(in.Content)
|
||||
}
|
||||
|
||||
geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{
|
||||
// Vorher nachsehen, ob es den Eintrag schon gibt — nur um in der
|
||||
// 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,
|
||||
Slug: slug,
|
||||
Title: titel,
|
||||
|
|
@ -427,39 +360,17 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
|
|||
return nil, rememberOutput{}, err
|
||||
}
|
||||
|
||||
ws.Search.Update(project.ID, geschrieben)
|
||||
h.search.Update(project.ID, geschrieben)
|
||||
|
||||
sess, _ := web.SessionFromContext(ctx)
|
||||
ws.Store.LogAudit(ctx, store.AuditEvent{
|
||||
h.store.LogAudit(ctx, store.AuditEvent{
|
||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
|
||||
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)
|
||||
switch {
|
||||
case istNeu:
|
||||
if istNeu {
|
||||
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{
|
||||
|
|
@ -478,26 +389,15 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) {
|
|||
mcp.AddTool(server,
|
||||
&mcp.Tool{
|
||||
Name: "forget",
|
||||
// Die Beschreibung sagt ehrlich, dass Löschen nicht endgültig ist —
|
||||
// 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),
|
||||
Description: "Löscht einen Eintrag endgültig. Nur verwenden, wenn Wissen nachweislich " +
|
||||
"falsch oder überholt ist — Veraltetes lässt sich meist besser mit \"remember\" richtigstellen.",
|
||||
},
|
||||
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 {
|
||||
return nil, forgetOutput{}, err
|
||||
}
|
||||
|
||||
err = ws.Store.MoveEntryToTrash(ctx, project.ID, in.Slug, actorName(ctx))
|
||||
err := h.store.DeleteEntry(ctx, project.ID, in.Slug)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, forgetOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q", in.Slug)
|
||||
}
|
||||
|
|
@ -505,85 +405,15 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) {
|
|||
return nil, forgetOutput{}, err
|
||||
}
|
||||
|
||||
ws.Search.Delete(project.ID, in.Slug)
|
||||
h.search.Delete(project.ID, in.Slug)
|
||||
|
||||
sess, _ := web.SessionFromContext(ctx)
|
||||
ws.Store.LogAudit(ctx, store.AuditEvent{
|
||||
h.store.LogAudit(ctx, store.AuditEvent{
|
||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
|
||||
Action: store.ActionEntryDelete, EntrySlug: in.Slug, Source: store.SourceMCP,
|
||||
Detail: "in den Papierkorb",
|
||||
})
|
||||
|
||||
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
|
||||
return nil, forgetOutput{Message: fmt.Sprintf("Eintrag %q gelöscht.", in.Slug)}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,21 +11,7 @@ import (
|
|||
// nicht an Tippfehlern scheitern.
|
||||
const (
|
||||
ActionEntryWrite = "entry_write"
|
||||
|
||||
// ActionEntryDelete heißt seit dem Papierkorb (migrations/005): "in den
|
||||
// Papierkorb gelegt". Der Name bleibt, damit ältere Protokollzeilen und
|
||||
// neue gleich ausgewertet werden — auch vorher stand er für den Wunsch,
|
||||
// einen Eintrag loszuwerden.
|
||||
ActionEntryDelete = "entry_delete"
|
||||
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"
|
||||
ActionAuthFailure = "auth_failure"
|
||||
ActionProjectCreated = "project_created"
|
||||
|
|
@ -35,44 +21,6 @@ const (
|
|||
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.
|
||||
const (
|
||||
SourceWeb = "web"
|
||||
|
|
|
|||
|
|
@ -1,224 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
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,7 +7,6 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag.
|
||||
|
|
@ -33,31 +32,8 @@ type EntryWrite struct {
|
|||
// mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass
|
||||
// sich etwas geändert hat, und kann neu lesen.
|
||||
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.
|
||||
const entryColumns = `id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at`
|
||||
|
||||
|
|
@ -129,23 +105,12 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
|
|||
|
||||
err := s.tx(ctx, func(tx *sql.Tx) error {
|
||||
// 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 version int
|
||||
var geloeschtAm sql.NullString
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`SELECT id, version, created_at, deleted_at FROM entries WHERE project_id = ? AND slug = ?`,
|
||||
`SELECT id, version, created_at FROM entries WHERE project_id = ? AND slug = ?`,
|
||||
w.ProjectID, w.Slug,
|
||||
).Scan(&id, &version, &createdAt, &geloeschtAm)
|
||||
imPapierkorb := geloeschtAm.Valid
|
||||
).Scan(&id, &version, &createdAt)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
|
|
@ -161,12 +126,6 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
|
|||
id = NewID()
|
||||
version = 1
|
||||
createdAt = jetzt
|
||||
if !w.CreatedAt.IsZero() {
|
||||
createdAt = formatTime(w.CreatedAt)
|
||||
}
|
||||
if !w.UpdatedAt.IsZero() {
|
||||
jetzt = formatTime(w.UpdatedAt)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at)
|
||||
|
|
@ -182,36 +141,14 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
|
|||
|
||||
default:
|
||||
// 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 {
|
||||
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++
|
||||
|
||||
// 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,
|
||||
`UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?,
|
||||
deleted_at = NULL, deleted_by = ''
|
||||
`UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, jetzt, id,
|
||||
); err != nil {
|
||||
|
|
@ -242,8 +179,6 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
|
|||
Version: version,
|
||||
CreatedAt: parseTime(createdAt),
|
||||
UpdatedAt: parseTime(jetzt),
|
||||
|
||||
RestoredFromTrash: imPapierkorb,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
|
@ -254,114 +189,6 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
|
|||
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.
|
||||
//
|
||||
// project_id wird mitgeschrieben, obwohl es schon am Eintrag steht — siehe die
|
||||
|
|
@ -402,7 +229,7 @@ func replaceLinks(ctx context.Context, tx *sql.Tx, projectID, entryID string, li
|
|||
// EntryBySlug liest einen Eintrag samt Schlagworten.
|
||||
func (s *Store) EntryBySlug(ctx context.Context, projectID, slug string) (Entry, error) {
|
||||
row := s.lesen.QueryRowContext(ctx,
|
||||
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
|
||||
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ?`,
|
||||
projectID, slug,
|
||||
)
|
||||
|
||||
|
|
@ -440,7 +267,7 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter
|
|||
// Die Abfrage wird stückweise zusammengesetzt. Wichtig: Werte gehen immer als
|
||||
// Parameter (?) in die Abfrage, nie per String-Verkettung — sonst wäre das
|
||||
// eine SQL-Injection-Lücke.
|
||||
abfrage := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + nichtImPapierkorb
|
||||
abfrage := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ?`
|
||||
werte := []any{projectID}
|
||||
|
||||
if f.Tag != "" {
|
||||
|
|
@ -478,12 +305,10 @@ 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
|
||||
// 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.
|
||||
// Suchindex beim Start und für den Export.
|
||||
func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, error) {
|
||||
rows, err := s.lesen.QueryContext(ctx,
|
||||
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+nichtImPapierkorb+` ORDER BY slug`,
|
||||
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? ORDER BY slug`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -506,11 +331,22 @@ func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, erro
|
|||
return eintraege, s.attachTagsToSlice(ctx, eintraege)
|
||||
}
|
||||
|
||||
// CountEntries zählt die Einträge eines Projekts (ohne Papierkorb).
|
||||
// DeleteEntry löscht einen Eintrag samt Schlagworten und Verweisen (per CASCADE).
|
||||
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) {
|
||||
var n int
|
||||
err := s.lesen.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID,
|
||||
`SELECT COUNT(*) FROM entries WHERE project_id = ?`, projectID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err)
|
||||
|
|
@ -527,20 +363,13 @@ 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.
|
||||
// Siehe migrations/002 — diese Abfrage läuft bei jedem Seitenaufruf, weil
|
||||
// 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,
|
||||
`SELECT tag, COUNT(*) AS anzahl
|
||||
FROM entry_tags
|
||||
WHERE project_id = ?
|
||||
AND entry_id NOT IN (SELECT id FROM entries WHERE project_id = ? AND deleted_at IS NOT NULL)
|
||||
GROUP BY tag
|
||||
ORDER BY anzahl DESC, tag`,
|
||||
projectID, projectID,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
|
||||
|
|
@ -614,121 +443,6 @@ func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error {
|
|||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Backlink ist ein Eintrag, der auf einen anderen verweist.
|
||||
|
|
@ -755,7 +469,7 @@ func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backli
|
|||
`SELECT e.slug, e.title
|
||||
FROM entry_links l
|
||||
JOIN entries e ON e.id = l.from_entry_id
|
||||
WHERE l.project_id = ? AND l.to_slug = ? AND e.deleted_at IS NULL`,
|
||||
WHERE l.project_id = ? AND l.to_slug = ?`,
|
||||
projectID, slug,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -779,85 +493,6 @@ func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backli
|
|||
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.
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
|
|
|
|||
|
|
@ -11,20 +11,20 @@
|
|||
-- * Kein SQLite-spezifisches SQL (kein AUTOINCREMENT, kein INSERT OR REPLACE,
|
||||
-- keine datetime()-Funktionen), damit der Umstieg offen bleibt.
|
||||
|
||||
-- Diese Datei enthält genau einen Workspace (eine Abteilung). Die
|
||||
-- Workspace-Verwaltung selbst (Tabelle "workspaces") lebt in einer eigenen,
|
||||
-- zentralen Kontroll-Datenbank — siehe internal/store/migrations_control und
|
||||
-- AUSBAUSTUFEN.md 4.4. "workspace_id" steht hier trotzdem an jeder Zeile, damit
|
||||
-- der Bezug beim Lesen einer einzelnen Datei erhalten bleibt, auch wenn er
|
||||
-- innerhalb der Datei immer denselben Wert hat. Es gibt bewusst keinen
|
||||
-- Fremdschlüssel auf eine "workspaces"-Tabelle — die existiert in dieser Datei
|
||||
-- nicht.
|
||||
-- Ein Workspace ist die oberste Klammer. Vorerst gibt es genau einen; die Spalte
|
||||
-- existiert von Anfang an, damit später Abteilungstrennung möglich ist, ohne
|
||||
-- jede Tabelle anfassen zu müssen.
|
||||
CREATE TABLE workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 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.
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
-- Ä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);
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
-- 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;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
-- 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,33 +66,6 @@ func (k APIKey) Revoked() bool {
|
|||
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.
|
||||
type Entry struct {
|
||||
ID string
|
||||
|
|
@ -107,67 +80,6 @@ type Entry struct {
|
|||
Version int
|
||||
CreatedAt 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.
|
||||
|
|
|
|||
|
|
@ -1,314 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
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,11 +61,44 @@ func Slugify(text string) string {
|
|||
return slug
|
||||
}
|
||||
|
||||
// ── Projekte ─────────────────────────────────────────────────────────────────
|
||||
// ── Workspace ────────────────────────────────────────────────────────────────
|
||||
|
||||
// EnsureWorkspace legt beim ersten Start den Standard-Workspace an und gibt ihn
|
||||
// zurück. Existiert bereits einer, wird dieser verwendet.
|
||||
//
|
||||
// Die Workspace-Verwaltung selbst (EnsureDefaultWorkspace, CreateWorkspace, …)
|
||||
// lebt in control.go — sie arbeitet auf der zentralen Kontroll-Datenbank, nicht
|
||||
// auf dieser Workspace-Datei.
|
||||
// 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// CreateProject legt ein neues Projekt an.
|
||||
func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, description string) (Project, error) {
|
||||
|
|
|
|||
|
|
@ -1,270 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
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,14 +35,10 @@ import (
|
|||
_ "modernc.org/sqlite" // meldet sich als Treiber "sqlite" an
|
||||
)
|
||||
|
||||
// migrationsFS enthält die .sql-Dateien, die das Schema einer Workspace-Datei
|
||||
// aufbauen (Projekte, Einträge, Keys, Protokoll — siehe internal/store/migrations).
|
||||
// migrationsFS enthält die .sql-Dateien, die das Schema aufbauen.
|
||||
// Durch das Einbetten wandern sie mit ins Binary — beim Start ist kein
|
||||
// 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
|
||||
var migrationsFS embed.FS
|
||||
|
||||
|
|
@ -71,24 +67,9 @@ type Store struct {
|
|||
schreiben *sql.DB // genau eine Verbindung — alles, was verändert
|
||||
}
|
||||
|
||||
// Open öffnet die Workspace-Datenbankdatei, setzt die nötigen Einstellungen und
|
||||
// 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.
|
||||
// Open öffnet die Datenbankdatei, setzt die nötigen Einstellungen und bringt das
|
||||
// Schema auf den aktuellen Stand.
|
||||
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
|
||||
// gleichzeitigen Zugriffen unangenehm wird:
|
||||
//
|
||||
|
|
@ -114,39 +95,40 @@ func openPool(path string, migrationsFS embed.FS, migrationsDir string) (lesen,
|
|||
// statt erst beim ersten Schreibbefehl. Ohne das kann eine Transaktion
|
||||
// mitten drin auf die Sperre laufen — und dort hilft busy_timeout nicht mehr,
|
||||
// weil SQLite den Wartenden nicht zurücksetzen kann, ohne die Transaktion
|
||||
// abzubrechen. Genau daran scheiterten 296 von 320 gleichzeitigen
|
||||
// Schreibvorgängen in einer früheren Messung (siehe AUSBAUSTUFEN.md).
|
||||
schreiben, err = sql.Open("sqlite", dsn+"&_txlock=immediate")
|
||||
// abzubrechen. Genau daran scheiterten die 296 Schreibvorgänge oben.
|
||||
schreiben, err := sql.Open("sqlite", dsn+"&_txlock=immediate")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err)
|
||||
return nil, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err)
|
||||
}
|
||||
schreiben.SetMaxOpenConns(1)
|
||||
|
||||
if err := schreiben.Ping(); err != nil {
|
||||
schreiben.Close()
|
||||
return nil, nil, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err)
|
||||
return nil, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err)
|
||||
}
|
||||
|
||||
if err := runMigrations(schreiben, migrationsFS, migrationsDir); err != nil {
|
||||
s := &Store{schreiben: schreiben}
|
||||
if err := s.migrate(); err != nil {
|
||||
schreiben.Close()
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
schreiben.Close()
|
||||
return nil, nil, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err)
|
||||
return nil, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err)
|
||||
}
|
||||
lesen.SetMaxOpenConns(maxLeseverbindungen)
|
||||
|
||||
if err := lesen.Ping(); err != nil {
|
||||
schreiben.Close()
|
||||
lesen.Close()
|
||||
return nil, nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err)
|
||||
return nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err)
|
||||
}
|
||||
|
||||
return lesen, schreiben, nil
|
||||
s.lesen = lesen
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// maxLeseverbindungen begrenzt die gleichzeitigen Lesezugriffe.
|
||||
|
|
@ -165,25 +147,20 @@ func (s *Store) Close() error {
|
|||
return fehlerLesen
|
||||
}
|
||||
|
||||
// runMigrations wendet alle noch nicht ausgeführten Migrationen aus fsys/dir
|
||||
// über die gegebene Schreibverbindung an.
|
||||
// migrate wendet alle noch nicht ausgeführten Migrationen an.
|
||||
//
|
||||
// # Wie das funktioniert
|
||||
//
|
||||
// Die Dateien sind durchnummeriert (001_…, 002_…). Eine Tabelle merkt sich,
|
||||
// welche davon schon gelaufen sind. Beim Start wird die Liste durchgegangen
|
||||
// und alles Fehlende ausgeführt — in einer Transaktion pro Datei, damit eine
|
||||
// halb angewandte Migration nicht möglich ist.
|
||||
// Die Dateien in migrations/ sind durchnummeriert (001_…, 002_…). Eine Tabelle
|
||||
// merkt sich, welche davon schon gelaufen sind. Beim Start wird die Liste
|
||||
// durchgegangen und alles Fehlende ausgeführt — in einer Transaktion pro Datei,
|
||||
// damit eine halb angewandte Migration nicht möglich ist.
|
||||
//
|
||||
// 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, versteht auch, was beim Start mit seiner Datenbank passiert.
|
||||
//
|
||||
// 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 (
|
||||
// Das sind bewusst nur ein paar Zeilen statt einer Bibliothek: Für ein Schema
|
||||
// dieser Größe ist der Mechanismus leicht zu überblicken, und wer ihn 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 (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`)
|
||||
|
|
@ -191,12 +168,12 @@ func runMigrations(schreiben *sql.DB, fsys embed.FS, dir string) error {
|
|||
return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err)
|
||||
}
|
||||
|
||||
angewandt, err := appliedMigrations(schreiben)
|
||||
angewandt, err := s.appliedMigrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dateien, err := migrationFiles(fsys, dir)
|
||||
dateien, err := migrationFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -206,12 +183,12 @@ func runMigrations(schreiben *sql.DB, fsys embed.FS, dir string) error {
|
|||
continue
|
||||
}
|
||||
|
||||
inhalt, err := fsys.ReadFile(dir + "/" + name)
|
||||
inhalt, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Migration %s konnte nicht gelesen werden: %w", name, err)
|
||||
}
|
||||
|
||||
if err := applyMigration(schreiben, name, string(inhalt)); err != nil {
|
||||
if err := s.applyMigration(name, string(inhalt)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -220,8 +197,8 @@ func runMigrations(schreiben *sql.DB, fsys embed.FS, dir string) error {
|
|||
}
|
||||
|
||||
// appliedMigrations liest die Namen der bereits ausgeführten Migrationen.
|
||||
func appliedMigrations(schreiben *sql.DB) (map[string]bool, error) {
|
||||
rows, err := schreiben.Query(`SELECT name FROM schema_migrations`)
|
||||
func (s *Store) appliedMigrations() (map[string]bool, error) {
|
||||
rows, err := s.schreiben.Query(`SELECT name FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
|
||||
}
|
||||
|
|
@ -239,8 +216,8 @@ func appliedMigrations(schreiben *sql.DB) (map[string]bool, error) {
|
|||
}
|
||||
|
||||
// migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge.
|
||||
func migrationFiles(fsys embed.FS, dir string) ([]string, error) {
|
||||
eintraege, err := fsys.ReadDir(dir)
|
||||
func migrationFiles() ([]string, error) {
|
||||
eintraege, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err)
|
||||
}
|
||||
|
|
@ -259,8 +236,8 @@ func migrationFiles(fsys embed.FS, dir string) ([]string, error) {
|
|||
}
|
||||
|
||||
// applyMigration führt eine einzelne Migration in einer Transaktion aus.
|
||||
func applyMigration(schreiben *sql.DB, name, sqlText string) error {
|
||||
tx, err := schreiben.Begin()
|
||||
func (s *Store) applyMigration(name, sqlText string) error {
|
||||
tx, err := s.schreiben.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Transaktion für %s konnte nicht gestartet werden: %w", name, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,21 +28,15 @@ func testStore(t *testing.T) *Store {
|
|||
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.
|
||||
func testProject(t *testing.T, s *Store) Project {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
ws := testWorkspace()
|
||||
ws, err := s.EnsureWorkspace(ctx, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("Workspace: %v", err)
|
||||
}
|
||||
p, err := s.CreateProject(ctx, ws.ID, "test", "Testprojekt", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Projekt: %v", err)
|
||||
|
|
@ -86,7 +80,7 @@ func TestMigrationBestandsdaten(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
ws := testWorkspace()
|
||||
ws, _ := s.EnsureWorkspace(ctx, "M")
|
||||
p, _ := s.CreateProject(ctx, ws.ID, "alt", "Alt", "")
|
||||
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel",
|
||||
Content: "x", Tags: []string{"ops"}, Source: SourceHuman})
|
||||
|
|
@ -220,14 +214,29 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
|
|||
}
|
||||
|
||||
// ── Workspace und Projekte ───────────────────────────────────────────────────
|
||||
//
|
||||
// Die Stabilität von EnsureDefaultWorkspace wird in control_test.go geprüft —
|
||||
// die Funktion gehört zur zentralen Kontroll-Datenbank, nicht zu dieser Datei.
|
||||
|
||||
func TestEnsureWorkspaceIsStable(t *testing.T) {
|
||||
s := testStore(t)
|
||||
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) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
ws := testWorkspace()
|
||||
ws, _ := s.EnsureWorkspace(ctx, "Test")
|
||||
|
||||
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 {
|
||||
|
|
@ -239,7 +248,7 @@ func TestCreateProjectRejectsBadSlug(t *testing.T) {
|
|||
func TestCreateProjectRejectsDuplicateSlug(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
ws := testWorkspace()
|
||||
ws, _ := s.EnsureWorkspace(ctx, "Test")
|
||||
|
||||
if _, err := s.CreateProject(ctx, ws.ID, "doppelt", "Erst", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -418,33 +427,6 @@ 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) {
|
||||
s := testStore(t)
|
||||
p := testProject(t, s)
|
||||
|
|
@ -576,7 +558,7 @@ func TestPutEntryValidation(t *testing.T) {
|
|||
func TestEntriesAreProjectScoped(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
ws := testWorkspace()
|
||||
ws, _ := s.EnsureWorkspace(ctx, "Test")
|
||||
|
||||
a, _ := s.CreateProject(ctx, ws.ID, "projekt-a", "A", "")
|
||||
b, _ := s.CreateProject(ctx, ws.ID, "projekt-b", "B", "")
|
||||
|
|
@ -644,20 +626,20 @@ func TestListTagsCounts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMoveEntryToTrash(t *testing.T) {
|
||||
func TestDeleteEntry(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
p := testProject(t, s)
|
||||
|
||||
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
|
||||
|
||||
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); err != nil {
|
||||
if err := s.DeleteEntry(ctx, p.ID, "weg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) {
|
||||
t.Error("der Eintrag ist nach dem Löschen noch sichtbar")
|
||||
t.Error("der Eintrag existiert noch")
|
||||
}
|
||||
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); !errors.Is(err, ErrNotFound) {
|
||||
if err := s.DeleteEntry(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) {
|
||||
t.Error("zweimaliges Löschen meldete keinen Fehler")
|
||||
}
|
||||
}
|
||||
|
|
@ -824,258 +806,3 @@ func TestNormalizeTags(t *testing.T) {
|
|||
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,7 +8,6 @@ import (
|
|||
|
||||
"munin/internal/auth"
|
||||
"munin/internal/store"
|
||||
"munin/internal/workspace"
|
||||
)
|
||||
|
||||
// Session beschreibt, wer gerade zugreift.
|
||||
|
|
@ -16,17 +15,12 @@ import (
|
|||
// Es gibt zwei Ausprägungen:
|
||||
//
|
||||
// - Plattform-Admin (ADMIN_TOKEN): darf Projekte anlegen und löschen und auf
|
||||
// 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).
|
||||
// jedes Projekt zugreifen. Key und Project sind leer.
|
||||
// - Projekt-Zugang: an genau einen API-Key gebunden, der zu genau einem
|
||||
// Projekt in genau einem Workspace gehört. Was er darf, sagt Role.
|
||||
// Projekt gehört. Was er darf, sagt Role.
|
||||
type Session struct {
|
||||
Subject string // für die CSRF-Token-Berechnung
|
||||
IsPlatformAdmin bool
|
||||
WorkspaceID string
|
||||
WorkspaceName string // nur beim Plattform-Admin gefüllt, für den Umschalter in der Kopfleiste
|
||||
Key store.APIKey
|
||||
Project store.Project
|
||||
Role auth.Role
|
||||
|
|
@ -73,7 +67,6 @@ const (
|
|||
sessionContextKey contextKey = iota
|
||||
projectContextKey
|
||||
aktorContextKey
|
||||
workspaceContextKey
|
||||
)
|
||||
|
||||
// aktorHalter ist ein veränderbarer Platz im Context, in den die
|
||||
|
|
@ -134,37 +127,6 @@ func ProjectFrom(r *http.Request) (store.Project, bool) {
|
|||
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.
|
||||
//
|
||||
// Die MCP-Werkzeuge bekommen vom SDK nur den Context übergeben, keinen
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@ package web
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"munin/internal/auth"
|
||||
"munin/internal/store"
|
||||
|
|
@ -24,11 +22,6 @@ type projectListItem struct {
|
|||
Project store.Project
|
||||
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,
|
||||
// Frei der Rest im laufenden Fenster. Limit 0 heißt "unbegrenzt".
|
||||
//
|
||||
|
|
@ -46,9 +39,7 @@ type projectListItem struct {
|
|||
|
||||
// handleProjectList zeigt alle Projekte — nur für den Plattform-Admin.
|
||||
func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
|
||||
ws, sess := s.workspaceAndSession(r)
|
||||
|
||||
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
|
||||
projekte, err := s.store.ListProjects(r.Context(), s.workspaceID)
|
||||
if err != nil {
|
||||
s.serverError(w, "Projekte konnten nicht geladen werden", err)
|
||||
return
|
||||
|
|
@ -70,8 +61,6 @@ func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// handleProjectCreate legt ein neues Projekt an.
|
||||
func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||
ws, sess := s.workspaceAndSession(r)
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
slug := strings.TrimSpace(r.FormValue("slug"))
|
||||
beschreibung := strings.TrimSpace(r.FormValue("description"))
|
||||
|
|
@ -80,13 +69,13 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
|||
slug = store.Slugify(name)
|
||||
}
|
||||
|
||||
projekt, err := ws.Store.CreateProject(r.Context(), sess.WorkspaceID, slug, name, beschreibung)
|
||||
projekt, err := s.store.CreateProject(r.Context(), s.workspaceID, slug, name, beschreibung)
|
||||
if err != nil {
|
||||
s.renderProjectListWithError(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
ProjectID: projekt.ID, Actor: "platform-admin",
|
||||
Action: store.ActionProjectCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||
})
|
||||
|
|
@ -98,10 +87,9 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// handleProjectDelete löscht ein Projekt samt Einträgen und Keys.
|
||||
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ws, _ := s.workspaceAndSession(r)
|
||||
id := r.FormValue("id")
|
||||
|
||||
projekt, err := ws.Store.ProjectByID(r.Context(), id)
|
||||
projekt, err := s.store.ProjectByID(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
|
@ -120,13 +108,13 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := ws.Store.DeleteProject(r.Context(), id); err != nil {
|
||||
if err := s.store.DeleteProject(r.Context(), id); err != nil {
|
||||
s.serverError(w, "Projekt konnte nicht gelöscht werden", err)
|
||||
return
|
||||
}
|
||||
|
||||
ws.Search.Forget(id)
|
||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||
s.search.Forget(id)
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
ProjectID: id, Actor: "platform-admin",
|
||||
Action: store.ActionProjectDeleted, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||
Detail: projekt.Slug,
|
||||
|
|
@ -137,9 +125,7 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// renderProjectListWithError zeigt die Projektübersicht mit einer Fehlermeldung.
|
||||
func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Request, meldung string) {
|
||||
ws, sess := s.workspaceAndSession(r)
|
||||
|
||||
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
|
||||
projekte, err := s.store.ListProjects(r.Context(), s.workspaceID)
|
||||
if err != nil {
|
||||
s.serverError(w, "Projekte konnten nicht geladen werden", err)
|
||||
return
|
||||
|
|
@ -157,30 +143,23 @@ func (s *Server) renderProjectListWithError(w http.ResponseWriter, r *http.Reque
|
|||
})
|
||||
}
|
||||
|
||||
// projectListItems reichert die Projekte mit Eintragszahl, Nutzung und
|
||||
// Schreibbudget an.
|
||||
// projectListItems reichert die Projekte mit Eintragszahl und Schreibbudget an.
|
||||
//
|
||||
// Eine eigene Funktion, weil die Übersicht an zwei Stellen gerendert wird — mit
|
||||
// und ohne Fehlermeldung. Ohne sie liefen beide Fassungen früher oder später
|
||||
// auseinander.
|
||||
func (s *Server) projectListItems(r *http.Request, projekte []store.Project) ([]projectListItem, error) {
|
||||
ws, _ := s.workspaceAndSession(r)
|
||||
items := make([]projectListItem, 0, len(projekte))
|
||||
|
||||
// Die Kennzahlen aller Projekte in einem Rutsch (zwei Abfragen), statt
|
||||
// je Projekt einzeln nachzufragen — bei 40 Projekten wären das sonst 80.
|
||||
// Die Eintragszahl steckt mit darin.
|
||||
nutzung, err := ws.Store.UsageSummaries(r.Context(), "", time.Now())
|
||||
for _, p := range projekte {
|
||||
anzahl, err := s.store.CountEntries(r.Context(), p.ID)
|
||||
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{
|
||||
Project: p,
|
||||
Entries: u.Entries,
|
||||
Usage: u,
|
||||
Entries: anzahl,
|
||||
EigenesLimit: p.WriteLimitHourly,
|
||||
}
|
||||
if s.writeBudget != nil {
|
||||
|
|
@ -215,8 +194,6 @@ func (s *Server) handleProjectEditForm(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// handleProjectEditSave speichert Name, Beschreibung und Obergrenze.
|
||||
func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
|
||||
ws, _ := s.workspaceAndSession(r)
|
||||
|
||||
projekt, ok := s.projectFromSlug(w, r)
|
||||
if !ok {
|
||||
return
|
||||
|
|
@ -244,7 +221,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := ws.Store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{
|
||||
if err := s.store.UpdateProject(r.Context(), projekt.ID, store.ProjectUpdate{
|
||||
Name: name,
|
||||
Description: beschreibung,
|
||||
WriteLimitHourly: limit,
|
||||
|
|
@ -257,7 +234,7 @@ func (s *Server) handleProjectEditSave(w http.ResponseWriter, r *http.Request) {
|
|||
// DASS jemand etwas geändert hat. Gerade bei der Obergrenze ist das der
|
||||
// interessante Teil: Wer das Budget eines Mandanten anhebt, trifft eine
|
||||
// Betriebsentscheidung.
|
||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
ProjectID: projekt.ID, Actor: "platform-admin",
|
||||
Action: store.ActionProjectUpdated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||
Detail: strings.Join(aenderungen, "; "),
|
||||
|
|
@ -293,9 +270,7 @@ func projektAenderungen(alt store.Project, name, beschreibung string, limit int)
|
|||
// schon nur der Plattform-Admin unterwegs (siehe Routes), und der Bezeichner
|
||||
// steht unter einem anderen Platzhalter.
|
||||
func (s *Server) projectFromSlug(w http.ResponseWriter, r *http.Request) (store.Project, bool) {
|
||||
ws, sess := s.workspaceAndSession(r)
|
||||
|
||||
projekt, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, r.PathValue("slug"))
|
||||
projekt, err := s.store.ProjectBySlug(r.Context(), s.workspaceID, r.PathValue("slug"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return store.Project{}, false
|
||||
|
|
@ -337,21 +312,6 @@ type keyListData struct {
|
|||
// er hier genau einmal angezeigt.
|
||||
NewKey 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.
|
||||
|
|
@ -361,8 +321,8 @@ func (s *Server) handleKeyList(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// handleKeyCreate erzeugt einen neuen Key und zeigt ihn genau einmal an.
|
||||
func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
ws, sess := s.workspaceAndSession(r)
|
||||
project, _ := ProjectFrom(r)
|
||||
sess, _ := SessionFrom(r)
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
rolle := auth.Role(r.FormValue("role"))
|
||||
|
|
@ -378,23 +338,13 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
erzeugt, err := ws.Store.CreateAPIKey(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle))
|
||||
erzeugt, err := s.store.CreateAPIKey(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle))
|
||||
if err != nil {
|
||||
s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 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{
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(),
|
||||
Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||
Detail: erzeugt.Name + " (" + string(rolle) + ")",
|
||||
|
|
@ -407,29 +357,25 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// handleKeyRevoke zieht einen Key zurück.
|
||||
func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
ws, sess := s.workspaceAndSession(r)
|
||||
project, _ := ProjectFrom(r)
|
||||
sess, _ := SessionFrom(r)
|
||||
id := r.FormValue("id")
|
||||
|
||||
// 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
|
||||
// fremden Projekts sperren.
|
||||
key, err := ws.Store.APIKeyByID(r.Context(), id)
|
||||
key, err := s.store.APIKeyByID(r.Context(), id)
|
||||
if err != nil || key.ProjectID != project.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ws.Store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
if err := s.store.RevokeAPIKey(r.Context(), id); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.serverError(w, "Key konnte nicht zurückgezogen werden", err)
|
||||
return
|
||||
}
|
||||
|
||||
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{
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
ProjectID: project.ID, APIKeyID: id, Actor: sess.ActorName(),
|
||||
Action: store.ActionKeyRevoked, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||
Detail: key.Name,
|
||||
|
|
@ -440,22 +386,14 @@ func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// renderKeyList zeigt die Key-Verwaltung mit optionaler Meldung.
|
||||
func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status int, daten keyListData, hinweis, fehler string) {
|
||||
ws, _ := s.workspaceAndSession(r)
|
||||
project, _ := ProjectFrom(r)
|
||||
|
||||
keys, err := ws.Store.ListAPIKeys(r.Context(), project.ID)
|
||||
keys, err := s.store.ListAPIKeys(r.Context(), project.ID)
|
||||
if err != nil {
|
||||
s.serverError(w, "Keys konnten nicht geladen werden", err)
|
||||
return
|
||||
}
|
||||
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{
|
||||
Title: "Zugänge – " + project.Name,
|
||||
|
|
@ -489,10 +427,9 @@ func (s *Server) handleGlobalAudit(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// renderAudit lädt Ereignisse samt Klarnamen und rendert die Tabelle.
|
||||
func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter store.AuditFilter, global bool, titel string) {
|
||||
ws, sess := s.workspaceAndSession(r)
|
||||
ctx := r.Context()
|
||||
|
||||
ereignisse, err := ws.Store.ListAudit(ctx, filter)
|
||||
ereignisse, err := s.store.ListAudit(ctx, filter)
|
||||
if err != nil {
|
||||
s.serverError(w, "Protokoll konnte nicht geladen werden", err)
|
||||
return
|
||||
|
|
@ -500,7 +437,7 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor
|
|||
|
||||
// IDs in lesbare Namen übersetzen — in der Tabelle sollen keine rohen
|
||||
// Kennungen stehen.
|
||||
projektNamen, err := ws.Store.ProjectNames(ctx, sess.WorkspaceID)
|
||||
projektNamen, err := s.store.ProjectNames(ctx, s.workspaceID)
|
||||
if err != nil {
|
||||
s.serverError(w, "Projektnamen konnten nicht geladen werden", err)
|
||||
return
|
||||
|
|
@ -511,7 +448,7 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor
|
|||
projektIDs = append(projektIDs, id)
|
||||
}
|
||||
|
||||
keyNamen, err := ws.Store.KeyNames(ctx, projektIDs)
|
||||
keyNamen, err := s.store.KeyNames(ctx, projektIDs)
|
||||
if err != nil {
|
||||
s.serverError(w, "Key-Namen konnten nicht geladen werden", err)
|
||||
return
|
||||
|
|
@ -536,28 +473,13 @@ 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
|
||||
// Fall gedacht, dass jemand unmittelbar vor einer Sicherung oder nach einer
|
||||
// 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) {
|
||||
if s.exporter == nil {
|
||||
http.Error(w, "Der Export ist nicht eingerichtet (EXPORT_DIR fehlt).", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
stats, err := s.exporter.ExportAll(r.Context(), s.workspaceID)
|
||||
if err != nil {
|
||||
s.serverError(w, "Export fehlgeschlagen", err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package web
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
|
@ -16,10 +15,6 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
|||
sess, _ := SessionFrom(r)
|
||||
|
||||
if sess.IsPlatformAdmin {
|
||||
if sess.WorkspaceID == "" {
|
||||
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
|
@ -49,9 +44,9 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
s.limiter.RecordFailure(ip)
|
||||
// Ein ungültiger Key oder Klartext lässt sich keinem Workspace zuordnen
|
||||
// — siehe der entsprechende Kommentar in APIAuth (middleware.go).
|
||||
log.Printf("Ungültiger Anmeldeversuch von %s", ip)
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceWeb, IP: ip,
|
||||
})
|
||||
|
||||
s.render(w, r, "login.html", http.StatusUnauthorized, pageData{
|
||||
Title: "Anmelden",
|
||||
|
|
@ -66,11 +61,6 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
if key == "" {
|
||||
return "", false
|
||||
|
|
@ -80,7 +70,10 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
|
|||
// die Hashes, damit auch hier keine Zeichenkette Zeichen für Zeichen
|
||||
// verglichen wird und die Antwortzeit nichts verrät.
|
||||
if auth.HashKey(key) == s.adminTokenHash {
|
||||
log.Printf("Anmeldung Plattform-Admin von %s", auth.ClientIP(r))
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
Actor: "platform-admin", Action: store.ActionLoginSuccess,
|
||||
Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||
})
|
||||
return auth.SubjectPlatformAdmin, true
|
||||
}
|
||||
|
||||
|
|
@ -88,27 +81,17 @@ func (s *Server) resolveLoginKey(r *http.Request, key string) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
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)
|
||||
resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
ProjectID: resolved.Project.ID, APIKeyID: resolved.Key.ID, Actor: resolved.Key.Name,
|
||||
Action: store.ActionLoginSuccess, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||
})
|
||||
|
||||
return auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID, true
|
||||
return auth.SubjectKeyPrefix + resolved.Key.ID, true
|
||||
}
|
||||
|
||||
// handleLogout löscht den Session-Cookie.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"munin/internal/entry"
|
||||
"munin/internal/store"
|
||||
|
|
@ -35,7 +29,6 @@ type entryListItem struct {
|
|||
// handleEntryList zeigt die Einträge eines Projekts — gefiltert, gesucht oder
|
||||
// einfach nach letzter Änderung.
|
||||
func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
|
||||
ws, _ := WorkspaceFrom(r)
|
||||
project, _ := ProjectFrom(r)
|
||||
ctx := r.Context()
|
||||
|
||||
|
|
@ -55,11 +48,11 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if daten.Tags, err = ws.Store.ListTags(ctx, project.ID); err != nil {
|
||||
if daten.Tags, err = s.store.ListTags(ctx, project.ID); err != nil {
|
||||
s.serverError(w, "Schlagworte konnten nicht geladen werden", err)
|
||||
return
|
||||
}
|
||||
if daten.Total, err = ws.Store.CountEntries(ctx, project.ID); err != nil {
|
||||
if daten.Total, err = s.store.CountEntries(ctx, project.ID); err != nil {
|
||||
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
|
||||
return
|
||||
}
|
||||
|
|
@ -68,17 +61,15 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
|
|||
Title: project.Name,
|
||||
Bereich: "eintraege",
|
||||
AktivesTag: tag,
|
||||
Flash: r.URL.Query().Get("hinweis"),
|
||||
Data: daten,
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ws, _ := WorkspaceFrom(r)
|
||||
ctx := r.Context()
|
||||
|
||||
idx, err := ws.Search.For(ctx, project.ID)
|
||||
idx, err := s.search.For(ctx, project.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -87,7 +78,7 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str
|
|||
|
||||
items := make([]entryListItem, 0, len(treffer))
|
||||
for _, t := range treffer {
|
||||
e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID)
|
||||
e, err := s.store.EntryBySlug(ctx, project.ID, t.DocID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// Der Index kann einen Eintrag kennen, den es nicht mehr gibt —
|
||||
// dann wird er hier stillschweigend übersprungen.
|
||||
|
|
@ -107,8 +98,7 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str
|
|||
|
||||
// listEntries lädt die Einträge ohne Suche, zuletzt geänderte zuerst.
|
||||
func (s *Server) listEntries(r *http.Request, project store.Project, tag string) ([]entryListItem, error) {
|
||||
ws, _ := WorkspaceFrom(r)
|
||||
eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100})
|
||||
eintraege, err := s.store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -128,20 +118,14 @@ type entryViewData struct {
|
|||
Entry store.Entry
|
||||
HTML template.HTML
|
||||
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.
|
||||
func (s *Server) handleEntryView(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)
|
||||
e, err := s.store.EntryBySlug(r.Context(), project.ID, slug)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
|
@ -158,7 +142,7 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
backlinks, err := ws.Store.Backlinks(r.Context(), project.ID, slug)
|
||||
backlinks, err := s.store.Backlinks(r.Context(), project.ID, slug)
|
||||
if err != nil {
|
||||
s.serverError(w, "Rückverweise konnten nicht geladen werden", err)
|
||||
return
|
||||
|
|
@ -167,11 +151,7 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
|
|||
s.render(w, r, "entry.html", http.StatusOK, pageData{
|
||||
Title: e.Title,
|
||||
Bereich: "eintraege",
|
||||
Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung
|
||||
Data: entryViewData{
|
||||
Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks,
|
||||
Stale: e.Stale(time.Now()),
|
||||
},
|
||||
Data: entryViewData{Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -183,29 +163,19 @@ type entryEditData struct {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
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{
|
||||
Title: "Neuer Eintrag",
|
||||
Bereich: "eintraege",
|
||||
Data: entryEditData{IsNew: true, Entry: vorbelegt},
|
||||
Data: entryEditData{IsNew: true},
|
||||
})
|
||||
}
|
||||
|
||||
// handleEntryEdit zeigt das Formular für einen bestehenden Eintrag.
|
||||
func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) {
|
||||
ws, _ := WorkspaceFrom(r)
|
||||
project, _ := ProjectFrom(r)
|
||||
|
||||
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, r.PathValue("slug"))
|
||||
e, err := s.store.EntryBySlug(r.Context(), project.ID, r.PathValue("slug"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
|
@ -222,88 +192,8 @@ 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.
|
||||
func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
|
||||
ws, _ := WorkspaceFrom(r)
|
||||
project, _ := ProjectFrom(r)
|
||||
sess, _ := SessionFrom(r)
|
||||
|
||||
|
|
@ -321,7 +211,7 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
|
|||
// Hinweis statt eines stillen Überschreibens.
|
||||
version, _ := strconv.Atoi(r.FormValue("version"))
|
||||
|
||||
geschrieben, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{
|
||||
geschrieben, err := s.store.PutEntry(r.Context(), store.EntryWrite{
|
||||
ProjectID: project.ID,
|
||||
Slug: slug,
|
||||
Title: titel,
|
||||
|
|
@ -353,440 +243,33 @@ func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
ws.Search.Update(project.ID, geschrieben)
|
||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||
s.search.Update(project.ID, geschrieben)
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
||||
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb,
|
||||
Detail: store.EntryWriteDetail(geschrieben),
|
||||
})
|
||||
|
||||
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)
|
||||
http.Redirect(w, r, "/p/"+project.Slug+"/e/"+slug, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleEntryDelete legt einen Eintrag in den Papierkorb (AUSBAUSTUFEN.md 3.9).
|
||||
// Endgültig gelöscht wird nur über die Papierkorb-Seite oder nach Ablauf der
|
||||
// Frist.
|
||||
// handleEntryDelete löscht einen Eintrag.
|
||||
func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ws, _ := WorkspaceFrom(r)
|
||||
project, _ := ProjectFrom(r)
|
||||
sess, _ := SessionFrom(r)
|
||||
slug := r.PathValue("slug")
|
||||
|
||||
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 {
|
||||
if err := s.store.DeleteEntry(r.Context(), project.ID, slug); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.serverError(w, "Eintrag konnte nicht gelöscht werden", err)
|
||||
return
|
||||
}
|
||||
|
||||
ws.Search.Delete(project.ID, slug)
|
||||
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||
s.search.Delete(project.ID, slug)
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
||||
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb,
|
||||
Detail: "in den Papierkorb",
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
http.Redirect(w, r, "/p/"+project.Slug+"/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// splitTags zerlegt die Schlagwort-Eingabe an Kommas.
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
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,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
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,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
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))
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
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,7 +11,6 @@ import (
|
|||
|
||||
"munin/internal/auth"
|
||||
"munin/internal/store"
|
||||
"munin/internal/workspace"
|
||||
)
|
||||
|
||||
// requireSession prüft den Session-Cookie und legt die Sitzung im Context ab.
|
||||
|
|
@ -49,36 +48,11 @@ func (s *Server) requireSession(next http.Handler) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
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))
|
||||
next.ServeHTTP(w, r.WithContext(withSession(r.Context(), sess)))
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if subject == auth.SubjectPlatformAdmin {
|
||||
return Session{
|
||||
|
|
@ -88,47 +62,23 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b
|
|||
}, true
|
||||
}
|
||||
|
||||
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, ":")
|
||||
keyID, ok := auth.KeyIDFromSubject(subject)
|
||||
if !ok {
|
||||
return Session{}, false
|
||||
}
|
||||
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return Session{}, false
|
||||
}
|
||||
|
||||
resolved, err := ws.Store.APIKeyByID(r.Context(), keyID)
|
||||
resolved, err := s.store.APIKeyByID(r.Context(), keyID)
|
||||
if err != nil || resolved.Revoked() {
|
||||
return Session{}, false
|
||||
}
|
||||
|
||||
project, err := ws.Store.ProjectByID(r.Context(), resolved.ProjectID)
|
||||
project, err := s.store.ProjectByID(r.Context(), resolved.ProjectID)
|
||||
if err != nil {
|
||||
return Session{}, false
|
||||
}
|
||||
|
||||
return Session{
|
||||
Subject: subject,
|
||||
WorkspaceID: workspaceID,
|
||||
Key: resolved,
|
||||
Project: project,
|
||||
Role: auth.Role(resolved.Role),
|
||||
|
|
@ -179,16 +129,8 @@ func (s *Server) requireProject(next http.Handler) http.Handler {
|
|||
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")
|
||||
project, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, slug)
|
||||
project, err := s.store.ProjectBySlug(r.Context(), s.workspaceID, slug)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
|
@ -210,22 +152,6 @@ 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.
|
||||
func (s *Server) requirePlatformAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -341,48 +267,6 @@ 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) ─────────────────────────────────────────────────────────
|
||||
|
||||
// APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im
|
||||
|
|
@ -404,30 +288,17 @@ func (s *Server) APIAuth(next http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
if auth.LooksLikeKey(key) {
|
||||
hash := auth.HashKey(key)
|
||||
|
||||
// Erster Schritt: welcher Workspace? Erst danach lässt sich die
|
||||
// richtige Datei überhaupt ansprechen (siehe ControlStore in
|
||||
// internal/store/control.go und AUSBAUSTUFEN.md 4.4).
|
||||
workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash)
|
||||
if err == nil {
|
||||
var ws *workspace.Workspace
|
||||
ws, err = s.workspaces.Get(workspaceID)
|
||||
if err == nil {
|
||||
var resolved store.ResolvedKey
|
||||
resolved, err = ws.Store.ResolveKeyHash(r.Context(), hash)
|
||||
resolved, err := s.store.ResolveKeyHash(r.Context(), auth.HashKey(key))
|
||||
if err == nil {
|
||||
s.limiter.Reset(ip)
|
||||
|
||||
// Nebenbei vermerken, dass der Key benutzt wurde — damit man
|
||||
// in der Verwaltung sieht, welche Keys noch aktiv sind. Ein
|
||||
// Fehler dabei ist unerheblich und darf den Request nicht
|
||||
// scheitern lassen.
|
||||
_ = ws.Store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt)
|
||||
// Nebenbei vermerken, dass der Key benutzt wurde — damit man in
|
||||
// der Verwaltung sieht, welche Keys noch aktiv sind. Ein Fehler
|
||||
// dabei ist unerheblich und darf den Request nicht scheitern lassen.
|
||||
_ = s.store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt)
|
||||
|
||||
sess := Session{
|
||||
Subject: auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID,
|
||||
WorkspaceID: workspaceID,
|
||||
Subject: auth.SubjectKeyPrefix + resolved.Key.ID,
|
||||
Key: resolved.Key,
|
||||
Project: resolved.Project,
|
||||
Role: auth.Role(resolved.Key.Role),
|
||||
|
|
@ -435,13 +306,10 @@ func (s *Server) APIAuth(next http.Handler) http.Handler {
|
|||
|
||||
ctx := withSession(r.Context(), sess)
|
||||
ctx = withProject(ctx, resolved.Project)
|
||||
ctx = withWorkspace(ctx, ws)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if allowed, retryAfter := s.limiter.Allow(ip); !allowed {
|
||||
sekunden := int(retryAfter.Seconds()) + 1
|
||||
|
|
@ -452,12 +320,9 @@ func (s *Server) APIAuth(next http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
s.limiter.RecordFailure(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)
|
||||
s.store.LogAudit(r.Context(), store.AuditEvent{
|
||||
Actor: "unbekannt", Action: store.ActionAuthFailure, Source: store.SourceMCP, IP: ip,
|
||||
})
|
||||
|
||||
http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import (
|
|||
|
||||
"munin/internal/auth"
|
||||
"munin/internal/export"
|
||||
"munin/internal/search"
|
||||
"munin/internal/store"
|
||||
"munin/internal/workspace"
|
||||
)
|
||||
|
||||
// Server bündelt alles, was die Handler brauchen.
|
||||
|
|
@ -19,12 +19,13 @@ import (
|
|||
// worauf sie zugreift, und im Test lässt sich ein Server mit anderen
|
||||
// Bestandteilen zusammensetzen.
|
||||
type Server struct {
|
||||
workspaces *workspace.Manager
|
||||
control *store.ControlStore
|
||||
store *store.Store
|
||||
search *search.Registry
|
||||
signer *auth.Signer
|
||||
cookies auth.CookiePolicy
|
||||
limiter *auth.RateLimiter
|
||||
templates *template.Template
|
||||
workspaceID string
|
||||
|
||||
// exporter ist nil, wenn EXPORT_DIR nicht gesetzt ist — dann blendet die
|
||||
// Oberfläche den Knopf aus, statt eine Funktion anzubieten, die nichts tut.
|
||||
|
|
@ -43,12 +44,13 @@ type Server struct {
|
|||
|
||||
// Config sammelt die Angaben zum Bauen eines Servers.
|
||||
type Config struct {
|
||||
Workspaces *workspace.Manager
|
||||
Control *store.ControlStore
|
||||
Store *store.Store
|
||||
Search *search.Registry
|
||||
Signer *auth.Signer
|
||||
Cookies auth.CookiePolicy
|
||||
Limiter *auth.RateLimiter
|
||||
Templates fs.FS
|
||||
WorkspaceID string
|
||||
AdminToken string
|
||||
Exporter *export.Exporter
|
||||
WriteBudget *auth.WriteBudget
|
||||
|
|
@ -64,12 +66,13 @@ func NewServer(cfg Config) (*Server, error) {
|
|||
}
|
||||
|
||||
return &Server{
|
||||
workspaces: cfg.Workspaces,
|
||||
control: cfg.Control,
|
||||
store: cfg.Store,
|
||||
search: cfg.Search,
|
||||
signer: cfg.Signer,
|
||||
cookies: cfg.Cookies,
|
||||
limiter: cfg.Limiter,
|
||||
templates: tmpl,
|
||||
workspaceID: cfg.WorkspaceID,
|
||||
exporter: cfg.Exporter,
|
||||
writeBudget: cfg.WriteBudget,
|
||||
adminTokenHash: auth.HashKey(cfg.AdminToken),
|
||||
|
|
@ -111,69 +114,30 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
|
|||
|
||||
mux.Handle("GET /{$}", geschuetzt(s.handleHome))
|
||||
|
||||
// Eingebaute Hilfe — für jede Sitzung, auch ohne gewählten Workspace.
|
||||
// "/hilfe" zeigt die Startseite, "/hilfe/{seite}" eine bestimmte Seite.
|
||||
mux.Handle("GET /hilfe", geschuetzt(s.handleHilfe))
|
||||
mux.Handle("GET /hilfe/{seite}", geschuetzt(s.handleHilfe))
|
||||
|
||||
// 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))
|
||||
// Plattform-Verwaltung.
|
||||
mux.Handle("GET /admin/projects", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectList)))
|
||||
mux.Handle("POST /admin/projects/create", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectCreate)))
|
||||
mux.Handle("POST /admin/projects/delete", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectDelete)))
|
||||
// Die Bearbeitung liegt unter {slug}/edit statt direkt unter {slug}: Sonst
|
||||
// wären "create" und "delete" oben mehrdeutig mit einem gleichnamigen
|
||||
// Projekt-Bezeichner.
|
||||
mux.Handle("GET /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditForm))
|
||||
mux.Handle("POST /admin/projects/{slug}/edit", imWorkspace(s.handleProjectEditSave))
|
||||
mux.Handle("GET /admin/audit", imWorkspace(s.handleGlobalAudit))
|
||||
mux.Handle("POST /admin/export", imWorkspace(s.handleExport))
|
||||
mux.Handle("GET /admin/projects/{slug}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectEditForm)))
|
||||
mux.Handle("POST /admin/projects/{slug}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleProjectEditSave)))
|
||||
mux.Handle("GET /admin/audit", geschuetzt(s.requirePlatformAdminFunc(s.handleGlobalAudit)))
|
||||
mux.Handle("POST /admin/export", geschuetzt(s.requirePlatformAdminFunc(s.handleExport)))
|
||||
|
||||
// Projektseiten.
|
||||
mux.Handle("GET /p/{project}/{$}", imProjekt(s.handleEntryList))
|
||||
mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew))
|
||||
mux.Handle("POST /p/{project}/preview", mitSchreibrecht(s.handleEntryPreview))
|
||||
mux.Handle("GET /p/{project}/entries/suggest", mitSchreibrecht(s.handleEntrySuggest))
|
||||
mux.Handle("GET /p/{project}/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("POST /p/{project}/e/{slug}/save", mitSchreibrecht(s.handleEntrySave))
|
||||
mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete))
|
||||
mux.Handle("POST /p/{project}/save", mitSchreibrecht(s.handleEntrySave))
|
||||
mux.Handle("POST /p/{project}/entries/bulk-tags", mitSchreibrecht(s.handleEntryBulkTags))
|
||||
// Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_papierkorb.go.
|
||||
mux.Handle("GET /p/{project}/trash", mitSchreibrecht(s.handleTrashList))
|
||||
mux.Handle("POST /p/{project}/trash/restore", mitSchreibrecht(s.handleTrashRestore))
|
||||
mux.Handle("POST /p/{project}/trash/purge", mitSchreibrecht(s.handleTrashPurge))
|
||||
mux.Handle("GET /p/{project}/audit", imProjekt(s.handleProjectAudit))
|
||||
mux.Handle("GET /p/{project}/usage", imProjekt(s.handleProjectUsage))
|
||||
|
||||
// Key-Verwaltung.
|
||||
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/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke)))))
|
||||
}
|
||||
|
|
@ -202,7 +166,7 @@ type pageData struct {
|
|||
ExportEnabled bool
|
||||
|
||||
// Bereich markiert den aktiven Punkt in der Seitenleiste:
|
||||
// "eintraege", "zugaenge", "protokoll", "projekte" oder "hilfe".
|
||||
// "eintraege", "zugaenge", "protokoll" oder "projekte".
|
||||
Bereich string
|
||||
|
||||
// Nav trägt die Inhalte der Seitenleiste. Wird in render() gefüllt, damit
|
||||
|
|
@ -268,15 +232,8 @@ 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 {
|
||||
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 {
|
||||
projekte, err := ws.Store.ListProjects(r.Context(), sess.WorkspaceID)
|
||||
projekte, err := s.store.ListProjects(r.Context(), s.workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("Seitenleiste: Projekte konnten nicht geladen werden: %v", err)
|
||||
} else {
|
||||
|
|
@ -285,7 +242,7 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project)
|
|||
}
|
||||
|
||||
if project.ID != "" {
|
||||
tags, err := ws.Store.ListTags(r.Context(), project.ID)
|
||||
tags, err := s.store.ListTags(r.Context(), project.ID)
|
||||
if err != nil {
|
||||
log.Printf("Seitenleiste: Schlagworte konnten nicht geladen werden: %v", err)
|
||||
} else {
|
||||
|
|
@ -296,16 +253,6 @@ func (s *Server) buildNav(r *http.Request, sess Session, project store.Project)
|
|||
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
|
||||
// eine allgemeine Störung.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
207
main.go
|
|
@ -14,9 +14,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
|
@ -32,9 +30,9 @@ import (
|
|||
"munin/internal/auth"
|
||||
"munin/internal/export"
|
||||
"munin/internal/mcpserver"
|
||||
"munin/internal/search"
|
||||
"munin/internal/store"
|
||||
"munin/internal/web"
|
||||
"munin/internal/workspace"
|
||||
)
|
||||
|
||||
// //go:embed bettet Dateien beim Kompilieren ins Binary ein. Nach dem Bauen sind
|
||||
|
|
@ -58,12 +56,6 @@ const (
|
|||
|
||||
// maxAPIBodyBytes gilt für MCP-Aufrufe.
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
|
@ -128,54 +120,38 @@ func main() {
|
|||
cfg := readConfig()
|
||||
|
||||
// ── Datenbank ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// 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 {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.dbPath), 0750); err != nil {
|
||||
log.Fatal("Datenverzeichnis konnte nicht angelegt werden: ", err)
|
||||
}
|
||||
|
||||
control, err := store.OpenControl(filepath.Join(cfg.dataDir, "control.db"))
|
||||
db, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
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()
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Beim allerersten Start gibt es noch keinen Workspace — dann wird einer
|
||||
// angelegt, damit der Server ohne Zwischenschritt benutzbar ist.
|
||||
if _, err := control.EnsureDefaultWorkspace(ctx, "Intern"); err != nil {
|
||||
workspace, err := db.EnsureWorkspace(ctx, "Intern")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Der Export ist abgeschaltet, solange EXPORT_DIR nicht gesetzt ist.
|
||||
var exporter *export.Exporter
|
||||
if cfg.exportDir != "" {
|
||||
exporter = export.New(cfg.exportDir)
|
||||
exporter = export.New(db, cfg.exportDir)
|
||||
}
|
||||
|
||||
if *nurExport {
|
||||
if exporter == nil {
|
||||
log.Fatal("EXPORT_DIR ist nicht gesetzt — ohne Zielverzeichnis gibt es nichts zu exportieren.")
|
||||
}
|
||||
stats, err := exportiereAlle(ctx, exporter, control, workspaces)
|
||||
log.Printf("Export abgeschlossen: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
|
||||
stats, err := exporter.ExportAll(ctx, workspace.ID)
|
||||
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)
|
||||
log.Fatal("Export fehlgeschlagen: ", err)
|
||||
}
|
||||
log.Printf("Export abgeschlossen: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -184,14 +160,16 @@ func main() {
|
|||
cookies := auth.NewCookiePolicy(os.Getenv("COOKIE_SECURE"))
|
||||
limiter := auth.NewRateLimiter()
|
||||
writeBudget := auth.NewWriteBudget(cfg.writeLimitHourly, writeLimitWindow)
|
||||
searchRegistry := search.NewRegistry(db)
|
||||
|
||||
webServer, err := web.NewServer(web.Config{
|
||||
Workspaces: workspaces,
|
||||
Control: control,
|
||||
Store: db,
|
||||
Search: searchRegistry,
|
||||
Signer: signer,
|
||||
Cookies: cookies,
|
||||
Limiter: limiter,
|
||||
Templates: templatesFS,
|
||||
WorkspaceID: workspace.ID,
|
||||
AdminToken: cfg.adminToken,
|
||||
Exporter: exporter,
|
||||
WriteBudget: writeBudget,
|
||||
|
|
@ -200,7 +178,7 @@ func main() {
|
|||
log.Fatal("Templates konnten nicht geladen werden: ", err)
|
||||
}
|
||||
|
||||
mcpHandler := mcpserver.New(writeBudget)
|
||||
mcpHandler := mcpserver.New(db, searchRegistry, writeBudget)
|
||||
|
||||
// ── Routen ───────────────────────────────────────────────────────────────
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
|
|
@ -213,33 +191,23 @@ func main() {
|
|||
|
||||
// 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.
|
||||
mux.Handle("/mcp", webServer.APIAuth(mcpHandler))
|
||||
mux.Handle("/mcp", web.LimitBody(webServer.APIAuth(mcpHandler), cfg.maxAPIBody))
|
||||
|
||||
// Reihenfolge von außen nach innen:
|
||||
// LogRequests erfasst jede Antwort, auch abgewiesene
|
||||
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
|
||||
// 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
|
||||
// LimitBody deckelt den Body, bevor ein Handler ihn liest
|
||||
handler := web.LogRequests(
|
||||
web.SecurityHeaders(
|
||||
web.LimitBodies(mux, web.BodyLimits{
|
||||
Web: maxWebBodyBytes,
|
||||
API: cfg.maxAPIBody,
|
||||
Import: maxImportBodyBytes,
|
||||
}),
|
||||
web.LimitBody(mux, maxWebBodyBytes),
|
||||
cfg.enableHSTS,
|
||||
),
|
||||
)
|
||||
|
||||
// ── Wiederkehrende Aufgaben ──────────────────────────────────────────────
|
||||
//
|
||||
// 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)
|
||||
go pruneAuditRegularly(db)
|
||||
if exporter != nil {
|
||||
go exportRegularly(exporter, control, workspaces)
|
||||
go exportRegularly(exporter, workspace.ID)
|
||||
log.Printf("Markdown-Export aktiv: %s (täglich)", cfg.exportDir)
|
||||
}
|
||||
|
||||
|
|
@ -261,9 +229,9 @@ func main() {
|
|||
defer stop()
|
||||
|
||||
go func() {
|
||||
alle, _ := control.ListWorkspaces(ctx)
|
||||
log.Printf("Munin läuft auf http://localhost:%s (%d Workspaces)", cfg.port, len(alle))
|
||||
log.Printf("Workspaces: %s (Kontrolle: %s)", workspacesDir, filepath.Join(cfg.dataDir, "control.db"))
|
||||
projekte, _ := db.ListProjects(ctx, workspace.ID)
|
||||
log.Printf("Munin läuft auf http://localhost:%s (%d Projekte)", cfg.port, len(projekte))
|
||||
log.Printf("Datenbank: %s", cfg.dbPath)
|
||||
if !cfg.enableHSTS {
|
||||
log.Println("Hinweis: ENABLE_HSTS ist aus — bei Betrieb unter HTTPS auf true setzen.")
|
||||
}
|
||||
|
|
@ -293,9 +261,7 @@ func main() {
|
|||
|
||||
// config sammelt alle Einstellungen aus der Umgebung.
|
||||
type config struct {
|
||||
// dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner
|
||||
// workspaces/ mit einer Datei je Workspace — siehe AUSBAUSTUFEN.md 4.4.
|
||||
dataDir string
|
||||
dbPath string
|
||||
port string
|
||||
adminToken string
|
||||
sessionSecret string
|
||||
|
|
@ -315,7 +281,7 @@ type config struct {
|
|||
// jedes Projekt. Ein erratbarer Wert wäre eine Hintertür.
|
||||
func readConfig() config {
|
||||
cfg := config{
|
||||
dataDir: envOr("DATA_DIR", "data"),
|
||||
dbPath: envOr("DB_PATH", "data/munin.db"),
|
||||
port: envOr("PORT", "8080"),
|
||||
adminToken: os.Getenv("ADMIN_TOKEN"),
|
||||
sessionSecret: os.Getenv("SESSION_SECRET"),
|
||||
|
|
@ -351,60 +317,16 @@ func readConfig() config {
|
|||
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.
|
||||
//
|
||||
// Einmal direkt beim Start, damit auch ein Server, der nie einen ganzen Tag
|
||||
// durchläuft, einen aktuellen Stand hinterlässt.
|
||||
func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) {
|
||||
func exportRegularly(e *export.Exporter, workspaceID string) {
|
||||
lauf := func() {
|
||||
stats, err := exportiereAlle(context.Background(), e, control, workspaces)
|
||||
stats, err := e.ExportAll(context.Background(), workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("Export fehlgeschlagen: %v", err)
|
||||
return
|
||||
}
|
||||
if stats.Removed > 0 {
|
||||
log.Printf("Export: %d Dateien geschrieben, %d verwaiste entfernt", stats.Written, stats.Removed)
|
||||
|
|
@ -420,84 +342,29 @@ func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces
|
|||
}
|
||||
}
|
||||
|
||||
// aufraeumenRegelmaessig räumt täglich in allen Workspaces auf: das Protokoll
|
||||
// (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) {
|
||||
// pruneAuditRegularly räumt das Protokoll täglich auf.
|
||||
func pruneAuditRegularly(db *store.Store) {
|
||||
// Einmal direkt beim Start, damit auch ein Server aufräumt, der nie 24
|
||||
// Stunden am Stück läuft.
|
||||
pruneAlle(control, workspaces)
|
||||
papierkorbLeerenAlle(control, workspaces)
|
||||
prune(db)
|
||||
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
pruneAlle(control, workspaces)
|
||||
papierkorbLeerenAlle(control, workspaces)
|
||||
prune(db)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
func prune(db *store.Store) {
|
||||
grenze := time.Now().UTC().Add(-auditRetention).Format(time.RFC3339Nano)
|
||||
|
||||
err := fuerJedenWorkspace(ctx, control, workspaces, func(w store.Workspace, offen *workspace.Workspace) error {
|
||||
n, err := offen.Store.PruneAudit(ctx, grenze)
|
||||
n, err := db.PruneAudit(context.Background(), grenze)
|
||||
if err != nil {
|
||||
return err
|
||||
log.Printf("Protokoll konnte nicht aufgeräumt werden: %v", err)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
log.Printf("Protokoll konnte nicht vollständig aufgeräumt werden: %v", err)
|
||||
log.Printf("Protokoll aufgeräumt: %d Einträge älter als %d Tage entfernt", n, int(auditRetention.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
123
main_test.go
|
|
@ -1,123 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
// 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,13 +18,7 @@ document.addEventListener("submit", function (event) {
|
|||
|
||||
// closest() sucht vom Element aufwärts nach dem passenden Vorfahren —
|
||||
// hier reicht das Formular selbst, aber so bleibt es robust.
|
||||
// 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"));
|
||||
var message = form.getAttribute && form.getAttribute("data-confirm");
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
380
static/graph.js
|
|
@ -1,380 +0,0 @@
|
|||
// 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: 120 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 128 KiB |
|
|
@ -1,86 +0,0 @@
|
|||
// 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,28 +185,6 @@ pre code { background: none; border: none; padding: 0; }
|
|||
}
|
||||
.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. */
|
||||
.gerüst {
|
||||
max-width: var(--breite-seite);
|
||||
|
|
@ -312,34 +290,6 @@ pre code { background: none; border: none; padding: 0; }
|
|||
}
|
||||
.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. */
|
||||
.meldung {
|
||||
padding: var(--s3) var(--s4);
|
||||
|
|
@ -387,12 +337,6 @@ a.marke:hover {
|
|||
border-color: transparent;
|
||||
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. */
|
||||
.meta {
|
||||
|
|
@ -459,9 +403,6 @@ button.warnung:hover { background: var(--warn-flach); filter: none; }
|
|||
|
||||
.reihe { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
||||
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.
|
||||
*
|
||||
|
|
@ -499,77 +440,6 @@ textarea {
|
|||
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. */
|
||||
.feld.kurz { max-width: 18rem; }
|
||||
.feld.mittel { max-width: 28rem; }
|
||||
|
|
@ -638,10 +508,8 @@ table.tabelle {
|
|||
padding: var(--s5);
|
||||
}
|
||||
.anmeldeseite .karte { width: 100%; padding: var(--s6); }
|
||||
.anmeldeseite h1 { margin-bottom: var(--s2); }
|
||||
.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 {
|
||||
|
|
@ -664,63 +532,12 @@ table.tabelle {
|
|||
overflow: hidden;
|
||||
}
|
||||
.eintraege li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s3);
|
||||
padding: var(--s4);
|
||||
border-bottom: 1px solid var(--rahmen);
|
||||
}
|
||||
.eintraege li:last-child { border-bottom: none; }
|
||||
.eintraege li:hover { background: var(--flaeche-leis); }
|
||||
|
||||
/* 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 {
|
||||
display: block;
|
||||
font-size: var(--t-gross);
|
||||
|
|
@ -767,12 +584,6 @@ table.tabelle {
|
|||
.eintrag-text table { border-collapse: collapse; width: 100%; font-size: var(--t-meta); }
|
||||
.eintrag-text th, .eintrag-text td { border: 1px solid var(--rahmen); padding: var(--s2) var(--s3); }
|
||||
.eintrag-text hr { border: none; border-top: 1px solid var(--rahmen); margin: var(--s6) 0; }
|
||||
/* 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 h2 { font-size: var(--t-meta); color: var(--text-leise); margin-bottom: var(--s3); }
|
||||
|
|
@ -800,85 +611,6 @@ table.tabelle {
|
|||
}
|
||||
.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
|
||||
* 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
|
||||
|
|
@ -886,58 +618,6 @@ table.tabelle {
|
|||
.gefahrenzone { border-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 ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Unterhalb dieser Breite ist für zwei Spalten kein Platz mehr: Die Seitenleiste
|
||||
|
|
@ -995,5 +675,4 @@ table.tabelle {
|
|||
.tabelle th, .tabelle td { padding: var(--s2) var(--s3); }
|
||||
.feld.kurz, .feld.mittel, .feld.lang { max-width: none; }
|
||||
textarea { min-height: 14rem; }
|
||||
.diff-koepfe { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
<div class="seitenkopf">
|
||||
<div class="seitenkopf-titel">
|
||||
{{/* 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>
|
||||
<h1>Protokoll</h1>
|
||||
<span class="hilfe">
|
||||
Die jüngsten {{len .Data.Events}} Vorgänge, neueste zuerst.
|
||||
{{if .Data.Global}}Über alle Projekte hinweg.{{end}}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@
|
|||
|
||||
<div class="karte">
|
||||
<form method="POST" class="formular"
|
||||
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">
|
||||
action="{{if .Data.IsNew}}/p/{{.Project.Slug}}/save{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/save{{end}}">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
|
||||
{{/* Die Fassung wandert unverändert durchs Formular und zurück. Hat jemand
|
||||
|
|
@ -28,7 +27,7 @@
|
|||
<label for="slug">Bezeichner</label>
|
||||
<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_-]*"
|
||||
value="{{.Data.Entry.Slug}}" placeholder="wird-abgeleitet" autocomplete="off">
|
||||
placeholder="wird-abgeleitet" autocomplete="off">
|
||||
</div>
|
||||
{{else}}
|
||||
<input type="hidden" name="slug" value="{{.Data.Entry.Slug}}">
|
||||
|
|
@ -40,27 +39,10 @@
|
|||
<input type="text" id="tags" name="tags" value="{{.Data.TagsIn}}" placeholder="ops, deployment">
|
||||
</div>
|
||||
|
||||
<div class="feld" data-editor>
|
||||
<label for="content">Inhalt {{template "hilfelink" "weboberflaeche#markdown-im-inhalt"}}</label>
|
||||
<div class="feld">
|
||||
<label for="content">Inhalt</label>
|
||||
<span class="hilfe">Markdown. <code>[[bezeichner]]</code> verweist auf einen anderen Eintrag.</span>
|
||||
|
||||
{{/* Ohne JavaScript bleibt einfach der erste Reiter aktiv — das
|
||||
Formular funktioniert dann genau wie vorher, nur ohne
|
||||
Vorschau-Knopf-Wirkung. Kein <noscript>-Sonderfall nötig. */}}
|
||||
<div class="editor-reiter" role="tablist">
|
||||
<button type="button" class="editor-reiter-knopf aktiv"
|
||||
data-editor-tab="schreiben" role="tab" aria-selected="true">Schreiben</button>
|
||||
<button type="button" class="editor-reiter-knopf"
|
||||
data-editor-tab="vorschau" role="tab" aria-selected="false">Vorschau</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<textarea id="content" name="content" rows="24" spellcheck="true">{{.Data.Entry.Content}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="feld ankreuz">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div class="seitenkopf">
|
||||
<div class="seitenkopf-titel">
|
||||
<h1>Einträge {{template "hilfelink" "weboberflaeche#finden"}}</h1>
|
||||
<h1>Einträge</h1>
|
||||
<span class="hilfe">
|
||||
{{if .Project.Description}}{{.Project.Description}}{{else}}Wissen dieses Projekts — von Menschen und Agenten.{{end}}
|
||||
</span>
|
||||
|
|
@ -28,24 +28,10 @@
|
|||
{{end}}
|
||||
|
||||
{{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">
|
||||
{{$slug := .Project.Slug}}
|
||||
{{range .Data.Entries}}
|
||||
<li>
|
||||
{{if $sammelbearbeitung}}
|
||||
<label class="eintrag-auswahl">
|
||||
<input type="checkbox" name="ids" value="{{.Entry.ID}}" aria-label="{{.Entry.Title}} auswählen">
|
||||
</label>
|
||||
{{end}}
|
||||
|
||||
<div class="eintrag-inhalt">
|
||||
<a class="eintrag-titel" href="/p/{{$slug}}/e/{{.Entry.Slug}}">{{.Entry.Title}}</a>
|
||||
|
||||
<div class="meta">
|
||||
|
|
@ -57,20 +43,9 @@
|
|||
</div>
|
||||
|
||||
{{if .Snippet}}<p class="eintrag-ausschnitt">{{.Snippet}}</p>{{end}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</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}}
|
||||
<div class="leer">
|
||||
{{if .Data.Query}}
|
||||
|
|
@ -82,8 +57,6 @@
|
|||
{{else}}
|
||||
<p>Noch keine Einträge.</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}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
{{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" .}}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
{{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">
|
||||
<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"
|
||||
data-confirm="Eintrag „{{.Data.Entry.Title}}“ in den Papierkorb legen? Er lässt sich dort wiederherstellen.">
|
||||
data-confirm="Eintrag „{{.Data.Entry.Title}}“ wirklich löschen?">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="warnung">Löschen</button>
|
||||
</form>
|
||||
|
|
@ -19,16 +19,11 @@
|
|||
<div class="meta">
|
||||
<code>{{.Data.Entry.Slug}}</code>
|
||||
{{if .Data.Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}}
|
||||
{{if .Data.Stale}}<span class="marke warnung" title="Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt.">über ein Jahr alt</span>{{end}}
|
||||
<span class="marke">{{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span>
|
||||
{{$slug := .Project.Slug}}
|
||||
{{range .Data.Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}}
|
||||
<span>geändert {{datum .Data.Entry.UpdatedAt}}{{if .Data.Entry.Author}} von {{.Data.Entry.Author}}{{end}}</span>
|
||||
{{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>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* .HTML ist bereits gerendertes, sicheres HTML — goldmark lässt kein Roh-HTML
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
{{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" .}}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{{template "kopf" .}}
|
||||
|
||||
<div class="seitenkopf">
|
||||
<div class="seitenkopf-titel">
|
||||
<h1>{{.Data.Seite.Titel}}</h1>
|
||||
<span class="hilfe">{{.Data.Seite.Kurz}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Die Hilfeseiten stehen als Reiterleiste über dem Text statt in der
|
||||
Seitenleiste. Die Seitenleiste bleibt so dieselbe wie auf allen anderen
|
||||
Seiten, und wer die Hilfe liest, sieht trotzdem auf einen Blick, welche
|
||||
Themen es gibt. */}}
|
||||
<nav class="hilfe-navigation" aria-label="Hilfethemen">
|
||||
{{$aktuell := .Data.Seite.Slug}}
|
||||
{{range .Data.Seiten}}
|
||||
<a href="/hilfe/{{.Slug}}" {{if eq $aktuell .Slug}}class="aktiv" aria-current="page"{{end}}>{{.Titel}}</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
|
||||
<article class="eintrag-text hilfe-text">
|
||||
{{.Data.HTML}}
|
||||
</article>
|
||||
|
||||
{{template "fuss" .}}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
{{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-titel">
|
||||
<h1>Zugänge {{template "hilfelink" "zugaenge"}}</h1>
|
||||
<h1>Zugänge</h1>
|
||||
<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
|
||||
sich zurückziehen, ohne alle anderen zu stören.</span>
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
<summary>MCP-Client einrichten</summary>
|
||||
|
||||
<strong>Claude Code</strong>
|
||||
<pre><code>claude mcp add --transport http munin-{{.Project.Slug}} {{.Data.ServerURL}}/mcp \
|
||||
<pre><code>claude mcp add --transport http munin-{{.Project.Slug}} https://DEIN-SERVER/mcp \
|
||||
--header "Authorization: Bearer {{.Data.NewKey}}"</code></pre>
|
||||
|
||||
<strong>Cline</strong> <span class="hilfe">(<code>cline_mcp_settings.json</code>)</span>
|
||||
|
|
@ -32,9 +32,9 @@
|
|||
"mcpServers": {
|
||||
"munin-{{.Project.Slug}}": {
|
||||
"type": "streamableHttp",
|
||||
"url": "{{.Data.ServerURL}}/mcp",
|
||||
"url": "https://DEIN-SERVER/mcp",
|
||||
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" },
|
||||
"autoApprove": ["recall", "get", "list", "related"]
|
||||
"autoApprove": ["recall", "get", "list"]
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
"mcp": {
|
||||
"munin-{{.Project.Slug}}": {
|
||||
"type": "remote",
|
||||
"url": "{{.Data.ServerURL}}/mcp",
|
||||
"url": "https://DEIN-SERVER/mcp",
|
||||
"enabled": true,
|
||||
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" }
|
||||
}
|
||||
|
|
@ -54,15 +54,6 @@
|
|||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Data.Ungenutzt}}
|
||||
<div class="meldung fehler">
|
||||
{{if eq .Data.Ungenutzt 1}}Ein aktiver Zugang wurde{{else}}{{.Data.Ungenutzt}} aktive Zugänge wurden{{end}}
|
||||
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}}
|
||||
<div class="tabelle-huelle">
|
||||
<table class="tabelle">
|
||||
|
|
@ -77,17 +68,14 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}{{$jetzt := .Data.Jetzt}}
|
||||
{{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}
|
||||
{{range .Data.Keys}}
|
||||
<tr {{if .Revoked}}class="gedaempft"{{end}}>
|
||||
<td>{{.Name}}</td>
|
||||
<td><code>{{.Role}}</code></td>
|
||||
<td>{{datum .CreatedAt}}</td>
|
||||
<td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td>
|
||||
<td>
|
||||
{{if .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>{{if .Revoked}}zurückgezogen{{else}}aktiv{{end}}</td>
|
||||
<td class="schmal rechts">
|
||||
{{if not .Revoked}}
|
||||
<form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe"
|
||||
|
|
|
|||
|
|
@ -4,14 +4,8 @@
|
|||
|
||||
<div class="anmeldeseite">
|
||||
<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">
|
||||
<h1>Munin</h1>
|
||||
<span class="hilfe">Mit dem API-Key des Projekts anmelden. Der Plattform-Admin
|
||||
verwendet sein Admin-Token.</span>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
{{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" .}}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
{{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,35 +23,17 @@
|
|||
|
||||
{{define "html-ende"}}
|
||||
<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>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "kopf"}}{{template "html-anfang" .}}
|
||||
|
||||
{{/* Schmale Kopfleiste über die ganze Breite: Marke, Workspace-Umschalter und
|
||||
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. */}}
|
||||
{{/* Schmale Kopfleiste über die ganze Breite: Marke und Abmelden. Alles
|
||||
Weitere steht in der Seitenleiste. */}}
|
||||
<header class="kopfleiste">
|
||||
<div class="kopfleiste-innen">
|
||||
<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">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="wie-link">Abmelden</button>
|
||||
|
|
@ -89,16 +71,10 @@
|
|||
<h2 class="nav-titel">{{.Project.Name}}</h2>
|
||||
<ul class="nav-liste">
|
||||
<li><a href="/p/{{.Project.Slug}}/" {{if eq .Bereich "eintraege"}}class="aktiv"{{end}}>Einträge</a></li>
|
||||
<li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li>
|
||||
{{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}}/import" {{if eq .Bereich "import"}}class="aktiv"{{end}}>Import</a></li>
|
||||
{{end}}
|
||||
<li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li>
|
||||
<li><a href="/p/{{.Project.Slug}}/audit" {{if eq .Bereich "protokoll"}}class="aktiv"{{end}}>Protokoll</a></li>
|
||||
{{if .Session.CanWrite}}
|
||||
<li><a href="/p/{{.Project.Slug}}/trash" {{if eq .Bereich "papierkorb"}}class="aktiv"{{end}}>Papierkorb</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
|
@ -125,18 +101,9 @@
|
|||
<h2 class="nav-titel">Plattform</h2>
|
||||
<ul class="nav-liste">
|
||||
<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>
|
||||
</nav>
|
||||
{{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>
|
||||
|
||||
<main class="inhalt">
|
||||
|
|
@ -144,20 +111,6 @@
|
|||
{{if .Flash}}<div class="meldung hinweis">{{.Flash}}</div>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* hilfelink setzt ein kleines "?" neben eine Überschrift oder ein Feld, das
|
||||
zur passenden Stelle der eingebauten Hilfe führt (AUSBAUSTUFEN.md 5.4).
|
||||
Aufruf mit dem Ziel unterhalb von /hilfe/, auf Wunsch mit Abschnitt:
|
||||
{{template "hilfelink" "weboberflaeche#markdown-im-inhalt"}}
|
||||
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"}}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div class="seitenkopf">
|
||||
<div class="seitenkopf-titel">
|
||||
<h1>Projekte {{template "hilfelink" "plattform-admin#projekte"}}</h1>
|
||||
<h1>Projekte</h1>
|
||||
<span class="hilfe">Jedes Projekt hat eigene Einträge, eigene Zugänge und einen
|
||||
eigenen MCP-Endpunkt. Projekte sehen sich gegenseitig nicht.</span>
|
||||
</div>
|
||||
|
|
@ -26,8 +26,6 @@
|
|||
<th>Projekt</th>
|
||||
<th>Bezeichner</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="schmal rechts">Verwaltung</th>
|
||||
</tr>
|
||||
|
|
@ -40,23 +38,7 @@
|
|||
{{if .Project.Description}}<div class="hilfe">{{.Project.Description}}</div>{{end}}
|
||||
</td>
|
||||
<td><code>{{.Project.Slug}}</code></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">{{.Entries}}</td>
|
||||
<td class="zahl">
|
||||
{{if .Limit}}
|
||||
{{.Frei}} / {{.Limit}}
|
||||
|
|
@ -69,7 +51,6 @@
|
|||
</td>
|
||||
<td class="schmal rechts">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
{{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" .}}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
{{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" .}}
|
||||