Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6, 5.1–5.4 (Details in AUSBAUSTUFEN.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
4.2 KiB
Go
126 lines
4.2 KiB
Go
// 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 (
|
|
"fmt"
|
|
"html"
|
|
"strings"
|
|
|
|
"munin/internal/markdown"
|
|
"munin/internal/store"
|
|
)
|
|
|
|
// RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML.
|
|
//
|
|
// Das eigentliche Umwandeln übernimmt internal/markdown — derselbe Renderer
|
|
// wie für die Hilfe, damit dort beschriebenes Markdown (Tabellen, Checklisten,
|
|
// Durchstreichen) im Eintrag genauso aussieht. Dort steht auch der
|
|
// Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf
|
|
// verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres
|
|
// Escaping einsetzt.
|
|
func RenderHTML(quelle 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.
|
|
//
|
|
// (Der Parameter heißt "quelle" statt "markdown", weil "markdown" jetzt der
|
|
// Name des importierten Pakets ist — sonst wäre das Paket in dieser
|
|
// Funktion verdeckt.)
|
|
vorbereitet := store.ReplaceLinks(quelle, func(slug string) string {
|
|
return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug)
|
|
})
|
|
|
|
return markdown.Render(vorbereitet)
|
|
}
|
|
|
|
// 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 <mark>-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] + "<mark>" + sicher[idx:idx+len(nadel)] + "</mark>" + 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"
|
|
}
|