munin/internal/web/handlers_import.go
Thomas Adamek cf35c3f5db Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)
- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:47 +02:00

142 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package web
// Import von Markdown-Dateien in ein Projekt (AUSBAUSTUFEN.md 3.2).
//
// # Warum über die Weboberfläche und nicht als Kommandozeilenbefehl?
//
// Der Suchindex liegt im Arbeitsspeicher des laufenden Servers (siehe
// internal/search). Ein zweiter Prozess, der direkt in die Datenbank
// schreibt, könnte ihn nicht nachziehen, und die importierten Einträge blieben
// bis zum nächsten Neustart für recall unsichtbar. Hier läuft der Import im
// Server selbst und aktualisiert den Index im selben Zug.
//
// Dazu kommt: So können Projekt-Admins ihre vorhandene Dokumentation selbst
// einspielen, ohne auf den Plattform-Admin zu warten.
//
// # Rechte
//
// Nur mit der Rolle admin, wie die Zugänge. Ein Import schreibt womöglich
// tausende Einträge auf einmal, und wer ihn im Modus „Überschreiben“ startet,
// ändert Einträge, die andere geschrieben haben.
import (
"errors"
"io"
"net/http"
"munin/internal/auth"
"munin/internal/importer"
"munin/internal/store"
)
// importData sind die Daten der Import-Seite.
type importData struct {
// Bericht ist nach einem Lauf gesetzt, sonst nil.
Bericht *importer.Bericht
// Vorschau: Der Bericht zeigt nur, was passieren würde.
Vorschau bool
// Modus ist die zuletzt gewählte Einstellung, damit das Formular sie nach
// einer Vorschau beibehält.
Modus importer.Modus
MaxDateien int
}
// handleImportForm zeigt das Formular zum Hochladen.
func (s *Server) handleImportForm(w http.ResponseWriter, r *http.Request) {
s.renderImport(w, r, http.StatusOK, importData{Modus: importer.ModusUeberspringen}, "", "")
}
// handleImport nimmt ein ZIP-Archiv entgegen und importiert es, oder zeigt
// bei gesetzter Vorschau nur, was passieren würde.
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
project, _ := ProjectFrom(r)
daten := importData{
Modus: importer.Modus(r.FormValue("modus")),
Vorschau: r.FormValue("vorschau") == "1",
}
if daten.Modus != importer.ModusUeberschreiben {
daten.Modus = importer.ModusUeberspringen
}
// r.FormFile liest das hochgeladene Feld "archiv". Die Größe ist schon
// begrenzt (LimitBodies in middleware.go), ReadAll kann also nicht mehr
// als das Import-Limit in den Speicher holen.
datei, _, err := r.FormFile("archiv")
if err != nil {
var zuGross *http.MaxBytesError
if errors.As(err, &zuGross) {
s.renderImport(w, r, http.StatusRequestEntityTooLarge, daten, "",
"Das Archiv ist zu groß. Bitte in mehrere kleinere aufteilen.")
return
}
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Bitte ein ZIP-Archiv auswählen.")
return
}
defer datei.Close()
inhalt, err := io.ReadAll(datei)
if err != nil {
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Das Archiv konnte nicht gelesen werden.")
return
}
dateien, err := importer.LeseZip(inhalt)
if err != nil {
s.renderImport(w, r, http.StatusBadRequest, daten, "", "Import nicht möglich: "+err.Error()+".")
return
}
bericht, err := importer.Importiere(r.Context(), ws.Store, project.ID, dateien, importer.Optionen{
Modus: daten.Modus,
Vorschau: daten.Vorschau,
Autor: sess.ActorName(),
})
// Auch nach einem Abbruch sind die bis dahin geschriebenen Einträge da.
// Deshalb Index und Protokoll in jedem Fall nachziehen, erst danach den
// Fehler melden.
for _, e := range bericht.Geschrieben {
ws.Search.Update(project.ID, e)
}
if !daten.Vorschau && len(bericht.Geschrieben) > 0 {
// Ein Protokolleintrag für den ganzen Import statt einer je Datei.
// Sonst verschwände alles andere im Protokoll unter tausend Zeilen.
// Und die Nutzungsübersicht (4.6) soll zählen, was die Teams im
// Alltag schreiben, nicht einen einmaligen Import.
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryImport, Source: store.SourceWeb, IP: auth.ClientIP(r),
Detail: bericht.Zusammenfassung(),
})
}
if err != nil {
s.serverError(w, "Import abgebrochen nach "+bericht.Zusammenfassung(), err)
return
}
daten.Bericht = &bericht
hinweis := "Import abgeschlossen: " + bericht.Zusammenfassung() + "."
if daten.Vorschau {
hinweis = "Vorschau, noch nichts geschrieben: " + bericht.Zusammenfassung() +
". Zum Übernehmen dasselbe Archiv ohne Häkchen bei „Nur Vorschau“ erneut hochladen."
}
s.renderImport(w, r, http.StatusOK, daten, hinweis, "")
}
// renderImport zeigt die Import-Seite mit optionaler Meldung.
func (s *Server) renderImport(w http.ResponseWriter, r *http.Request, status int, daten importData, hinweis, fehler string) {
project, _ := ProjectFrom(r)
daten.MaxDateien = importer.MaxDateien
s.render(w, r, "import.html", status, pageData{
Title: "Import – " + project.Name,
Bereich: "import",
Flash: hinweis,
Error: fehler,
Data: daten,
})
}