munin/internal/web/handlers_entries.go
2026-09-15 20:36:26 +02:00

286 lines
8.2 KiB
Go

package web
import (
"errors"
"html/template"
"net/http"
"strconv"
"strings"
"munin/internal/entry"
"munin/internal/store"
)
// entryListData sind die Daten der Übersichtsseite eines Projekts.
type entryListData struct {
Entries []entryListItem
Tags []store.TagCount
Query string
Tag string
Total int
}
// entryListItem ist eine Zeile der Übersicht bzw. ein Suchtreffer.
type entryListItem struct {
Entry store.Entry
Snippet template.HTML // bei einer Suche: Textstelle mit hervorgehobenem Treffer
}
// handleEntryList zeigt die Einträge eines Projekts — gefiltert, gesucht oder
// einfach nach letzter Änderung.
func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
ctx := r.Context()
query := strings.TrimSpace(r.URL.Query().Get("q"))
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
daten := entryListData{Query: query, Tag: tag}
var err error
if query != "" {
daten.Entries, err = s.searchEntries(r, project, query)
} else {
daten.Entries, err = s.listEntries(r, project, tag)
}
if err != nil {
s.serverError(w, "Einträge konnten nicht geladen werden", err)
return
}
if daten.Tags, err = s.store.ListTags(ctx, project.ID); err != nil {
s.serverError(w, "Schlagworte konnten nicht geladen werden", err)
return
}
if daten.Total, err = s.store.CountEntries(ctx, project.ID); err != nil {
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
return
}
s.render(w, r, "entries.html", http.StatusOK, pageData{
Title: project.Name,
Bereich: "eintraege",
AktivesTag: tag,
Data: daten,
})
}
// searchEntries führt eine Volltextsuche durch und lädt die Treffer nach.
func (s *Server) searchEntries(r *http.Request, project store.Project, query string) ([]entryListItem, error) {
ctx := r.Context()
idx, err := s.search.For(ctx, project.ID)
if err != nil {
return nil, err
}
treffer := idx.Search(query, 30)
items := make([]entryListItem, 0, len(treffer))
for _, t := range treffer {
e, err := s.store.EntryBySlug(ctx, project.ID, t.DocID)
if errors.Is(err, store.ErrNotFound) {
// Der Index kann einen Eintrag kennen, den es nicht mehr gibt —
// dann wird er hier stillschweigend übersprungen.
continue
}
if err != nil {
return nil, err
}
items = append(items, entryListItem{
Entry: e,
Snippet: template.HTML(entry.HighlightSnippet(e.Content, query)),
})
}
return items, nil
}
// listEntries lädt die Einträge ohne Suche, zuletzt geänderte zuerst.
func (s *Server) listEntries(r *http.Request, project store.Project, tag string) ([]entryListItem, error) {
eintraege, err := s.store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100})
if err != nil {
return nil, err
}
items := make([]entryListItem, 0, len(eintraege))
for _, e := range eintraege {
items = append(items, entryListItem{
Entry: e,
Snippet: template.HTML(template.HTMLEscapeString(entry.Snippet(e.Content, ""))),
})
}
return items, nil
}
// entryViewData sind die Daten der Detailansicht.
type entryViewData struct {
Entry store.Entry
HTML template.HTML
Backlinks []store.Backlink
}
// handleEntryView zeigt einen einzelnen Eintrag.
func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
slug := r.PathValue("slug")
e, err := s.store.EntryBySlug(r.Context(), project.ID, slug)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Eintrag konnte nicht geladen werden", err)
return
}
// Die [[slug]]-Verweise zeigen auf die Detailansicht desselben Projekts.
inhalt, err := entry.RenderHTML(e.Content, "/p/"+project.Slug+"/e/")
if err != nil {
s.serverError(w, "Inhalt konnte nicht dargestellt werden", err)
return
}
backlinks, err := s.store.Backlinks(r.Context(), project.ID, slug)
if err != nil {
s.serverError(w, "Rückverweise konnten nicht geladen werden", err)
return
}
s.render(w, r, "entry.html", http.StatusOK, pageData{
Title: e.Title,
Bereich: "eintraege",
Data: entryViewData{Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks},
})
}
// entryEditData sind die Daten des Bearbeitungsformulars.
type entryEditData struct {
Entry store.Entry
TagsIn string // Schlagworte als kommagetrennte Zeile fürs Eingabefeld
IsNew bool
}
// handleEntryNew zeigt das Formular für einen neuen Eintrag.
func (s *Server) handleEntryNew(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "edit.html", http.StatusOK, pageData{
Title: "Neuer Eintrag",
Bereich: "eintraege",
Data: entryEditData{IsNew: true},
})
}
// handleEntryEdit zeigt das Formular für einen bestehenden Eintrag.
func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
e, err := s.store.EntryBySlug(r.Context(), project.ID, r.PathValue("slug"))
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Eintrag konnte nicht geladen werden", err)
return
}
s.render(w, r, "edit.html", http.StatusOK, pageData{
Title: "Bearbeiten: " + e.Title,
Bereich: "eintraege",
Data: entryEditData{Entry: e, TagsIn: strings.Join(e.Tags, ", ")},
})
}
// handleEntrySave speichert einen Eintrag aus dem Formular.
func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
titel := strings.TrimSpace(r.FormValue("title"))
inhalt := r.FormValue("content")
slug := strings.TrimSpace(r.FormValue("slug"))
// Kein Bezeichner angegeben (neuer Eintrag) → aus dem Titel ableiten.
if slug == "" {
slug = store.Slugify(titel)
}
// Die im Formular mitgeführte Version ermöglicht das optimistische Sperren:
// Hat jemand anderes den Eintrag zwischenzeitlich geändert, gibt es einen
// Hinweis statt eines stillen Überschreibens.
version, _ := strconv.Atoi(r.FormValue("version"))
geschrieben, err := s.store.PutEntry(r.Context(), store.EntryWrite{
ProjectID: project.ID,
Slug: slug,
Title: titel,
Content: inhalt,
Tags: splitTags(r.FormValue("tags")),
Source: store.SourceHuman,
Author: sess.ActorName(),
Curated: r.FormValue("curated") != "",
ExpectedVersion: version,
})
if err != nil {
// Fachliche Fehler (fehlender Titel, ungültiger Bezeichner, Konflikt)
// führen zurück ins Formular statt auf eine Fehlerseite — der eingegebene
// Text darf dabei nicht verloren gehen.
s.render(w, r, "edit.html", http.StatusBadRequest, pageData{
Title: "Bearbeiten",
Bereich: "eintraege",
Error: err.Error(),
Data: entryEditData{
Entry: store.Entry{
Slug: slug, Title: titel, Content: inhalt,
Version: version, Curated: r.FormValue("curated") != "",
},
TagsIn: r.FormValue("tags"),
IsNew: version == 0,
},
})
return
}
s.search.Update(project.ID, geschrieben)
s.store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb,
})
http.Redirect(w, r, "/p/"+project.Slug+"/e/"+slug, http.StatusSeeOther)
}
// handleEntryDelete löscht einen Eintrag.
func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
slug := r.PathValue("slug")
if err := s.store.DeleteEntry(r.Context(), project.ID, slug); err != nil && !errors.Is(err, store.ErrNotFound) {
s.serverError(w, "Eintrag konnte nicht gelöscht werden", err)
return
}
s.search.Delete(project.ID, slug)
s.store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb,
})
http.Redirect(w, r, "/p/"+project.Slug+"/", http.StatusSeeOther)
}
// splitTags zerlegt die Schlagwort-Eingabe an Kommas.
// Das Vereinheitlichen (klein, ohne Dubletten) übernimmt danach der Store.
func splitTags(eingabe string) []string {
if strings.TrimSpace(eingabe) == "" {
return nil
}
teile := strings.Split(eingabe, ",")
for i := range teile {
teile[i] = strings.TrimSpace(teile[i])
}
return teile
}