munin/internal/web/handlers_entries.go

803 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"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) {
ws, _ := WorkspaceFrom(r)
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 = ws.Store.ListTags(ctx, project.ID); err != nil {
s.serverError(w, "Schlagworte konnten nicht geladen werden", err)
return
}
if daten.Total, err = ws.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,
Flash: r.URL.Query().Get("hinweis"),
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) {
ws, _ := WorkspaceFrom(r)
ctx := r.Context()
idx, err := ws.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 := ws.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) {
ws, _ := WorkspaceFrom(r)
eintraege, err := ws.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
// Stale: seit über einem Jahr unverändert (siehe store.Entry.Stale). Die
// Seite zeigt dann einen Vermerk, damit Menschen den Eintrag genauso
// vorsichtig lesen wie ein Agent, dem MCP dasselbe sagt.
Stale bool
}
// handleEntryView zeigt einen einzelnen Eintrag.
func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
slug := r.PathValue("slug")
e, err := ws.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 := ws.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",
Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung
Data: entryViewData{
Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks,
Stale: e.Stale(time.Now()),
},
})
}
// 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.
//
// Mit ?slug=… ist der Bezeichner vorbelegt — so führt die Nutzungsseite von
// einem Verweis ins Leere direkt zum passenden neuen Eintrag. Ein ungültiger
// Wert wird ignoriert statt ins Formular übernommen.
func (s *Server) handleEntryNew(w http.ResponseWriter, r *http.Request) {
var vorbelegt store.Entry
if slug := r.URL.Query().Get("slug"); store.ValidSlug(slug) {
vorbelegt.Slug = slug
}
s.render(w, r, "edit.html", http.StatusOK, pageData{
Title: "Neuer Eintrag",
Bereich: "eintraege",
Data: entryEditData{IsNew: true, Entry: vorbelegt},
})
}
// handleEntryEdit zeigt das Formular für einen bestehenden Eintrag.
func (s *Server) handleEntryEdit(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
e, err := ws.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, ", ")},
})
}
// suggestLimit begrenzt die Vorschläge der Autovervollständigung — mehr
// würde die Liste unübersichtlich machen, ohne beim Tippen zu helfen.
const suggestLimit = 8
// suggestItem ist ein Vorschlag der Autovervollständigung für [[…]]-Verweise.
type suggestItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
}
// handleEntrySuggest liefert Bezeichner-Vorschläge fürs Tippen von
// [[…]]-Verweisen im Bearbeitungsformular (siehe static/autocomplete.js).
//
// Ohne Suchbegriff kommen die zuletzt geänderten Einträge — meist das, was
// gerade im Sinn ist. Mit Suchbegriff läuft dieselbe Volltextsuche wie bei
// "recall" (MCP) und der Projektsuche: ein eigener Präfix-Abgleich auf dem
// Bezeichner wäre eine zweite Suchlogik, die mit der Zeit von der ersten
// abweicht.
func (s *Server) handleEntrySuggest(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
query := strings.TrimSpace(r.URL.Query().Get("q"))
// Nicht nil, damit die Antwort immer ein JSON-Array ist ([] statt null) —
// das JavaScript ruft direkt .forEach darauf auf.
vorschlaege := []suggestItem{}
if query == "" {
eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Limit: suggestLimit})
if err != nil {
s.serverError(w, "Vorschläge konnten nicht geladen werden", err)
return
}
for _, e := range eintraege {
vorschlaege = append(vorschlaege, suggestItem{Slug: e.Slug, Title: e.Title})
}
} else {
idx, err := ws.Search.For(r.Context(), project.ID)
if err != nil {
s.serverError(w, "Vorschläge konnten nicht geladen werden", err)
return
}
for _, treffer := range idx.Search(query, suggestLimit) {
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, treffer.DocID)
if err != nil {
continue // Index kann einen Eintrag kennen, den es nicht mehr gibt
}
vorschlaege = append(vorschlaege, suggestItem{Slug: e.Slug, Title: e.Title})
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(vorschlaege); err != nil {
log.Printf("Vorschläge konnten nicht gesendet werden: %v", err)
}
}
// handleEntryPreview rendert eingereichten Markdown-Text als HTML — für die
// Vorschau im Bearbeitungsformular (siehe static/preview.js).
//
// Nimmt bewusst nur den Inhalt entgegen, keinen ganzen Eintrag: Es wird
// nichts gespeichert, nur derselbe Renderer aufgerufen, den auch die
// Detailansicht benutzt (entry.RenderHTML) — Vorschau und späteres Ergebnis
// können dadurch nicht auseinanderlaufen. Ein leerer oder fehlerhafter Inhalt
// ist hier kein Fehlerfall: goldmark rendert auch unfertigen Markdown-Text,
// den jemand gerade erst zu tippen begonnen hat.
func (s *Server) handleEntryPreview(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
html, err := entry.RenderHTML(r.FormValue("content"), "/p/"+project.Slug+"/e/")
if err != nil {
s.serverError(w, "Vorschau konnte nicht erzeugt werden", err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
// handleEntrySave speichert einen Eintrag aus dem Formular.
func (s *Server) handleEntrySave(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
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 := ws.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
}
ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug)
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceWeb,
Detail: store.EntryWriteDetail(geschrieben),
})
ziel := "/p/" + project.Slug + "/e/" + slug
if geschrieben.RestoredFromTrash {
// Der Bezeichner gehörte einem Eintrag im Papierkorb. PutEntry hat ihn
// zurückgeholt und überschrieben — das soll im Protokoll stehen und
// der Mensch soll es erfahren, sonst wundert er sich über "Fassung 4"
// bei einem vermeintlich neuen Eintrag.
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceWeb,
Detail: "durch Speichern unter demselben Bezeichner",
})
ziel += "?hinweis=" + url.QueryEscape(
"Unter diesem Bezeichner lag ein Eintrag im Papierkorb. Er wurde zurückgeholt und überschrieben; der gelöschte Stand steht im Verlauf.")
}
http.Redirect(w, r, ziel, http.StatusSeeOther)
}
// handleEntryDelete legt einen Eintrag in den Papierkorb (AUSBAUSTUFEN.md 3.9).
// Endgültig gelöscht wird nur über die Papierkorb-Seite oder nach Ablauf der
// Frist.
func (s *Server) handleEntryDelete(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
slug := r.PathValue("slug")
err := ws.Store.MoveEntryToTrash(r.Context(), project.ID, slug, sess.ActorName())
if errors.Is(err, store.ErrNotFound) {
// Schon weg (etwa doppelt abgeschickt) — kein Fehler, einfach zur Liste.
http.Redirect(w, r, "/p/"+project.Slug+"/", http.StatusSeeOther)
return
}
if err != nil {
s.serverError(w, "Eintrag konnte nicht gelöscht werden", err)
return
}
ws.Search.Refresh(r.Context(), project.ID, slug)
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb,
Detail: "in den Papierkorb",
})
meldung := fmt.Sprintf("„%s“ liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.",
slug, store.TrashRetentionDays)
http.Redirect(w, r, "/p/"+project.Slug+"/?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
}
// handleEntryBulkTags fügt einer Auswahl von Einträgen Schlagworte hinzu
// und/oder entfernt welche — die Sammelbearbeitung aus der Übersichtsseite.
//
// Die Auswahl kommt als mehrere gleichnamige Formularfelder "ids" — das
// HTML-Formular in entries.html legt für jeden angehakten Eintrag ein eigenes
// verstecktes Feld an.
func (s *Server) handleEntryBulkTags(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
if err := r.ParseForm(); err != nil {
s.serverError(w, "Formular konnte nicht gelesen werden", err)
return
}
ziel := "/p/" + project.Slug + "/"
if tag := r.FormValue("tag"); tag != "" {
ziel += "?tag=" + url.QueryEscape(tag)
}
ids := r.Form["ids"]
add := splitTags(r.FormValue("add_tags"))
remove := splitTags(r.FormValue("remove_tags"))
if len(ids) == 0 || (len(add) == 0 && len(remove) == 0) {
http.Redirect(w, r, ziel, http.StatusSeeOther)
return
}
geaendert, err := ws.Store.BulkUpdateTags(r.Context(), project.ID, ids, add, remove)
if err != nil {
s.serverError(w, "Schlagworte konnten nicht geändert werden", err)
return
}
for _, e := range geaendert {
ws.Search.Refresh(r.Context(), project.ID, e.Slug)
}
if len(geaendert) > 0 {
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, Source: store.SourceWeb,
Detail: fmt.Sprintf("Sammelbearbeitung: %d Einträge, +%v -%v", len(geaendert), add, remove),
})
}
meldung := fmt.Sprintf("%d Eintrag/Einträge aktualisiert", len(geaendert))
trenner := "?"
if strings.Contains(ziel, "?") {
trenner = "&"
}
http.Redirect(w, r, ziel+trenner+"hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
}
// ── Änderungshistorie ────────────────────────────────────────────────────────
// entryVersionListData sind die Daten der Verlaufsseite eines Eintrags.
type entryVersionListData struct {
Entry store.Entry
Versions []store.EntryVersion
}
// handleEntryVersions zeigt den Verlauf eines Eintrags — jede abgelöste
// Fassung mit Zeitpunkt und Zugang, verlinkt zum Vergleich mit ihrer
// Nachfolgerin.
func (s *Server) handleEntryVersions(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
slug := r.PathValue("slug")
e, err := ws.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
}
s.renderEntryVersions(w, r, http.StatusOK, e, "")
}
// renderEntryVersions zeigt die Verlaufsseite, auf Wunsch mit Fehlermeldung.
// Eine eigene Funktion, weil auch die Wiederherstellung bei einem Konflikt
// hierher zurückführt (siehe handleEntryRestore).
func (s *Server) renderEntryVersions(w http.ResponseWriter, r *http.Request, status int, e store.Entry, fehler string) {
ws, _ := WorkspaceFrom(r)
versionen, err := ws.Store.EntryVersions(r.Context(), e.ID)
if err != nil {
s.serverError(w, "Änderungshistorie konnte nicht geladen werden", err)
return
}
s.render(w, r, "entry-versions.html", status, pageData{
Title: "Verlauf – " + e.Title,
Bereich: "eintraege",
Error: fehler,
Data: entryVersionListData{Entry: e, Versions: versionen},
})
}
// handleEntryRestore holt eine abgelöste Fassung zurück (AUSBAUSTUFEN.md 5.1).
//
// # Kein eigener Speicherweg
//
// Technisch ist das ein ganz gewöhnliches PutEntry mit Titel, Inhalt und
// Schlagworten der alten Fassung. Das hat zwei Vorteile:
//
// 1. Der aktuelle Stand wird dabei — wie bei jeder Änderung — selbst zu einer
// abgelösten Fassung. Es geht also nichts verloren; auch eine
// Wiederherstellung lässt sich wiederherstellen.
// 2. Suchindex, Verweise und Schlagworte werden auf demselben Weg nachgezogen
// wie beim normalen Speichern. Es gibt keine zweite Stelle, die man bei
// künftigen Änderungen vergessen könnte.
//
// Die Markierung "kuratiert" bleibt, wie sie aktuell ist: Sie ist eine Aussage
// über den Eintrag, nicht über einen bestimmten Textstand.
//
// # Schutz vor Überschreiben
//
// Das Formular schickt die Fassungsnummer mit, die beim Anzeigen aktuell war
// ("current_version"). Hat in der Zwischenzeit jemand — etwa ein Agent — den
// Eintrag geändert, schlägt PutEntry mit ErrConflict fehl, statt diese
// Änderung stillschweigend zu verdrängen.
func (s *Server) handleEntryRestore(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
slug := r.PathValue("slug")
// Die Fassungsnummer steht im Pfad. strconv.Atoi wandelt Text in eine Zahl;
// alles, was keine positive Zahl ist, kann es als Fassung nicht geben.
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil || version < 1 {
http.NotFound(w, r)
return
}
e, err := ws.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
}
// EntryVersionByNumber sucht über die Eintrags-ID. Eine Fassung eines
// fremden Eintrags (oder fremden Projekts) ist darüber nicht erreichbar.
alt, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Fassung konnte nicht geladen werden", err)
return
}
// Fehlt das Feld (oder ist es kaputt), bleibt erwartet bei 0 — dann würde
// PutEntry ohne Konfliktprüfung schreiben. Das wollen wir hier nicht: Wer
// eine alte Fassung zurückholt, soll dabei nie unbemerkt eine neuere
// Änderung eines anderen überschreiben. Deshalb lieber ablehnen.
erwartet, err := strconv.Atoi(r.FormValue("current_version"))
if err != nil || erwartet < 1 {
http.Error(w, "Fassungsangabe fehlt", http.StatusBadRequest)
return
}
geschrieben, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{
ProjectID: project.ID,
Slug: e.Slug,
Title: alt.Title,
Content: alt.Content,
Tags: alt.Tags,
Source: store.SourceHuman,
Author: sess.ActorName(),
Curated: e.Curated,
ExpectedVersion: erwartet,
})
if errors.Is(err, store.ErrConflict) {
// Den Eintrag frisch laden, damit die Verlaufsseite die Änderung zeigt,
// die uns zuvorgekommen ist. Klappt das nicht, reicht auch der alte Stand.
if frisch, ladeFehler := ws.Store.EntryBySlug(r.Context(), project.ID, slug); ladeFehler == nil {
e = frisch
}
s.renderEntryVersions(w, r, http.StatusConflict, e,
"Der Eintrag wurde inzwischen geändert. Bitte den Verlauf prüfen und dann erneut wiederherstellen.")
return
}
if err != nil {
s.serverError(w, "Fassung konnte nicht wiederhergestellt werden", err)
return
}
ws.Search.Refresh(r.Context(), project.ID, geschrieben.Slug)
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryWrite, EntrySlug: e.Slug, Source: store.SourceWeb,
Detail: store.EntryRestoreDetail(alt.Version),
})
meldung := fmt.Sprintf("Fassung %d wiederhergestellt — jetzt Fassung %d.", alt.Version, geschrieben.Version)
http.Redirect(w, r, "/p/"+project.Slug+"/e/"+e.Slug+"?hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
}
// entryDiffData sind die Daten der Vergleichsseite zweier Fassungen.
type entryDiffData struct {
Entry store.Entry
OldVersion int
OldTitle string
OldAuthor string
OldTime time.Time
OldTags []string
NewVersion int
NewTitle string
NewAuthor string
NewTime time.Time
NewTags []string
NewIsCurrent bool // true, wenn "neu" die aktuell gültige Fassung ist
Lines []entry.DiffLine
}
// handleEntryDiff vergleicht eine abgelöste Fassung mit ihrer Nachfolgerin —
// entweder der nächsten abgelösten Fassung oder, falls es keine mehr gibt,
// der aktuellen.
func (s *Server) handleEntryDiff(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
slug := r.PathValue("slug")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil || version < 1 {
http.NotFound(w, r)
return
}
e, err := ws.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
}
alt, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, "Fassung konnte nicht geladen werden", err)
return
}
daten := entryDiffData{
Entry: e,
OldVersion: alt.Version, OldTitle: alt.Title, OldAuthor: alt.Author, OldTime: alt.CreatedAt, OldTags: alt.Tags,
}
neu, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version+1)
switch {
case err == nil:
daten.NewVersion, daten.NewTitle, daten.NewAuthor = neu.Version, neu.Title, neu.Author
daten.NewTime, daten.NewTags = neu.CreatedAt, neu.Tags
daten.Lines = entry.DiffLines(alt.Content, neu.Content)
case errors.Is(err, store.ErrNotFound):
// Keine weitere abgelöste Fassung mehr — die Nachfolgerin ist die
// aktuell gültige.
daten.NewVersion, daten.NewTitle, daten.NewAuthor = e.Version, e.Title, e.Author
daten.NewTime, daten.NewTags, daten.NewIsCurrent = e.UpdatedAt, e.Tags, true
daten.Lines = entry.DiffLines(alt.Content, e.Content)
default:
s.serverError(w, "Fassung konnte nicht geladen werden", err)
return
}
s.render(w, r, "entry-diff.html", http.StatusOK, pageData{
Title: "Vergleich – " + e.Title,
Bereich: "eintraege",
Data: daten,
})
}
// ── Graph ────────────────────────────────────────────────────────────────────
// graphNodeLimit begrenzt, wie viele Knoten die Graph-Ansicht überhaupt an
// den Browser schickt.
//
// Ohne Grenze würde ein sehr großes Projekt zwei Probleme gleichzeitig
// bekommen: Das Layout im Browser läuft mit jedem Knotenpaar (siehe
// static/graph.js — die Abstoßung zwischen allen Knoten ist O(n²)), und die
// Darstellung wird ab einigen hundert Punkten ohnehin unlesbar. Bei
// Überschreitung bleiben die am stärksten verknüpften Knoten übrig — die
// tragen das meiste zur Struktur bei.
const graphNodeLimit = 150
type graphNode struct {
Slug string `json:"slug"`
Title string `json:"title"`
Exists bool `json:"exists"`
Grad int `json:"degree"`
}
type graphEdge struct {
From string `json:"from"`
To string `json:"to"`
}
type graphResponse struct {
Nodes []graphNode `json:"nodes"`
Edges []graphEdge `json:"edges"`
Truncated bool `json:"truncated"`
Total int `json:"total"`
}
// handleEntryGraph zeigt die Graph-Seite — nur das Gerüst, die Daten holt
// static/graph.js selbst über handleEntryGraphData nach.
func (s *Server) handleEntryGraph(w http.ResponseWriter, r *http.Request) {
project, _ := ProjectFrom(r)
s.render(w, r, "graph.html", http.StatusOK, pageData{
Title: "Graph – " + project.Name,
Bereich: "graph",
})
}
// handleEntryGraphData liefert Knoten und Kanten der Graph-Ansicht als JSON.
func (s *Server) handleEntryGraphData(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
knoten, kanten, err := ws.Store.LinkGraph(r.Context(), project.ID)
if err != nil {
s.serverError(w, "Graph konnte nicht geladen werden", err)
return
}
grad := make(map[string]int, len(knoten))
for _, k := range kanten {
grad[k.From]++
grad[k.To]++
}
resp := graphResponse{Nodes: []graphNode{}, Edges: []graphEdge{}, Total: len(knoten)}
behalten := knoten
if len(knoten) > graphNodeLimit {
resp.Truncated = true
sort.Slice(knoten, func(i, j int) bool { return grad[knoten[i].Slug] > grad[knoten[j].Slug] })
behalten = knoten[:graphNodeLimit]
}
erlaubt := make(map[string]bool, len(behalten))
for _, k := range behalten {
erlaubt[k.Slug] = true
resp.Nodes = append(resp.Nodes, graphNode{Slug: k.Slug, Title: k.Title, Exists: k.Exists, Grad: grad[k.Slug]})
}
for _, e := range kanten {
if erlaubt[e.From] && erlaubt[e.To] {
resp.Edges = append(resp.Edges, graphEdge{From: e.From, To: e.To})
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("Graph-Daten konnten nicht gesendet werden: %v", err)
}
}
// 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
}