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 }