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:
parent
c4a1941e7f
commit
cf35c3f5db
30 changed files with 2352 additions and 21 deletions
|
|
@ -37,6 +37,7 @@ und im Protokoll.
|
||||||
| `recall` | `query`, `tags?`, `limit?` | read |
|
| `recall` | `query`, `tags?`, `limit?` | read |
|
||||||
| `get` | `slug` | read |
|
| `get` | `slug` | read |
|
||||||
| `list` | `tag?`, `curated?`, `limit?` | read |
|
| `list` | `tag?`, `curated?`, `limit?` | read |
|
||||||
|
| `related` | `slug`, `distance?`, `limit?` | read |
|
||||||
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write |
|
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write |
|
||||||
| `forget` | `slug` | write |
|
| `forget` | `slug` | write |
|
||||||
|
|
||||||
|
|
@ -62,11 +63,14 @@ eigenen Anwendung:
|
||||||
## Munin
|
## Munin
|
||||||
|
|
||||||
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
|
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
|
||||||
get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
|
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
|
||||||
|
|
||||||
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
|
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
|
||||||
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
|
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
|
||||||
- Passenden Treffer mit `get` vollständig laden.
|
- Passenden Treffer mit `get` vollständig laden.
|
||||||
|
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
|
||||||
|
- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen
|
||||||
|
und in der Antwort erwähnen, dass der Stand alt ist.
|
||||||
- Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das
|
- Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das
|
||||||
kenntlich machen.
|
kenntlich machen.
|
||||||
- Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht.
|
- Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht.
|
||||||
|
|
@ -175,7 +179,7 @@ sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Hak
|
||||||
|
|
||||||
| Rolle | darf | wofür |
|
| Rolle | darf | wofür |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `read` | recall, get, list | Agenten, die nur auskunftsfähig sein sollen |
|
| `read` | recall, get, list, related | Agenten, die nur auskunftsfähig sein sollen |
|
||||||
| `write` | zusätzlich remember, forget | der Normalfall |
|
| `write` | zusätzlich remember, forget | der Normalfall |
|
||||||
| `admin` | zusätzlich Zugänge verwalten | Menschen |
|
| `admin` | zusätzlich Zugänge verwalten | Menschen |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
**Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben.
|
||||||
|
|
||||||
### 3.2 Importer als Gegenstück zum Export
|
### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
|
||||||
|
|
||||||
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
|
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
|
||||||
Felder im Kopf, ein Einlesen wäre also möglich.
|
Felder im Kopf, ein Einlesen wäre also möglich.
|
||||||
|
|
@ -210,6 +210,40 @@ Erstbefüllung aus vorhandener Dokumentation.
|
||||||
**Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert
|
**Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert
|
||||||
— überschreiben, überspringen oder abbrechen.
|
— überschreiben, überspringen oder abbrechen.
|
||||||
|
|
||||||
|
**✅ Umgesetzt 2026-09-25:**
|
||||||
|
|
||||||
|
- **Weg:** ZIP-Upload unter `/p/{projekt}/import`, Rolle `admin`. Bewusst kein
|
||||||
|
Kommandozeilenbefehl: Der Suchindex liegt im Speicher des laufenden
|
||||||
|
Servers, ein zweiter Prozess könnte ihn nicht nachziehen. Nebenbei können
|
||||||
|
Projekt-Admins so selbst importieren.
|
||||||
|
- **Zusammenstoß:** *überspringen* (Standard) oder *überschreiben*. Beim
|
||||||
|
Überschreiben wandert der alte Stand in den Verlauf, der Autor ist dann der
|
||||||
|
importierende Zugang. Ein *Abbrechen* gibt es nicht, dafür eine Vorschau,
|
||||||
|
die beim ersten Hochladen angehakt ist. Dateien, die nichts ändern würden,
|
||||||
|
werden nie geschrieben. Ein wiederholter Import ist damit harmlos.
|
||||||
|
- **Papierkorb:** Im Modus *überspringen* holt ein Import keinen gelöschten
|
||||||
|
Eintrag zurück. Das war die Falle, weil `AllEntries` den Papierkorb nicht
|
||||||
|
sieht.
|
||||||
|
- **Felder:** Beim Anlegen bleiben Autor, Herkunft, Prüfvermerk und beide
|
||||||
|
Zeitstempel erhalten (neu: `EntryWrite.CreatedAt/UpdatedAt`). Sonst sähe
|
||||||
|
nach einer Wiederherstellung alles frisch aus, und 3.5 liefe ins Leere. Der
|
||||||
|
Verlauf wird nicht übernommen, der Export enthält ihn nicht.
|
||||||
|
- **Fremde Dateien:** Ohne Kopf gilt der Dateiname als Bezeichner und die
|
||||||
|
erste Überschrift als Titel. Fremde Köpfe (Hugo, Obsidian) werden gelesen,
|
||||||
|
soweit sie `title` und `tags` betreffen. Kein YAML-Paket, ein kleiner
|
||||||
|
eigener Leser in `internal/importer/parse.go`.
|
||||||
|
- **Grenzen:** 20 MB Upload, 5.000 Dateien, 1 MB je Datei, 100 MB ausgepackt.
|
||||||
|
Gezählt wird, was tatsächlich ausgepackt wird, nicht was das Archiv
|
||||||
|
behauptet.
|
||||||
|
- **Protokoll:** ein Eintrag `entry_import` je Import mit Zusammenfassung,
|
||||||
|
nicht einer je Datei. So verzerrt ein einmaliger Import die
|
||||||
|
Nutzungsübersicht (4.6) nicht.
|
||||||
|
- **Nebenbei behoben:** Das 1-MB-Limit für Formulare lag über dem ganzen Mux,
|
||||||
|
auch über `/mcp`. Zwei geschachtelte `MaxBytesReader` begrenzen auf den
|
||||||
|
kleineren Wert, MCP war also nie bei den dokumentierten 4 MB, und
|
||||||
|
`MAX_API_BODY_BYTES` wirkte nur nach unten. Jetzt wählt `LimitBodies` die
|
||||||
|
Grenze einmal außen je Route.
|
||||||
|
|
||||||
### 3.3 Dubletten über Ähnlichkeit erkennen
|
### 3.3 Dubletten über Ähnlichkeit erkennen
|
||||||
|
|
||||||
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
|
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
|
||||||
|
|
@ -256,7 +290,7 @@ Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen
|
||||||
|
|
||||||
**Vorbild:** Engrams `mem_suggest_topic_key`.
|
**Vorbild:** Engrams `mem_suggest_topic_key`.
|
||||||
|
|
||||||
### 3.5 Aktualität von Wissen
|
### 3.5 Aktualität von Wissen — kleiner Schritt ✅ umgesetzt
|
||||||
|
|
||||||
Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von
|
Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von
|
||||||
vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit
|
vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit
|
||||||
|
|
@ -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
|
Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein
|
||||||
Bestätigen, das die Frist zurücksetzt.
|
Bestätigen, das die Frist zurücksetzt.
|
||||||
|
|
||||||
|
**✅ Kleiner Schritt umgesetzt 2026-09-25:**
|
||||||
|
|
||||||
|
- **Regel:** `Entry.Stale` in `internal/store/models.go`. Ein Eintrag, der seit
|
||||||
|
über einem Jahr (`StaleAfter`) nicht geändert wurde, gilt als veraltet. Das
|
||||||
|
gilt auch für geprüfte Einträge: Das Häkchen sagt nicht, *wann* geprüft
|
||||||
|
wurde. Ein Jahr ist bewusst eher lang. Ein Vermerk an jedem zweiten Eintrag
|
||||||
|
liest bald niemand mehr.
|
||||||
|
- **MCP:** `recall`, `list` und `related` tragen `"stale": true`, `get`
|
||||||
|
zusätzlich einen `hint` mit Handlungsanweisung. `recall` nennt im Hinweis,
|
||||||
|
wie viele Treffer alt sind. Alte Treffer werden weder ausgeblendet noch
|
||||||
|
nach hinten sortiert, denn ein alter Eintrag kann der einzige zum Thema
|
||||||
|
sein. Die `instructions` erklären das Feld.
|
||||||
|
- **Oberfläche:** Auf der Eintragsseite steht die Marke *über ein Jahr alt*.
|
||||||
|
- **Offen:** der größere Schritt (Frist je Art, „fällige Einträge“,
|
||||||
|
Bestätigen).
|
||||||
|
|
||||||
**Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung,
|
**Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung,
|
||||||
mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich
|
mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich
|
||||||
ausgebauter als das, was wir bräuchten — aber die Richtung stimmt.
|
ausgebauter als das, was wir bräuchten — aber die Richtung stimmt.
|
||||||
|
|
@ -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`
|
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.
|
gesetzt ist, während die Historie in der Datenbank immer mitläuft.
|
||||||
|
|
||||||
### 3.7 Verwandte Einträge
|
### 3.7 Verwandte Einträge — ✅ umgesetzt
|
||||||
|
|
||||||
Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein
|
Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein
|
||||||
Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten
|
Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten
|
||||||
|
|
@ -307,6 +357,16 @@ ein zweiter Weg zum Kontext — neben der Suche.
|
||||||
|
|
||||||
**Aufwand:** gering, die Daten liegen vor.
|
**Aufwand:** gering, die Daten liegen vor.
|
||||||
|
|
||||||
|
**✅ Umgesetzt 2026-09-25:** MCP-Werkzeug `related(slug, distance?, limit?)`.
|
||||||
|
Liefert Nachbarn in beide Richtungen (wer verweist auf mich, worauf verweise
|
||||||
|
ich), standardmäßig bis zur zweiten Reihe, höchstens 50. Jeder Treffer nennt
|
||||||
|
Abstand, den Eintrag, über den er gefunden wurde (`via`), die Richtung und
|
||||||
|
`stale`. Verweise ins Leere und Einträge im Papierkorb fallen heraus, auch als
|
||||||
|
Brücke in die zweite Reihe. Mehr als zwei Schritte gibt es bewusst nicht: In
|
||||||
|
einem gut verknüpften Projekt hängt in der dritten Reihe fast alles mit allem
|
||||||
|
zusammen. `get` liefert weiterhin `links_to`/`linked_by` als nackte
|
||||||
|
Bezeichner. `related` ergänzt Titel, Datum und die zweite Reihe.
|
||||||
|
|
||||||
### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt
|
### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt
|
||||||
|
|
||||||
Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in
|
Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in
|
||||||
|
|
@ -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
|
knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder
|
||||||
das Limit zu eng oder ein Agent tut etwas Unsinniges.
|
das Limit zu eng oder ein Agent tut etwas Unsinniges.
|
||||||
|
|
||||||
|
Zusätzlich vorgezogen und erledigt 2026-09-25: **Verwandte Einträge** (3.7),
|
||||||
|
**Aktualität, kleiner Schritt** (3.5) und **Importer** (3.2).
|
||||||
|
|
||||||
Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird.
|
Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird.
|
||||||
|
|
||||||
**Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken,
|
**Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken,
|
||||||
|
|
|
||||||
|
|
@ -148,13 +148,13 @@ Kommandozeilenfassung liegt dieselbe Datei unter `~/.cline/mcp.json`.
|
||||||
"url": "https://munin.intern/mcp",
|
"url": "https://munin.intern/mcp",
|
||||||
"headers": { "Authorization": "Bearer wk_…" },
|
"headers": { "Authorization": "Bearer wk_…" },
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"autoApprove": ["recall", "get", "list"]
|
"autoApprove": ["recall", "get", "list", "related"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei
|
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier
|
||||||
lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und
|
lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und
|
||||||
ein Agent, der für jeden Blick ins Gedächtnis nachfragen muss, benutzt es nicht.
|
ein Agent, der für jeden Blick ins Gedächtnis nachfragen muss, benutzt es nicht.
|
||||||
`remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern
|
`remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern
|
||||||
|
|
@ -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. |
|
| `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. |
|
||||||
| `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. |
|
| `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. |
|
||||||
| `list` | read | Überblick: zuletzt geändert, alle Schlagworte. |
|
| `list` | read | Überblick: zuletzt geändert, alle Schlagworte. |
|
||||||
|
| `related` | read | Über `[[Verweise]]` verknüpfte Einträge, direkt und über einen Zwischeneintrag. |
|
||||||
| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. |
|
| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. |
|
||||||
| `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). |
|
| `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). |
|
||||||
|
|
||||||
|
|
|
||||||
31
REPOMAP.md
31
REPOMAP.md
|
|
@ -105,6 +105,10 @@ munin/
|
||||||
│ │ │ TrashRetention (30 Tage) steht hier, damit
|
│ │ │ TrashRetention (30 Tage) steht hier, damit
|
||||||
│ │ │ Oberfläche und Aufräumen dieselbe Zahl nutzen.
|
│ │ │ Oberfläche und Aufräumen dieselbe Zahl nutzen.
|
||||||
│ │ │
|
│ │ │
|
||||||
|
│ │ ├── related.go ← RelatedEntries: Nachbarn im Verweisgraphen, in
|
||||||
|
│ │ │ beide Richtungen, bis zu zwei Schritte weit
|
||||||
|
│ │ │ (Breitensuche, eine Abfrage je Richtung und Ebene).
|
||||||
|
│ │ │
|
||||||
│ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie
|
│ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie
|
||||||
│ │ │ beim Rendern. Das ersetzt die frühere
|
│ │ │ beim Rendern. Das ersetzt die frühere
|
||||||
│ │ │ Ordnerhierarchie als Struktur zwischen Einträgen.
|
│ │ │ Ordnerhierarchie als Struktur zwischen Einträgen.
|
||||||
|
|
@ -222,6 +226,19 @@ munin/
|
||||||
│ │ Dateien gelöschter Einträge werden entfernt,
|
│ │ Dateien gelöschter Einträge werden entfernt,
|
||||||
│ │ fremde Dateien im Zielordner bleiben unberührt.
|
│ │ fremde Dateien im Zielordner bleiben unberührt.
|
||||||
│ │
|
│ │
|
||||||
|
│ ├── importer/ ── Gegenstück zum Export ──────────────────────────
|
||||||
|
│ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit
|
||||||
|
│ │ │ Grenzen für Anzahl und ausgepackte Größe.
|
||||||
|
│ │ │ Übergeht _index.md, versteckte Ordner, __MACOSX.
|
||||||
|
│ │ ├── parse.go ← Kleiner Frontmatter-Leser (kein YAML-Paket):
|
||||||
|
│ │ │ Munin-Export und fremde Köpfe (Hugo, Obsidian).
|
||||||
|
│ │ │ Ohne Kopf: Bezeichner aus dem Dateinamen, Titel
|
||||||
|
│ │ │ aus der ersten Überschrift.
|
||||||
|
│ │ └── importer.go ← Importiere: Vorschau oder Schreiben, bei
|
||||||
|
│ │ belegtem Bezeichner überspringen oder
|
||||||
|
│ │ überschreiben (alter Stand in den Verlauf).
|
||||||
|
│ │ Erkennt Unverändertes und den Papierkorb.
|
||||||
|
│ │
|
||||||
│ ├── search/ ── Volltextsuche ──────────────────────────────────
|
│ ├── search/ ── Volltextsuche ──────────────────────────────────
|
||||||
│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen
|
│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen
|
||||||
│ │ │ dreifach, Schlagworte zweifach. Enthält die
|
│ │ │ dreifach, Schlagworte zweifach. Enthält die
|
||||||
|
|
@ -273,6 +290,9 @@ munin/
|
||||||
│ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage),
|
│ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage),
|
||||||
│ │ │ für jede Rolle. Die Admin-Projektliste nutzt
|
│ │ │ für jede Rolle. Die Admin-Projektliste nutzt
|
||||||
│ │ │ dieselben Zahlen (projectListItems).
|
│ │ │ dieselben Zahlen (projectListItems).
|
||||||
|
│ │ ├── handlers_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
|
│ │ ├── handlers_papierkorb.go
|
||||||
│ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen,
|
│ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen,
|
||||||
│ │ │ endgültig löschen, Filter nach löschendem Zugang.
|
│ │ │ endgültig löschen, Filter nach löschendem Zugang.
|
||||||
|
|
@ -302,9 +322,11 @@ munin/
|
||||||
│ │ Qualität der Antworten, weil er bei jedem Client
|
│ │ Qualität der Antworten, weil er bei jedem Client
|
||||||
│ │ im Kontext steht.
|
│ │ im Kontext steht.
|
||||||
│ │
|
│ │
|
||||||
│ └── tools.go ← Die fünf Werkzeuge: recall, get, list, remember,
|
│ └── tools.go ← Die sechs Werkzeuge: recall, get, list, related,
|
||||||
│ forget. Jedes ist eine Closure über genau ein
|
│ remember, forget. Alte Einträge tragen in den
|
||||||
│ Projekt und kann strukturell nicht an ein anderes.
|
│ Antworten "stale": true. Jedes ist eine Closure
|
||||||
|
│ über genau ein Projekt und kann strukturell
|
||||||
|
│ nicht an ein anderes.
|
||||||
│
|
│
|
||||||
├── templates/ ← HTML der Weboberfläche. partials.html enthält die
|
├── templates/ ← HTML der Weboberfläche. partials.html enthält die
|
||||||
│ gemeinsamen Blöcke "kopf" und "fuss", die jede
|
│ gemeinsamen Blöcke "kopf" und "fuss", die jede
|
||||||
|
|
@ -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/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe |
|
||||||
| `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen |
|
| `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen |
|
||||||
| `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen |
|
| `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen |
|
||||||
|
| `internal/store/related_test.go` | Verwandte Einträge über zwei Ebenen, Papierkorb als Sackgasse, Grenze für veraltete Einträge, übernommene Zeitstempel beim Anlegen |
|
||||||
|
| `internal/importer/importer_test.go` | **Rundlauf Export → ZIP → Import ohne Verlust**, Vorschau, Überspringen/Überschreiben, Papierkorb, doppelte Bezeichner, fremde Köpfe, Grenzen des Archivs |
|
||||||
| `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe |
|
| `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe |
|
||||||
|
| `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route |
|
||||||
| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse |
|
| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse |
|
||||||
| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** |
|
| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** |
|
||||||
| `internal/hilfe/hilfe_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML |
|
| `internal/hilfe/hilfe_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML |
|
||||||
|
|
|
||||||
|
|
@ -72,13 +72,13 @@ In Cline: *MCP Servers* → *Configure* → *Configure MCP Servers*.
|
||||||
"url": "{{SERVER}}/mcp",
|
"url": "{{SERVER}}/mcp",
|
||||||
"headers": { "Authorization": "Bearer wk_DEIN_KEY" },
|
"headers": { "Authorization": "Bearer wk_DEIN_KEY" },
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"autoApprove": ["recall", "get", "list"]
|
"autoApprove": ["recall", "get", "list", "related"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei
|
`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier
|
||||||
lesenden gehören hinein, sonst muss jede Suche einzeln bestätigt werden.
|
lesenden gehören hinein, sonst muss jede Suche einzeln bestätigt werden.
|
||||||
`remember` und `forget` gehören **nicht** hinein, denn sie verändern das
|
`remember` und `forget` gehören **nicht** hinein, denn sie verändern das
|
||||||
gemeinsame Wissen.
|
gemeinsame Wissen.
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,14 @@ deiner Anwendung übernehmen:
|
||||||
## Munin
|
## Munin
|
||||||
|
|
||||||
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
|
Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
|
||||||
get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
|
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
|
||||||
|
|
||||||
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
|
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
|
||||||
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
|
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
|
||||||
- Passenden Treffer mit `get` vollständig laden.
|
- Passenden Treffer mit `get` vollständig laden.
|
||||||
|
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
|
||||||
|
- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen
|
||||||
|
und in der Antwort erwähnen, dass der Stand alt ist.
|
||||||
- Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das
|
- Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das
|
||||||
kenntlich machen.
|
kenntlich machen.
|
||||||
- Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht.
|
- Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht.
|
||||||
|
|
@ -51,6 +54,7 @@ WIE festgehalten wird:
|
||||||
| `recall` | read | Suchen. Der wichtigste Einstieg, auch vor dem Schreiben. |
|
| `recall` | read | Suchen. Der wichtigste Einstieg, auch vor dem Schreiben. |
|
||||||
| `get` | read | Einen Eintrag vollständig laden, mit Verweisen und Fassungsnummer. |
|
| `get` | read | Einen Eintrag vollständig laden, mit Verweisen und Fassungsnummer. |
|
||||||
| `list` | read | Überblick: zuletzt geänderte Einträge und alle Schlagworte. |
|
| `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. |
|
| `remember` | write | Einen Eintrag anlegen oder aktualisieren. |
|
||||||
| `forget` | write | Einen Eintrag in den Papierkorb legen. |
|
| `forget` | write | Einen Eintrag in den Papierkorb legen. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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*
|
zusätzliche, lesbare Sicherung, etwa für ein Git-Repository. Unter *Projekte*
|
||||||
exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten
|
exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten
|
||||||
Workspace, zum Beispiel vor einer größeren Änderung.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
hinzufügen oder entfernen, mehrere durch Komma getrennt. Praktisch, um ein
|
||||||
Schlagwort umzubenennen: das neue hinzufügen, das alte entfernen.
|
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
|
## Protokoll
|
||||||
|
|
||||||
Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas
|
Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas
|
||||||
|
|
|
||||||
122
internal/importer/archiv.go
Normal file
122
internal/importer/archiv.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grenzen für ein hochgeladenes Archiv.
|
||||||
|
//
|
||||||
|
// Ein ZIP-Archiv kann ausgepackt ein Vielfaches seiner Größe haben. Ein
|
||||||
|
// „Zip-Bomb“ macht aus wenigen Kilobyte Gigabytes. Deshalb zählt hier, was
|
||||||
|
// tatsächlich ausgepackt wird, nicht was das Archiv über sich behauptet: Die
|
||||||
|
// Größenangaben im Inneren eines ZIP-Archivs kann jeder frei hineinschreiben.
|
||||||
|
const (
|
||||||
|
// MaxDateien ist die Höchstzahl an Markdown-Dateien je Import.
|
||||||
|
MaxDateien = 5000
|
||||||
|
|
||||||
|
// MaxDateiBytes entspricht der Grenze für ein Formular der Weboberfläche.
|
||||||
|
// Ein Eintrag, der sich nicht im Browser anlegen ließe, soll auch über
|
||||||
|
// den Import nicht hineinkommen.
|
||||||
|
MaxDateiBytes = 1 << 20 // 1 MB
|
||||||
|
|
||||||
|
// MaxGesamtBytes begrenzt den ausgepackten Inhalt insgesamt.
|
||||||
|
MaxGesamtBytes = 100 << 20 // 100 MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// Datei ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet.
|
||||||
|
type Datei struct {
|
||||||
|
Pfad string
|
||||||
|
Inhalt []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeseZip liest alle Markdown-Dateien aus einem ZIP-Archiv.
|
||||||
|
//
|
||||||
|
// Übergangen werden:
|
||||||
|
// - alles, was nicht auf .md oder .markdown endet (Bilder, PDFs, …)
|
||||||
|
// - Dateien, deren Name mit "_" beginnt: _index.md und _workspace.md sind
|
||||||
|
// Übersichten aus dem Export, keine Einträge
|
||||||
|
// - versteckte Dateien und Ordner (".git", ".obsidian") und der Ordner
|
||||||
|
// "__MACOSX", den der Finder beim Packen dazulegt
|
||||||
|
//
|
||||||
|
// Unterordner sind erlaubt. So lässt sich ein ganzer Doku-Ordner auf einmal
|
||||||
|
// hochladen. Die Einträge landen trotzdem alle nebeneinander im Projekt,
|
||||||
|
// denn Munin kennt keine Ordner.
|
||||||
|
func LeseZip(daten []byte) ([]Datei, error) {
|
||||||
|
archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("die Datei ist kein lesbares ZIP-Archiv")
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateien []Datei
|
||||||
|
var gesamt int64
|
||||||
|
|
||||||
|
for _, f := range archiv.File {
|
||||||
|
if f.FileInfo().IsDir() || !istEintragsdatei(f.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dateien) >= MaxDateien {
|
||||||
|
return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxDateien)
|
||||||
|
}
|
||||||
|
|
||||||
|
inhalt, err := leseBegrenzt(f, MaxDateiBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", f.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gesamt += int64(len(inhalt))
|
||||||
|
if gesamt > MaxGesamtBytes {
|
||||||
|
return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxGesamtBytes>>20)
|
||||||
|
}
|
||||||
|
|
||||||
|
dateien = append(dateien, Datei{Pfad: f.Name, Inhalt: inhalt})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dateien) == 0 {
|
||||||
|
return nil, errors.New("im Archiv liegt keine Markdown-Datei (.md)")
|
||||||
|
}
|
||||||
|
return dateien, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// istEintragsdatei entscheidet anhand des Pfads, ob eine Datei ein Eintrag
|
||||||
|
// werden soll (siehe LeseZip).
|
||||||
|
func istEintragsdatei(pfad string) bool {
|
||||||
|
for _, teil := range strings.Split(pfad, "/") {
|
||||||
|
if strings.HasPrefix(teil, ".") || teil == "__MACOSX" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
name := path.Base(pfad)
|
||||||
|
if strings.HasPrefix(name, "_") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
endung := strings.ToLower(path.Ext(name))
|
||||||
|
return endung == ".md" || endung == ".markdown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// leseBegrenzt packt eine Datei aus und bricht ab, sobald sie größer als
|
||||||
|
// grenze wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die
|
||||||
|
// Datei zu groß.
|
||||||
|
func leseBegrenzt(f *zip.File, grenze int64) ([]byte, error) {
|
||||||
|
r, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
inhalt, err := io.ReadAll(io.LimitReader(r, grenze+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
|
||||||
|
}
|
||||||
|
if int64(len(inhalt)) > grenze {
|
||||||
|
return nil, fmt.Errorf("größer als %d KB", grenze>>10)
|
||||||
|
}
|
||||||
|
return inhalt, nil
|
||||||
|
}
|
||||||
254
internal/importer/importer.go
Normal file
254
internal/importer/importer.go
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
// Package importer liest Markdown-Dateien in ein Projekt ein, als Gegenstück
|
||||||
|
// zum Export (siehe AUSBAUSTUFEN.md 3.2).
|
||||||
|
//
|
||||||
|
// # Wozu?
|
||||||
|
//
|
||||||
|
// - Umzug auf einen anderen Server
|
||||||
|
// - Wiederherstellung aus der Markdown-Sicherung
|
||||||
|
// - Erstbefüllung aus vorhandener Dokumentation eines Teams
|
||||||
|
//
|
||||||
|
// # Was passiert bei einem Zusammenstoß?
|
||||||
|
//
|
||||||
|
// Gibt es einen Bezeichner schon im Projekt, entscheidet der Modus:
|
||||||
|
//
|
||||||
|
// - Überspringen (Standard): Der vorhandene Eintrag bleibt, wie er ist. So
|
||||||
|
// lässt sich derselbe Import gefahrlos wiederholen, und nichts, was ein
|
||||||
|
// Team seit der Sicherung geschrieben hat, geht verloren.
|
||||||
|
// - Überschreiben: Die Datei wird zur neuen Fassung. Die bisherige wandert
|
||||||
|
// wie bei jeder Änderung in den Verlauf und lässt sich von dort
|
||||||
|
// zurückholen. Überschreiben ist also nie endgültig.
|
||||||
|
//
|
||||||
|
// Ein „Abbrechen bei Zusammenstoß“ gibt es nicht. Dafür ist die Vorschau da:
|
||||||
|
// Sie zeigt vorher Zeile für Zeile, was passieren würde, ohne etwas zu
|
||||||
|
// schreiben.
|
||||||
|
//
|
||||||
|
// Ist der Inhalt einer Datei gleich dem vorhandenen Eintrag, wird in beiden
|
||||||
|
// Modi nichts geschrieben. Ein wiederholter Import erzeugt so keine leeren
|
||||||
|
// Fassungen im Verlauf.
|
||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Modus legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist.
|
||||||
|
type Modus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModusUeberspringen = Modus("skip")
|
||||||
|
ModusUeberschreiben = Modus("overwrite")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Aktion ist das Ergebnis für eine einzelne Datei.
|
||||||
|
type Aktion string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AktionNeu = Aktion("neu")
|
||||||
|
AktionUeberschrieben = Aktion("überschrieben")
|
||||||
|
AktionUnveraendert = Aktion("unverändert")
|
||||||
|
AktionUebersprungen = Aktion("übersprungen")
|
||||||
|
AktionFehler = Aktion("Fehler")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Optionen steuern einen Import.
|
||||||
|
type Optionen struct {
|
||||||
|
Modus Modus
|
||||||
|
|
||||||
|
// Vorschau: nur ausrechnen, was passieren würde, nichts schreiben.
|
||||||
|
Vorschau bool
|
||||||
|
|
||||||
|
// Autor steht an Einträgen, deren Datei keinen Autor nennt, etwa bei
|
||||||
|
// vorhandener Dokumentation. Üblich ist der Name des importierenden
|
||||||
|
// Zugangs.
|
||||||
|
Autor string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeile ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt.
|
||||||
|
type Zeile struct {
|
||||||
|
Pfad string
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Aktion Aktion
|
||||||
|
Grund string // Erläuterung, etwa bei Fehler oder Übersprungen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bericht fasst einen Import zusammen.
|
||||||
|
type Bericht struct {
|
||||||
|
Zeilen []Zeile
|
||||||
|
|
||||||
|
Neu int
|
||||||
|
Ueberschrieben int
|
||||||
|
Unveraendert int
|
||||||
|
Uebersprungen int
|
||||||
|
Fehler int
|
||||||
|
|
||||||
|
// Geschrieben sind die tatsächlich geschriebenen Einträge. Der Aufrufer
|
||||||
|
// braucht sie, um den Suchindex nachzuziehen. Bei einer Vorschau leer.
|
||||||
|
Geschrieben []store.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusammenfassung ist eine Zeile für Meldung und Protokoll.
|
||||||
|
func (b Bericht) Zusammenfassung() string {
|
||||||
|
return fmt.Sprintf("%d neu, %d überschrieben, %d unverändert, %d übersprungen, %d Fehler",
|
||||||
|
b.Neu, b.Ueberschrieben, b.Unveraendert, b.Uebersprungen, b.Fehler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Importiere liest die Dateien in das Projekt ein, oder rechnet bei
|
||||||
|
// opt.Vorschau nur aus, was passieren würde.
|
||||||
|
//
|
||||||
|
// Ein Fehler in einer einzelnen Datei bricht den Import nicht ab. Er steht im
|
||||||
|
// Bericht, und die übrigen Dateien laufen weiter. Nur ein Fehler der
|
||||||
|
// Datenbank selbst beendet den Import. Was bis dahin geschrieben war, bleibt
|
||||||
|
// geschrieben: Jeder Eintrag ist für sich vollständig oder gar nicht da (eine
|
||||||
|
// Transaktion je Eintrag), aber der Import als Ganzes ist keine Transaktion.
|
||||||
|
// Ihn danach im Modus „Überspringen“ zu wiederholen, macht den Rest.
|
||||||
|
func Importiere(ctx context.Context, s *store.Store, projectID string, dateien []Datei, opt Optionen) (Bericht, error) {
|
||||||
|
var b Bericht
|
||||||
|
|
||||||
|
if opt.Modus != ModusUeberspringen && opt.Modus != ModusUeberschreiben {
|
||||||
|
return b, fmt.Errorf("unbekannter Modus %q", opt.Modus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Den Bestand einmal vorab laden statt je Datei nachzufragen: Bei einigen
|
||||||
|
// tausend Dateien sind das zwei Abfragen statt einiger tausend.
|
||||||
|
vorhanden, err := s.AllEntries(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
bestand := make(map[string]store.Entry, len(vorhanden))
|
||||||
|
for _, e := range vorhanden {
|
||||||
|
bestand[e.Slug] = e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einträge im Papierkorb belegen ihren Bezeichner weiter (siehe
|
||||||
|
// store.PutEntry). Sie müssen eigens erkannt werden: Sonst holte ein
|
||||||
|
// Import im Modus „Überspringen“ gelöschte Einträge stillschweigend
|
||||||
|
// zurück, weil sie für AllEntries nicht existieren.
|
||||||
|
geloescht, err := s.ListTrash(ctx, projectID, "")
|
||||||
|
if err != nil {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
imPapierkorb := make(map[string]bool, len(geloescht))
|
||||||
|
for _, e := range geloescht {
|
||||||
|
imPapierkorb[e.Slug] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welche Datei hat welchen Bezeichner schon belegt? Zwei Dateien mit
|
||||||
|
// demselben Bezeichner in einem Archiv (etwa "ops/deploy.md" und
|
||||||
|
// "alt/deploy.md") würden sich sonst gegenseitig überschreiben.
|
||||||
|
belegtDurch := make(map[string]string)
|
||||||
|
|
||||||
|
for _, d := range dateien {
|
||||||
|
e, err := Parse(d.Pfad, d.Inhalt)
|
||||||
|
zeile := Zeile{Pfad: d.Pfad, Slug: e.Slug, Title: e.Title}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
zeile.Aktion, zeile.Grund = AktionFehler, err.Error()
|
||||||
|
|
||||||
|
case belegtDurch[e.Slug] != "":
|
||||||
|
zeile.Aktion = AktionFehler
|
||||||
|
zeile.Grund = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, belegtDurch[e.Slug])
|
||||||
|
|
||||||
|
default:
|
||||||
|
belegtDurch[e.Slug] = d.Pfad
|
||||||
|
alt, gibtEs := bestand[e.Slug]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case gibtEs && gleicherInhalt(alt, e):
|
||||||
|
zeile.Aktion = AktionUnveraendert
|
||||||
|
case (gibtEs || imPapierkorb[e.Slug]) && opt.Modus == ModusUeberspringen:
|
||||||
|
zeile.Aktion = AktionUebersprungen
|
||||||
|
zeile.Grund = "Bezeichner schon vorhanden"
|
||||||
|
if imPapierkorb[e.Slug] {
|
||||||
|
zeile.Grund = "Bezeichner gehört einem Eintrag im Papierkorb"
|
||||||
|
}
|
||||||
|
case gibtEs || imPapierkorb[e.Slug]:
|
||||||
|
zeile.Aktion = AktionUeberschrieben
|
||||||
|
zeile.Grund = "bisheriger Stand steht im Verlauf"
|
||||||
|
if imPapierkorb[e.Slug] {
|
||||||
|
zeile.Grund = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
zeile.Aktion = AktionNeu
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opt.Vorschau && (zeile.Aktion == AktionNeu || zeile.Aktion == AktionUeberschrieben) {
|
||||||
|
geschrieben, err := s.PutEntry(ctx, schreibauftrag(projectID, e, opt.Autor, zeile.Aktion == AktionNeu))
|
||||||
|
if err != nil {
|
||||||
|
// Ungültige Angaben (etwa ein zu langer Titel) meldet
|
||||||
|
// PutEntry vor jedem Schreibzugriff. Sie betreffen nur
|
||||||
|
// diese Datei. Ein Fehler der Datenbank dagegen würde
|
||||||
|
// auch jede weitere Datei treffen: Dann lieber aufhören.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return b, ctx.Err()
|
||||||
|
}
|
||||||
|
zeile.Aktion, zeile.Grund = AktionFehler, err.Error()
|
||||||
|
} else {
|
||||||
|
b.Geschrieben = append(b.Geschrieben, geschrieben)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Zeilen = append(b.Zeilen, zeile)
|
||||||
|
switch zeile.Aktion {
|
||||||
|
case AktionNeu:
|
||||||
|
b.Neu++
|
||||||
|
case AktionUeberschrieben:
|
||||||
|
b.Ueberschrieben++
|
||||||
|
case AktionUnveraendert:
|
||||||
|
b.Unveraendert++
|
||||||
|
case AktionUebersprungen:
|
||||||
|
b.Uebersprungen++
|
||||||
|
case AktionFehler:
|
||||||
|
b.Fehler++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// schreibauftrag macht aus einer ausgewerteten Datei einen Schreibvorgang.
|
||||||
|
//
|
||||||
|
// Der Autor hängt davon ab, was passiert. Beim Anlegen bleibt der aus der
|
||||||
|
// Datei, denn eine Wiederherstellung soll zeigen, wer den Eintrag
|
||||||
|
// ursprünglich geschrieben hat. Beim Überschreiben steht der importierende
|
||||||
|
// Zugang da: Diese Fassung hat er erzeugt, und so steht es auch im Verlauf.
|
||||||
|
func schreibauftrag(projectID string, e Eintrag, autor string, neu bool) store.EntryWrite {
|
||||||
|
w := store.EntryWrite{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Slug: e.Slug,
|
||||||
|
Title: e.Title,
|
||||||
|
Content: e.Content,
|
||||||
|
Tags: e.Tags,
|
||||||
|
Source: e.Source,
|
||||||
|
Author: e.Author,
|
||||||
|
Curated: e.Curated,
|
||||||
|
|
||||||
|
// Wirken nur beim Anlegen, siehe store.EntryWrite.
|
||||||
|
CreatedAt: e.CreatedAt,
|
||||||
|
UpdatedAt: e.UpdatedAt,
|
||||||
|
}
|
||||||
|
if w.Source == "" {
|
||||||
|
w.Source = store.SourceHuman
|
||||||
|
}
|
||||||
|
if w.Author == "" || !neu {
|
||||||
|
w.Author = autor
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// gleicherInhalt sagt, ob eine Datei nichts am vorhandenen Eintrag ändern
|
||||||
|
// würde. Verglichen wird, was ein Mensch als Inhalt ansieht: Titel, Text,
|
||||||
|
// Schlagworte, Prüfvermerk. Autor und Zeitstempel zählen nicht. Sonst wäre
|
||||||
|
// nach einem Export und Re-Import nie etwas „unverändert“.
|
||||||
|
func gleicherInhalt(alt store.Entry, neu Eintrag) bool {
|
||||||
|
return alt.Title == neu.Title &&
|
||||||
|
alt.Content == neu.Content &&
|
||||||
|
alt.Curated == neu.Curated &&
|
||||||
|
slices.Equal(alt.Tags, store.NormalizeTags(neu.Tags))
|
||||||
|
}
|
||||||
319
internal/importer/importer_test.go
Normal file
319
internal/importer/importer_test.go
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"munin/internal/export"
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testStore öffnet eine frische Datenbank mit einem Projekt.
|
||||||
|
func testStore(t *testing.T, projektSlug string) (*store.Store, store.Project) {
|
||||||
|
t.Helper()
|
||||||
|
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
|
||||||
|
p, err := s.CreateProject(context.Background(), store.NewID(), projektSlug, "Projekt "+projektSlug, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s, p
|
||||||
|
}
|
||||||
|
|
||||||
|
// packe baut ein ZIP-Archiv aus Pfad → Inhalt.
|
||||||
|
func packe(t *testing.T, dateien map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var puffer bytes.Buffer
|
||||||
|
w := zip.NewWriter(&puffer)
|
||||||
|
for pfad, inhalt := range dateien {
|
||||||
|
f, err := w.Create(pfad)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.Write([]byte(inhalt))
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return puffer.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// packeOrdner packt alle Dateien eines Ordners, so wie ein Mensch den
|
||||||
|
// Exportordner eines Projekts zippen würde.
|
||||||
|
func packeOrdner(t *testing.T, ordner string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
dateien := map[string]string{}
|
||||||
|
eintraege, err := os.ReadDir(ordner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range eintraege {
|
||||||
|
inhalt, err := os.ReadFile(filepath.Join(ordner, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dateien["projekt/"+e.Name()] = string(inhalt)
|
||||||
|
}
|
||||||
|
return packe(t, dateien)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRoundTripFromExport ist der wichtigste Test: Was der Export schreibt,
|
||||||
|
// kommt beim Import unverändert wieder heraus, samt Schlagworten, Herkunft,
|
||||||
|
// Autor, Prüfvermerk und Änderungsdatum.
|
||||||
|
func TestRoundTripFromExport(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
quelle, qp := testStore(t, "quelle")
|
||||||
|
|
||||||
|
alt := time.Date(2023, 5, 1, 8, 30, 0, 0, time.UTC)
|
||||||
|
original := []store.EntryWrite{
|
||||||
|
{Slug: "deployment", Title: "Deployment: so geht's", Content: "Schritt 1\n\n# Keine Titelzeile\n\nsiehe [[rollback]]",
|
||||||
|
Tags: []string{"ops", "release"}, Source: store.SourceAgent, Author: "agent-1", Curated: true,
|
||||||
|
CreatedAt: alt, UpdatedAt: alt},
|
||||||
|
{Slug: "rollback", Title: `Rollback mit "Anführungszeichen"`, Content: "Zurück.\nZweite Zeile",
|
||||||
|
Source: store.SourceHuman, Author: "anna"},
|
||||||
|
}
|
||||||
|
for _, w := range original {
|
||||||
|
w.ProjectID = qp.ID
|
||||||
|
if _, err := quelle.PutEntry(ctx, w); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportOrdner := t.TempDir()
|
||||||
|
qp.WorkspaceID = "ws-test"
|
||||||
|
if _, err := export.New(exportOrdner).ExportProject(ctx, quelle, qp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dateien, err := LeseZip(packeOrdner(t, filepath.Join(exportOrdner, "ws-test", "quelle")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(dateien) != 2 {
|
||||||
|
t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(dateien))
|
||||||
|
}
|
||||||
|
|
||||||
|
ziel, zp := testStore(t, "ziel")
|
||||||
|
bericht, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "importeur"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bericht.Neu != 2 || bericht.Fehler != 0 {
|
||||||
|
t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range original {
|
||||||
|
a, _ := quelle.EntryBySlug(ctx, qp.ID, w.Slug)
|
||||||
|
b, err := ziel.EntryBySlug(ctx, zp.ID, w.Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s fehlt nach dem Import: %v", w.Slug, err)
|
||||||
|
}
|
||||||
|
if a.Title != b.Title || a.Content != b.Content || !slices.Equal(a.Tags, b.Tags) ||
|
||||||
|
a.Source != b.Source || a.Author != b.Author || a.Curated != b.Curated {
|
||||||
|
t.Errorf("%s nach dem Import verändert:\nvorher %+v\nnachher %+v", w.Slug, a, b)
|
||||||
|
}
|
||||||
|
if !a.UpdatedAt.Truncate(time.Second).Equal(b.UpdatedAt) {
|
||||||
|
t.Errorf("%s: Änderungsdatum %v, erwartet %v", w.Slug, b.UpdatedAt, a.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Verweise sind wieder da, rollback wird von deployment verlinkt.
|
||||||
|
rueck, _ := ziel.Backlinks(ctx, zp.ID, "rollback")
|
||||||
|
if len(rueck) != 1 || rueck[0].Slug != "deployment" {
|
||||||
|
t.Errorf("Rückverweise nach dem Import: %+v", rueck)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein zweiter Lauf ändert nichts und erzeugt keine neuen Fassungen.
|
||||||
|
nochmal, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "importeur"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if nochmal.Unveraendert != 2 {
|
||||||
|
t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", nochmal.Zusammenfassung())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictModes(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, p := testStore(t, "test")
|
||||||
|
|
||||||
|
if _, err := s.PutEntry(ctx, store.EntryWrite{
|
||||||
|
ProjectID: p.ID, Slug: "vorhanden", Title: "Vorhanden", Content: "alt",
|
||||||
|
Source: store.SourceHuman, Author: "anna",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dateien := []Datei{
|
||||||
|
{Pfad: "vorhanden.md", Inhalt: []byte("# Vorhanden\n\nneu")},
|
||||||
|
{Pfad: "frisch.md", Inhalt: []byte("# Frisch\n\nText")},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschau schreibt nichts.
|
||||||
|
vorschau, err := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Vorschau: true, Autor: "imp"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if vorschau.Neu != 1 || vorschau.Ueberschrieben != 1 || len(vorschau.Geschrieben) != 0 {
|
||||||
|
t.Errorf("Vorschau: %s", vorschau.Zusammenfassung())
|
||||||
|
}
|
||||||
|
if _, err := s.EntryBySlug(ctx, p.ID, "frisch"); err == nil {
|
||||||
|
t.Error("die Vorschau hat geschrieben")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überspringen lässt Vorhandenes in Ruhe.
|
||||||
|
ueber, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "imp"})
|
||||||
|
if ueber.Neu != 1 || ueber.Uebersprungen != 1 {
|
||||||
|
t.Errorf("Überspringen: %s", ueber.Zusammenfassung())
|
||||||
|
}
|
||||||
|
if e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden"); e.Content != "alt" {
|
||||||
|
t.Error("Überspringen hat überschrieben")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überschreiben legt eine neue Fassung an, die alte steht im Verlauf.
|
||||||
|
neu, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "imp"})
|
||||||
|
if neu.Ueberschrieben != 1 || neu.Unveraendert != 1 {
|
||||||
|
t.Errorf("Überschreiben: %s", neu.Zusammenfassung())
|
||||||
|
}
|
||||||
|
e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden")
|
||||||
|
if e.Content != "neu" || e.Version != 2 || e.Author != "imp" {
|
||||||
|
t.Errorf("nach dem Überschreiben: Inhalt %q, Fassung %d, Autor %q", e.Content, e.Version, e.Author)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrashIsNotRevivedWhenSkipping prüft, dass ein Import im Modus
|
||||||
|
// „Überspringen“ keinen gelöschten Eintrag zurückholt.
|
||||||
|
func TestTrashIsNotRevivedWhenSkipping(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, p := testStore(t, "test")
|
||||||
|
|
||||||
|
s.PutEntry(ctx, store.EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Content: "x", Source: store.SourceHuman})
|
||||||
|
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "anna"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dateien := []Datei{{Pfad: "weg.md", Inhalt: []byte("# Weg\n\nwieder da")}}
|
||||||
|
b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen})
|
||||||
|
if b.Uebersprungen != 1 || !strings.Contains(b.Zeilen[0].Grund, "Papierkorb") {
|
||||||
|
t.Errorf("Bericht: %s, Grund %q", b.Zusammenfassung(), b.Zeilen[0].Grund)
|
||||||
|
}
|
||||||
|
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); err == nil {
|
||||||
|
t.Error("der gelöschte Eintrag wurde zurückgeholt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateSlugInArchive(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, p := testStore(t, "test")
|
||||||
|
|
||||||
|
dateien := []Datei{
|
||||||
|
{Pfad: "ops/deploy.md", Inhalt: []byte("eins")},
|
||||||
|
{Pfad: "alt/deploy.md", Inhalt: []byte("zwei")},
|
||||||
|
}
|
||||||
|
b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen})
|
||||||
|
if b.Neu != 1 || b.Fehler != 1 || !strings.Contains(b.Zeilen[1].Grund, "ops/deploy.md") {
|
||||||
|
t.Errorf("Bericht: %s — %+v", b.Zusammenfassung(), b.Zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePlainMarkdown(t *testing.T) {
|
||||||
|
e, err := Parse("Doku/Mein Leitfaden.md", []byte("\uFEFF# Leitfaden für Neue\r\n\r\nErster Absatz.\r\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if e.Slug != "mein-leitfaden" || e.Title != "Leitfaden für Neue" || e.Content != "Erster Absatz." {
|
||||||
|
t.Errorf("gelesen: %+v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
ohneUeberschrift, err := Parse("notiz.md", []byte("Nur Text"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ohneUeberschrift.Title != "notiz" {
|
||||||
|
t.Errorf("Titel ohne Überschrift: %q", ohneUeberschrift.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseForeignFrontmatter(t *testing.T) {
|
||||||
|
datei := `---
|
||||||
|
title: 'It''s a title'
|
||||||
|
date: 2024-01-01
|
||||||
|
tags:
|
||||||
|
- Ops
|
||||||
|
- "release notes"
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
Inhalt`
|
||||||
|
e, err := Parse("post.md", []byte(datei))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if e.Title != "It's a title" || !slices.Equal(e.Tags, []string{"Ops", "release notes"}) || e.Content != "Inhalt" {
|
||||||
|
t.Errorf("gelesen: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInlineListWithCommaInQuotes(t *testing.T) {
|
||||||
|
e, err := Parse("x.md", []byte("---\ntags: [\"a, b\", c]\n---\nText"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(e.Tags, []string{"a, b", "c"}) {
|
||||||
|
t.Errorf("Schlagworte: %q", e.Tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejects(t *testing.T) {
|
||||||
|
faelle := map[string]string{
|
||||||
|
"offener Kopf": "---\ntitle: x\nText ohne Ende",
|
||||||
|
"leer": "---\ntitle: x\n---\n\n",
|
||||||
|
"ungültiger slug": "---\nslug: \"Mit Leerzeichen\"\n---\nText",
|
||||||
|
"nur eine Überschrift": "# Titel\n",
|
||||||
|
}
|
||||||
|
for name, inhalt := range faelle {
|
||||||
|
if _, err := Parse("x.md", []byte(inhalt)); err == nil {
|
||||||
|
t.Errorf("%s: kein Fehler", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeseZipSkipsAndLimits(t *testing.T) {
|
||||||
|
archiv := packe(t, map[string]string{
|
||||||
|
"projekt/_index.md": "Übersicht",
|
||||||
|
"projekt/eintrag.md": "Text",
|
||||||
|
"projekt/bild.png": "PNG",
|
||||||
|
"__MACOSX/projekt/._eintrag.md": "Müll",
|
||||||
|
"projekt/.obsidian/workspace.md": "Einstellungen",
|
||||||
|
})
|
||||||
|
dateien, err := LeseZip(archiv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(dateien) != 1 || dateien[0].Pfad != "projekt/eintrag.md" {
|
||||||
|
t.Errorf("gelesen: %+v", dateien)
|
||||||
|
}
|
||||||
|
|
||||||
|
zuGross := packe(t, map[string]string{"gross.md": strings.Repeat("x", MaxDateiBytes+1)})
|
||||||
|
if _, err := LeseZip(zuGross); err == nil {
|
||||||
|
t.Error("eine zu große Datei wurde angenommen")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := LeseZip([]byte("kein zip")); err == nil {
|
||||||
|
t.Error("Unsinn wurde als Archiv angenommen")
|
||||||
|
}
|
||||||
|
if _, err := LeseZip(packe(t, map[string]string{"bild.png": "x"})); err == nil {
|
||||||
|
t.Error("ein Archiv ohne Markdown wurde angenommen")
|
||||||
|
}
|
||||||
|
}
|
||||||
274
internal/importer/parse.go
Normal file
274
internal/importer/parse.go
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Eintrag ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben.
|
||||||
|
type Eintrag struct {
|
||||||
|
Pfad string // Pfad innerhalb des Archivs, für die Anzeige
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
Tags []string
|
||||||
|
Source string // leer = nicht angegeben, der Importer setzt dann "human"
|
||||||
|
Author string // leer = nicht angegeben, der Importer setzt den importierenden Zugang
|
||||||
|
Curated bool
|
||||||
|
|
||||||
|
// Zeitstempel aus dem Kopf der Datei. Leer, wenn keiner angegeben oder
|
||||||
|
// lesbar war; dann gilt beim Anlegen „jetzt“.
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse liest eine Markdown-Datei.
|
||||||
|
//
|
||||||
|
// # Zwei Arten von Dateien
|
||||||
|
//
|
||||||
|
// 1. Dateien aus dem Munin-Export (internal/export). Sie tragen im Kopf alle
|
||||||
|
// Felder: slug, title, tags, source, author, curated, created_at,
|
||||||
|
// updated_at. Daraus entsteht der Eintrag so, wie er exportiert wurde.
|
||||||
|
//
|
||||||
|
// 2. Beliebige Markdown-Dateien, etwa vorhandene Dokumentation eines Teams.
|
||||||
|
// Sie haben meist keinen Kopf oder einen mit anderen Feldern. Dann gilt:
|
||||||
|
// Bezeichner aus dem Dateinamen, Titel aus der ersten Überschrift „# …“
|
||||||
|
// oder, falls es keine gibt, aus dem Dateinamen.
|
||||||
|
//
|
||||||
|
// Felder, die Munin nicht kennt, werden ignoriert. So scheitert der Import
|
||||||
|
// nicht an einem Kopf aus Hugo, Obsidian oder Jekyll.
|
||||||
|
func Parse(pfad string, inhalt []byte) (Eintrag, error) {
|
||||||
|
e := Eintrag{Pfad: pfad}
|
||||||
|
|
||||||
|
// Windows-Zeilenenden und ein Byte-Order-Mark am Anfang würden sonst den
|
||||||
|
// Kopf unkenntlich machen. Beides kommt bei Dateien aus Editoren unter
|
||||||
|
// Windows häufig vor.
|
||||||
|
text := strings.ReplaceAll(string(inhalt), "\r\n", "\n")
|
||||||
|
text = strings.TrimPrefix(text, "\uFEFF")
|
||||||
|
|
||||||
|
kopf, rumpf, err := trenneKopf(text)
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, wert := range kopf {
|
||||||
|
switch name {
|
||||||
|
case "slug":
|
||||||
|
e.Slug = wert.text
|
||||||
|
case "title":
|
||||||
|
e.Title = wert.text
|
||||||
|
case "tags":
|
||||||
|
e.Tags = wert.liste
|
||||||
|
if len(e.Tags) == 0 && wert.text != "" {
|
||||||
|
// "tags: ops" ohne Klammern: ein einzelnes Schlagwort.
|
||||||
|
e.Tags = []string{wert.text}
|
||||||
|
}
|
||||||
|
case "source":
|
||||||
|
if wert.text == store.SourceHuman || wert.text == store.SourceAgent {
|
||||||
|
e.Source = wert.text
|
||||||
|
}
|
||||||
|
case "author":
|
||||||
|
e.Author = wert.text
|
||||||
|
case "curated":
|
||||||
|
e.Curated = wert.text == "true"
|
||||||
|
case "created_at":
|
||||||
|
e.CreatedAt = leseZeit(wert.text)
|
||||||
|
case "updated_at":
|
||||||
|
e.UpdatedAt = leseZeit(wert.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Export schreibt den Titel zusätzlich als Überschrift über den
|
||||||
|
// Inhalt (siehe export.rendere). Die gehört nicht zum Inhalt und muss
|
||||||
|
// wieder weg, sonst stünde sie nach jedem Export und Import einmal mehr
|
||||||
|
// da. Eine Überschrift, die NICHT dem Titel entspricht, bleibt stehen:
|
||||||
|
// Dann ist sie Teil des Textes.
|
||||||
|
rumpf = strings.TrimLeft(rumpf, "\n")
|
||||||
|
if ueberschrift, rest, ok := ersteUeberschrift(rumpf); ok {
|
||||||
|
switch {
|
||||||
|
case e.Title == "":
|
||||||
|
e.Title = ueberschrift
|
||||||
|
rumpf = rest
|
||||||
|
case ueberschrift == e.Title:
|
||||||
|
rumpf = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Content = strings.TrimSpace(rumpf)
|
||||||
|
|
||||||
|
name := strings.TrimSuffix(path.Base(pfad), path.Ext(pfad))
|
||||||
|
if e.Title == "" {
|
||||||
|
e.Title = name
|
||||||
|
}
|
||||||
|
if e.Slug == "" {
|
||||||
|
e.Slug = store.Slugify(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !store.ValidSlug(e.Slug) {
|
||||||
|
return e, fmt.Errorf("ungültiger Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", e.Slug)
|
||||||
|
}
|
||||||
|
if e.Content == "" {
|
||||||
|
return e, errors.New("die Datei hat keinen Inhalt")
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ersteUeberschrift erkennt eine Überschrift erster Ebene („# Titel“) in der
|
||||||
|
// ersten Zeile und gibt sie samt dem Rest des Textes zurück.
|
||||||
|
func ersteUeberschrift(text string) (ueberschrift, rest string, ok bool) {
|
||||||
|
zeile, rest, _ := strings.Cut(text, "\n")
|
||||||
|
if !strings.HasPrefix(zeile, "# ") {
|
||||||
|
return "", text, false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(zeile[2:]), rest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// leseZeit versteht die Schreibweise des Exports (RFC 3339) und ein bloßes
|
||||||
|
// Datum. Alles andere ergibt den Nullzeitpunkt, also „jetzt“ beim Anlegen.
|
||||||
|
// Ein unlesbares Datum ist kein Grund, eine ganze Datei abzulehnen.
|
||||||
|
func leseZeit(s string) time.Time {
|
||||||
|
for _, format := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
||||||
|
if t, err := time.Parse(format, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kopf (Frontmatter) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// kopfWert ist ein Feld aus dem Kopf: entweder ein einzelner Text oder eine
|
||||||
|
// Liste.
|
||||||
|
type kopfWert struct {
|
||||||
|
text string
|
||||||
|
liste []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// trenneKopf trennt einen Kopf zwischen zwei Zeilen „---“ vom Rest der Datei.
|
||||||
|
// Ohne Kopf ist die ganze Datei der Rumpf.
|
||||||
|
//
|
||||||
|
// # Warum kein YAML-Paket?
|
||||||
|
//
|
||||||
|
// YAML ist groß und voller Überraschungen: „no“ wird zu false, „1.10“ zu 1.1.
|
||||||
|
// Gebraucht wird nur ein kleiner Teil davon: der, den der Export schreibt,
|
||||||
|
// plus die üblichen Formen von Schlagwortlisten aus anderen Werkzeugen. Dafür
|
||||||
|
// reichen ein paar Zeilen, die man vollständig lesen kann, und das Programm
|
||||||
|
// bekommt keine zusätzliche Abhängigkeit.
|
||||||
|
//
|
||||||
|
// Verstanden wird:
|
||||||
|
//
|
||||||
|
// name: "Text mit \"Maskierung\"" (so schreibt es der Export)
|
||||||
|
// name: 'Text'
|
||||||
|
// name: Text ohne Anführungszeichen
|
||||||
|
// tags: ["a", "b"] oder tags: [a, b]
|
||||||
|
// tags:
|
||||||
|
// - a
|
||||||
|
// - b
|
||||||
|
func trenneKopf(text string) (map[string]kopfWert, string, error) {
|
||||||
|
if !strings.HasPrefix(text, "---\n") {
|
||||||
|
return nil, text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
zeilen := strings.Split(text, "\n")
|
||||||
|
kopf := make(map[string]kopfWert)
|
||||||
|
var offeneListe string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt
|
||||||
|
|
||||||
|
for i := 1; i < len(zeilen); i++ {
|
||||||
|
zeile := zeilen[i]
|
||||||
|
|
||||||
|
if strings.TrimSpace(zeile) == "---" {
|
||||||
|
return kopf, strings.Join(zeilen[i+1:], "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listenpunkt einer Blockliste, etwa " - ops" unter "tags:".
|
||||||
|
if eingerueckt := strings.TrimSpace(zeile); offeneListe != "" && strings.HasPrefix(eingerueckt, "- ") {
|
||||||
|
w := kopf[offeneListe]
|
||||||
|
w.liste = append(w.liste, einzelwert(strings.TrimSpace(eingerueckt[2:])))
|
||||||
|
kopf[offeneListe] = w
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offeneListe = ""
|
||||||
|
|
||||||
|
if strings.TrimSpace(zeile) == "" || strings.HasPrefix(strings.TrimSpace(zeile), "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name, wert, ok := strings.Cut(zeile, ":")
|
||||||
|
if !ok {
|
||||||
|
continue // keine Zuweisung, etwas, das dieser kleine Leser nicht kennt
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
wert = strings.TrimSpace(wert)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case wert == "":
|
||||||
|
offeneListe = name
|
||||||
|
kopf[name] = kopfWert{}
|
||||||
|
case strings.HasPrefix(wert, "["):
|
||||||
|
kopf[name] = kopfWert{liste: inlineListe(wert)}
|
||||||
|
default:
|
||||||
|
kopf[name] = kopfWert{text: einzelwert(wert)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", errors.New("der Kopf der Datei (zwischen den Zeilen \"---\") ist nicht abgeschlossen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// einzelwert entfernt Anführungszeichen und löst die Maskierungen auf, die
|
||||||
|
// der Export schreibt (siehe export.yamlWert).
|
||||||
|
func einzelwert(s string) string {
|
||||||
|
switch {
|
||||||
|
case len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"':
|
||||||
|
return strings.NewReplacer(`\\`, `\`, `\"`, `"`, `\n`, "\n", `\t`, "\t").Replace(s[1 : len(s)-1])
|
||||||
|
case len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'':
|
||||||
|
// In YAML steht '' innerhalb einfacher Anführungszeichen für ein '.
|
||||||
|
return strings.ReplaceAll(s[1:len(s)-1], "''", "'")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineListe zerlegt „[a, "b, c", d]“ in seine Bestandteile. Kommas innerhalb
|
||||||
|
// von Anführungszeichen trennen nicht.
|
||||||
|
func inlineListe(s string) []string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.TrimPrefix(s, "[")
|
||||||
|
s = strings.TrimSuffix(s, "]")
|
||||||
|
|
||||||
|
var teile []string
|
||||||
|
var aktuell strings.Builder
|
||||||
|
var inZeichen byte // 0 = außerhalb, sonst das öffnende Anführungszeichen
|
||||||
|
|
||||||
|
fertig := func() {
|
||||||
|
if t := strings.TrimSpace(aktuell.String()); t != "" {
|
||||||
|
teile = append(teile, einzelwert(t))
|
||||||
|
}
|
||||||
|
aktuell.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case inZeichen == '"' && c == '\\' && i+1 < len(s):
|
||||||
|
// Maskiertes Zeichen: beide Bytes übernehmen, einzelwert löst es auf.
|
||||||
|
aktuell.WriteByte(c)
|
||||||
|
i++
|
||||||
|
aktuell.WriteByte(s[i])
|
||||||
|
case inZeichen != 0 && c == inZeichen:
|
||||||
|
inZeichen = 0
|
||||||
|
aktuell.WriteByte(c)
|
||||||
|
case inZeichen == 0 && (c == '"' || c == '\''):
|
||||||
|
inZeichen = c
|
||||||
|
aktuell.WriteByte(c)
|
||||||
|
case inZeichen == 0 && c == ',':
|
||||||
|
fertig()
|
||||||
|
default:
|
||||||
|
aktuell.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fertig()
|
||||||
|
return teile
|
||||||
|
}
|
||||||
|
|
@ -143,7 +143,7 @@ func TestToolsAndInstructions(t *testing.T) {
|
||||||
vorhanden[tool.Name] = true
|
vorhanden[tool.Name] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range []string{"recall", "get", "list", "remember", "forget"} {
|
for _, name := range []string{"recall", "get", "list", "remember", "forget", "related"} {
|
||||||
if !vorhanden[name] {
|
if !vorhanden[name] {
|
||||||
t.Errorf("Werkzeug %q fehlt", name)
|
t.Errorf("Werkzeug %q fehlt", name)
|
||||||
}
|
}
|
||||||
|
|
@ -685,3 +685,85 @@ func TestRememberRevivesFromTrash(t *testing.T) {
|
||||||
t.Error("das Protokoll vermerkt das Zurückholen nicht")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,14 @@ Zum Bezeichner (slug):
|
||||||
Verweise:
|
Verweise:
|
||||||
Im Inhalt verweist [[bezeichner]] auf einen anderen Eintrag. Verwandtes so zu
|
Im Inhalt verweist [[bezeichner]] auf einen anderen Eintrag. Verwandtes so zu
|
||||||
verknüpfen ist ausdrücklich erwünscht — es hilft beim späteren Wiederfinden.
|
verknüpfen ist ausdrücklich erwünscht — es hilft beim späteren Wiederfinden.
|
||||||
|
"related" liefert zu einem Eintrag die verknüpften Einträge, auch solche, die
|
||||||
|
"recall" nicht findet, weil kein Suchwort passt.
|
||||||
|
|
||||||
|
Alter von Wissen:
|
||||||
|
Einträge mit "stale": true sind seit über einem Jahr unverändert. Sie können
|
||||||
|
noch stimmen, sollten aber nicht ungeprüft als aktueller Stand weitergegeben
|
||||||
|
werden. Wer weiß, dass sich etwas geändert hat, aktualisiert den Eintrag mit
|
||||||
|
"remember".
|
||||||
|
|
||||||
Gleichzeitiges Schreiben:
|
Gleichzeitiges Schreiben:
|
||||||
"get" liefert eine Feldnummer "version". Wird sie bei "remember" mitgegeben,
|
"get" liefert eine Feldnummer "version". Wird sie bei "remember" mitgegeben,
|
||||||
|
|
@ -157,6 +165,7 @@ type recallMatch struct {
|
||||||
Source string `json:"source" jsonschema:"human oder agent"`
|
Source string `json:"source" jsonschema:"human oder agent"`
|
||||||
Curated bool `json:"curated" jsonschema:"true, wenn ein Mensch den Eintrag geprüft hat"`
|
Curated bool `json:"curated" jsonschema:"true, wenn ein Mensch den Eintrag geprüft hat"`
|
||||||
Updated string `json:"updated_at" jsonschema:"Zeitpunkt der letzten Änderung"`
|
Updated string `json:"updated_at" jsonschema:"Zeitpunkt der letzten Änderung"`
|
||||||
|
Stale bool `json:"stale,omitempty" jsonschema:"true, wenn der Eintrag seit über einem Jahr unverändert ist — vor Verwendung prüfen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type recallOutput struct {
|
type recallOutput struct {
|
||||||
|
|
@ -181,6 +190,8 @@ type getOutput struct {
|
||||||
Updated string `json:"updated_at"`
|
Updated string `json:"updated_at"`
|
||||||
LinksTo []string `json:"links_to" jsonschema:"Bezeichner, auf die dieser Eintrag verweist"`
|
LinksTo []string `json:"links_to" jsonschema:"Bezeichner, auf die dieser Eintrag verweist"`
|
||||||
LinkedBy []string `json:"linked_by" jsonschema:"Bezeichner, die auf diesen Eintrag verweisen"`
|
LinkedBy []string `json:"linked_by" jsonschema:"Bezeichner, die auf diesen Eintrag verweisen"`
|
||||||
|
Stale bool `json:"stale,omitempty" jsonschema:"true, wenn der Eintrag seit über einem Jahr unverändert ist"`
|
||||||
|
Hint string `json:"hint,omitempty" jsonschema:"Hinweis zum Umgang mit dem Eintrag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type rememberInput struct {
|
type rememberInput struct {
|
||||||
|
|
@ -219,6 +230,7 @@ type listItem struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Curated bool `json:"curated"`
|
Curated bool `json:"curated"`
|
||||||
Updated string `json:"updated_at"`
|
Updated string `json:"updated_at"`
|
||||||
|
Stale bool `json:"stale,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type listOutput struct {
|
type listOutput struct {
|
||||||
|
|
@ -226,3 +238,25 @@ type listOutput struct {
|
||||||
Total int `json:"total" jsonschema:"Gesamtzahl der Einträge im Projekt"`
|
Total int `json:"total" jsonschema:"Gesamtzahl der Einträge im Projekt"`
|
||||||
Tags []string `json:"tags" jsonschema:"Alle im Projekt vergebenen Schlagworte"`
|
Tags []string `json:"tags" jsonschema:"Alle im Projekt vergebenen Schlagworte"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type relatedInput struct {
|
||||||
|
Slug string `json:"slug" jsonschema:"Bezeichner des Eintrags, dessen Umfeld gesucht wird"`
|
||||||
|
Distance int `json:"distance,omitempty" jsonschema:"Optional: 1 = nur direkt verknüpfte Einträge, 2 = auch deren Nachbarn (Standard 2)"`
|
||||||
|
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl (Standard 20, Maximum 50)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type relatedItem struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Distance int `json:"distance" jsonschema:"1 = direkt verknüpft, 2 = über einen Zwischeneintrag"`
|
||||||
|
Via string `json:"via" jsonschema:"Eintrag, über den dieser gefunden wurde"`
|
||||||
|
Relation string `json:"relation" jsonschema:"links_to: via verweist hierhin; linked_by: dieser Eintrag verweist auf via"`
|
||||||
|
Updated string `json:"updated_at"`
|
||||||
|
Stale bool `json:"stale,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type relatedOutput struct {
|
||||||
|
Results []relatedItem `json:"results" jsonschema:"Verwandte Einträge, nächste zuerst"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
|
@ -40,8 +41,22 @@ const (
|
||||||
recallMaxLimit = 50
|
recallMaxLimit = 50
|
||||||
listDefaultLimit = 50
|
listDefaultLimit = 50
|
||||||
listMaxLimit = 200
|
listMaxLimit = 200
|
||||||
|
|
||||||
|
relatedDefaultLimit = 20
|
||||||
|
relatedMaxLimit = 50
|
||||||
|
relatedDefaultDistance = 2
|
||||||
|
|
||||||
|
// relatedMaxDistance ist bewusst klein. Schon in der dritten Reihe hängt
|
||||||
|
// in einem gut verknüpften Projekt fast alles mit allem zusammen, und die
|
||||||
|
// Antwort sagt nichts mehr über das Thema aus.
|
||||||
|
relatedMaxDistance = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// staleHint steht bei einem veralteten Eintrag in der Antwort von "get". Er
|
||||||
|
// sagt dem Modell, was es tun soll, nicht nur, was der Fall ist.
|
||||||
|
const staleHint = "Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt, " +
|
||||||
|
"und bei der Antwort erwähnen, dass der Stand alt ist. Ist bekannt, was sich geändert hat: mit \"remember\" aktualisieren."
|
||||||
|
|
||||||
// buildServer erstellt den MCP-Server eines Projekts.
|
// buildServer erstellt den MCP-Server eines Projekts.
|
||||||
//
|
//
|
||||||
// Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht
|
// Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht
|
||||||
|
|
@ -57,6 +72,7 @@ func (h *Handler) buildServer(project store.Project) *mcp.Server {
|
||||||
h.addList(server, project)
|
h.addList(server, project)
|
||||||
h.addRemember(server, project)
|
h.addRemember(server, project)
|
||||||
h.addForget(server, project)
|
h.addForget(server, project)
|
||||||
|
h.addRelated(server, project)
|
||||||
|
|
||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +173,9 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
||||||
treffer := idx.Search(in.Query, limit*3)
|
treffer := idx.Search(in.Query, limit*3)
|
||||||
gesucht := store.NormalizeTags(in.Tags)
|
gesucht := store.NormalizeTags(in.Tags)
|
||||||
|
|
||||||
|
jetzt := time.Now()
|
||||||
|
veraltet := 0
|
||||||
|
|
||||||
ergebnisse := make([]recallMatch, 0, limit)
|
ergebnisse := make([]recallMatch, 0, limit)
|
||||||
for _, t := range treffer {
|
for _, t := range treffer {
|
||||||
if len(ergebnisse) >= limit {
|
if len(ergebnisse) >= limit {
|
||||||
|
|
@ -175,6 +194,11 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alt := e.Stale(jetzt)
|
||||||
|
if alt {
|
||||||
|
veraltet++
|
||||||
|
}
|
||||||
|
|
||||||
ergebnisse = append(ergebnisse, recallMatch{
|
ergebnisse = append(ergebnisse, recallMatch{
|
||||||
Slug: e.Slug,
|
Slug: e.Slug,
|
||||||
Title: e.Title,
|
Title: e.Title,
|
||||||
|
|
@ -183,6 +207,7 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
||||||
Source: e.Source,
|
Source: e.Source,
|
||||||
Curated: e.Curated,
|
Curated: e.Curated,
|
||||||
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
||||||
|
Stale: alt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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."
|
"oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen."
|
||||||
} else {
|
} else {
|
||||||
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
|
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
|
||||||
|
// Veraltete Treffer bleiben in der Liste und rutschen auch
|
||||||
|
// nicht nach hinten: Ein alter Eintrag kann der einzige zum
|
||||||
|
// Thema sein. Der Hinweis sorgt dafür, dass das Modell das
|
||||||
|
// Feld "stale" nicht übersieht.
|
||||||
|
if veraltet > 0 {
|
||||||
|
out.Hint += fmt.Sprintf(" %d der Treffer sind seit über einem Jahr unverändert (\"stale\": true) "+
|
||||||
|
"— vor Verwendung prüfen, ob sie noch stimmen.", veraltet)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil, out, nil
|
return nil, out, nil
|
||||||
},
|
},
|
||||||
|
|
@ -249,7 +282,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
|
||||||
linkedBy = append(linkedBy, b.Slug)
|
linkedBy = append(linkedBy, b.Slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, getOutput{
|
out := getOutput{
|
||||||
Slug: e.Slug,
|
Slug: e.Slug,
|
||||||
Title: e.Title,
|
Title: e.Title,
|
||||||
Content: e.Content,
|
Content: e.Content,
|
||||||
|
|
@ -261,7 +294,12 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
|
||||||
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
||||||
LinksTo: store.ExtractLinks(e.Content),
|
LinksTo: store.ExtractLinks(e.Content),
|
||||||
LinkedBy: linkedBy,
|
LinkedBy: linkedBy,
|
||||||
}, nil
|
}
|
||||||
|
if e.Stale(time.Now()) {
|
||||||
|
out.Stale = true
|
||||||
|
out.Hint = staleHint
|
||||||
|
}
|
||||||
|
return nil, out, nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -308,6 +346,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
|
||||||
return nil, listOutput{}, err
|
return nil, listOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jetzt := time.Now()
|
||||||
items := make([]listItem, 0, len(eintraege))
|
items := make([]listItem, 0, len(eintraege))
|
||||||
for _, e := range eintraege {
|
for _, e := range eintraege {
|
||||||
items = append(items, listItem{
|
items = append(items, listItem{
|
||||||
|
|
@ -317,6 +356,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
|
||||||
Source: e.Source,
|
Source: e.Source,
|
||||||
Curated: e.Curated,
|
Curated: e.Curated,
|
||||||
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
|
||||||
|
Stale: e.Stale(jetzt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ const (
|
||||||
ActionEntryRestore = "entry_restore" // aus dem Papierkorb zurückgeholt
|
ActionEntryRestore = "entry_restore" // aus dem Papierkorb zurückgeholt
|
||||||
ActionEntryPurge = "entry_purge" // endgültig gelöscht, nicht rückgängig zu machen
|
ActionEntryPurge = "entry_purge" // endgültig gelöscht, nicht rückgängig zu machen
|
||||||
|
|
||||||
|
// ActionEntryImport steht einmal je Import über die Weboberfläche, mit der
|
||||||
|
// Zusammenfassung als Detail (siehe internal/importer). Die einzelnen
|
||||||
|
// Einträge tauchen bewusst nicht als entry_write auf, damit ein
|
||||||
|
// einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt.
|
||||||
|
ActionEntryImport = "entry_import"
|
||||||
|
|
||||||
ActionLoginSuccess = "login_success"
|
ActionLoginSuccess = "login_success"
|
||||||
ActionAuthFailure = "auth_failure"
|
ActionAuthFailure = "auth_failure"
|
||||||
ActionProjectCreated = "project_created"
|
ActionProjectCreated = "project_created"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag.
|
// EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag.
|
||||||
|
|
@ -32,6 +33,19 @@ type EntryWrite struct {
|
||||||
// mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass
|
// mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass
|
||||||
// sich etwas geändert hat, und kann neu lesen.
|
// sich etwas geändert hat, und kann neu lesen.
|
||||||
ExpectedVersion int
|
ExpectedVersion int
|
||||||
|
|
||||||
|
// CreatedAt und UpdatedAt geben beim ANLEGEN eines Eintrags die
|
||||||
|
// Zeitstempel vor. Leer bedeutet „jetzt“, und das ist der Normalfall.
|
||||||
|
//
|
||||||
|
// Gebraucht wird das nur beim Import (internal/importer): Wer eine
|
||||||
|
// Sicherung einspielt, will das ursprüngliche Änderungsdatum behalten.
|
||||||
|
// Sonst sähe jeder wiederhergestellte Eintrag brandneu aus, und der
|
||||||
|
// Vermerk für veraltetes Wissen (Entry.Stale) liefe ins Leere.
|
||||||
|
//
|
||||||
|
// Beim Ändern eines vorhandenen Eintrags werden beide Felder ignoriert.
|
||||||
|
// Eine Änderung passiert jetzt, und der Verlauf soll das ehrlich zeigen.
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// nichtImPapierkorb gehört in JEDE lesende Abfrage auf entries (siehe
|
// 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()
|
id = NewID()
|
||||||
version = 1
|
version = 1
|
||||||
createdAt = jetzt
|
createdAt = jetzt
|
||||||
|
if !w.CreatedAt.IsZero() {
|
||||||
|
createdAt = formatTime(w.CreatedAt)
|
||||||
|
}
|
||||||
|
if !w.UpdatedAt.IsZero() {
|
||||||
|
jetzt = formatTime(w.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at)
|
`INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at)
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,35 @@ type Entry struct {
|
||||||
RestoredFromTrash bool
|
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
|
// TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite
|
||||||
// braucht: wer ihn wann gelöscht hat, ohne den ganzen Inhalt.
|
// braucht: wer ihn wann gelöscht hat, ohne den ganzen Inhalt.
|
||||||
type TrashedEntry struct {
|
type TrashedEntry struct {
|
||||||
|
|
|
||||||
270
internal/store/related.go
Normal file
270
internal/store/related.go
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Richtung eines Verweises aus Sicht des Eintrags, über den ein verwandter
|
||||||
|
// Eintrag gefunden wurde (siehe RelatedEntry.Relation).
|
||||||
|
const (
|
||||||
|
RelationLinksTo = "links_to" // der Vorgänger verweist auf diesen Eintrag
|
||||||
|
RelationLinkedBy = "linked_by" // dieser Eintrag verweist auf den Vorgänger
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelatedEntry ist ein Nachbar eines Eintrags im Verweisgraphen.
|
||||||
|
type RelatedEntry struct {
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
// Distance zählt die Verweise bis zum Ausgangseintrag: 1 = direkt
|
||||||
|
// verknüpft, 2 = über einen Zwischeneintrag.
|
||||||
|
Distance int
|
||||||
|
|
||||||
|
// Via ist der Eintrag, über den dieser gefunden wurde. Bei Distance 1 ist
|
||||||
|
// das der Ausgangseintrag selbst, bei Distance 2 der Zwischeneintrag.
|
||||||
|
Via string
|
||||||
|
|
||||||
|
// Relation sagt, in welche Richtung der Verweis zwischen Via und diesem
|
||||||
|
// Eintrag zeigt: RelationLinksTo oder RelationLinkedBy.
|
||||||
|
Relation string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelatedEntries liefert die Nachbarn eines Eintrags im Verweisgraphen, bis zu
|
||||||
|
// maxDistance Schritte weit und höchstens limit Stück (siehe AUSBAUSTUFEN.md
|
||||||
|
// 3.7).
|
||||||
|
//
|
||||||
|
// # Wozu, wenn "get" schon links_to und linked_by liefert?
|
||||||
|
//
|
||||||
|
// "get" kennt nur die direkten Nachbarn, und das nur als nackte Bezeichner.
|
||||||
|
// Hier kommen Titel und Änderungsdatum dazu, und auf Wunsch auch die zweite
|
||||||
|
// Reihe: Was hängt an den Einträgen, die mit meinem Thema verknüpft sind? Für
|
||||||
|
// einen Agenten ist das ein zweiter Weg zum Kontext, neben der Suche. Er
|
||||||
|
// findet Einträge, die kein Suchwort gemeinsam haben, aber von einem Menschen
|
||||||
|
// bewusst verknüpft wurden.
|
||||||
|
//
|
||||||
|
// # Vorgehen
|
||||||
|
//
|
||||||
|
// Breitensuche, eine Ebene nach der anderen: erst alle direkten Nachbarn, dann
|
||||||
|
// deren Nachbarn. Die Richtung der Verweise spielt für die Suche keine Rolle.
|
||||||
|
// Wer auf mich verweist, ist genauso verwandt wie das, worauf ich verweise.
|
||||||
|
// Näheres steht vor Fernerem, innerhalb einer Ebene nach Titel sortiert.
|
||||||
|
//
|
||||||
|
// Verweise auf Bezeichner ohne Eintrag und Einträge im Papierkorb tauchen nicht
|
||||||
|
// auf. Für einen Agenten, der Kontext sucht, gibt es dort nichts zu lesen.
|
||||||
|
//
|
||||||
|
// Gibt es den Ausgangseintrag nicht, ist das ErrNotFound.
|
||||||
|
func (s *Store) RelatedEntries(ctx context.Context, projectID, slug string, maxDistance, limit int) ([]RelatedEntry, error) {
|
||||||
|
// Den Ausgangseintrag selbst prüfen. Sonst bekäme ein Tippfehler im
|
||||||
|
// Bezeichner eine leere Liste zurück, und der Agent hielte das Thema für
|
||||||
|
// unverknüpft statt für nicht vorhanden.
|
||||||
|
var vorhanden int
|
||||||
|
if err := s.lesen.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
|
||||||
|
projectID, slug,
|
||||||
|
).Scan(&vorhanden); err != nil {
|
||||||
|
return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
if vorhanden == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// besucht verhindert, dass ein Eintrag zweimal auftaucht, etwa wenn A auf
|
||||||
|
// B verweist und B zurück auf A. Der Ausgangseintrag zählt von Anfang an
|
||||||
|
// als besucht, er soll nicht als sein eigener Nachbar erscheinen.
|
||||||
|
besucht := map[string]bool{slug: true}
|
||||||
|
ebene := []string{slug}
|
||||||
|
|
||||||
|
var ergebnis []RelatedEntry
|
||||||
|
|
||||||
|
for abstand := 1; abstand <= maxDistance && len(ebene) > 0 && len(ergebnis) < limit; abstand++ {
|
||||||
|
kanten, err := s.nachbarn(ctx, projectID, ebene)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Je neu entdecktem Bezeichner nur die erste Kante merken. Welcher
|
||||||
|
// Vorgänger „gewinnt“, ist egal, aber es muss derselbe sein, egal in
|
||||||
|
// welcher Reihenfolge die Datenbank die Zeilen liefert. Deshalb sind
|
||||||
|
// die Kanten vorher sortiert (siehe nachbarn).
|
||||||
|
neu := make(map[string]RelatedEntry)
|
||||||
|
var neueSlugs []string
|
||||||
|
for _, k := range kanten {
|
||||||
|
if besucht[k.nach] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, schonDa := neu[k.nach]; schonDa {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
neu[k.nach] = RelatedEntry{Slug: k.nach, Distance: abstand, Via: k.von, Relation: k.relation}
|
||||||
|
neueSlugs = append(neueSlugs, k.nach)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne
|
||||||
|
// Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in
|
||||||
|
// der Antwort.
|
||||||
|
details, err := s.eintragsKoepfe(ctx, projectID, neueSlugs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var dieseEbene []RelatedEntry
|
||||||
|
ebene = ebene[:0]
|
||||||
|
for _, nachbar := range neueSlugs {
|
||||||
|
besucht[nachbar] = true
|
||||||
|
kopf, ok := details[nachbar]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := neu[nachbar]
|
||||||
|
r.Title = kopf.Title
|
||||||
|
r.UpdatedAt = kopf.UpdatedAt
|
||||||
|
dieseEbene = append(dieseEbene, r)
|
||||||
|
ebene = append(ebene, nachbar)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(dieseEbene, func(i, j int) bool { return dieseEbene[i].Title < dieseEbene[j].Title })
|
||||||
|
ergebnis = append(ergebnis, dieseEbene...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ergebnis) > limit {
|
||||||
|
ergebnis = ergebnis[:limit]
|
||||||
|
}
|
||||||
|
return ergebnis, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kante ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus.
|
||||||
|
type kante struct {
|
||||||
|
von, nach string
|
||||||
|
relation string
|
||||||
|
}
|
||||||
|
|
||||||
|
// nachbarn liefert alle Verweise, die einen der Bezeichner in slugs berühren,
|
||||||
|
// in beide Richtungen.
|
||||||
|
//
|
||||||
|
// Zwei Abfragen statt einer mit OR: So nutzt jede ihren eigenen Index. Die
|
||||||
|
// ausgehenden laufen über den Primärschlüssel (from_entry_id, to_slug), die
|
||||||
|
// eingehenden über idx_entry_links_projekt (project_id, to_slug).
|
||||||
|
func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) ([]kante, error) {
|
||||||
|
platz, args := inListe(projectID, slugs)
|
||||||
|
|
||||||
|
var kanten []kante
|
||||||
|
|
||||||
|
// Ausgehend: Einträge der Ebene verweisen auf etwas.
|
||||||
|
rows, err := s.lesen.QueryContext(ctx,
|
||||||
|
`SELECT e.slug, l.to_slug
|
||||||
|
FROM entries e
|
||||||
|
JOIN entry_links l ON l.from_entry_id = e.id
|
||||||
|
WHERE e.project_id = ? AND e.slug IN (`+platz+`) AND e.deleted_at IS NULL`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var k kante
|
||||||
|
if err := rows.Scan(&k.von, &k.nach); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
k.relation = RelationLinksTo
|
||||||
|
kanten = append(kanten, k)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Eingehend: Etwas verweist auf Einträge der Ebene. Der verweisende
|
||||||
|
// Eintrag ist hier der Nachbar, deshalb stehen die Spalten andersherum.
|
||||||
|
rows, err = s.lesen.QueryContext(ctx,
|
||||||
|
`SELECT l.to_slug, e.slug
|
||||||
|
FROM entry_links l
|
||||||
|
JOIN entries e ON e.id = l.from_entry_id
|
||||||
|
WHERE l.project_id = ? AND l.to_slug IN (`+platz+`) AND e.deleted_at IS NULL`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Rückverweise konnten nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var k kante
|
||||||
|
if err := rows.Scan(&k.von, &k.nach); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
k.relation = RelationLinkedBy
|
||||||
|
kanten = append(kanten, k)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feste Reihenfolge, damit dasselbe Projekt immer dieselbe Antwort gibt
|
||||||
|
// (siehe RelatedEntries).
|
||||||
|
sort.Slice(kanten, func(i, j int) bool {
|
||||||
|
if kanten[i].nach != kanten[j].nach {
|
||||||
|
return kanten[i].nach < kanten[j].nach
|
||||||
|
}
|
||||||
|
if kanten[i].von != kanten[j].von {
|
||||||
|
return kanten[i].von < kanten[j].von
|
||||||
|
}
|
||||||
|
return kanten[i].relation < kanten[j].relation
|
||||||
|
})
|
||||||
|
return kanten, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eintragsKopf ist das Wenige, was RelatedEntries über einen Nachbarn braucht.
|
||||||
|
type eintragsKopf struct {
|
||||||
|
Title string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// eintragsKoepfe lädt Titel und Änderungsdatum zu einer Liste von Bezeichnern.
|
||||||
|
// Was nicht (mehr) existiert oder im Papierkorb liegt, fehlt in der Antwort.
|
||||||
|
func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []string) (map[string]eintragsKopf, error) {
|
||||||
|
koepfe := make(map[string]eintragsKopf, len(slugs))
|
||||||
|
if len(slugs) == 0 {
|
||||||
|
return koepfe, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
platz, args := inListe(projectID, slugs)
|
||||||
|
rows, err := s.lesen.QueryContext(ctx,
|
||||||
|
`SELECT slug, title, updated_at FROM entries
|
||||||
|
WHERE project_id = ? AND slug IN (`+platz+`) AND `+nichtImPapierkorb,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var slug, title, geaendert string
|
||||||
|
if err := rows.Scan(&slug, &title, &geaendert); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
koepfe[slug] = eintragsKopf{Title: title, UpdatedAt: parseTime(geaendert)}
|
||||||
|
}
|
||||||
|
return koepfe, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// inListe baut die Platzhalter für "IN (?, ?, …)" und die passenden
|
||||||
|
// Argumente, mit der Projekt-ID vorneweg.
|
||||||
|
//
|
||||||
|
// Die Werte werden nie in den SQL-Text geschrieben, sondern immer als
|
||||||
|
// Parameter übergeben. Nur so ist ausgeschlossen, dass ein Bezeichner die
|
||||||
|
// Abfrage verändert (SQL-Injection).
|
||||||
|
func inListe(projectID string, werte []string) (string, []any) {
|
||||||
|
args := make([]any, 0, len(werte)+1)
|
||||||
|
args = append(args, projectID)
|
||||||
|
for _, w := range werte {
|
||||||
|
args = append(args, w)
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(strings.Repeat("?, ", len(werte)), ", "), args
|
||||||
|
}
|
||||||
197
internal/store/related_test.go
Normal file
197
internal/store/related_test.go
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// schreibe legt einen Eintrag an und bricht den Test bei einem Fehler ab.
|
||||||
|
func schreibe(t *testing.T, s *Store, projectID, slug, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.PutEntry(context.Background(), EntryWrite{
|
||||||
|
ProjectID: projectID, Slug: slug, Title: "Titel " + slug, Content: content,
|
||||||
|
Source: SourceHuman, Author: "test",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Eintrag %q: %v", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelatedEntries prüft Nachbarn im Verweisgraphen:
|
||||||
|
//
|
||||||
|
// start → a → fern
|
||||||
|
// b → start
|
||||||
|
// start → fehlt (Verweis ohne Eintrag)
|
||||||
|
// c (unverknüpft)
|
||||||
|
func TestRelatedEntries(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
p := testProject(t, s)
|
||||||
|
|
||||||
|
schreibe(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]")
|
||||||
|
schreibe(t, s, p.ID, "a", "weiter bei [[fern]]")
|
||||||
|
schreibe(t, s, p.ID, "b", "hängt an [[start]]")
|
||||||
|
schreibe(t, s, p.ID, "fern", "Ende")
|
||||||
|
schreibe(t, s, p.ID, "c", "allein")
|
||||||
|
|
||||||
|
verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gefunden := make(map[string]RelatedEntry)
|
||||||
|
for _, v := range verwandte {
|
||||||
|
gefunden[v.Slug] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
erwartet := map[string]struct {
|
||||||
|
abstand int
|
||||||
|
via string
|
||||||
|
relation string
|
||||||
|
}{
|
||||||
|
"a": {1, "start", RelationLinksTo},
|
||||||
|
"b": {1, "start", RelationLinkedBy},
|
||||||
|
"fern": {2, "a", RelationLinksTo},
|
||||||
|
}
|
||||||
|
for slug, e := range erwartet {
|
||||||
|
v, ok := gefunden[slug]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%q fehlt", slug)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v.Distance != e.abstand || v.Via != e.via || v.Relation != e.relation {
|
||||||
|
t.Errorf("%q: Abstand %d über %q (%s), erwartet %d über %q (%s)",
|
||||||
|
slug, v.Distance, v.Via, v.Relation, e.abstand, e.via, e.relation)
|
||||||
|
}
|
||||||
|
if v.Title != "Titel "+slug || v.UpdatedAt.IsZero() {
|
||||||
|
t.Errorf("%q: Titel oder Änderungsdatum fehlen: %+v", slug, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, slug := range []string{"start", "fehlt", "c"} {
|
||||||
|
if _, ok := gefunden[slug]; ok {
|
||||||
|
t.Errorf("%q darf nicht unter den verwandten Einträgen stehen", slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Näheres vor Fernerem.
|
||||||
|
if verwandte[len(verwandte)-1].Slug != "fern" {
|
||||||
|
t.Errorf("die zweite Reihe steht nicht am Ende: %+v", verwandte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit Abstand 1 nur die direkten Nachbarn.
|
||||||
|
direkt, err := s.RelatedEntries(ctx, p.ID, "start", 1, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(direkt) != 2 {
|
||||||
|
t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direkt), direkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Obergrenze gilt.
|
||||||
|
begrenzt, err := s.RelatedEntries(ctx, p.ID, "start", 2, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(begrenzt) != 1 {
|
||||||
|
t.Errorf("Limit 1 lieferte %d Einträge", len(begrenzt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRelatedEntriesSkipsTrash prüft, dass gelöschte Einträge weder als
|
||||||
|
// Nachbar erscheinen noch als Brücke in die zweite Reihe dienen.
|
||||||
|
func TestRelatedEntriesSkipsTrash(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
p := testProject(t, s)
|
||||||
|
|
||||||
|
schreibe(t, s, p.ID, "start", "siehe [[weg]]")
|
||||||
|
schreibe(t, s, p.ID, "weg", "siehe [[dahinter]]")
|
||||||
|
schreibe(t, s, p.ID, "dahinter", "Ende")
|
||||||
|
|
||||||
|
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "test"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(verwandte) != 0 {
|
||||||
|
t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", verwandte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Ausgangseintrag im Papierkorb gilt als nicht vorhanden.
|
||||||
|
if _, err := s.RelatedEntries(ctx, p.ID, "weg", 2, 20); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelatedEntriesUnknownSlug(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
p := testProject(t, s)
|
||||||
|
|
||||||
|
if _, err := s.RelatedEntries(context.Background(), p.ID, "gibtesnicht", 2, 20); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryStale prüft die Grenze für veraltetes Wissen (AUSBAUSTUFEN.md 3.5).
|
||||||
|
func TestEntryStale(t *testing.T) {
|
||||||
|
jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
faelle := []struct {
|
||||||
|
name string
|
||||||
|
geaendert time.Time
|
||||||
|
erwartet bool
|
||||||
|
}{
|
||||||
|
{"gestern", jetzt.AddDate(0, 0, -1), false},
|
||||||
|
{"vor elf Monaten", jetzt.AddDate(0, -11, 0), false},
|
||||||
|
{"vor zwei Jahren", jetzt.AddDate(-2, 0, 0), true},
|
||||||
|
{"ohne Datum", time.Time{}, false},
|
||||||
|
}
|
||||||
|
for _, f := range faelle {
|
||||||
|
t.Run(f.name, func(t *testing.T) {
|
||||||
|
if got := (Entry{UpdatedAt: f.geaendert}).Stale(jetzt); got != f.erwartet {
|
||||||
|
t.Errorf("Stale = %v, erwartet %v", got, f.erwartet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPutEntryKeepsImportedTimestamps prüft, dass vorgegebene Zeitstempel
|
||||||
|
// beim Anlegen übernommen und beim Ändern ignoriert werden.
|
||||||
|
func TestPutEntryKeepsImportedTimestamps(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
p := testProject(t, s)
|
||||||
|
|
||||||
|
alt := time.Date(2023, 3, 11, 9, 0, 0, 0, time.UTC)
|
||||||
|
e, err := s.PutEntry(ctx, EntryWrite{
|
||||||
|
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "x",
|
||||||
|
Source: SourceHuman, CreatedAt: alt, UpdatedAt: alt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gelesen, _ := s.EntryBySlug(ctx, p.ID, "alt")
|
||||||
|
if !gelesen.CreatedAt.Equal(alt) || !gelesen.UpdatedAt.Equal(alt) || !e.UpdatedAt.Equal(alt) {
|
||||||
|
t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", gelesen.CreatedAt, gelesen.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Änderung passiert jetzt, egal was mitgegeben wird.
|
||||||
|
if _, err := s.PutEntry(ctx, EntryWrite{
|
||||||
|
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "neu",
|
||||||
|
Source: SourceHuman, UpdatedAt: alt,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gelesen, _ = s.EntryBySlug(ctx, p.ID, "alt")
|
||||||
|
if gelesen.UpdatedAt.Equal(alt) {
|
||||||
|
t.Error("beim Ändern wurde der vorgegebene Zeitstempel übernommen")
|
||||||
|
}
|
||||||
|
if !gelesen.CreatedAt.Equal(alt) {
|
||||||
|
t.Error("beim Ändern ging das Anlegedatum verloren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -128,6 +128,11 @@ type entryViewData struct {
|
||||||
Entry store.Entry
|
Entry store.Entry
|
||||||
HTML template.HTML
|
HTML template.HTML
|
||||||
Backlinks []store.Backlink
|
Backlinks []store.Backlink
|
||||||
|
|
||||||
|
// Stale: seit über einem Jahr unverändert (siehe store.Entry.Stale). Die
|
||||||
|
// Seite zeigt dann einen Vermerk, damit Menschen den Eintrag genauso
|
||||||
|
// vorsichtig lesen wie ein Agent, dem MCP dasselbe sagt.
|
||||||
|
Stale bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEntryView zeigt einen einzelnen Eintrag.
|
// handleEntryView zeigt einen einzelnen Eintrag.
|
||||||
|
|
@ -163,7 +168,10 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
|
||||||
Title: e.Title,
|
Title: e.Title,
|
||||||
Bereich: "eintraege",
|
Bereich: "eintraege",
|
||||||
Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung
|
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()),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
142
internal/web/handlers_import.go
Normal file
142
internal/web/handlers_import.go
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
// Import von Markdown-Dateien in ein Projekt (AUSBAUSTUFEN.md 3.2).
|
||||||
|
//
|
||||||
|
// # Warum über die Weboberfläche und nicht als Kommandozeilenbefehl?
|
||||||
|
//
|
||||||
|
// Der Suchindex liegt im Arbeitsspeicher des laufenden Servers (siehe
|
||||||
|
// internal/search). Ein zweiter Prozess, der direkt in die Datenbank
|
||||||
|
// schreibt, könnte ihn nicht nachziehen, und die importierten Einträge blieben
|
||||||
|
// bis zum nächsten Neustart für recall unsichtbar. Hier läuft der Import im
|
||||||
|
// Server selbst und aktualisiert den Index im selben Zug.
|
||||||
|
//
|
||||||
|
// Dazu kommt: So können Projekt-Admins ihre vorhandene Dokumentation selbst
|
||||||
|
// einspielen, ohne auf den Plattform-Admin zu warten.
|
||||||
|
//
|
||||||
|
// # Rechte
|
||||||
|
//
|
||||||
|
// Nur mit der Rolle admin, wie die Zugänge. Ein Import schreibt womöglich
|
||||||
|
// tausende Einträge auf einmal, und wer ihn im Modus „Überschreiben“ startet,
|
||||||
|
// ändert Einträge, die andere geschrieben haben.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/importer"
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// importData sind die Daten der Import-Seite.
|
||||||
|
type importData struct {
|
||||||
|
// Bericht ist nach einem Lauf gesetzt, sonst nil.
|
||||||
|
Bericht *importer.Bericht
|
||||||
|
|
||||||
|
// Vorschau: Der Bericht zeigt nur, was passieren würde.
|
||||||
|
Vorschau bool
|
||||||
|
|
||||||
|
// Modus ist die zuletzt gewählte Einstellung, damit das Formular sie nach
|
||||||
|
// einer Vorschau beibehält.
|
||||||
|
Modus importer.Modus
|
||||||
|
|
||||||
|
MaxDateien int
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleImportForm zeigt das Formular zum Hochladen.
|
||||||
|
func (s *Server) handleImportForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderImport(w, r, http.StatusOK, importData{Modus: importer.ModusUeberspringen}, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleImport nimmt ein ZIP-Archiv entgegen und importiert es, oder zeigt
|
||||||
|
// bei gesetzter Vorschau nur, was passieren würde.
|
||||||
|
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws, sess := s.workspaceAndSession(r)
|
||||||
|
project, _ := ProjectFrom(r)
|
||||||
|
|
||||||
|
daten := importData{
|
||||||
|
Modus: importer.Modus(r.FormValue("modus")),
|
||||||
|
Vorschau: r.FormValue("vorschau") == "1",
|
||||||
|
}
|
||||||
|
if daten.Modus != importer.ModusUeberschreiben {
|
||||||
|
daten.Modus = importer.ModusUeberspringen
|
||||||
|
}
|
||||||
|
|
||||||
|
// r.FormFile liest das hochgeladene Feld "archiv". Die Größe ist schon
|
||||||
|
// begrenzt (LimitBodies in middleware.go), ReadAll kann also nicht mehr
|
||||||
|
// als das Import-Limit in den Speicher holen.
|
||||||
|
datei, _, err := r.FormFile("archiv")
|
||||||
|
if err != nil {
|
||||||
|
var zuGross *http.MaxBytesError
|
||||||
|
if errors.As(err, &zuGross) {
|
||||||
|
s.renderImport(w, r, http.StatusRequestEntityTooLarge, daten, "",
|
||||||
|
"Das Archiv ist zu groß. Bitte in mehrere kleinere aufteilen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Bitte ein ZIP-Archiv auswählen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer datei.Close()
|
||||||
|
|
||||||
|
inhalt, err := io.ReadAll(datei)
|
||||||
|
if err != nil {
|
||||||
|
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Das Archiv konnte nicht gelesen werden.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dateien, err := importer.LeseZip(inhalt)
|
||||||
|
if err != nil {
|
||||||
|
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Import nicht möglich: "+err.Error()+".")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bericht, err := importer.Importiere(r.Context(), ws.Store, project.ID, dateien, importer.Optionen{
|
||||||
|
Modus: daten.Modus,
|
||||||
|
Vorschau: daten.Vorschau,
|
||||||
|
Autor: sess.ActorName(),
|
||||||
|
})
|
||||||
|
// Auch nach einem Abbruch sind die bis dahin geschriebenen Einträge da.
|
||||||
|
// Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den
|
||||||
|
// Fehler melden.
|
||||||
|
for _, e := range bericht.Geschrieben {
|
||||||
|
ws.Search.Update(project.ID, e)
|
||||||
|
}
|
||||||
|
if !daten.Vorschau && len(bericht.Geschrieben) > 0 {
|
||||||
|
// Ein Protokolleintrag für den ganzen Import statt einer je Datei.
|
||||||
|
// Sonst verschwände alles andere im Protokoll unter tausend Zeilen.
|
||||||
|
// Und die Nutzungsübersicht (4.6) soll zählen, was die Teams im
|
||||||
|
// Alltag schreiben, nicht einen einmaligen Import.
|
||||||
|
ws.Store.LogAudit(r.Context(), store.AuditEvent{
|
||||||
|
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
|
||||||
|
Action: store.ActionEntryImport, Source: store.SourceWeb, IP: auth.ClientIP(r),
|
||||||
|
Detail: bericht.Zusammenfassung(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "Import abgebrochen nach "+bericht.Zusammenfassung(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
daten.Bericht = &bericht
|
||||||
|
hinweis := "Import abgeschlossen: " + bericht.Zusammenfassung() + "."
|
||||||
|
if daten.Vorschau {
|
||||||
|
hinweis = "Vorschau, noch nichts geschrieben: " + bericht.Zusammenfassung() +
|
||||||
|
". Zum Übernehmen dasselbe Archiv ohne Häkchen bei „Nur Vorschau“ erneut hochladen."
|
||||||
|
}
|
||||||
|
s.renderImport(w, r, http.StatusOK, daten, hinweis, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderImport zeigt die Import-Seite mit optionaler Meldung.
|
||||||
|
func (s *Server) renderImport(w http.ResponseWriter, r *http.Request, status int, daten importData, hinweis, fehler string) {
|
||||||
|
project, _ := ProjectFrom(r)
|
||||||
|
daten.MaxDateien = importer.MaxDateien
|
||||||
|
|
||||||
|
s.render(w, r, "import.html", status, pageData{
|
||||||
|
Title: "Import – " + project.Name,
|
||||||
|
Bereich: "import",
|
||||||
|
Flash: hinweis,
|
||||||
|
Error: fehler,
|
||||||
|
Data: daten,
|
||||||
|
})
|
||||||
|
}
|
||||||
194
internal/web/import_test.go
Normal file
194
internal/web/import_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"munin/internal/auth"
|
||||||
|
"munin/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// importAnfrage baut einen Upload wie das Formular der Import-Seite.
|
||||||
|
func importAnfrage(t *testing.T, cookie *http.Cookie, csrf string, dateien map[string]string, felder map[string]string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var archiv bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&archiv)
|
||||||
|
for pfad, inhalt := range dateien {
|
||||||
|
f, _ := zw.Create(pfad)
|
||||||
|
f.Write([]byte(inhalt))
|
||||||
|
}
|
||||||
|
zw.Close()
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
mw.WriteField(auth.CSRFFieldName, csrf)
|
||||||
|
for name, wert := range felder {
|
||||||
|
mw.WriteField(name, wert)
|
||||||
|
}
|
||||||
|
teil, _ := mw.CreateFormFile("archiv", "import.zip")
|
||||||
|
teil.Write(archiv.Bytes())
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body)
|
||||||
|
r.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
r.AddCookie(cookie)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminMitToken meldet einen Admin-Key an und berechnet sein CSRF-Token so,
|
||||||
|
// wie es das Template tut.
|
||||||
|
func adminMitToken(t *testing.T, srv *Server, db *store.Store, projekt store.Project, h http.Handler) (*http.Cookie, string) {
|
||||||
|
t.Helper()
|
||||||
|
key := erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)
|
||||||
|
cookie := anmelden(t, h, key)
|
||||||
|
|
||||||
|
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
||||||
|
subject := auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID
|
||||||
|
return cookie, auth.NewSigner([]byte(testSecret)).CSRFToken(subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportPreviewThenApply(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
cookie, csrf := adminMitToken(t, srv, db, projekt, h)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Suchindex vorher aufbauen. Der Import muss ihn nachziehen, sonst fände
|
||||||
|
// die Suche die neuen Einträge erst nach einem Neustart.
|
||||||
|
suche := httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil)
|
||||||
|
suche.AddCookie(cookie)
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), suche)
|
||||||
|
|
||||||
|
dateien := map[string]string{
|
||||||
|
"doku/verkehr.md": "# Verkehr\n\nAm Zebrastreifen anhalten.",
|
||||||
|
"doku/_index.md": "Übersicht",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschau: nichts geschrieben.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, importAnfrage(t, cookie, csrf, dateien, map[string]string{"vorschau": "1", "modus": "skip"}))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Vorschau: Status %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "Vorschau") || !strings.Contains(w.Body.String(), "verkehr") {
|
||||||
|
t.Error("die Vorschau zeigt die Datei nicht")
|
||||||
|
}
|
||||||
|
if _, err := db.EntryBySlug(ctx, projekt.ID, "verkehr"); err == nil {
|
||||||
|
t.Fatal("die Vorschau hat geschrieben")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Übernehmen.
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, importAnfrage(t, cookie, csrf, dateien, map[string]string{"modus": "skip"}))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Import: Status %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
e, err := db.EntryBySlug(ctx, projekt.ID, "verkehr")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Eintrag fehlt nach dem Import: %v", err)
|
||||||
|
}
|
||||||
|
if e.Author != "verwalterin" || e.Title != "Verkehr" {
|
||||||
|
t.Errorf("Autor %q, Titel %q", e.Author, e.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
suche = httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil)
|
||||||
|
suche.AddCookie(cookie)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, suche)
|
||||||
|
if !strings.Contains(w.Body.String(), "/e/verkehr") {
|
||||||
|
t.Error("die Suche findet den importierten Eintrag nicht — Suchindex nicht nachgezogen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genau ein Protokolleintrag für den ganzen Import.
|
||||||
|
ereignisse, err := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID, Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
anzahl := 0
|
||||||
|
for _, ev := range ereignisse {
|
||||||
|
if ev.Action == store.ActionEntryImport {
|
||||||
|
anzahl++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anzahl != 1 {
|
||||||
|
t.Errorf("%d Import-Einträge im Protokoll, erwartet 1", anzahl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportNeedsAdmin(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
|
||||||
|
key := erzeugeKey(t, srv, db, projekt.ID, "schreiber", auth.RoleWrite)
|
||||||
|
cookie := anmelden(t, h, key)
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/import", nil)
|
||||||
|
r.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportRejectsNonZip(t *testing.T) {
|
||||||
|
srv, db, projekt := testServer(t)
|
||||||
|
h := testHandler(t, srv)
|
||||||
|
cookie, csrf := adminMitToken(t, srv, db, projekt, h)
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
mw.WriteField(auth.CSRFFieldName, csrf)
|
||||||
|
teil, _ := mw.CreateFormFile("archiv", "kaputt.zip")
|
||||||
|
teil.Write([]byte("das ist kein zip"))
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body)
|
||||||
|
r.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
r.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "ZIP") {
|
||||||
|
t.Errorf("Status %d, Antwort ohne verständliche Meldung", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLimitBodiesPerRoute prüft, dass /mcp und der Import ihre eigene,
|
||||||
|
// größere Grenze bekommen und alles andere die kleine. Früher lag ein
|
||||||
|
// einziges Limit über allem und schnitt MCP auf 1 MB ab.
|
||||||
|
func TestLimitBodiesPerRoute(t *testing.T) {
|
||||||
|
var gelesen int64
|
||||||
|
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gelesen, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
})
|
||||||
|
h := LimitBodies(inner, BodyLimits{Web: 100, API: 1000, Import: 5000})
|
||||||
|
|
||||||
|
faelle := []struct {
|
||||||
|
methode, pfad string
|
||||||
|
erwartet int64
|
||||||
|
}{
|
||||||
|
{http.MethodPost, "/p/x/save", 100},
|
||||||
|
{http.MethodPost, "/mcp", 1000},
|
||||||
|
{http.MethodPost, "/p/x/import", 5000},
|
||||||
|
{http.MethodGet, "/p/x/import", 100},
|
||||||
|
{http.MethodPost, "/p/x/e/import", 100}, // ein Eintrag namens "import", nicht die Import-Seite
|
||||||
|
}
|
||||||
|
for _, f := range faelle {
|
||||||
|
gelesen = 0
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(),
|
||||||
|
httptest.NewRequest(f.methode, f.pfad, strings.NewReader(strings.Repeat("x", 10000))))
|
||||||
|
if gelesen != f.erwartet {
|
||||||
|
t.Errorf("%s %s: %d Bytes gelesen, erwartet %d", f.methode, f.pfad, gelesen, f.erwartet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) ─────────────────────────────────────────────────────────
|
// ── API-Zugang (MCP) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im
|
// APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,11 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
|
||||||
|
|
||||||
// Key-Verwaltung.
|
// Key-Verwaltung.
|
||||||
mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList)))))
|
mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList)))))
|
||||||
|
|
||||||
|
// Import: dieselbe Rolle wie die Key-Verwaltung, siehe handlers_import.go.
|
||||||
|
// Die größere Body-Grenze für POST setzt LimitBodies (middleware.go).
|
||||||
|
mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImportForm)))))
|
||||||
|
mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImport)))))
|
||||||
mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate)))))
|
mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate)))))
|
||||||
mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke)))))
|
mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke)))))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
main.go
18
main.go
|
|
@ -58,6 +58,12 @@ const (
|
||||||
|
|
||||||
// maxAPIBodyBytes gilt für MCP-Aufrufe.
|
// maxAPIBodyBytes gilt für MCP-Aufrufe.
|
||||||
defaultMaxAPIBodyBytes = 4 << 20 // 4 MB
|
defaultMaxAPIBodyBytes = 4 << 20 // 4 MB
|
||||||
|
|
||||||
|
// maxImportBodyBytes gilt für das Hochladen eines ZIP-Archivs zum Import
|
||||||
|
// (AUSBAUSTUFEN.md 3.2). Ein exportiertes Projekt mit einigen tausend
|
||||||
|
// Einträgen passt gepackt bequem hinein. Was ausgepackt daraus werden darf,
|
||||||
|
// begrenzt der Importer selbst (siehe internal/importer).
|
||||||
|
maxImportBodyBytes = 20 << 20 // 20 MB
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Server-Timeouts ──────────────────────────────────────────────────────────
|
// ── Server-Timeouts ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -207,15 +213,21 @@ func main() {
|
||||||
|
|
||||||
// Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier
|
// Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier
|
||||||
// kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key.
|
// kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key.
|
||||||
mux.Handle("/mcp", web.LimitBody(webServer.APIAuth(mcpHandler), cfg.maxAPIBody))
|
mux.Handle("/mcp", webServer.APIAuth(mcpHandler))
|
||||||
|
|
||||||
// Reihenfolge von außen nach innen:
|
// Reihenfolge von außen nach innen:
|
||||||
// LogRequests erfasst jede Antwort, auch abgewiesene
|
// LogRequests erfasst jede Antwort, auch abgewiesene
|
||||||
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
|
// SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten
|
||||||
// LimitBody deckelt den Body, bevor ein Handler ihn liest
|
// LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit
|
||||||
|
// einer eigenen Grenze für /mcp und den Import, siehe
|
||||||
|
// dort, warum das nicht je Route geschehen kann
|
||||||
handler := web.LogRequests(
|
handler := web.LogRequests(
|
||||||
web.SecurityHeaders(
|
web.SecurityHeaders(
|
||||||
web.LimitBody(mux, maxWebBodyBytes),
|
web.LimitBodies(mux, web.BodyLimits{
|
||||||
|
Web: maxWebBodyBytes,
|
||||||
|
API: cfg.maxAPIBody,
|
||||||
|
Import: maxImportBodyBytes,
|
||||||
|
}),
|
||||||
cfg.enableHSTS,
|
cfg.enableHSTS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<code>{{.Data.Entry.Slug}}</code>
|
<code>{{.Data.Entry.Slug}}</code>
|
||||||
{{if .Data.Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}}
|
{{if .Data.Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}}
|
||||||
|
{{if .Data.Stale}}<span class="marke warnung" title="Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt.">über ein Jahr alt</span>{{end}}
|
||||||
<span class="marke">{{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span>
|
<span class="marke">{{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span>
|
||||||
{{$slug := .Project.Slug}}
|
{{$slug := .Project.Slug}}
|
||||||
{{range .Data.Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}}
|
{{range .Data.Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}}
|
||||||
|
|
|
||||||
82
templates/import.html
Normal file
82
templates/import.html
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
{{template "kopf" .}}
|
||||||
|
|
||||||
|
<div class="seitenkopf">
|
||||||
|
<div class="seitenkopf-titel">
|
||||||
|
<h1>Import {{template "hilfelink" "weboberflaeche#import"}}</h1>
|
||||||
|
<span class="hilfe">Markdown-Dateien als ZIP-Archiv hochladen: einen Export aus Munin
|
||||||
|
oder vorhandene Dokumentation. Jede Datei wird ein Eintrag.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="abschnitt">
|
||||||
|
<div class="karte">
|
||||||
|
{{/* enctype="multipart/form-data" ist nötig, damit der Browser die Datei
|
||||||
|
selbst mitschickt und nicht nur ihren Namen. */}}
|
||||||
|
<form method="POST" action="/p/{{.Project.Slug}}/import" enctype="multipart/form-data" class="formular">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|
||||||
|
<div class="feld mittel">
|
||||||
|
<label for="archiv">ZIP-Archiv</label>
|
||||||
|
<span class="hilfe">Dateien auf <code>.md</code>, auch in Unterordnern, höchstens
|
||||||
|
{{.Data.MaxDateien}} Stück. Alles andere wird übergangen.</span>
|
||||||
|
<input type="file" id="archiv" name="archiv" accept=".zip,application/zip" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld kurz">
|
||||||
|
<label for="modus">Wenn es den Bezeichner schon gibt</label>
|
||||||
|
<select id="modus" name="modus">
|
||||||
|
<option value="skip" {{if eq .Data.Modus "skip"}}selected{{end}}>überspringen — Vorhandenes bleibt, wie es ist</option>
|
||||||
|
<option value="overwrite" {{if eq .Data.Modus "overwrite"}}selected{{end}}>überschreiben — alter Stand wandert in den Verlauf</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feld ankreuz">
|
||||||
|
{{/* Beim ersten Aufruf ist die Vorschau angehakt. Wer eine
|
||||||
|
Vorschau gesehen hat, bekommt das Formular ohne Haken
|
||||||
|
zurück: Der nächste Schritt ist dann das Übernehmen. */}}
|
||||||
|
<input type="checkbox" id="vorschau" name="vorschau" value="1" {{if not .Data.Bericht}}checked{{end}}>
|
||||||
|
<label for="vorschau">
|
||||||
|
Nur Vorschau
|
||||||
|
<span class="hilfe">Zeigt, was passieren würde, ohne etwas zu schreiben.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formular-aktionen">
|
||||||
|
<button type="submit">Hochladen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{with .Data.Bericht}}
|
||||||
|
<section class="abschnitt">
|
||||||
|
<h2>{{if $.Data.Vorschau}}Vorschau{{else}}Ergebnis{{end}}</h2>
|
||||||
|
<div class="tabelle-huelle">
|
||||||
|
<table class="tabelle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datei</th>
|
||||||
|
<th>Bezeichner</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Ergebnis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Zeilen}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.Pfad}}</code></td>
|
||||||
|
<td>{{if .Slug}}<code>{{.Slug}}</code>{{end}}</td>
|
||||||
|
<td>{{.Title}}</td>
|
||||||
|
<td>
|
||||||
|
<span class="marke{{if eq .Aktion "Fehler"}} warnung{{else if eq .Aktion "neu"}} betont{{end}}">{{.Aktion}}</span>
|
||||||
|
{{if .Grund}}<span class="hilfe">{{.Grund}}</span>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "fuss" .}}
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
"type": "streamableHttp",
|
"type": "streamableHttp",
|
||||||
"url": "{{.Data.ServerURL}}/mcp",
|
"url": "{{.Data.ServerURL}}/mcp",
|
||||||
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" },
|
"headers": { "Authorization": "Bearer {{.Data.NewKey}}" },
|
||||||
"autoApprove": ["recall", "get", "list"]
|
"autoApprove": ["recall", "get", "list", "related"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}</code></pre>
|
}</code></pre>
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@
|
||||||
<li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li>
|
<li><a href="/p/{{.Project.Slug}}/graph" {{if eq .Bereich "graph"}}class="aktiv"{{end}}>Graph</a></li>
|
||||||
{{if .Session.CanManageKeys}}
|
{{if .Session.CanManageKeys}}
|
||||||
<li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li>
|
<li><a href="/p/{{.Project.Slug}}/keys" {{if eq .Bereich "zugaenge"}}class="aktiv"{{end}}>Zugänge</a></li>
|
||||||
|
<li><a href="/p/{{.Project.Slug}}/import" {{if eq .Bereich "import"}}class="aktiv"{{end}}>Import</a></li>
|
||||||
{{end}}
|
{{end}}
|
||||||
<li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li>
|
<li><a href="/p/{{.Project.Slug}}/usage" {{if eq .Bereich "nutzung"}}class="aktiv"{{end}}>Nutzung</a></li>
|
||||||
<li><a href="/p/{{.Project.Slug}}/audit" {{if eq .Bereich "protokoll"}}class="aktiv"{{end}}>Protokoll</a></li>
|
<li><a href="/p/{{.Project.Slug}}/audit" {{if eq .Bereich "protokoll"}}class="aktiv"{{end}}>Protokoll</a></li>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue