166 lines
6 KiB
Go
166 lines
6 KiB
Go
// 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")
|
|
}
|