286 lines
8.2 KiB
Go
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
|
|
}
|