1004 lines
33 KiB
Go
1004 lines
33 KiB
Go
package web
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html/template"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"munin/internal/duplicates"
|
||
"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
|
||
|
||
// Stale: Die Liste zeigt nur Einträge, die seit über einem Jahr
|
||
// unverändert sind (?veraltet=1). StaleCount nennt, wie viele das im
|
||
// ganzen Projekt sind — für den Filterlink, auch wenn er nicht aktiv ist.
|
||
Stale bool
|
||
StaleCount 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
|
||
|
||
// Stale: seit über einem Jahr unverändert (store.Entry.Stale). Die Liste
|
||
// zeigt dann dieselbe Marke wie die Einzelansicht.
|
||
Stale bool
|
||
}
|
||
|
||
// 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"))
|
||
stale := r.URL.Query().Get("veraltet") != ""
|
||
now := time.Now()
|
||
|
||
data := entryListData{Query: query, Tag: tag, Stale: stale, RetentionDays: store.TrashRetentionDays}
|
||
|
||
var err error
|
||
if query != "" {
|
||
data.Entries, err = s.searchEntries(r, project, query)
|
||
} else {
|
||
filter := store.EntryFilter{Tag: tag, Limit: 100}
|
||
if stale {
|
||
filter.ChangedBefore = store.StaleCutoff(now)
|
||
}
|
||
data.Entries, err = s.listEntries(r, project, filter)
|
||
}
|
||
if err != nil {
|
||
s.serverError(w, "Einträge konnten nicht geladen werden", err)
|
||
return
|
||
}
|
||
for i := range data.Entries {
|
||
data.Entries[i].Stale = data.Entries[i].Entry.Stale(now)
|
||
}
|
||
if data.StaleCount, err = ws.Store.CountStaleEntries(ctx, project.ID, now); err != nil {
|
||
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
|
||
return
|
||
}
|
||
|
||
if data.Tags, err = ws.Store.ListTags(ctx, project.ID); err != nil {
|
||
s.serverError(w, "Schlagworte konnten nicht geladen werden", err)
|
||
return
|
||
}
|
||
if data.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,
|
||
Section: "eintraege",
|
||
ActiveTag: tag,
|
||
Flash: s.hintFrom(r),
|
||
Data: data,
|
||
})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
hits := idx.SearchFuzzy(query, 30)
|
||
|
||
items := make([]entryListItem, 0, len(hits))
|
||
for _, t := range hits {
|
||
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, filter store.EntryFilter) ([]entryListItem, error) {
|
||
ws, _ := WorkspaceFrom(r)
|
||
entries, err := ws.Store.ListEntries(r.Context(), project.ID, filter)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
items := make([]entryListItem, 0, len(entries))
|
||
for _, e := range entries {
|
||
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
|
||
|
||
// Related sind Einträge über einen Zwischeneintrag (zweite Reihe im
|
||
// Verweisgraphen, store.RelatedEntries). Die erste Reihe steht schon im
|
||
// Text (eigene Verweise) bzw. unter Backlinks — doppelt wäre Rauschen.
|
||
// Dasselbe liefert MCP "related" den Agenten.
|
||
Related []store.RelatedEntry
|
||
|
||
// 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.
|
||
content, 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
|
||
}
|
||
|
||
neighbors, err := ws.Store.RelatedEntries(r.Context(), project.ID, slug, 2, relatedLimit)
|
||
if err != nil {
|
||
s.serverError(w, "Verwandte Einträge konnten nicht geladen werden", err)
|
||
return
|
||
}
|
||
var related []store.RelatedEntry
|
||
for _, n := range neighbors {
|
||
if n.Distance == 2 {
|
||
related = append(related, n)
|
||
}
|
||
}
|
||
|
||
s.render(w, r, "entry.html", http.StatusOK, pageData{
|
||
Title: e.Title,
|
||
Section: "eintraege",
|
||
Flash: s.hintFrom(r), // z. B. nach einer Wiederherstellung
|
||
Data: entryViewData{
|
||
Entry: e, HTML: template.HTML(content), Backlinks: backlinks,
|
||
Related: related,
|
||
Stale: e.Stale(time.Now()),
|
||
},
|
||
})
|
||
}
|
||
|
||
// relatedLimit begrenzt die Nachbarn, die die Eintragsansicht lädt (beide
|
||
// Reihen zusammen). Mehr als eine Handvoll in der zweiten Reihe hilft beim
|
||
// Lesen nicht, sondern lenkt ab; wer mehr will, hat den Graph.
|
||
const relatedLimit = 30
|
||
|
||
// 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 prefilled store.Entry
|
||
if slug := r.URL.Query().Get("slug"); store.ValidSlug(slug) {
|
||
prefilled.Slug = slug
|
||
}
|
||
|
||
s.render(w, r, "edit.html", http.StatusOK, pageData{
|
||
Title: "Neuer Eintrag",
|
||
Section: "eintraege",
|
||
Data: entryEditData{IsNew: true, Entry: prefilled},
|
||
})
|
||
}
|
||
|
||
// 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,
|
||
Section: "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.
|
||
suggestions := []suggestItem{}
|
||
|
||
if query == "" {
|
||
entries, 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 entries {
|
||
suggestions = append(suggestions, 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 _, hits := range idx.SearchFuzzy(query, suggestLimit) {
|
||
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, hits.DocID)
|
||
if err != nil {
|
||
continue // Index kann einen Eintrag kennen, den es nicht mehr gibt
|
||
}
|
||
suggestions = append(suggestions, suggestItem{Slug: e.Slug, Title: e.Title})
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
if err := json.NewEncoder(w).Encode(suggestions); err != nil {
|
||
log.Printf("Vorschläge konnten nicht gesendet werden: %v", err)
|
||
}
|
||
}
|
||
|
||
// similarItem ist ein vorhandener Eintrag, der dem gerade angelegten ähnelt
|
||
// — für den Hinweis „Gibt es schon?" im Formular (static/similar.js).
|
||
type similarItem struct {
|
||
Slug string `json:"slug"`
|
||
Title string `json:"title"`
|
||
Overlap int `json:"overlap"`
|
||
Shared []string `json:"shared"`
|
||
}
|
||
|
||
// handleEntrySimilar liefert vorhandene Einträge, die einem neuen Titel
|
||
// ähneln — dieselbe Prüfung, mit der "remember" über MCP bei Agenten
|
||
// nachfragt (internal/duplicates).
|
||
//
|
||
// Anders als bei MCP ist das nur ein Hinweis: Das Formular zeigt die
|
||
// Kandidaten unter dem Titelfeld an, speichern lässt sich trotzdem. Ein
|
||
// Mensch, der die Liste sieht, entscheidet selbst; eine Sperre wäre hier nur
|
||
// lästig.
|
||
func (s *Server) handleEntrySimilar(w http.ResponseWriter, r *http.Request) {
|
||
ws, _ := WorkspaceFrom(r)
|
||
project, _ := ProjectFrom(r)
|
||
title := strings.TrimSpace(r.URL.Query().Get("title"))
|
||
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
|
||
|
||
// Nicht nil, damit die Antwort immer ein JSON-Array ist.
|
||
answer := []similarItem{}
|
||
|
||
if title != "" {
|
||
if slug == "" {
|
||
slug = store.Slugify(title)
|
||
}
|
||
candidates, err := duplicates.Check(r.Context(), ws, project, slug, title, "")
|
||
if err != nil {
|
||
s.serverError(w, "Ähnliche Einträge konnten nicht gesucht werden", err)
|
||
return
|
||
}
|
||
for _, k := range candidates {
|
||
answer = append(answer, similarItem{
|
||
Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, Shared: k.MatchedWords,
|
||
})
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
if err := json.NewEncoder(w).Encode(answer); err != nil {
|
||
log.Printf("Ähnliche Einträ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)
|
||
|
||
title := strings.TrimSpace(r.FormValue("title"))
|
||
content := r.FormValue("content")
|
||
slug := strings.TrimSpace(r.FormValue("slug"))
|
||
|
||
// Kein Bezeichner angegeben (neuer Eintrag) → aus dem Titel ableiten.
|
||
if slug == "" {
|
||
slug = store.Slugify(title)
|
||
}
|
||
|
||
// 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"))
|
||
|
||
written, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{
|
||
ProjectID: project.ID,
|
||
Slug: slug,
|
||
Title: title,
|
||
Content: content,
|
||
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",
|
||
Section: "eintraege",
|
||
Error: err.Error(),
|
||
Data: entryEditData{
|
||
Entry: store.Entry{
|
||
Slug: slug, Title: title, Content: content,
|
||
Version: version, Curated: r.FormValue("curated") != "",
|
||
},
|
||
TagsIn: r.FormValue("tags"),
|
||
IsNew: version == 0,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
ws.Search.Refresh(r.Context(), project.ID, written.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(written),
|
||
})
|
||
|
||
target := "/p/" + project.Slug + "/e/" + slug
|
||
if written.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",
|
||
})
|
||
target = s.withHint(target,
|
||
"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, target, 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",
|
||
})
|
||
|
||
message := fmt.Sprintf("„%s“ liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.",
|
||
slug, store.TrashRetentionDays)
|
||
http.Redirect(w, r, s.withHint("/p/"+project.Slug+"/", message), 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
|
||
}
|
||
|
||
target := bulkTarget(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, target, http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
changed, 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 changed {
|
||
ws.Search.Refresh(r.Context(), project.ID, e.Slug)
|
||
}
|
||
|
||
if len(changed) > 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(changed), add, remove),
|
||
})
|
||
}
|
||
|
||
message := fmt.Sprintf("%d Eintrag/Einträge aktualisiert", len(changed))
|
||
http.Redirect(w, r, s.withHint(target, message), 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
|
||
}
|
||
|
||
target := bulkTarget(r, project)
|
||
|
||
ids := r.Form["ids"]
|
||
if len(ids) == 0 {
|
||
http.Redirect(w, r, s.withHint(target, "Nichts ausgewählt — es wurde nichts gelöscht."), http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
deleted, 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 deleted {
|
||
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)",
|
||
})
|
||
}
|
||
|
||
message := fmt.Sprintf("%d Einträge liegen jetzt im Papierkorb und lassen sich dort %d Tage lang wiederherstellen.",
|
||
len(deleted), store.TrashRetentionDays)
|
||
if len(deleted) == 1 {
|
||
message = fmt.Sprintf("1 Eintrag liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.",
|
||
store.TrashRetentionDays)
|
||
}
|
||
http.Redirect(w, r, s.withHint(target, message), http.StatusSeeOther)
|
||
}
|
||
|
||
// bulkTarget 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 bulkTarget(r *http.Request, project store.Project) string {
|
||
values := url.Values{}
|
||
if tag := r.FormValue("tag"); tag != "" {
|
||
values.Set("tag", tag)
|
||
}
|
||
if q := r.FormValue("q"); q != "" {
|
||
values.Set("q", q)
|
||
}
|
||
|
||
target := "/p/" + project.Slug + "/"
|
||
if len(values) > 0 {
|
||
target += "?" + values.Encode()
|
||
}
|
||
return target
|
||
}
|
||
|
||
// hintParam trägt eine Erfolgsmeldung über einen Redirect, hintSigParam ihre
|
||
// Signatur (siehe auth.Signer.SignHint).
|
||
const (
|
||
hintParam = "hinweis"
|
||
hintSigParam = "hs"
|
||
)
|
||
|
||
// withHint hängt eine Meldung als Parameter "hinweis" an eine Adresse an —
|
||
// mit "?" oder "&", je nachdem, ob schon Parameter dastehen. Dazu kommt die
|
||
// Signatur: Angezeigt wird später nur, was der Server selbst geschrieben hat
|
||
// (siehe hintFrom).
|
||
func (s *Server) withHint(target, message string) string {
|
||
values := url.Values{}
|
||
values.Set(hintParam, message)
|
||
values.Set(hintSigParam, s.signer.SignHint(message))
|
||
|
||
sep := "?"
|
||
if strings.Contains(target, "?") {
|
||
sep = "&"
|
||
}
|
||
return target + sep + values.Encode()
|
||
}
|
||
|
||
// hintFrom liest die Meldung aus der Adresse — aber nur, wenn die Signatur
|
||
// passt. Ein Link mit selbst ausgedachtem Text zeigt also nichts an.
|
||
func (s *Server) hintFrom(r *http.Request) string {
|
||
query := r.URL.Query()
|
||
message := query.Get(hintParam)
|
||
if message == "" || !s.signer.ValidHint(message, query.Get(hintSigParam)) {
|
||
return ""
|
||
}
|
||
return message
|
||
}
|
||
|
||
// ── Ä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, errs string) {
|
||
ws, _ := WorkspaceFrom(r)
|
||
|
||
versions, 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,
|
||
Section: "eintraege",
|
||
Error: errs,
|
||
Data: entryVersionListData{Entry: e, Versions: versions},
|
||
})
|
||
}
|
||
|
||
// 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.
|
||
old, 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.
|
||
want, err := strconv.Atoi(r.FormValue("current_version"))
|
||
if err != nil || want < 1 {
|
||
http.Error(w, "Fassungsangabe fehlt", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
written, err := ws.Store.PutEntry(r.Context(), store.EntryWrite{
|
||
ProjectID: project.ID,
|
||
Slug: e.Slug,
|
||
Title: old.Title,
|
||
Content: old.Content,
|
||
Tags: old.Tags,
|
||
Source: store.SourceHuman,
|
||
Author: sess.ActorName(),
|
||
Curated: e.Curated,
|
||
ExpectedVersion: want,
|
||
})
|
||
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 fresh, loadErr := ws.Store.EntryBySlug(r.Context(), project.ID, slug); loadErr == nil {
|
||
e = fresh
|
||
}
|
||
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, written.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(old.Version),
|
||
})
|
||
|
||
message := fmt.Sprintf("Fassung %d wiederhergestellt — jetzt Fassung %d.", old.Version, written.Version)
|
||
http.Redirect(w, r, s.withHint("/p/"+project.Slug+"/e/"+e.Slug, message), 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
|
||
}
|
||
|
||
old, 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
|
||
}
|
||
|
||
data := entryDiffData{
|
||
Entry: e,
|
||
OldVersion: old.Version, OldTitle: old.Title, OldAuthor: old.Author, OldTime: old.CreatedAt, OldTags: old.Tags,
|
||
}
|
||
|
||
newer, err := ws.Store.EntryVersionByNumber(r.Context(), e.ID, version+1)
|
||
switch {
|
||
case err == nil:
|
||
data.NewVersion, data.NewTitle, data.NewAuthor = newer.Version, newer.Title, newer.Author
|
||
data.NewTime, data.NewTags = newer.CreatedAt, newer.Tags
|
||
data.Lines = entry.DiffLines(old.Content, newer.Content)
|
||
case errors.Is(err, store.ErrNotFound):
|
||
// Keine weitere abgelöste Fassung mehr — die Nachfolgerin ist die
|
||
// aktuell gültige.
|
||
data.NewVersion, data.NewTitle, data.NewAuthor = e.Version, e.Title, e.Author
|
||
data.NewTime, data.NewTags, data.NewIsCurrent = e.UpdatedAt, e.Tags, true
|
||
data.Lines = entry.DiffLines(old.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,
|
||
Section: "eintraege",
|
||
Data: data,
|
||
})
|
||
}
|
||
|
||
// ── 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"`
|
||
Degree 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,
|
||
Section: "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)
|
||
|
||
nodes, edges, err := ws.Store.LinkGraph(r.Context(), project.ID)
|
||
if err != nil {
|
||
s.serverError(w, "Graph konnte nicht geladen werden", err)
|
||
return
|
||
}
|
||
|
||
degree := make(map[string]int, len(nodes))
|
||
for _, k := range edges {
|
||
degree[k.From]++
|
||
degree[k.To]++
|
||
}
|
||
|
||
resp := graphResponse{Nodes: []graphNode{}, Edges: []graphEdge{}, Total: len(nodes)}
|
||
|
||
kept := nodes
|
||
if len(nodes) > graphNodeLimit {
|
||
resp.Truncated = true
|
||
sort.Slice(nodes, func(i, j int) bool { return degree[nodes[i].Slug] > degree[nodes[j].Slug] })
|
||
kept = nodes[:graphNodeLimit]
|
||
}
|
||
|
||
allowed := make(map[string]bool, len(kept))
|
||
for _, k := range kept {
|
||
allowed[k.Slug] = true
|
||
resp.Nodes = append(resp.Nodes, graphNode{Slug: k.Slug, Title: k.Title, Exists: k.Exists, Degree: degree[k.Slug]})
|
||
}
|
||
for _, e := range edges {
|
||
if allowed[e.From] && allowed[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(input string) []string {
|
||
if strings.TrimSpace(input) == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(input, ",")
|
||
for i := range parts {
|
||
parts[i] = strings.TrimSpace(parts[i])
|
||
}
|
||
return parts
|
||
}
|