// Dieses File implementiert einen In-Memory-Cache für Wiki-Seiten. // // # Warum ein Cache? // // Ohne Cache liest der Server bei jedem Request jede .md-Datei vom Dateisystem // und parst das Frontmatter neu. Bei list_pages bedeutet das: alle Dateien öffnen, // YAML parsen, schließen — für jede Anfrage. Mit vielen Seiten wird das spürbar. // // Der Cache löst das: beim Serverstart werden alle Seiten einmal geladen und im // Arbeitsspeicher gehalten. Danach sind Metadaten (Titel, Tags, Hidden) sofort // verfügbar ohne Dateisystem-Zugriff. // // # Nebenläufigkeit (Concurrency) // // Go-Server bearbeiten mehrere HTTP-Requests gleichzeitig in sogenannten Goroutinen // (leichtgewichtige Threads). Das bedeutet: zwei Requests könnten gleichzeitig den // Cache lesen — das ist kein Problem. Aber wenn gleichzeitig einer liest und einer // schreibt, kann es zu Datenverlust oder Abstürzen kommen (Race Condition). // // sync.RWMutex (Read-Write-Mutex) löst das: // - Lesen (RLock): Mehrere Goroutinen dürfen gleichzeitig lesen // - Schreiben (Lock): Nur eine Goroutine darf schreiben, alle anderen warten package handler import ( "os" "path/filepath" "strings" "sync" "wiki/render" ) // CachedEntry ist ein einzelner Eintrag im Cache — eine geladene Seite mit ihrem URL-Pfad. type CachedEntry struct { URLPath string // z.B. "/projekte/notizen" Page *render.Page // die geladene Seite mit Frontmatter } // PageCache hält alle Wiki-Seiten im Arbeitsspeicher. type PageCache struct { mu sync.RWMutex // schützt entries vor gleichzeitigem Lesen+Schreiben entries map[string]*render.Page // key: URL-Pfad wie "/projekte/notizen" docsRoot string // Pfad zum docs-Ordner, wird für Pfadumrechnung gebraucht SearchIndex *SearchIndex // BM25-Volltextindex — wird parallel zum Cache aktuell gehalten } // NewPageCache erstellt einen neuen Cache und befüllt ihn sofort mit allen // vorhandenen .md-Dateien im docsRoot-Ordner. // Gleichzeitig wird der BM25-Suchindex initial aufgebaut. func NewPageCache(docsRoot string) (*PageCache, error) { c := &PageCache{ entries: make(map[string]*render.Page), docsRoot: docsRoot, SearchIndex: NewSearchIndex(), } // Beim Start einmal alle Seiten laden — befüllt Cache und Suchindex gemeinsam. if err := c.build(); err != nil { return nil, err } return c, nil } // build geht rekursiv durch den docs-Ordner und lädt alle .md-Dateien in den Cache. // Gleichzeitig werden alle Seiten in den BM25-Suchindex aufgenommen. // Wird nur einmal beim Start aufgerufen — danach werden Einträge einzeln aktualisiert. func (c *PageCache) build() error { return filepath.Walk(c.docsRoot, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || !strings.HasSuffix(path, ".md") { return nil } page, err := render.LoadPage(path) if err != nil { return nil // fehlerhafte Datei überspringen, nicht abbrechen } urlPath := c.FilePathToURLPath(path) // Kein Lock nötig hier, weil build() nur einmal vor dem ersten Request läuft c.entries[urlPath] = page // Seite in den Suchindex aufnehmen — Hidden-Seiten werden mitindexiert, // aber search_pages filtert sie beim Anzeigen heraus. c.SearchIndex.Index(urlPath, page.Title, page.RawBody, page.Tags) return nil }) } // Get liest eine Seite aus dem Cache anhand ihres URL-Pfads. // Gibt (nil, false) zurück wenn die Seite nicht im Cache ist. func (c *PageCache) Get(urlPath string) (*render.Page, bool) { // RLock = Lesesperre: andere Goroutinen dürfen gleichzeitig lesen c.mu.RLock() defer c.mu.RUnlock() // Sperre am Funktionsende freigeben — defer läuft immer, auch bei return page, ok := c.entries[urlPath] return page, ok } // Set fügt eine Seite in den Cache ein oder überschreibt einen bestehenden Eintrag. // Aktualisiert gleichzeitig den BM25-Suchindex. // Wird nach einem erfolgreichen Upload aufgerufen. func (c *PageCache) Set(urlPath string, page *render.Page) { // Lock = exklusive Schreibsperre: alle anderen Goroutinen warten bis wir fertig sind c.mu.Lock() defer c.mu.Unlock() c.entries[urlPath] = page // Suchindex synchron aktualisieren — Index() ersetzt automatisch einen bestehenden Eintrag c.SearchIndex.Index(urlPath, page.Title, page.RawBody, page.Tags) } // Delete entfernt eine Seite aus dem Cache und aus dem BM25-Suchindex. // Wird nach einem erfolgreichen Delete aufgerufen. func (c *PageCache) Delete(urlPath string) { c.mu.Lock() defer c.mu.Unlock() delete(c.entries, urlPath) c.SearchIndex.Remove(urlPath) } // All gibt eine Momentaufnahme aller Cache-Einträge zurück. // Wir erstellen eine Kopie der Liste damit der Aufrufer sie ohne Sperre lesen kann. // (Würde der Aufrufer direkt auf c.entries zugreifen, müsste er die Sperre halten.) func (c *PageCache) All() []CachedEntry { c.mu.RLock() defer c.mu.RUnlock() // make mit Kapazität = aktuelle Anzahl Einträge — vermeidet Speicher-Neuallokierungen result := make([]CachedEntry, 0, len(c.entries)) for urlPath, page := range c.entries { result = append(result, CachedEntry{URLPath: urlPath, Page: page}) } return result } // FilePathToURLPath wandelt einen Dateisystem-Pfad in einen URL-Pfad um. // // data/docs/projekte/notizen.md → /projekte/notizen // data/docs/index.md → / func (c *PageCache) FilePathToURLPath(filePath string) string { rel := strings.TrimPrefix(filePath, c.docsRoot) rel = strings.TrimSuffix(rel, ".md") if rel == "/index" { return "/" } return rel } // URLPathToFilePath wandelt einen URL-Pfad in einen Dateisystem-Pfad um. // // /projekte/notizen → data/docs/projekte/notizen.md // / → data/docs/index.md func (c *PageCache) URLPathToFilePath(urlPath string) string { if urlPath == "/" { return filepath.Join(c.docsRoot, "index.md") } clean := strings.TrimPrefix(urlPath, "/") // Falls der Pfad schon auf .md endet (z.B. vom MCP-Client), nicht doppelt anhängen. clean = strings.TrimSuffix(clean, ".md") return filepath.Join(c.docsRoot, clean+".md") }