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 }