884 lines
29 KiB
Go
884 lines
29 KiB
Go
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
|
||
|
||
// RetentionDays: so lange liegen gelöschte Einträge im Papierkorb — für
|
||
// die Rückfrage beim Sammellöschen, damit dort dieselbe Zahl steht, nach
|
||
// der auch aufgeräumt wird.
|
||
RetentionDays 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, RetentionDays: store.TrashRetentionDays}
|
||
|
||
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.SearchFuzzy(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.SearchFuzzy(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 := sammelZiel(r, project)
|
||
|
||
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))
|
||
http.Redirect(w, r, mitHinweis(ziel, meldung), http.StatusSeeOther)
|
||
}
|
||
|
||
// handleEntryBulkDelete legt die in der Eintragsliste ausgewählten Einträge
|
||
// in den Papierkorb — das Gegenstück zu handleEntryDelete für viele auf
|
||
// einmal. Die Rückfrage stellt der Browser vorher (data-confirm am Knopf,
|
||
// siehe static/confirm.js).
|
||
//
|
||
// Wie beim einzelnen Löschen ist nichts endgültig: Die Einträge liegen 30
|
||
// Tage im Papierkorb, und dort holt „Alle … wiederherstellen" sie in einem Zug
|
||
// zurück, falls versehentlich zu viel ausgewählt war.
|
||
func (s *Server) handleEntryBulkDelete(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 := sammelZiel(r, project)
|
||
|
||
ids := r.Form["ids"]
|
||
if len(ids) == 0 {
|
||
http.Redirect(w, r, mitHinweis(ziel, "Nichts ausgewählt — es wurde nichts gelöscht."), http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
geloescht, err := ws.Store.MoveEntriesToTrash(r.Context(), project.ID, ids, sess.ActorName())
|
||
if err != nil {
|
||
s.serverError(w, "Einträge konnten nicht gelöscht werden", err)
|
||
return
|
||
}
|
||
|
||
// Je Eintrag eine eigene Protokollzeile, genau wie beim einzelnen
|
||
// Löschen. So zählt die Nutzungsübersicht jede Löschung, und im Protokoll
|
||
// steht bei jedem Eintrag, wer ihn wann gelöscht hat.
|
||
for _, slug := range geloescht {
|
||
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 (Sammelauswahl)",
|
||
})
|
||
}
|
||
|
||
meldung := fmt.Sprintf("%d Einträge liegen jetzt im Papierkorb und lassen sich dort %d Tage lang wiederherstellen.",
|
||
len(geloescht), store.TrashRetentionDays)
|
||
if len(geloescht) == 1 {
|
||
meldung = fmt.Sprintf("1 Eintrag liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.",
|
||
store.TrashRetentionDays)
|
||
}
|
||
http.Redirect(w, r, mitHinweis(ziel, meldung), http.StatusSeeOther)
|
||
}
|
||
|
||
// sammelZiel ist die Adresse, zu der die Sammelaktionen der Eintragsliste
|
||
// zurückführen: dieselbe Liste, mit demselben Schlagwortfilter und derselben
|
||
// Suche wie vorher. Sonst stünde man nach dem Löschen aus einer Trefferliste
|
||
// plötzlich vor allen Einträgen.
|
||
func sammelZiel(r *http.Request, project store.Project) string {
|
||
werte := url.Values{}
|
||
if tag := r.FormValue("tag"); tag != "" {
|
||
werte.Set("tag", tag)
|
||
}
|
||
if q := r.FormValue("q"); q != "" {
|
||
werte.Set("q", q)
|
||
}
|
||
|
||
ziel := "/p/" + project.Slug + "/"
|
||
if len(werte) > 0 {
|
||
ziel += "?" + werte.Encode()
|
||
}
|
||
return ziel
|
||
}
|
||
|
||
// mitHinweis hängt eine Meldung als Parameter "hinweis" an eine Adresse an —
|
||
// mit "?" oder "&", je nachdem, ob schon Parameter dastehen.
|
||
func mitHinweis(ziel, meldung string) string {
|
||
trenner := "?"
|
||
if strings.Contains(ziel, "?") {
|
||
trenner = "&"
|
||
}
|
||
return ziel + trenner + "hinweis=" + url.QueryEscape(meldung)
|
||
}
|
||
|
||
// ── Ä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
|
||
}
|