go-wiki/handler/mcp.go
2026-07-14 20:46:19 +02:00

681 lines
25 KiB
Go

// Dieses File implementiert einen MCP-Server (Model Context Protocol) für das Wiki.
//
// # Was ist MCP?
//
// MCP (Model Context Protocol) ist ein offener Standard der festlegt wie KI-Assistenten
// (Claude, Cline, Opencode, ...) mit externen Systemen kommunizieren. Statt dass der
// Nutzer copy-paste zwischen Wiki und Chat-Fenster macht, kann das KI-Tool direkt
// Seiten lesen, schreiben und löschen.
//
// # Wie funktioniert Streamable HTTP als Transport?
//
// MCP kann über verschiedene Transportwege laufen. Wir nutzen Streamable HTTP
// (MCP-Spezifikation 2025-11-25) — den modernen Standard.
//
// Der Ablauf ist einfach: jede MCP-Anfrage ist ein normaler HTTP POST-Request.
//
// 1. Das KI-Tool schickt POST /mcp mit einem JSON-RPC-Body:
// { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "search_pages", ... } }
//
// 2. Der Server antwortet direkt auf diesen POST — entweder als normales JSON
// oder als kurzer SSE-Stream wenn die Antwort gestreamt werden soll.
//
// Warum Streamable HTTP statt dem älteren SSE-Transport?
//
// Der ältere SSE-Transport hielt eine dauerhafte GET-Verbindung offen.
// Proxies und HTTP-Clients trennen solche Verbindungen nach einiger Zeit Inaktivität
// (Timeout) — die MCP-Verbindung bricht dann ab bis man das Tool neu startet.
// Streamable HTTP hat dieses Problem nicht: keine persistente Verbindung,
// kein Timeout, funktioniert zuverlässig auch nach längerer Pause.
//
// # Resources vs. Tools — der wichtige Unterschied
//
// MCP unterscheidet zwei Konzepte:
//
// - Resources: Dokumente und Daten die das KI-Tool lesen kann.
// Sie haben eine URI (wie eine Web-Adresse), einen MIME-Type und einen Inhalt.
// Das KI-Tool kann Resources direkt in seinen Kontext laden.
// Beispiel: wiki://pages (Seitenliste), wiki://page/projekte/notizen (eine Seite)
//
// - Tools: Aktionen die das KI-Tool ausführen kann — wie Funktionsaufrufe.
// Sie haben Parameter und geben ein Ergebnis zurück.
// Beispiel: upload_page, delete_page, search_pages
//
// Wiki-Seiten sind semantisch Resources (Daten zum Lesen), keine Tools (Aktionen).
// Deshalb: lesen/auflisten → Resources, schreiben/löschen/suchen → Tools.
//
// # Cache
//
// Alle lesenden Operationen nutzen den PageCache aus cache.go statt direkt
// vom Dateisystem zu lesen. So sind list und read schnell auch bei vielen Seiten.
// Schreibende Operationen (upload, delete) aktualisieren den Cache sofort.
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"sync"
"github.com/modelcontextprotocol/go-sdk/mcp"
"wiki/render"
)
// mcpActorLabel gibt "admin" oder "team" zurück, je nachdem womit der
// MCP-Request authentifiziert wurde — fürs Audit-Log.
func mcpActorLabel(ctx context.Context) string {
if IsAdminFromContext(ctx) {
return "admin"
}
return "team"
}
// ── Tool-Typen ────────────────────────────────────────────────────────────────
// Nur noch für Tools (Aktionen) — Resources brauchen keine eigenen Input/Output-Structs.
// uploadPageInput sind die Parameter für das upload_page-Tool.
type uploadPageInput struct {
Path string `json:"path" jsonschema:"Zielpfad inkl. .md, z.B. /projekte/notizen.md"`
Content string `json:"content" jsonschema:"Vollständiger Markdown-Inhalt inkl. Frontmatter"`
}
// uploadPageOutput bestätigt den erfolgreichen Upload.
type uploadPageOutput struct {
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
}
// deletePageInput ist der Parameter für das delete_page-Tool.
type deletePageInput struct {
Path string `json:"path" jsonschema:"Pfad zur Datei, z.B. /projekte/notizen.md"`
}
// deletePageOutput bestätigt das erfolgreiche Löschen.
type deletePageOutput struct {
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
}
// searchInput ist der Parameter für das search_pages-Tool.
type searchInput struct {
Query string `json:"query" jsonschema:"Suchbegriff — wird mit BM25-Ranking in Titel, Inhalt und Tags aller Seiten gesucht"`
}
// searchMatch ist ein einzelnes Suchergebnis.
type searchMatch struct {
Title string `json:"title" jsonschema:"Titel der Seite"`
URL string `json:"url" jsonschema:"URL der Seite"`
Snippet string `json:"snippet" jsonschema:"Textstelle um den Treffer herum"`
}
// searchOutput enthält alle Suchergebnisse, absteigend nach BM25-Relevanz sortiert.
type searchOutput struct {
Results []searchMatch `json:"results" jsonschema:"Gefundene Seiten, sortiert nach Relevanz (bester Treffer zuerst)"`
Total int `json:"total" jsonschema:"Anzahl Treffer"`
}
// rebuildIndexOutput ist die Bestätigung nach dem Aufbau des Wiki-Index.
type rebuildIndexOutput struct {
Message string `json:"message" jsonschema:"Bestätigungsmeldung"`
PageCount int `json:"page_count" jsonschema:"Anzahl der indizierten Seiten"`
}
// ── pageListEntry wird für die JSON-Ausgabe der wiki://pages Resource verwendet ─
type pageListEntry struct {
Title string `json:"title"`
URL string `json:"url"`
Tags []string `json:"tags"`
Protected bool `json:"protected"` // true wenn Passwortschutz aktiv
}
// ── MCPHandler ────────────────────────────────────────────────────────────────
// MCPHandler hält pro Team einen eigenen MCP-Server (jeweils mit Tool-Closures die
// nur auf den Cache/Docs-Ordner dieses Teams zugreifen) und stellt sie als
// gemeinsamen HTTP-Handler bereit.
//
// Team-Isolation: middleware.RequireTeamOrAdminKey legt das aufgelöste Team (oder
// den Admin-Status) bereits im Request-Context ab, bevor ServeHTTP hier läuft.
// Ein Team-Key kann also strukturell gar nicht an den Server eines anderen Teams
// geraten. Der globale Admin-Key muss das Zielteam explizit über den Header
// "X-Wiki-Team" angeben.
type MCPHandler struct {
registry *TeamRegistry
audit *AuditLogger
mu sync.Mutex
servers map[string]*mcp.Server // key: Team-ID — lazy befüllt, siehe getOrBuildServer
streamableHandler http.Handler
}
// NewMCPHandler erstellt den MCP-Handler. Die Team-Server selbst werden lazy
// gebaut (siehe getOrBuildServer) statt alle beim Start — so bekommen auch
// Teams die erst später per Hot-Reload von teams.yaml dazukommen (siehe
// TeamRegistry.Reload) automatisch einen funktionierenden MCP-Server, ohne
// dass der Prozess neu gestartet werden muss.
func NewMCPHandler(registry *TeamRegistry, audit *AuditLogger) *MCPHandler {
h := &MCPHandler{registry: registry, audit: audit, servers: make(map[string]*mcp.Server)}
// Streamable HTTP Transport statt SSE.
//
// SSE (Server-Sent Events) hält eine dauerhafte HTTP-Verbindung offen —
// Proxies und Clients trennen diese nach einiger Zeit Inaktivität (Timeout).
// Das führt dazu dass MCP-Verbindungen nach längerem Nichtbenutzen abbrechen.
//
// Streamable HTTP löst das: jede MCP-Anfrage ist ein normaler POST-Request,
// keine persistente Verbindung. Kein Timeout-Problem, moderner Standard (Spec 2025-11-25).
// Cline, Opencode und Claude Desktop unterstützen alle Streamable HTTP.
h.streamableHandler = mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
team, _ := TeamFromContext(r.Context())
if team == nil {
return nil
}
return h.getOrBuildServer(team)
}, nil)
return h
}
// getOrBuildServer gibt den MCP-Server eines Teams zurück und baut ihn beim
// ersten Zugriff auf. Bereits existierende Teams behalten ihren einmal gebauten
// Server (der auf team.Cache zeigt, das bei einem Reload für bestehende Teams
// wiederverwendet wird — siehe TeamRegistry.Reload), neue Teams bekommen einen frischen.
func (h *MCPHandler) getOrBuildServer(team *Team) *mcp.Server {
h.mu.Lock()
defer h.mu.Unlock()
if s, ok := h.servers[team.ID]; ok {
return s
}
s := buildTeamServer(team.ID, team.Cache, h.audit)
h.servers[team.ID] = s
return s
}
// ServeHTTP löst zunächst das Ziel-Team auf (aus dem Request-Context, den
// middleware.RequireTeamOrAdminKey vorher befüllt hat) und reicht den Request dann
// an den passenden Team-Server weiter.
func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
team, ok := TeamFromContext(r.Context())
isAdmin := IsAdminFromContext(r.Context())
if !ok {
if !isAdmin {
http.Error(w, "Ungültiger Key", http.StatusUnauthorized)
return
}
// Admin-Key: das Zielteam muss explizit über den Header angegeben werden —
// anders als bei der Web-Ansicht gibt es hier keine Team-Umschaltung.
teamID := r.Header.Get("X-Wiki-Team")
t, found := h.registry.ByID(teamID)
if !found {
http.Error(w, "Header 'X-Wiki-Team' erforderlich (unbekanntes oder fehlendes Team) für Admin-Zugriff", http.StatusBadRequest)
return
}
team = t
}
// isAdmin bewusst durchreichen (nicht hart auf false setzen) — die Tool-Handler
// nutzen es fürs Audit-Log (mcpActorLabel), um "admin" von "team" zu unterscheiden.
h.streamableHandler.ServeHTTP(w, r.WithContext(WithTeam(r.Context(), team, isAdmin)))
}
// buildTeamServer erstellt einen MCP-Server mit allen Resources und Tools, dessen
// Closures ausschließlich auf den übergebenen (team-eigenen) Cache zugreifen.
func buildTeamServer(teamID string, cache *PageCache, audit *AuditLogger) *mcp.Server {
server := mcp.NewServer(&mcp.Implementation{
Name: "wiki",
Version: "1.0.0",
}, nil)
// ── Resource: wiki://pages ────────────────────────────────────────────────
// Listet alle Wiki-Seiten als JSON auf.
// Ein KI-Tool kann diese Resource laden um einen Überblick über das Wiki zu bekommen.
server.AddResource(
&mcp.Resource{
URI: "wiki://pages",
Name: "Wiki-Seitenverzeichnis",
Description: "Liste aller Wiki-Seiten mit Titel, URL und Tags als JSON.",
MIMEType: "application/json",
},
func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
// Alle Einträge aus dem Cache holen — kein Dateisystem-Zugriff nötig
entries := cache.All()
pages := make([]pageListEntry, 0, len(entries))
for _, entry := range entries {
// Versteckte Seiten werden nicht aufgelistet
if entry.Page.Hidden {
continue
}
pages = append(pages, pageListEntry{
Title: entry.Page.Title,
URL: entry.URLPath,
Tags: entry.Page.Tags,
Protected: entry.Page.Password != "",
})
}
// Zur JSON-Darstellung umwandeln.
// MarshalIndent formatiert das JSON lesbar mit Einrückung.
data, err := json.MarshalIndent(pages, "", " ")
if err != nil {
return nil, fmt.Errorf("Seitenliste konnte nicht serialisiert werden: %w", err)
}
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{{
URI: req.Params.URI,
MIMEType: "application/json",
Text: string(data),
}},
}, nil
},
)
// ── ResourceTemplate: wiki://page/{+path} ─────────────────────────────────
// Stellt eine einzelne Wiki-Seite als Markdown-Resource bereit.
//
// URI-Template-Syntax: {+path} ist RFC 6570 "reserved expansion" —
// das + bedeutet dass Slashes im Pfad erlaubt sind (ohne + würden sie enkodiert).
// So matcht wiki://page/projekte/notizen mit path = "projekte/notizen".
//
// Beispiele:
// wiki://page/index → Startseite
// wiki://page/setup → /setup
// wiki://page/projekte/a → /projekte/a
server.AddResourceTemplate(
&mcp.ResourceTemplate{
URITemplate: "wiki://page/{+path}",
Name: "Wiki-Seite",
Description: "Liest eine Wiki-Seite als rohen Markdown-Inhalt. Pfad ohne führenden Slash, z.B. projekte/notizen",
MIMEType: "text/markdown",
},
func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
// Den Pfad aus der URI extrahieren.
// req.Params.URI ist z.B. "wiki://page/projekte/notizen"
path := strings.TrimPrefix(req.Params.URI, "wiki://page/")
// "index" als Sonderfall für die Startseite behandeln
urlPath := "/" + path
if path == "index" {
urlPath = "/"
}
filePath := cache.URLPathToFilePath(urlPath)
// Rohen Dateiinhalt lesen — wir wollen Markdown, nicht gerendertes HTML
rawBytes, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("Seite nicht gefunden: %s", urlPath)
}
// Frontmatter entfernen damit das KI-Tool nur den Markdown-Body bekommt
markdownBody := stripFrontmatter(string(rawBytes))
// Titel aus dem Cache holen (schneller als nochmal Frontmatter parsen)
title := path
if page, ok := cache.Get(urlPath); ok {
title = page.Title
}
// Titel als H1-Überschrift voranstellen falls nicht schon im Body
content := fmt.Sprintf("# %s\n\n%s", title, markdownBody)
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{{
URI: req.Params.URI,
MIMEType: "text/markdown",
Text: content,
}},
}, nil
},
)
// ── Tool: search_pages ────────────────────────────────────────────────────
// Durchsucht alle Wiki-Seiten mit BM25-Ranking.
//
// Gegenüber der früheren String-Suche (strings.Contains über alle Dateien) hat
// BM25 zwei Vorteile:
// 1. Geschwindigkeit: Der invertierte Index im Speicher wird genutzt — kein
// Dateisystem-Zugriff, keine lineare Schleife über alle Seiten.
// 2. Ranking: Ergebnisse sind nach Relevanz sortiert. Titel-Treffer erscheinen
// vor Body-Treffern; seltene, spezifische Begriffe wiegen mehr als häufige.
mcp.AddTool(server,
&mcp.Tool{
Name: "search_pages",
Description: "Durchsucht alle Wiki-Seiten mit BM25-Ranking — Titel-Treffer zuerst, dann Body. Ergebnisse sind nach Relevanz sortiert.",
},
func(_ context.Context, _ *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, searchOutput, error) {
if in.Query == "" {
return nil, searchOutput{}, fmt.Errorf("query darf nicht leer sein")
}
// BM25-Suche über den In-Memory-Index — kein Dateisystem-Zugriff nötig
hits := cache.SearchIndex.Search(in.Query, 20)
matches := make([]searchMatch, 0, len(hits))
for _, hit := range hits {
page, ok := cache.Get(hit.URLPath)
if !ok || page.Hidden {
continue
}
// Snippet aus dem im Page gecacheten Rohtext extrahieren
snippet := extractSnippet(page.RawBody, in.Query)
matches = append(matches, searchMatch{
Title: page.Title,
URL: hit.URLPath,
Snippet: snippet,
})
}
return nil, searchOutput{Results: matches, Total: len(matches)}, nil
},
)
// ── Tool: rebuild_index ───────────────────────────────────────────────────
// Generiert eine strukturierte _index.md aus allen vorhandenen Wiki-Seiten.
//
// Inspiration: Karpathy's "LLM Wiki" Muster (https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
// Der Index dient KI-Tools als Navigationshilfe: statt blind durch einzelne Seiten
// zu suchen, kann ein KI-Tool zuerst den Index lesen um zu verstehen was im Wiki
// überhaupt vorhanden ist — nach Kategorien (Tags) gegliedert.
//
// Die _index.md wird als versteckte Seite gespeichert (hidden: true) — sie erscheint
// nicht in der Sidebar-Navigation, ist aber per MCP als Resource abrufbar:
// wiki://page/_index
//
// Empfohlener Workflow für KI-Tools:
// 1. rebuild_index aufrufen nachdem neue Seiten hinzugefügt wurden
// 2. wiki://page/_index lesen für einen Überblick über das Wiki
// 3. search_pages für spezifische Inhaltssuche nutzen
mcp.AddTool(server,
&mcp.Tool{
Name: "rebuild_index",
Description: "Generiert _index.md neu — eine strukturierte Übersicht aller Wiki-Seiten, gegliedert nach Tags/Kategorien. Wird nach jedem upload_page automatisch aufgerufen, kann aber auch manuell ausgelöst werden.",
},
func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, rebuildIndexOutput, error) {
out, err := buildWikiIndex(cache)
return nil, out, err
},
)
// ── Tool: upload_page ─────────────────────────────────────────────────────
// Erstellt eine neue Seite oder überschreibt eine bestehende.
// Nach dem Schreiben wird der Cache sofort aktualisiert und — sofern es sich
// nicht um _index.md selbst handelt — der Wiki-Index automatisch neu generiert.
mcp.AddTool(server,
&mcp.Tool{
Name: "upload_page",
Description: "Erstellt eine neue Wiki-Seite oder überschreibt eine bestehende. Inhalt muss vollständiges Markdown mit Frontmatter sein. Der _index.md wird danach automatisch aktualisiert.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in uploadPageInput) (*mcp.CallToolResult, uploadPageOutput, error) {
if in.Path == "" {
return nil, uploadPageOutput{}, fmt.Errorf("path darf nicht leer sein")
}
if in.Content == "" {
return nil, uploadPageOutput{}, fmt.Errorf("content darf nicht leer sein")
}
if strings.Contains(in.Path, "..") {
return nil, uploadPageOutput{}, fmt.Errorf("ungültiger Pfad")
}
fullPath := cache.URLPathToFilePath(in.Path)
if err := os.MkdirAll(strings.TrimSuffix(fullPath, "/"+lastSegment(fullPath)), 0755); err != nil {
return nil, uploadPageOutput{}, fmt.Errorf("Ordner konnte nicht erstellt werden: %w", err)
}
if err := os.WriteFile(fullPath, []byte(in.Content), 0644); err != nil {
return nil, uploadPageOutput{}, fmt.Errorf("Datei konnte nicht geschrieben werden: %w", err)
}
// Cache und Suchindex sofort aktualisieren
if page, err := render.LoadPage(fullPath); err == nil {
cache.Set(in.Path, page)
}
// _index.md automatisch neu aufbauen — außer wenn _index.md selbst
// hochgeladen wird (das würde eine Endlosschleife verursachen).
if in.Path != "/_index.md" {
buildWikiIndex(cache) //nolint:errcheck — Fehler beim Index-Rebuild sind nicht kritisch
}
audit.Log(AuditEvent{
Team: teamID,
Actor: mcpActorLabel(ctx),
Action: "upload",
Path: in.Path,
Source: "mcp",
})
return nil, uploadPageOutput{Message: fmt.Sprintf("Seite gespeichert: %s", in.Path)}, nil
},
)
// ── Tool: delete_page ─────────────────────────────────────────────────────
// Löscht eine Wiki-Seite und entfernt sie sofort aus dem Cache.
mcp.AddTool(server,
&mcp.Tool{
Name: "delete_page",
Description: "Löscht eine Wiki-Seite anhand ihres Pfads.",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in deletePageInput) (*mcp.CallToolResult, deletePageOutput, error) {
if in.Path == "" {
return nil, deletePageOutput{}, fmt.Errorf("path darf nicht leer sein")
}
if strings.Contains(in.Path, "..") {
return nil, deletePageOutput{}, fmt.Errorf("ungültiger Pfad")
}
fullPath := cache.URLPathToFilePath(in.Path)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
return nil, deletePageOutput{}, fmt.Errorf("Seite nicht gefunden: %s", in.Path)
}
if err := os.Remove(fullPath); err != nil {
return nil, deletePageOutput{}, fmt.Errorf("Seite konnte nicht gelöscht werden: %w", err)
}
// Cache-Eintrag entfernen
cache.Delete(in.Path)
audit.Log(AuditEvent{
Team: teamID,
Actor: mcpActorLabel(ctx),
Action: "delete",
Path: in.Path,
Source: "mcp",
})
return nil, deletePageOutput{Message: fmt.Sprintf("Seite gelöscht: %s", in.Path)}, nil
},
)
return server
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
// extractSnippet gibt ~100 Zeichen um den ersten Treffer von query in content zurück.
// Nützlich um dem KI-Tool Kontext zum Suchtreffer zu geben.
func extractSnippet(content, query string) string {
lowerContent := strings.ToLower(content)
lowerQuery := strings.ToLower(query)
idx := strings.Index(lowerContent, lowerQuery)
if idx == -1 {
// Kein Treffer im Inhalt — leeren String zurückgeben
return ""
}
const radius = 100 // Zeichen vor und nach dem Treffer
start := idx - radius
if start < 0 {
start = 0
}
end := idx + len(query) + radius
if end > len(content) {
end = len(content)
}
snippet := content[start:end]
// "..." hinzufügen wenn der Snippet nicht am Anfang/Ende des Texts beginnt
if start > 0 {
snippet = "..." + snippet
}
if end < len(content) {
snippet += "..."
}
return strings.TrimSpace(snippet)
}
// stripFrontmatter entfernt den YAML-Frontmatter-Block vom Anfang eines Markdown-Strings.
func stripFrontmatter(content string) string {
if !strings.HasPrefix(content, "---") {
return content
}
rest := strings.TrimPrefix(content, "---\n")
idx := strings.Index(rest, "---")
if idx == -1 {
return content
}
return strings.TrimSpace(rest[idx+4:])
}
// lastSegment gibt den letzten Teil eines Dateipfads zurück (nach dem letzten "/").
func lastSegment(path string) string {
parts := strings.Split(path, "/")
return parts[len(parts)-1]
}
// buildWikiIndex generiert _index.md aus allen aktuellen Wiki-Seiten und schreibt
// sie ins Dateisystem. Cache und Suchindex werden danach sofort aktualisiert.
//
// Die Funktion ist als eigenständige Hilfsfunktion ausgelagert damit sowohl
// das rebuild_index-Tool als auch upload_page sie aufrufen können, ohne Logik
// zu duplizieren.
func buildWikiIndex(cache *PageCache) (rebuildIndexOutput, error) {
entries := cache.All()
// Seiten nach erstem Tag gruppieren.
// Seiten ohne Tag landen in der Gruppe "Allgemein".
groups := make(map[string][]CachedEntry)
for _, entry := range entries {
// _index selbst nicht aufnehmen — sonst würde er sich selbst referenzieren
if entry.URLPath == "/_index" {
continue
}
category := "Allgemein"
if len(entry.Page.Tags) > 0 {
category = entry.Page.Tags[0]
}
groups[category] = append(groups[category], entry)
}
// Kategorien alphabetisch sortieren, "Allgemein" ans Ende
categories := make([]string, 0, len(groups))
for cat := range groups {
categories = append(categories, cat)
}
sortCategoriesWithFallbackLast(categories, "Allgemein")
// Markdown aufbauen
var sb strings.Builder
sb.WriteString("---\n")
sb.WriteString("title: Wiki Index\n")
sb.WriteString("hidden: true\n")
sb.WriteString("---\n\n")
sb.WriteString("Automatisch generierter Index aller Wiki-Seiten, gegliedert nach Kategorien.\n")
sb.WriteString("Wird bei jedem `upload_page` automatisch aktualisiert.\n\n")
totalPages := 0
for _, cat := range categories {
pages := groups[cat]
sortEntriesByTitle(pages)
sb.WriteString("## ")
sb.WriteString(cat)
sb.WriteString("\n\n")
for _, entry := range pages {
title := entry.Page.Title
if title == "" {
title = entry.URLPath
}
extraTags := filterOutFirst(entry.Page.Tags)
tagStr := ""
if len(extraTags) > 0 {
tagStr = " — Tags: " + strings.Join(extraTags, ", ")
}
protection := ""
if entry.Page.Password != "" {
protection = " 🔒"
}
sb.WriteString(fmt.Sprintf("- [%s](%s)%s%s\n", title, entry.URLPath, protection, tagStr))
totalPages++
}
sb.WriteString("\n")
}
// _index.md schreiben und Cache aktualisieren
fullPath := cache.URLPathToFilePath("/_index.md")
if err := os.WriteFile(fullPath, []byte(sb.String()), 0644); err != nil {
return rebuildIndexOutput{}, fmt.Errorf("_index.md konnte nicht geschrieben werden: %w", err)
}
if page, err := render.LoadPage(fullPath); err == nil {
cache.Set("/_index", page)
}
return rebuildIndexOutput{
Message: fmt.Sprintf("_index.md aktualisiert — %d Seiten in %d Kategorien.", totalPages, len(categories)),
PageCount: totalPages,
}, nil
}
// sortCategoriesWithFallbackLast sortiert eine Kategorienliste alphabetisch,
// verschiebt aber eine bestimmte Fallback-Kategorie (z.B. "Allgemein") ans Ende.
func sortCategoriesWithFallbackLast(cats []string, fallback string) {
// sort.Slice sortiert in-place mit einer benutzerdefinierten Vergleichsfunktion.
// "less(i, j) = true" bedeutet: Element i soll vor Element j stehen.
sort.Slice(cats, func(i, j int) bool {
if cats[i] == fallback {
return false // fallback kommt immer nach allem anderen
}
if cats[j] == fallback {
return true // fallback kommt immer nach allem anderen
}
return cats[i] < cats[j] // sonst alphabetisch
})
}
// sortEntriesByTitle sortiert CachedEntries alphabetisch nach dem Seitentitel.
func sortEntriesByTitle(entries []CachedEntry) {
sort.Slice(entries, func(i, j int) bool {
return entries[i].Page.Title < entries[j].Page.Title
})
}
// filterOutFirst gibt alle Tags einer Seite zurück außer dem ersten.
// Der erste Tag wird als Kategorie genutzt und muss nicht doppelt angezeigt werden.
func filterOutFirst(tags []string) []string {
if len(tags) <= 1 {
return nil
}
return tags[1:]
}