From cf35c3f5dbf6624b1c0a360cf028fb5f0c949cad Mon Sep 17 00:00:00 2001 From: Thomas Adamek Date: Fri, 25 Sep 2026 08:18:47 +0200 Subject: [PATCH] =?UTF-8?q?Verwandte=20Eintr=C3=A4ge,=20Aktualit=C3=A4t,?= =?UTF-8?q?=20Importer=20(Ausbaustufen=203.7,=203.5,=203.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTEN-GUIDE.md | 8 +- AUSBAUSTUFEN.md | 69 +++- README.md | 5 +- REPOMAP.md | 31 +- internal/hilfe/seiten/mcp-einrichten.md | 4 +- internal/hilfe/seiten/mit-agenten-arbeiten.md | 6 +- internal/hilfe/seiten/plattform-admin.md | 5 + internal/hilfe/seiten/weboberflaeche.md | 28 ++ internal/importer/archiv.go | 122 +++++++ internal/importer/importer.go | 254 ++++++++++++++ internal/importer/importer_test.go | 319 ++++++++++++++++++ internal/importer/parse.go | 274 +++++++++++++++ internal/mcpserver/mcpserver_test.go | 84 ++++- internal/mcpserver/server.go | 34 ++ internal/mcpserver/tools.go | 111 +++++- internal/store/audit.go | 6 + internal/store/entries.go | 20 ++ internal/store/models.go | 29 ++ internal/store/related.go | 270 +++++++++++++++ internal/store/related_test.go | 197 +++++++++++ internal/web/handlers_entries.go | 10 +- internal/web/handlers_import.go | 142 ++++++++ internal/web/import_test.go | 194 +++++++++++ internal/web/middleware.go | 42 +++ internal/web/server.go | 5 + main.go | 18 +- templates/entry.html | 1 + templates/import.html | 82 +++++ templates/keys.html | 2 +- templates/partials.html | 1 + 30 files changed, 2352 insertions(+), 21 deletions(-) create mode 100644 internal/importer/archiv.go create mode 100644 internal/importer/importer.go create mode 100644 internal/importer/importer_test.go create mode 100644 internal/importer/parse.go create mode 100644 internal/store/related.go create mode 100644 internal/store/related_test.go create mode 100644 internal/web/handlers_import.go create mode 100644 internal/web/import_test.go create mode 100644 templates/import.html diff --git a/AGENTEN-GUIDE.md b/AGENTEN-GUIDE.md index b13093b..574a00d 100644 --- a/AGENTEN-GUIDE.md +++ b/AGENTEN-GUIDE.md @@ -37,6 +37,7 @@ und im Protokoll. | `recall` | `query`, `tags?`, `limit?` | read | | `get` | `slug` | read | | `list` | `tag?`, `curated?`, `limit?` | read | +| `related` | `slug`, `distance?`, `limit?` | read | | `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write | | `forget` | `slug` | write | @@ -62,11 +63,14 @@ eigenen Anwendung: ## Munin Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall, -get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams. +get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams. LESEN — vor jeder Antwort zu projektspezifischen Fragen: - Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten. - Passenden Treffer mit `get` vollständig laden. +- Für mehr Kontext mit `related` die verknüpften Einträge ansehen. +- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen + und in der Antwort erwähnen, dass der Stand alt ist. - Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das kenntlich machen. - Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht. @@ -175,7 +179,7 @@ sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Hak | Rolle | darf | wofür | |---|---|---| -| `read` | recall, get, list | Agenten, die nur auskunftsfähig sein sollen | +| `read` | recall, get, list, related | Agenten, die nur auskunftsfähig sein sollen | | `write` | zusätzlich remember, forget | der Normalfall | | `admin` | zusätzlich Zugänge verwalten | Menschen | diff --git a/AUSBAUSTUFEN.md b/AUSBAUSTUFEN.md index ee6d107..4b5144d 100644 --- a/AUSBAUSTUFEN.md +++ b/AUSBAUSTUFEN.md @@ -199,7 +199,7 @@ Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht. **Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben. -### 3.2 Importer als Gegenstück zum Export +### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle Felder im Kopf, ein Einlesen wäre also möglich. @@ -210,6 +210,40 @@ Erstbefüllung aus vorhandener Dokumentation. **Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert — überschreiben, überspringen oder abbrechen. +**✅ Umgesetzt 2026-09-25:** + +- **Weg:** ZIP-Upload unter `/p/{projekt}/import`, Rolle `admin`. Bewusst kein + Kommandozeilenbefehl: Der Suchindex liegt im Speicher des laufenden + Servers, ein zweiter Prozess könnte ihn nicht nachziehen. Nebenbei können + Projekt-Admins so selbst importieren. +- **Zusammenstoß:** *überspringen* (Standard) oder *überschreiben*. Beim + Überschreiben wandert der alte Stand in den Verlauf, der Autor ist dann der + importierende Zugang. Ein *Abbrechen* gibt es nicht, dafür eine Vorschau, + die beim ersten Hochladen angehakt ist. Dateien, die nichts ändern würden, + werden nie geschrieben. Ein wiederholter Import ist damit harmlos. +- **Papierkorb:** Im Modus *überspringen* holt ein Import keinen gelöschten + Eintrag zurück. Das war die Falle, weil `AllEntries` den Papierkorb nicht + sieht. +- **Felder:** Beim Anlegen bleiben Autor, Herkunft, Prüfvermerk und beide + Zeitstempel erhalten (neu: `EntryWrite.CreatedAt/UpdatedAt`). Sonst sähe + nach einer Wiederherstellung alles frisch aus, und 3.5 liefe ins Leere. Der + Verlauf wird nicht übernommen, der Export enthält ihn nicht. +- **Fremde Dateien:** Ohne Kopf gilt der Dateiname als Bezeichner und die + erste Überschrift als Titel. Fremde Köpfe (Hugo, Obsidian) werden gelesen, + soweit sie `title` und `tags` betreffen. Kein YAML-Paket, ein kleiner + eigener Leser in `internal/importer/parse.go`. +- **Grenzen:** 20 MB Upload, 5.000 Dateien, 1 MB je Datei, 100 MB ausgepackt. + Gezählt wird, was tatsächlich ausgepackt wird, nicht was das Archiv + behauptet. +- **Protokoll:** ein Eintrag `entry_import` je Import mit Zusammenfassung, + nicht einer je Datei. So verzerrt ein einmaliger Import die + Nutzungsübersicht (4.6) nicht. +- **Nebenbei behoben:** Das 1-MB-Limit für Formulare lag über dem ganzen Mux, + auch über `/mcp`. Zwei geschachtelte `MaxBytesReader` begrenzen auf den + kleineren Wert, MCP war also nie bei den dokumentierten 4 MB, und + `MAX_API_BODY_BYTES` wirkte nur nach unten. Jetzt wählt `LimitBodies` die + Grenze einmal außen je Route. + ### 3.3 Dubletten über Ähnlichkeit erkennen Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen @@ -256,7 +290,7 @@ Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen **Vorbild:** Engrams `mem_suggest_topic_key`. -### 3.5 Aktualität von Wissen +### 3.5 Aktualität von Wissen — kleiner Schritt ✅ umgesetzt Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit @@ -276,6 +310,22 @@ schnell abläuft — ein Zugangsverfahren altert schneller als eine Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein Bestätigen, das die Frist zurücksetzt. +**✅ Kleiner Schritt umgesetzt 2026-09-25:** + +- **Regel:** `Entry.Stale` in `internal/store/models.go`. Ein Eintrag, der seit + über einem Jahr (`StaleAfter`) nicht geändert wurde, gilt als veraltet. Das + gilt auch für geprüfte Einträge: Das Häkchen sagt nicht, *wann* geprüft + wurde. Ein Jahr ist bewusst eher lang. Ein Vermerk an jedem zweiten Eintrag + liest bald niemand mehr. +- **MCP:** `recall`, `list` und `related` tragen `"stale": true`, `get` + zusätzlich einen `hint` mit Handlungsanweisung. `recall` nennt im Hinweis, + wie viele Treffer alt sind. Alte Treffer werden weder ausgeblendet noch + nach hinten sortiert, denn ein alter Eintrag kann der einzige zum Thema + sein. Die `instructions` erklären das Feld. +- **Oberfläche:** Auf der Eintragsseite steht die Marke *über ein Jahr alt*. +- **Offen:** der größere Schritt (Frist je Art, „fällige Einträge“, + Bestätigen). + **Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung, mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich ausgebauter als das, was wir bräuchten — aber die Richtung stimmt. @@ -299,7 +349,7 @@ Die Alternative "Markdown-Export in einem Git-Repository" bleibt weiterhin möglich, ersetzt das hier aber nicht: Der Export läuft nur, wenn `EXPORT_DIR` gesetzt ist, während die Historie in der Datenbank immer mitläuft. -### 3.7 Verwandte Einträge +### 3.7 Verwandte Einträge — ✅ umgesetzt Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten @@ -307,6 +357,16 @@ ein zweiter Weg zum Kontext — neben der Suche. **Aufwand:** gering, die Daten liegen vor. +**✅ Umgesetzt 2026-09-25:** MCP-Werkzeug `related(slug, distance?, limit?)`. +Liefert Nachbarn in beide Richtungen (wer verweist auf mich, worauf verweise +ich), standardmäßig bis zur zweiten Reihe, höchstens 50. Jeder Treffer nennt +Abstand, den Eintrag, über den er gefunden wurde (`via`), die Richtung und +`stale`. Verweise ins Leere und Einträge im Papierkorb fallen heraus, auch als +Brücke in die zweite Reihe. Mehr als zwei Schritte gibt es bewusst nicht: In +einem gut verknüpften Projekt hängt in der dritten Reihe fast alles mit allem +zusammen. `get` liefert weiterhin `links_to`/`linked_by` als nackte +Bezeichner. `related` ergänzt Titel, Datum und die zweite Reihe. + ### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in @@ -908,6 +968,9 @@ sich in der Spalte „Schreibbudget" unter `/admin/projects`. Wenn dort nie etwa knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder das Limit zu eng oder ein Agent tut etwas Unsinniges. +Zusätzlich vorgezogen und erledigt 2026-09-25: **Verwandte Einträge** (3.7), +**Aktualität, kleiner Schritt** (3.5) und **Importer** (3.2). + Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird. **Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken, diff --git a/README.md b/README.md index dce1411..99b7202 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,13 @@ Kommandozeilenfassung liegt dieselbe Datei unter `~/.cline/mcp.json`. "url": "https://munin.intern/mcp", "headers": { "Authorization": "Bearer wk_…" }, "disabled": false, - "autoApprove": ["recall", "get", "list"] + "autoApprove": ["recall", "get", "list", "related"] } } } ``` -`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei +`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier lesenden gehören dort hinein — sonst muss jede Suche einzeln bestätigt werden, und ein Agent, der für jeden Blick ins Gedächtnis nachfragen muss, benutzt es nicht. `remember` und `forget` gehören ausdrücklich **nicht** hinein: Sie verändern @@ -208,6 +208,7 @@ Wie man Agenten so anspricht, dass sie den Speicher auch nutzen: | `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. | | `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. | | `list` | read | Überblick: zuletzt geändert, alle Schlagworte. | +| `related` | read | Über `[[Verweise]]` verknüpfte Einträge, direkt und über einen Zwischeneintrag. | | `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. | | `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). | diff --git a/REPOMAP.md b/REPOMAP.md index 04cd240..e82db5d 100644 --- a/REPOMAP.md +++ b/REPOMAP.md @@ -105,6 +105,10 @@ munin/ │ │ │ TrashRetention (30 Tage) steht hier, damit │ │ │ Oberfläche und Aufräumen dieselbe Zahl nutzen. │ │ │ +│ │ ├── related.go ← RelatedEntries: Nachbarn im Verweisgraphen, in +│ │ │ beide Richtungen, bis zu zwei Schritte weit +│ │ │ (Breitensuche, eine Abfrage je Richtung und Ebene). +│ │ │ │ │ ├── links.go ← Erkennt [[bezeichner]] im Inhalt und ersetzt sie │ │ │ beim Rendern. Das ersetzt die frühere │ │ │ Ordnerhierarchie als Struktur zwischen Einträgen. @@ -222,6 +226,19 @@ munin/ │ │ Dateien gelöschter Einträge werden entfernt, │ │ fremde Dateien im Zielordner bleiben unberührt. │ │ +│ ├── importer/ ── Gegenstück zum Export ────────────────────────── +│ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit +│ │ │ Grenzen für Anzahl und ausgepackte Größe. +│ │ │ Übergeht _index.md, versteckte Ordner, __MACOSX. +│ │ ├── parse.go ← Kleiner Frontmatter-Leser (kein YAML-Paket): +│ │ │ Munin-Export und fremde Köpfe (Hugo, Obsidian). +│ │ │ Ohne Kopf: Bezeichner aus dem Dateinamen, Titel +│ │ │ aus der ersten Überschrift. +│ │ └── importer.go ← Importiere: Vorschau oder Schreiben, bei +│ │ belegtem Bezeichner überspringen oder +│ │ überschreiben (alter Stand in den Verlauf). +│ │ Erkennt Unverändertes und den Papierkorb. +│ │ │ ├── search/ ── Volltextsuche ────────────────────────────────── │ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen │ │ │ dreifach, Schlagworte zweifach. Enthält die @@ -273,6 +290,9 @@ munin/ │ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage), │ │ │ für jede Rolle. Die Admin-Projektliste nutzt │ │ │ dieselben Zahlen (projectListItems). +│ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import), +│ │ │ nur mit Rolle admin. Läuft im Server, damit der +│ │ │ Suchindex im selben Zug nachgezogen wird. │ │ ├── handlers_papierkorb.go │ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen, │ │ │ endgültig löschen, Filter nach löschendem Zugang. @@ -302,9 +322,11 @@ munin/ │ │ Qualität der Antworten, weil er bei jedem Client │ │ im Kontext steht. │ │ -│ └── tools.go ← Die fünf Werkzeuge: recall, get, list, remember, -│ forget. Jedes ist eine Closure über genau ein -│ Projekt und kann strukturell nicht an ein anderes. +│ └── tools.go ← Die sechs Werkzeuge: recall, get, list, related, +│ remember, forget. Alte Einträge tragen in den +│ Antworten "stale": true. Jedes ist eine Closure +│ über genau ein Projekt und kann strukturell +│ nicht an ein anderes. │ ├── templates/ ← HTML der Weboberfläche. partials.html enthält die │ gemeinsamen Blöcke "kopf" und "fuss", die jede @@ -361,7 +383,10 @@ werden. | `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe | | `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen | | `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen | +| `internal/store/related_test.go` | Verwandte Einträge über zwei Ebenen, Papierkorb als Sackgasse, Grenze für veraltete Einträge, übernommene Zeitstempel beim Anlegen | +| `internal/importer/importer_test.go` | **Rundlauf Export → ZIP → Import ohne Verlust**, Vorschau, Überspringen/Überschreiben, Papierkorb, doppelte Bezeichner, fremde Köpfe, Grenzen des Archivs | | `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe | +| `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route | | `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse | | `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** | | `internal/hilfe/hilfe_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML | diff --git a/internal/hilfe/seiten/mcp-einrichten.md b/internal/hilfe/seiten/mcp-einrichten.md index e804782..a700b6e 100644 --- a/internal/hilfe/seiten/mcp-einrichten.md +++ b/internal/hilfe/seiten/mcp-einrichten.md @@ -72,13 +72,13 @@ In Cline: *MCP Servers* → *Configure* → *Configure MCP Servers*. "url": "{{SERVER}}/mcp", "headers": { "Authorization": "Bearer wk_DEIN_KEY" }, "disabled": false, - "autoApprove": ["recall", "get", "list"] + "autoApprove": ["recall", "get", "list", "related"] } } } ``` -`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die drei +`autoApprove` nennt die Werkzeuge, die ohne Rückfrage laufen dürfen. Die vier lesenden gehören hinein, sonst muss jede Suche einzeln bestätigt werden. `remember` und `forget` gehören **nicht** hinein, denn sie verändern das gemeinsame Wissen. diff --git a/internal/hilfe/seiten/mit-agenten-arbeiten.md b/internal/hilfe/seiten/mit-agenten-arbeiten.md index 59d89b8..0e326fb 100644 --- a/internal/hilfe/seiten/mit-agenten-arbeiten.md +++ b/internal/hilfe/seiten/mit-agenten-arbeiten.md @@ -16,11 +16,14 @@ deiner Anwendung übernehmen: ## Munin Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall, -get, list, remember, forget). Er ist das gemeinsame Gedächtnis des Teams. +get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams. LESEN — vor jeder Antwort zu projektspezifischen Fragen: - Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten. - Passenden Treffer mit `get` vollständig laden. +- Für mehr Kontext mit `related` die verknüpften Einträge ansehen. +- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen + und in der Antwort erwähnen, dass der Stand alt ist. - Erst wenn nichts Passendes da ist, aus eigenem Wissen antworten — und das kenntlich machen. - Nie raten, wenn es um Prozesse, Zugänge oder Entscheidungen dieses Teams geht. @@ -51,6 +54,7 @@ WIE festgehalten wird: | `recall` | read | Suchen. Der wichtigste Einstieg, auch vor dem Schreiben. | | `get` | read | Einen Eintrag vollständig laden, mit Verweisen und Fassungsnummer. | | `list` | read | Überblick: zuletzt geänderte Einträge und alle Schlagworte. | +| `related` | read | Verknüpfte Einträge finden, auch solche, die die Suche nicht trifft. | | `remember` | write | Einen Eintrag anlegen oder aktualisieren. | | `forget` | write | Einen Eintrag in den Papierkorb legen. | diff --git a/internal/hilfe/seiten/plattform-admin.md b/internal/hilfe/seiten/plattform-admin.md index 99bd947..54a984b 100644 --- a/internal/hilfe/seiten/plattform-admin.md +++ b/internal/hilfe/seiten/plattform-admin.md @@ -53,3 +53,8 @@ täglich alle Einträge aller Workspaces als Markdown-Dateien dorthin, als zusätzliche, lesbare Sicherung, etwa für ein Git-Repository. Unter *Projekte* exportiert der Knopf **Jetzt exportieren** sofort den gerade gewählten Workspace, zum Beispiel vor einer größeren Änderung. + +Zurück kommt ein Export über den [Import](/hilfe/weboberflaeche#import) im +jeweiligen Projekt: den Projektordner aus dem Export als ZIP-Archiv packen und +hochladen. So lässt sich auch ein einzelnes Projekt wiederherstellen, ohne +die ganze Workspace-Datei zurückzuspielen. diff --git a/internal/hilfe/seiten/weboberflaeche.md b/internal/hilfe/seiten/weboberflaeche.md index b7f37ca..be3517f 100644 --- a/internal/hilfe/seiten/weboberflaeche.md +++ b/internal/hilfe/seiten/weboberflaeche.md @@ -149,6 +149,34 @@ Unter der Liste lassen sich für alle ausgewählten Einträge Schlagworte hinzufügen oder entfernen, mehrere durch Komma getrennt. Praktisch, um ein Schlagwort umzubenennen: das neue hinzufügen, das alte entfernen. +## Import + +Mit der Rolle `admin` gibt es in der Seitenleiste den Punkt *Import*. Dort +lässt sich ein ZIP-Archiv mit Markdown-Dateien hochladen. Jede Datei wird ein +Eintrag. Das passt für drei Fälle: + +- **Wiederherstellen** aus dem Markdown-Export: den Ordner eines Projekts + packen und hochladen. Schlagworte, Autor, Herkunft, Prüfvermerk und + Änderungsdatum bleiben erhalten. +- **Umzug** eines Projekts auf einen anderen Server oder in einen anderen + Workspace, auf demselben Weg. +- **Vorhandene Dokumentation** übernehmen. Der Bezeichner kommt aus dem + Dateinamen, der Titel aus der ersten Überschrift `# …`. Unterordner sind + erlaubt, im Projekt stehen die Einträge danach aber nebeneinander. + +Gibt es einen Bezeichner schon, wird die Datei entweder **übersprungen** +(Standard) oder sie **überschreibt** den Eintrag. Beim Überschreiben wandert +der bisherige Stand in den Verlauf, verloren geht also nichts. Dateien, die +nichts ändern würden, werden in beiden Fällen nicht geschrieben. Derselbe +Import lässt sich deshalb gefahrlos wiederholen. + +**Nur Vorschau** ist beim ersten Hochladen angehakt. Die Tabelle zeigt dann +für jede Datei, was passieren würde, ohne etwas zu schreiben. Passt alles, +dasselbe Archiv ohne den Haken noch einmal hochladen. + +Nicht übernommen wird der Verlauf, denn der Export enthält nur den aktuellen +Stand. Ein importierter Eintrag beginnt deshalb wieder bei Fassung 1. + ## Protokoll Unter *Protokoll* in der Seitenleiste steht, wer im Projekt wann etwas diff --git a/internal/importer/archiv.go b/internal/importer/archiv.go new file mode 100644 index 0000000..ce394cd --- /dev/null +++ b/internal/importer/archiv.go @@ -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 +} diff --git a/internal/importer/importer.go b/internal/importer/importer.go new file mode 100644 index 0000000..c9b9bdd --- /dev/null +++ b/internal/importer/importer.go @@ -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)) +} diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go new file mode 100644 index 0000000..e76d1f5 --- /dev/null +++ b/internal/importer/importer_test.go @@ -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") + } +} diff --git a/internal/importer/parse.go b/internal/importer/parse.go new file mode 100644 index 0000000..732af5e --- /dev/null +++ b/internal/importer/parse.go @@ -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 +} diff --git a/internal/mcpserver/mcpserver_test.go b/internal/mcpserver/mcpserver_test.go index 473f55a..0c19d77 100644 --- a/internal/mcpserver/mcpserver_test.go +++ b/internal/mcpserver/mcpserver_test.go @@ -143,7 +143,7 @@ func TestToolsAndInstructions(t *testing.T) { vorhanden[tool.Name] = true } - for _, name := range []string{"recall", "get", "list", "remember", "forget"} { + for _, name := range []string{"recall", "get", "list", "remember", "forget", "related"} { if !vorhanden[name] { t.Errorf("Werkzeug %q fehlt", name) } @@ -685,3 +685,85 @@ func TestRememberRevivesFromTrash(t *testing.T) { t.Error("das Protokoll vermerkt das Zurückholen nicht") } } + +// ── Verwandte Einträge und Alter (AUSBAUSTUFEN.md 3.7, 3.5) ────────────────── + +func TestRelatedFindsNeighbours(t *testing.T) { + h, ws, projekt := aufbau(t) + + ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "release", "title": "Release-Prozess", "content": "Vorher [[checkliste]] durchgehen.", + }) + ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "checkliste", "title": "Checkliste", "content": "Siehe auch [[rollback]].", + }) + ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "rollback", "title": "Rollback", "content": "Zurück auf die letzte Fassung.", + }) + + // Auch ein Lesezugang darf das Umfeld abfragen. + ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release"}) + if ergebnis.IsError { + t.Fatalf("Fehler: %s", text(ergebnis)) + } + antwort := text(ergebnis) + for _, erwartet := range []string{`"checkliste"`, `"rollback"`, `"distance":2`, `"via":"checkliste"`} { + if !strings.Contains(antwort, erwartet) { + t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort) + } + } + + nurDirekt := text(ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "release", "distance": 1})) + if strings.Contains(nurDirekt, "rollback") { + t.Errorf("distance 1 lieferte die zweite Reihe: %s", nurDirekt) + } +} + +func TestRelatedUnknownSlugExplains(t *testing.T) { + h, ws, projekt := aufbau(t) + + ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "related", map[string]any{"slug": "gibtesnicht"}) + if !ergebnis.IsError || !strings.Contains(text(ergebnis), "recall") { + t.Errorf("unbekannter Bezeichner nicht hilfreich gemeldet: %s", text(ergebnis)) + } +} + +// TestStaleEntriesAreMarked prüft, dass alte Einträge in recall, get und list +// als "stale" erscheinen und frische nicht. +func TestStaleEntriesAreMarked(t *testing.T) { + h, ws, projekt := aufbau(t) + ctx := context.Background() + + vorZweiJahren := time.Now().AddDate(-2, 0, 0) + if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{ + ProjectID: projekt.ID, Slug: "altes-deployment", Title: "Deployment alt", + Content: "Deployment per FTP", Source: store.SourceHuman, UpdatedAt: vorZweiJahren, + }); err != nil { + t.Fatal(err) + } + ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "neues-deployment", "title": "Deployment neu", "content": "Deployment per Pipeline", + }) + + recall := text(ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "Deployment"})) + if strings.Count(recall, `"stale":true`) != 1 { + t.Errorf("recall: genau ein Treffer sollte stale sein: %s", recall) + } + if !strings.Contains(recall, "seit über einem Jahr") { + t.Errorf("recall: Hinweis auf veraltete Treffer fehlt: %s", recall) + } + + alt := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "altes-deployment"})) + if !strings.Contains(alt, `"stale":true`) || !strings.Contains(alt, "prüfen") { + t.Errorf("get: alter Eintrag nicht markiert: %s", alt) + } + neu := text(ruf(t, h, ws, projekt, auth.RoleRead, "get", map[string]any{"slug": "neues-deployment"})) + if strings.Contains(neu, "stale") { + t.Errorf("get: frischer Eintrag als stale markiert: %s", neu) + } + + liste := text(ruf(t, h, ws, projekt, auth.RoleRead, "list", nil)) + if strings.Count(liste, `"stale":true`) != 1 { + t.Errorf("list: genau ein Eintrag sollte stale sein: %s", liste) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 3969047..b8caeb5 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -61,6 +61,14 @@ Zum Bezeichner (slug): Verweise: Im Inhalt verweist [[bezeichner]] auf einen anderen Eintrag. Verwandtes so zu verknüpfen ist ausdrücklich erwünscht — es hilft beim späteren Wiederfinden. + "related" liefert zu einem Eintrag die verknüpften Einträge, auch solche, die + "recall" nicht findet, weil kein Suchwort passt. + +Alter von Wissen: + Einträge mit "stale": true sind seit über einem Jahr unverändert. Sie können + noch stimmen, sollten aber nicht ungeprüft als aktueller Stand weitergegeben + werden. Wer weiß, dass sich etwas geändert hat, aktualisiert den Eintrag mit + "remember". Gleichzeitiges Schreiben: "get" liefert eine Feldnummer "version". Wird sie bei "remember" mitgegeben, @@ -157,6 +165,7 @@ type recallMatch struct { Source string `json:"source" jsonschema:"human oder agent"` Curated bool `json:"curated" jsonschema:"true, wenn ein Mensch den Eintrag geprüft hat"` Updated string `json:"updated_at" jsonschema:"Zeitpunkt der letzten Änderung"` + Stale bool `json:"stale,omitempty" jsonschema:"true, wenn der Eintrag seit über einem Jahr unverändert ist — vor Verwendung prüfen"` } type recallOutput struct { @@ -181,6 +190,8 @@ type getOutput struct { Updated string `json:"updated_at"` LinksTo []string `json:"links_to" jsonschema:"Bezeichner, auf die dieser Eintrag verweist"` LinkedBy []string `json:"linked_by" jsonschema:"Bezeichner, die auf diesen Eintrag verweisen"` + Stale bool `json:"stale,omitempty" jsonschema:"true, wenn der Eintrag seit über einem Jahr unverändert ist"` + Hint string `json:"hint,omitempty" jsonschema:"Hinweis zum Umgang mit dem Eintrag"` } type rememberInput struct { @@ -219,6 +230,7 @@ type listItem struct { Source string `json:"source"` Curated bool `json:"curated"` Updated string `json:"updated_at"` + Stale bool `json:"stale,omitempty"` } type listOutput struct { @@ -226,3 +238,25 @@ type listOutput struct { Total int `json:"total" jsonschema:"Gesamtzahl der Einträge im Projekt"` Tags []string `json:"tags" jsonschema:"Alle im Projekt vergebenen Schlagworte"` } + +type relatedInput struct { + Slug string `json:"slug" jsonschema:"Bezeichner des Eintrags, dessen Umfeld gesucht wird"` + Distance int `json:"distance,omitempty" jsonschema:"Optional: 1 = nur direkt verknüpfte Einträge, 2 = auch deren Nachbarn (Standard 2)"` + Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl (Standard 20, Maximum 50)"` +} + +type relatedItem struct { + Slug string `json:"slug"` + Title string `json:"title"` + Distance int `json:"distance" jsonschema:"1 = direkt verknüpft, 2 = über einen Zwischeneintrag"` + Via string `json:"via" jsonschema:"Eintrag, über den dieser gefunden wurde"` + Relation string `json:"relation" jsonschema:"links_to: via verweist hierhin; linked_by: dieser Eintrag verweist auf via"` + Updated string `json:"updated_at"` + Stale bool `json:"stale,omitempty"` +} + +type relatedOutput struct { + Results []relatedItem `json:"results" jsonschema:"Verwandte Einträge, nächste zuerst"` + Total int `json:"total"` + Hint string `json:"hint,omitempty"` +} diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go index db6e6e3..b872d7c 100644 --- a/internal/mcpserver/tools.go +++ b/internal/mcpserver/tools.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "strings" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -40,8 +41,22 @@ const ( recallMaxLimit = 50 listDefaultLimit = 50 listMaxLimit = 200 + + relatedDefaultLimit = 20 + relatedMaxLimit = 50 + relatedDefaultDistance = 2 + + // relatedMaxDistance ist bewusst klein. Schon in der dritten Reihe hängt + // in einem gut verknüpften Projekt fast alles mit allem zusammen, und die + // Antwort sagt nichts mehr über das Thema aus. + relatedMaxDistance = 2 ) +// staleHint steht bei einem veralteten Eintrag in der Antwort von "get". Er +// sagt dem Modell, was es tun soll, nicht nur, was der Fall ist. +const staleHint = "Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt, " + + "und bei der Antwort erwähnen, dass der Stand alt ist. Ist bekannt, was sich geändert hat: mit \"remember\" aktualisieren." + // buildServer erstellt den MCP-Server eines Projekts. // // Alle Werkzeuge sind Closures über dieses eine Projekt — sie können gar nicht @@ -57,6 +72,7 @@ func (h *Handler) buildServer(project store.Project) *mcp.Server { h.addList(server, project) h.addRemember(server, project) h.addForget(server, project) + h.addRelated(server, project) return server } @@ -157,6 +173,9 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { treffer := idx.Search(in.Query, limit*3) gesucht := store.NormalizeTags(in.Tags) + jetzt := time.Now() + veraltet := 0 + ergebnisse := make([]recallMatch, 0, limit) for _, t := range treffer { if len(ergebnisse) >= limit { @@ -175,6 +194,11 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { continue } + alt := e.Stale(jetzt) + if alt { + veraltet++ + } + ergebnisse = append(ergebnisse, recallMatch{ Slug: e.Slug, Title: e.Title, @@ -183,6 +207,7 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { Source: e.Source, Curated: e.Curated, Updated: e.UpdatedAt.Format("2006-01-02 15:04"), + Stale: alt, }) } @@ -192,6 +217,14 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) { "oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen." } else { out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen." + // Veraltete Treffer bleiben in der Liste und rutschen auch + // nicht nach hinten: Ein alter Eintrag kann der einzige zum + // Thema sein. Der Hinweis sorgt dafür, dass das Modell das + // Feld "stale" nicht übersieht. + if veraltet > 0 { + out.Hint += fmt.Sprintf(" %d der Treffer sind seit über einem Jahr unverändert (\"stale\": true) "+ + "— vor Verwendung prüfen, ob sie noch stimmen.", veraltet) + } } return nil, out, nil }, @@ -249,7 +282,7 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) { linkedBy = append(linkedBy, b.Slug) } - return nil, getOutput{ + out := getOutput{ Slug: e.Slug, Title: e.Title, Content: e.Content, @@ -261,7 +294,12 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) { Updated: e.UpdatedAt.Format("2006-01-02 15:04"), LinksTo: store.ExtractLinks(e.Content), LinkedBy: linkedBy, - }, nil + } + if e.Stale(time.Now()) { + out.Stale = true + out.Hint = staleHint + } + return nil, out, nil }, ) } @@ -308,6 +346,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) { return nil, listOutput{}, err } + jetzt := time.Now() items := make([]listItem, 0, len(eintraege)) for _, e := range eintraege { items = append(items, listItem{ @@ -317,6 +356,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) { Source: e.Source, Curated: e.Curated, Updated: e.UpdatedAt.Format("2006-01-02 15:04"), + Stale: e.Stale(jetzt), }) } @@ -480,3 +520,70 @@ func (h *Handler) addForget(server *mcp.Server, project store.Project) { }, ) } + +// ── related ────────────────────────────────────────────────────────────────── + +func (h *Handler) addRelated(server *mcp.Server, project store.Project) { + mcp.AddTool(server, + &mcp.Tool{ + Name: "related", + Description: "Liefert die Einträge, die über [[Verweise]] mit einem Eintrag verknüpft sind — " + + "direkt und (Standard) auch über einen Zwischeneintrag. Findet Zusammenhänge, " + + "die \"recall\" nicht findet, weil kein Suchwort passt. Nützlich, nachdem \"get\" " + + "einen passenden Eintrag geliefert hat und mehr Kontext gebraucht wird.", + }, + func(ctx context.Context, _ *mcp.CallToolRequest, in relatedInput) (*mcp.CallToolResult, relatedOutput, error) { + ws, err := workspaceFrom(ctx) + if err != nil { + return nil, relatedOutput{}, err + } + + abstand := in.Distance + if abstand <= 0 { + abstand = relatedDefaultDistance + } + if abstand > relatedMaxDistance { + abstand = relatedMaxDistance + } + + limit := in.Limit + if limit <= 0 { + limit = relatedDefaultLimit + } + if limit > relatedMaxLimit { + limit = relatedMaxLimit + } + + verwandte, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, abstand, limit) + if errors.Is(err, store.ErrNotFound) { + return nil, relatedOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug) + } + if err != nil { + return nil, relatedOutput{}, err + } + + jetzt := time.Now() + items := make([]relatedItem, 0, len(verwandte)) + for _, v := range verwandte { + items = append(items, relatedItem{ + Slug: v.Slug, + Title: v.Title, + Distance: v.Distance, + Via: v.Via, + Relation: v.Relation, + Updated: v.UpdatedAt.Format("2006-01-02 15:04"), + Stale: store.StaleSince(v.UpdatedAt, jetzt), + }) + } + + out := relatedOutput{Results: items, Total: len(items)} + if len(items) == 0 { + out.Hint = "Dieser Eintrag ist mit keinem anderen verknüpft. Mit \"recall\" nach verwandten " + + "Themen suchen. Findet sich etwas, lohnt es sich, die Einträge mit [[bezeichner]] zu verknüpfen." + } else { + out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen." + } + return nil, out, nil + }, + ) +} diff --git a/internal/store/audit.go b/internal/store/audit.go index 3a86a28..ebdc0c5 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -20,6 +20,12 @@ const ( ActionEntryRestore = "entry_restore" // aus dem Papierkorb zurückgeholt ActionEntryPurge = "entry_purge" // endgültig gelöscht, nicht rückgängig zu machen + // ActionEntryImport steht einmal je Import über die Weboberfläche, mit der + // Zusammenfassung als Detail (siehe internal/importer). Die einzelnen + // Einträge tauchen bewusst nicht als entry_write auf, damit ein + // einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt. + ActionEntryImport = "entry_import" + ActionLoginSuccess = "login_success" ActionAuthFailure = "auth_failure" ActionProjectCreated = "project_created" diff --git a/internal/store/entries.go b/internal/store/entries.go index 6be701c..1be0903 100644 --- a/internal/store/entries.go +++ b/internal/store/entries.go @@ -7,6 +7,7 @@ import ( "fmt" "sort" "strings" + "time" ) // EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag. @@ -32,6 +33,19 @@ type EntryWrite struct { // mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass // sich etwas geändert hat, und kann neu lesen. ExpectedVersion int + + // CreatedAt und UpdatedAt geben beim ANLEGEN eines Eintrags die + // Zeitstempel vor. Leer bedeutet „jetzt“, und das ist der Normalfall. + // + // Gebraucht wird das nur beim Import (internal/importer): Wer eine + // Sicherung einspielt, will das ursprüngliche Änderungsdatum behalten. + // Sonst sähe jeder wiederhergestellte Eintrag brandneu aus, und der + // Vermerk für veraltetes Wissen (Entry.Stale) liefe ins Leere. + // + // Beim Ändern eines vorhandenen Eintrags werden beide Felder ignoriert. + // Eine Änderung passiert jetzt, und der Verlauf soll das ehrlich zeigen. + CreatedAt time.Time + UpdatedAt time.Time } // nichtImPapierkorb gehört in JEDE lesende Abfrage auf entries (siehe @@ -147,6 +161,12 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { id = NewID() version = 1 createdAt = jetzt + if !w.CreatedAt.IsZero() { + createdAt = formatTime(w.CreatedAt) + } + if !w.UpdatedAt.IsZero() { + jetzt = formatTime(w.UpdatedAt) + } if _, err := tx.ExecContext(ctx, `INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at) diff --git a/internal/store/models.go b/internal/store/models.go index 044835f..8549129 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -115,6 +115,35 @@ type Entry struct { RestoredFromTrash bool } +// StaleAfter ist die Zeitspanne ohne Änderung, nach der ein Eintrag als +// „möglicherweise veraltet“ gilt (siehe AUSBAUSTUFEN.md 3.5). +// +// Ein Jahr ist ein Kompromiss: Ein Zugangsverfahren kann schon nach Wochen +// überholt sein, eine Grundsatzentscheidung nach Jahren noch stimmen. Eine +// Frist je Art des Wissens wäre genauer, ist aber der größere Schritt. Bis +// dahin gilt eine Grenze für alle, und sie ist bewusst eher lang: Ein Vermerk, +// der an jedem zweiten Eintrag steht, liest bald niemand mehr. +const StaleAfter = 365 * 24 * time.Hour + +// Stale sagt, ob ein Eintrag seit mehr als StaleAfter nicht geändert wurde. +// +// Das heißt nicht, dass er falsch ist, sondern nur, dass ihn lange niemand +// angefasst hat. Deshalb wird er auch nicht versteckt oder schlechter +// gewichtet. Wer ihn liest, soll aber wissen, dass er ihn vor dem Verwenden +// prüfen sollte. +// +// Auch geprüfte Einträge (Curated) können veralten. Das Häkchen sagt nur, dass +// ein Mensch den Eintrag irgendwann bestätigt hat, nicht wann. +func (e Entry) Stale(jetzt time.Time) bool { + return StaleSince(e.UpdatedAt, jetzt) +} + +// StaleSince ist dieselbe Regel für Stellen, die nur den Änderungszeitpunkt +// kennen, aber keinen ganzen Entry, etwa verwandte Einträge (RelatedEntry). +func StaleSince(geaendert, jetzt time.Time) bool { + return !geaendert.IsZero() && jetzt.Sub(geaendert) > StaleAfter +} + // TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite // braucht: wer ihn wann gelöscht hat, ohne den ganzen Inhalt. type TrashedEntry struct { diff --git a/internal/store/related.go b/internal/store/related.go new file mode 100644 index 0000000..559ec51 --- /dev/null +++ b/internal/store/related.go @@ -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 +} diff --git a/internal/store/related_test.go b/internal/store/related_test.go new file mode 100644 index 0000000..4d61333 --- /dev/null +++ b/internal/store/related_test.go @@ -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") + } +} diff --git a/internal/web/handlers_entries.go b/internal/web/handlers_entries.go index 44aac2d..03961e7 100644 --- a/internal/web/handlers_entries.go +++ b/internal/web/handlers_entries.go @@ -128,6 +128,11 @@ type entryViewData struct { Entry store.Entry HTML template.HTML Backlinks []store.Backlink + + // Stale: seit über einem Jahr unverändert (siehe store.Entry.Stale). Die + // Seite zeigt dann einen Vermerk, damit Menschen den Eintrag genauso + // vorsichtig lesen wie ein Agent, dem MCP dasselbe sagt. + Stale bool } // handleEntryView zeigt einen einzelnen Eintrag. @@ -163,7 +168,10 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) { Title: e.Title, Bereich: "eintraege", Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung - Data: entryViewData{Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks}, + Data: entryViewData{ + Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks, + Stale: e.Stale(time.Now()), + }, }) } diff --git a/internal/web/handlers_import.go b/internal/web/handlers_import.go new file mode 100644 index 0000000..fc16bec --- /dev/null +++ b/internal/web/handlers_import.go @@ -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, + }) +} diff --git a/internal/web/import_test.go b/internal/web/import_test.go new file mode 100644 index 0000000..e925057 --- /dev/null +++ b/internal/web/import_test.go @@ -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) + } + } +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 1065f82..1a2c804 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -341,6 +341,48 @@ func LimitBody(next http.Handler, maxBytes int64) http.Handler { }) } +// BodyLimits sind die Höchstgrößen eines Request-Bodys je Art von Route. +type BodyLimits struct { + Web int64 // Formulare der Weboberfläche + API int64 // MCP-Aufrufe unter /mcp + Import int64 // Hochladen eines ZIP-Archivs (siehe handlers_import.go) +} + +// LimitBodies begrenzt den Body wie LimitBody, wählt die Grenze aber nach der +// Route. +// +// # Warum eine Stelle statt LimitBody je Route? +// +// Zwei ineinander geschachtelte MaxBytesReader begrenzen immer auf den +// KLEINEREN Wert. Ein großzügiges Limit an einer einzelnen Route unter einem +// strengen Limit für alles wirkt deshalb nie. Genau so war MCP früher +// verdrahtet: MAX_API_BODY_BYTES stand auf 4 MB, das äußere Limit von 1 MB +// schnitt trotzdem ab. Hier wird die Grenze einmal außen gewählt, bevor +// irgendetwas anderes den Body anfasst. +func LimitBodies(next http.Handler, limits BodyLimits) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + grenze := limits.Web + switch { + case r.URL.Path == "/mcp": + grenze = limits.API + case r.Method == http.MethodPost && istImportPfad(r.URL.Path): + grenze = limits.Import + } + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, grenze) + } + next.ServeHTTP(w, r) + }) +} + +// istImportPfad erkennt "/p/{projekt}/import". Der Mux hat zu diesem +// Zeitpunkt noch nicht entschieden, welche Route passt, deshalb wird der Pfad +// hier von Hand zerlegt: genau drei Teile, der erste "p", der letzte "import". +func istImportPfad(pfad string) bool { + teile := strings.Split(strings.Trim(pfad, "/"), "/") + return len(teile) == 3 && teile[0] == "p" && teile[2] == "import" +} + // ── API-Zugang (MCP) ───────────────────────────────────────────────────────── // APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im diff --git a/internal/web/server.go b/internal/web/server.go index 0b6dcec..691f95f 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -169,6 +169,11 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { // Key-Verwaltung. mux.Handle("GET /p/{project}/keys", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyList))))) + + // Import: dieselbe Rolle wie die Key-Verwaltung, siehe handlers_import.go. + // Die größere Body-Grenze für POST setzt LimitBodies (middleware.go). + mux.Handle("GET /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImportForm))))) + mux.Handle("POST /p/{project}/import", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleImport))))) mux.Handle("POST /p/{project}/keys/create", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyCreate))))) mux.Handle("POST /p/{project}/keys/revoke", s.requireSession(s.requireProject(s.requireKeyManagement(http.HandlerFunc(s.handleKeyRevoke))))) } diff --git a/main.go b/main.go index 13e7fd9..f4a9fb3 100644 --- a/main.go +++ b/main.go @@ -58,6 +58,12 @@ const ( // maxAPIBodyBytes gilt für MCP-Aufrufe. defaultMaxAPIBodyBytes = 4 << 20 // 4 MB + + // maxImportBodyBytes gilt für das Hochladen eines ZIP-Archivs zum Import + // (AUSBAUSTUFEN.md 3.2). Ein exportiertes Projekt mit einigen tausend + // Einträgen passt gepackt bequem hinein. Was ausgepackt daraus werden darf, + // begrenzt der Importer selbst (siehe internal/importer). + maxImportBodyBytes = 20 << 20 // 20 MB ) // ── Server-Timeouts ────────────────────────────────────────────────────────── @@ -207,15 +213,21 @@ func main() { // Der MCP-Endpunkt hängt an der Key-Prüfung statt an der Sitzung — hier // kommen keine Browser an, sondern KI-Werkzeuge mit einem Bearer-Key. - mux.Handle("/mcp", web.LimitBody(webServer.APIAuth(mcpHandler), cfg.maxAPIBody)) + mux.Handle("/mcp", webServer.APIAuth(mcpHandler)) // Reihenfolge von außen nach innen: // LogRequests erfasst jede Antwort, auch abgewiesene // SecurityHeaders gilt für jede Antwort, auch für Fehlerseiten - // LimitBody deckelt den Body, bevor ein Handler ihn liest + // LimitBodies deckelt den Body, bevor ein Handler ihn liest — mit + // einer eigenen Grenze für /mcp und den Import, siehe + // dort, warum das nicht je Route geschehen kann handler := web.LogRequests( web.SecurityHeaders( - web.LimitBody(mux, maxWebBodyBytes), + web.LimitBodies(mux, web.BodyLimits{ + Web: maxWebBodyBytes, + API: cfg.maxAPIBody, + Import: maxImportBodyBytes, + }), cfg.enableHSTS, ), ) diff --git a/templates/entry.html b/templates/entry.html index 6b335ac..ecb594e 100644 --- a/templates/entry.html +++ b/templates/entry.html @@ -19,6 +19,7 @@
{{.Data.Entry.Slug}} {{if .Data.Entry.Curated}}kuratiert{{end}} + {{if .Data.Stale}}über ein Jahr alt{{end}} {{if eq .Data.Entry.Source "agent"}}Agent{{else}}Mensch{{end}} {{$slug := .Project.Slug}} {{range .Data.Entry.Tags}}{{.}}{{end}} diff --git a/templates/import.html b/templates/import.html new file mode 100644 index 0000000..d249668 --- /dev/null +++ b/templates/import.html @@ -0,0 +1,82 @@ +{{template "kopf" .}} + +
+
+

Import {{template "hilfelink" "weboberflaeche#import"}}

+ Markdown-Dateien als ZIP-Archiv hochladen: einen Export aus Munin + oder vorhandene Dokumentation. Jede Datei wird ein Eintrag. +
+
+ +
+
+ {{/* enctype="multipart/form-data" ist nötig, damit der Browser die Datei + selbst mitschickt und nicht nur ihren Namen. */}} +
+ + +
+ + Dateien auf .md, auch in Unterordnern, höchstens + {{.Data.MaxDateien}} Stück. Alles andere wird übergangen. + +
+ +
+ + +
+ +
+ {{/* 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. */}} + + +
+ +
+ +
+
+
+
+ +{{with .Data.Bericht}} +
+

{{if $.Data.Vorschau}}Vorschau{{else}}Ergebnis{{end}}

+
+ + + + + + + + + + + {{range .Zeilen}} + + + + + + + {{end}} + +
DateiBezeichnerTitelErgebnis
{{.Pfad}}{{if .Slug}}{{.Slug}}{{end}}{{.Title}} + {{.Aktion}} + {{if .Grund}}{{.Grund}}{{end}} +
+
+
+{{end}} + +{{template "fuss" .}} diff --git a/templates/keys.html b/templates/keys.html index d7f2f8d..bb22389 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -34,7 +34,7 @@ "type": "streamableHttp", "url": "{{.Data.ServerURL}}/mcp", "headers": { "Authorization": "Bearer {{.Data.NewKey}}" }, - "autoApprove": ["recall", "get", "list"] + "autoApprove": ["recall", "get", "list", "related"] } } } diff --git a/templates/partials.html b/templates/partials.html index ad95e18..9ef65d9 100644 --- a/templates/partials.html +++ b/templates/partials.html @@ -92,6 +92,7 @@
  • Graph
  • {{if .Session.CanManageKeys}}
  • Zugänge
  • +
  • Import
  • {{end}}
  • Nutzung
  • Protokoll