// 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:] }