Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)

- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Adamek 2026-09-25 08:18:47 +02:00
parent c4a1941e7f
commit cf35c3f5db
30 changed files with 2352 additions and 21 deletions

View file

@ -37,6 +37,7 @@ 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 |
@ -62,11 +63,14 @@ eigenen Anwendung:
## Munin
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
- 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.
@ -175,7 +179,7 @@ sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Hak
| Rolle | darf | wofür |
|---|---|---|
| `read` | recall, get, list | Agenten, die nur auskunftsfähig sein sollen |
| `read` | recall, get, list, related | Agenten, die nur auskunftsfähig sein sollen |
| `write` | zusätzlich remember, forget | der Normalfall |
| `admin` | zusätzlich Zugänge verwalten | Menschen |

View file

@ -199,7 +199,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
### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
Felder im Kopf, ein Einlesen wäre also möglich.
@ -210,6 +210,40 @@ 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
@ -256,7 +290,7 @@ Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen
**Vorbild:** Engrams `mem_suggest_topic_key`.
### 3.5 Aktualität von Wissen
### 3.5 Aktualität von Wissen — kleiner Schritt ✅ umgesetzt
Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von
vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit
@ -276,6 +310,22 @@ 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.
@ -299,7 +349,7 @@ 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.
### 3.7 Verwandte Einträge
### 3.7 Verwandte Einträge — ✅ umgesetzt
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
@ -307,6 +357,16 @@ 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
@ -908,6 +968,9 @@ 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,

View file

@ -148,13 +148,13 @@ Kommandozeilenfassung liegt dieselbe Datei unter `~/.cline/mcp.json`.
"url": "https://munin.intern/mcp",
"headers": { "Authorization": "Bearer wk_…" },
"disabled": false,
"autoApprove": ["recall", "get", "list"]
"autoApprove": ["recall", "get", "list", "related"]
}
}
}
```
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier
lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und
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,6 +208,7 @@ 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). |

View file

@ -105,6 +105,10 @@ munin/
│ │ │ TrashRetention (30 Tage) steht hier, damit
│ │ │ Oberfläche und Aufräumen dieselbe Zahl nutzen.
│ │ │
│ │ ├── related.go ← RelatedEntries: Nachbarn im Verweisgraphen, in
│ │ │ beide Richtungen, bis zu zwei Schritte weit
│ │ │ (Breitensuche, eine Abfrage je Richtung und Ebene).
│ │ │
│ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie
│ │ │ beim Rendern. Das ersetzt die frühere
│ │ │ Ordnerhierarchie als Struktur zwischen Einträgen.
@ -222,6 +226,19 @@ munin/
│ │ 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
@ -273,6 +290,9 @@ munin/
│ │ ├── 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.
@ -302,9 +322,11 @@ munin/
│ │ Qualität der Antworten, weil er bei jedem Client
│ │ im Kontext steht.
│ │
│ └── 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.
│ └── 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.
│
├── templates/ ← HTML der Weboberfläche. partials.html enthält die
│ gemeinsamen Blöcke "kopf" und "fuss", die jede
@ -361,7 +383,10 @@ werden.
| `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/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 |

View file

@ -72,13 +72,13 @@ In Cline: *MCP Servers* → *Configure* → *Configure MCP Servers*.
"url": "{{SERVER}}/mcp",
"headers": { "Authorization": "Bearer wk_DEIN_KEY" },
"disabled": false,
"autoApprove": ["recall", "get", "list"]
"autoApprove": ["recall", "get", "list", "related"]
}
}
}
```
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier
lesenden gehören hinein, sonst muss jede Suche einzeln bestätigt werden.
`remember` und `forget` gehören **nicht** hinein, denn sie verändern das
gemeinsame Wissen.

View file

@ -16,11 +16,14 @@ deiner Anwendung übernehmen:
## Munin
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
- 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.
@ -51,6 +54,7 @@ WIE festgehalten wird:
| `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. |

View file

@ -53,3 +53,8 @@ täglich alle Einträge aller Workspaces als Markdown-Dateien dorthin, als
zusätzliche, lesbare Sicherung, etwa für ein Git-Repository. Unter *Projekte*
exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten
Workspace, zum Beispiel vor einer größeren Änderung.
Zurück kommt ein Export über den [Import](/hilfe/weboberflaeche#import) im
jeweiligen Projekt: den Projektordner aus dem Export als ZIP-Archiv packen und
hochladen. So lässt sich auch ein einzelnes Projekt wiederherstellen, ohne
die ganze Workspace-Datei zurückzuspielen.

View file

@ -149,6 +149,34 @@ 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

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

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

View file

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

View file

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

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

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

View file

@ -143,7 +143,7 @@ func TestToolsAndInstructions(t *testing.T) {
vorhanden[tool.Name] = true
}
for _, name := range []string{"recall", "get", "list", "remember", "forget"} {
for _, name := range []string{"recall", "get", "list", "remember", "forget", "related"} {
if !vorhanden[name] {
t.Errorf("Werkzeug %q fehlt", name)
}
@ -685,3 +685,85 @@ func TestRememberRevivesFromTrash(t *testing.T) {
t.Error("das Protokoll vermerkt das Zurückholen nicht")
}
}
// ── Verwandte Einträge und Alter (AUSBAUSTUFEN.md 3.7, 3.5) ──────────────────
func TestRelatedFindsNeighbours(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "release", "title": "Release-Prozess", "content": "Vorher [[checkliste]] durchgehen.",
})
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "checkliste", "title": "Checkliste", "content": "Siehe auch [[rollback]].",
})
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "rollback", "title": "Rollback", "content": "Zurück auf die letzte Fassung.",
})
// Auch ein Lesezugang darf das Umfeld abfragen.
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release"})
if ergebnis.IsError {
t.Fatalf("Fehler: %s", text(ergebnis))
}
antwort := text(ergebnis)
for _, erwartet := range []string{`"checkliste"`, `"rollback"`, `"distance":2`, `"via":"checkliste"`} {
if !strings.Contains(antwort, erwartet) {
t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort)
}
}
nurDirekt := text(ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release", "distance": 1}))
if strings.Contains(nurDirekt, "rollback") {
t.Errorf("distance 1 lieferte die zweite Reihe: %s", nurDirekt)
}
}
func TestRelatedUnknownSlugExplains(t *testing.T) {
h, ws, projekt := aufbau(t)
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "gibtesnicht"})
if !ergebnis.IsError || !strings.Contains(text(ergebnis), "recall") {
t.Errorf("unbekannter Bezeichner nicht hilfreich gemeldet: %s", text(ergebnis))
}
}
// TestStaleEntriesAreMarked prüft, dass alte Einträge in recall, get und list
// als "stale" erscheinen und frische nicht.
func TestStaleEntriesAreMarked(t *testing.T) {
h, ws, projekt := aufbau(t)
ctx := context.Background()
vorZweiJahren := time.Now().AddDate(-2, 0, 0)
if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "altes-deployment", Title: "Deployment alt",
Content: "Deployment per FTP", Source: store.SourceHuman, UpdatedAt: vorZweiJahren,
}); err != nil {
t.Fatal(err)
}
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "neues-deployment", "title": "Deployment neu", "content": "Deployment per Pipeline",
})
recall := text(ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "Deployment"}))
if strings.Count(recall, `"stale":true`) != 1 {
t.Errorf("recall: genau ein Treffer sollte stale sein: %s", recall)
}
if !strings.Contains(recall, "seit über einem Jahr") {
t.Errorf("recall: Hinweis auf veraltete Treffer fehlt: %s", recall)
}
alt := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "altes-deployment"}))
if !strings.Contains(alt, `"stale":true`) || !strings.Contains(alt, "prüfen") {
t.Errorf("get: alter Eintrag nicht markiert: %s", alt)
}
neu := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "neues-deployment"}))
if strings.Contains(neu, "stale") {
t.Errorf("get: frischer Eintrag als stale markiert: %s", neu)
}
liste := text(ruf(t, h, ws, projekt, auth.RoleRead, "list", nil))
if strings.Count(liste, `"stale":true`) != 1 {
t.Errorf("list: genau ein Eintrag sollte stale sein: %s", liste)
}
}

View file

@ -61,6 +61,14 @@ 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,
@ -157,6 +165,7 @@ 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 {
@ -181,6 +190,8 @@ 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 {
@ -219,6 +230,7 @@ type listItem struct {
Source string `json:"source"`
Curated bool `json:"curated"`
Updated string `json:"updated_at"`
Stale bool `json:"stale,omitempty"`
}
type listOutput struct {
@ -226,3 +238,25 @@ 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"`
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"log"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -40,8 +41,22 @@ 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
@ -57,6 +72,7 @@ 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
}
@ -157,6 +173,9 @@ 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 {
@ -175,6 +194,11 @@ 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,
@ -183,6 +207,7 @@ 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,
})
}
@ -192,6 +217,14 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
"oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen."
} 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
},
@ -249,7 +282,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
linkedBy = append(linkedBy, b.Slug)
}
return nil, getOutput{
out := getOutput{
Slug: e.Slug,
Title: e.Title,
Content: e.Content,
@ -261,7 +294,12 @@ 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,
}, nil
}
if e.Stale(time.Now()) {
out.Stale = true
out.Hint = staleHint
}
return nil, out, nil
},
)
}
@ -308,6 +346,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
return nil, listOutput{}, err
}
jetzt := time.Now()
items := make([]listItem, 0, len(eintraege))
for _, e := range eintraege {
items = append(items, listItem{
@ -317,6 +356,7 @@ 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),
})
}
@ -480,3 +520,70 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) {
},
)
}
// ── related ──────────────────────────────────────────────────────────────────
func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
mcp.AddTool(server,
&mcp.Tool{
Name: "related",
Description: "Liefert die Einträge, die über [[Verweise]] mit einem Eintrag verknüpft sind — " +
"direkt und (Standard) auch über einen Zwischeneintrag. Findet Zusammenhänge, " +
"die \"recall\" nicht findet, weil kein Suchwort passt. Nützlich, nachdem \"get\" " +
"einen passenden Eintrag geliefert hat und mehr Kontext gebraucht wird.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in relatedInput) (*mcp.CallToolResult, relatedOutput, error) {
ws, err := workspaceFrom(ctx)
if err != nil {
return nil, relatedOutput{}, err
}
abstand := in.Distance
if abstand <= 0 {
abstand = relatedDefaultDistance
}
if abstand > relatedMaxDistance {
abstand = relatedMaxDistance
}
limit := in.Limit
if limit <= 0 {
limit = relatedDefaultLimit
}
if limit > relatedMaxLimit {
limit = relatedMaxLimit
}
verwandte, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, abstand, limit)
if errors.Is(err, store.ErrNotFound) {
return nil, relatedOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug)
}
if err != nil {
return nil, relatedOutput{}, err
}
jetzt := time.Now()
items := make([]relatedItem, 0, len(verwandte))
for _, v := range verwandte {
items = append(items, relatedItem{
Slug: v.Slug,
Title: v.Title,
Distance: v.Distance,
Via: v.Via,
Relation: v.Relation,
Updated: v.UpdatedAt.Format("2006-01-02 15:04"),
Stale: store.StaleSince(v.UpdatedAt, jetzt),
})
}
out := relatedOutput{Results: items, Total: len(items)}
if len(items) == 0 {
out.Hint = "Dieser Eintrag ist mit keinem anderen verknüpft. Mit \"recall\" nach verwandten " +
"Themen suchen. Findet sich etwas, lohnt es sich, die Einträge mit [[bezeichner]] zu verknüpfen."
} else {
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
}
return nil, out, nil
},
)
}

View file

@ -20,6 +20,12 @@ const (
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"

View file

@ -7,6 +7,7 @@ import (
"fmt"
"sort"
"strings"
"time"
)
// EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag.
@ -32,6 +33,19 @@ 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
@ -147,6 +161,12 @@ 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)

View file

@ -115,6 +115,35 @@ type Entry struct {
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 {

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

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

View file

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

View file

@ -128,6 +128,11 @@ 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.
@ -163,7 +168,10 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
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},
Data: entryViewData{
Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks,
Stale: e.Stale(time.Now()),
},
})
}

View file

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

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

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

View file

@ -341,6 +341,48 @@ func LimitBody(next http.Handler, maxBytes int64) http.Handler {
})
}
// BodyLimits sind die Höchstgrößen eines Request-Bodys je Art von Route.
type BodyLimits struct {
Web int64 // Formulare der Weboberfläche
API int64 // MCP-Aufrufe unter /mcp
Import int64 // Hochladen eines ZIP-Archivs (siehe handlers_import.go)
}
// LimitBodies begrenzt den Body wie LimitBody, wählt die Grenze aber nach der
// Route.
//
// # Warum eine Stelle statt LimitBody je Route?
//
// Zwei ineinander geschachtelte MaxBytesReader begrenzen immer auf den
// KLEINEREN Wert. Ein großzügiges Limit an einer einzelnen Route unter einem
// strengen Limit für alles wirkt deshalb nie. Genau so war MCP früher
// verdrahtet: MAX_API_BODY_BYTES stand auf 4 MB, das äußere Limit von 1 MB
// schnitt trotzdem ab. Hier wird die Grenze einmal außen gewählt, bevor
// irgendetwas anderes den Body anfasst.
func LimitBodies(next http.Handler, limits BodyLimits) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
grenze := limits.Web
switch {
case r.URL.Path == "/mcp":
grenze = limits.API
case r.Method == http.MethodPost && istImportPfad(r.URL.Path):
grenze = limits.Import
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, grenze)
}
next.ServeHTTP(w, r)
})
}
// istImportPfad erkennt "/p/{projekt}/import". Der Mux hat zu diesem
// Zeitpunkt noch nicht entschieden, welche Route passt, deshalb wird der Pfad
// hier von Hand zerlegt: genau drei Teile, der erste "p", der letzte "import".
func istImportPfad(pfad string) bool {
teile := strings.Split(strings.Trim(pfad, "/"), "/")
return len(teile) == 3 && teile[0] == "p" && teile[2] == "import"
}
// ── API-Zugang (MCP) ─────────────────────────────────────────────────────────
// APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im

View file

@ -169,6 +169,11 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
// 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)))))
}

18
main.go
View file

@ -58,6 +58,12 @@ 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 ──────────────────────────────────────────────────────────
@ -207,15 +213,21 @@ 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", web.LimitBody(webServer.APIAuth(mcpHandler), cfg.maxAPIBody))
mux.Handle("/mcp", webServer.APIAuth(mcpHandler))
// Reihenfolge von außen nach innen:
// LogRequests erfasst jede Antwort, auch abgewiesene
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
// LimitBody deckelt den Body, bevor ein Handler ihn liest
// LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit
// einer eigenen Grenze für /mcp und den Import, siehe
// dort, warum das nicht je Route geschehen kann
handler := web.LogRequests(
web.SecurityHeaders(
web.LimitBody(mux, maxWebBodyBytes),
web.LimitBodies(mux, web.BodyLimits{
Web: maxWebBodyBytes,
API: cfg.maxAPIBody,
Import: maxImportBodyBytes,
}),
cfg.enableHSTS,
),
)

View file

@ -19,6 +19,7 @@
<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}}

82
templates/import.html Normal file
View file

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

View file

@ -34,7 +34,7 @@
"type": "streamableHttp",
"url": "{{.Data.ServerURL}}/mcp",
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" },
"autoApprove": ["recall", "get", "list"]
"autoApprove": ["recall", "get", "list", "related"]
}
}
}</code></pre>

View file

@ -92,6 +92,7 @@
<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>