// Package entry enthält die Fachlogik rund um einen Wissenseintrag: die
// Darstellung als HTML, Textausschnitte für Suchergebnisse und die Auflösung
// der [[slug]]-Verweise.
//
// Bewusst getrennt von internal/store: Dort geht es darum, wie ein Eintrag
// gespeichert wird, hier darum, wie er aussieht. Beides ändert sich aus
// unterschiedlichen Gründen.
package entry
import (
"bytes"
"fmt"
"html"
"strings"
"github.com/yuin/goldmark"
"munin/internal/store"
)
// RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML.
//
// # Sicherheitshinweis
//
// goldmark lässt in der Standardeinstellung eingebettetes Roh-HTML NICHT durch —
// aus im Markdown wird ein Kommentar, kein Skript.
// Genau darauf verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne
// weiteres Escaping einsetzt.
//
// Diese Einstellung darf deshalb nie auf "unsafe" gestellt werden. Als zweite
// Verteidigungslinie steht die Content-Security-Policy (siehe internal/web).
func RenderHTML(markdown string, linkBase string) (string, error) {
// Verweise vor dem Rendern in Markdown-Links umschreiben. Danach wäre es zu
// spät: goldmark hätte die eckigen Klammern dann schon als Text behandelt.
vorbereitet := store.ReplaceLinks(markdown, func(slug string) string {
return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug)
})
var puffer bytes.Buffer
if err := goldmark.Convert([]byte(vorbereitet), &puffer); err != nil {
return "", fmt.Errorf("Inhalt konnte nicht dargestellt werden: %w", err)
}
return puffer.String(), nil
}
// RenderPlain gibt den Inhalt als reinen Text zurück — Verweise werden auf ihren
// Slug reduziert. Gedacht für Textausschnitte, wo Markdown-Zeichen nur stören.
func RenderPlain(markdown string) string {
return store.ReplaceLinks(markdown, func(slug string) string { return slug })
}
// snippetRadius bestimmt, wie viel Text vor und nach einem Treffer im Ausschnitt steht.
const snippetRadius = 120
// Snippet gibt einen Textausschnitt um den ersten Treffer des Suchbegriffs zurück.
//
// Der Ausschnitt ist bewusst kurz: Er dient dazu, einen Treffer einzuordnen —
// bei einer KI-Anfrage entscheidet er darüber, ob der ganze Eintrag nachgeladen
// wird. Wer den vollen Inhalt will, holt ihn gezielt.
func Snippet(content, query string) string {
text := RenderPlain(content)
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
if idx == -1 {
// Kein wörtlicher Treffer (z.B. weil der Fund über die Präfix-Suche kam) —
// dann der Anfang des Texts als Vorschau.
return kuerzen(text, snippetRadius+snippetRadius)
}
start := max(idx-snippetRadius, 0)
ende := min(idx+len(query)+snippetRadius, len(text))
ausschnitt := strings.TrimSpace(text[start:ende])
if start > 0 {
ausschnitt = "…" + ausschnitt
}
if ende < len(text) {
ausschnitt += "…"
}
return ausschnitt
}
// kuerzen schneidet einen Text auf höchstens n Zeichen.
func kuerzen(text string, n int) string {
text = strings.TrimSpace(text)
if len(text) <= n {
return text
}
return strings.TrimSpace(text[:n]) + "…"
}
// HighlightSnippet baut einen Textausschnitt mit hervorgehobenem Treffer als
// fertiges HTML.
//
// Wichtig: Erst wird der Text escaped, dann werden die -Auszeichnungen
// eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen.
func HighlightSnippet(content, query string) string {
ausschnitt := Snippet(content, query)
sicher := html.EscapeString(ausschnitt)
if query == "" {
return sicher
}
// Im escapten Text nach dem (ebenfalls escapten) Suchbegriff suchen, damit
// die Positionen zusammenpassen.
nadel := html.EscapeString(query)
idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel))
if idx == -1 {
return sicher
}
return sicher[:idx] + "" + sicher[idx:idx+len(nadel)] + "" + sicher[idx+len(nadel):]
}
// TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
func TitleFromContent(content string) string {
for _, zeile := range strings.Split(content, "\n") {
zeile = strings.TrimSpace(zeile)
if zeile == "" {
continue
}
zeile = strings.TrimLeft(zeile, "#")
zeile = strings.TrimSpace(zeile)
if zeile != "" {
return kuerzen(zeile, 120)
}
}
return "Ohne Titel"
}