feat: batches with dry run, undo, and CSV import and export

- POST /_seo/api/v1/batch runs changes in one transaction: dry_run
  answers the diff and the conflicts (loops, chains, noindex on a
  published record, canonicals to redirected or noindex addresses) and
  rolls back; a refused change refuses the batch (D-12)
- undo of a change or a whole batch from History and through MCP, with
  the undo_stale, undo_loop and undo_gone refusals (D-13)
- CSV export and import of redirects, rules and records by URL: BOM,
  comma or semicolon, formula cells escaped, a dry-run preview, all or
  nothing with via import; the screen's apply token is HMAC-SHA256 over
  the file and a fingerprint of the data (transfer.stale)
- Import and export singleton with the seo_transfer widget action,
  History record actions undo and undo_batch, navigation entry
- MCP tools undo_change, undo_batch, apply_batch, export_csv, import_csv
- TestBatch, TestUndo, TestCSVTransfer, TestLoopRefused mcp and import
This commit is contained in:
2026-10-27 12:35:26 +01:00
parent aa0c3bcacf
commit ab3b219fec
27 changed files with 3319 additions and 96 deletions

199
controllers/transfer.go Normal file
View File

@@ -0,0 +1,199 @@
package controllers
import (
"context"
"encoding/json"
"errors"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// TransferControllerID is the import and export screen (UI-SPEC C13).
const TransferControllerID = "golem15.seo.transfer"
// TransferAction is the files widget's action (E6): ops export, preview
// and apply.
const TransferAction = "seo_transfer"
// transferAdminController is the import and export singleton (D-12, D-13,
// P-27) on the settings row: the delimiter and the E6 files widget.
type transferAdminController struct {
app func() *backpack.App
}
func (transferAdminController) ID() string { return TransferControllerID }
func (transferAdminController) ModelName() string { return `Golem15\SEO\Models\Transfer` }
func (transferAdminController) ConfigDir() string { return "controllers/transfer" }
func (transferAdminController) NewRecord() any { return &models.Transfer{} }
func (transferAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
func (c transferAdminController) service() *classes.Service {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil
}
return svc
}
// SingletonRecord returns the settings row as the screen's record; an
// unset delimiter shows semicolon when the site's default locale is pl
// (Excel in Polish), else comma.
func (c transferAdminController) SingletonRecord(ctx context.Context, db *gorm.DB) (any, error) {
if db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
if _, err := classes.SettingsRow(db); err != nil {
return nil, err
}
var rows []models.Transfer
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, errors.New("golem15.seo: settings row is missing")
}
row := rows[0]
if row.CSVDelimiter == "" {
row.CSVDelimiter = ","
if svc := c.service(); svc != nil && strings.HasPrefix(svc.DefaultLocale(ctx, db), "pl") {
row.CSVDelimiter = ";"
}
}
return &row, nil
}
// FormBeforeUpdate refuses a delimiter other than comma or semicolon.
func (c transferAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
row, ok := model.(*models.Transfer)
if !ok || row == nil {
return nil
}
if row.CSVDelimiter != "," && row.CSVDelimiter != ";" {
return &cabana.ValidationError{Details: map[string]any{"csv_delimiter": []string{Phrase(ctx, translator(c.app), "golem15.seo::lang.api.value_invalid", nil)}}}
}
return nil
}
// AdminJS loads the SEO element bundle (E6) on the screen.
func (transferAdminController) AdminJS() []string { return []string{"assets/dist/seo.js"} }
// AdminCSS loads the SEO element styles on the screen.
func (transferAdminController) AdminCSS() []string { return []string{"assets/dist/seo.css"} }
// transferPayload is the widget's request.
type transferPayload struct {
Op string `json:"op"`
Dataset string `json:"dataset"`
CSV string `json:"csv"`
Token string `json:"token"`
}
// AdminActions registers the files widget's action.
func (c transferAdminController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{{
Name: TransferAction,
Label: "golem15.seo::lang.transfer.preview",
Permissions: []string{PermissionManage},
Run: c.run,
}}
}
// refusal is an op's answer that the element shows above its actions: a
// refusal it can explain, sent as data so the preview stays on screen.
func refusal(op, code, message string) pact.AdminActionResult {
return pact.AdminActionResult{Data: map[string]any{"op": op, "error": code, "message": message}}
}
// run serves the widget (UI-SPEC C13): export answers {filename, csv};
// preview answers {token, summary, rows (the first 200, refused first),
// total, count}; apply re-runs the preview of the token's file, refuses
// with transfer.stale when the data changed since, and applies every row
// as one import batch.
func (c transferAdminController) run(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
svc := c.service()
if svc == nil {
return pact.AdminActionResult{}, classes.ErrNotBooted
}
var p transferPayload
if len(in.Payload) > 0 {
if err := json.Unmarshal(in.Payload, &p); err != nil {
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.transfer.unreadable"}}}
}
}
delimiter, _ := in.Values["csv_delimiter"].(string)
if delimiter != ";" {
delimiter = ","
}
phrase := func(key string, params map[string]string) string { return svc.PhraseCtx(ctx, key, params) }
failed := func(op string, err error) (pact.AdminActionResult, error) {
var te *classes.TransferError
var refused *classes.ImportRefusedError
switch {
case errors.As(err, &te):
return refusal(op, te.Code, phrase(te.Key, te.Params)), nil
case errors.As(err, &refused):
return refusal(op, "refused", phrase("golem15.seo::lang.transfer.refused_blocks", nil)), nil
}
return pact.AdminActionResult{}, err
}
switch p.Op {
case "export":
out, err := svc.Export(ctx, p.Dataset, delimiter, 0, 0)
if err != nil {
return failed("export", err)
}
if len(out.CSV) > classes.ExportActionLimit {
return refusal("export", "too_large", phrase("golem15.seo::lang.transfer.export_too_large", nil)), nil
}
return pact.AdminActionResult{Data: map[string]any{"op": "export", "filename": out.Filename, "csv": out.CSV}}, nil
case "preview":
if len(p.CSV) > classes.AdminImportLimit {
return refusal("preview", "too_large", phrase("golem15.seo::lang.transfer.too_large", nil)), nil
}
res, err := svc.Import(ctx, classes.ImportRequest{Dataset: p.Dataset, CSV: p.CSV, Delimiter: delimiter, DryRun: true})
if err != nil {
return failed("preview", err)
}
token, err := svc.TransferToken(p.Dataset, delimiter, p.CSV, res.Fingerprint)
if err != nil {
return pact.AdminActionResult{}, err
}
rows := res.Rows
if len(rows) > classes.PreviewRowCap {
rows = rows[:classes.PreviewRowCap]
}
return pact.AdminActionResult{Data: map[string]any{
"op": "preview", "token": token, "dataset": p.Dataset, "summary": res.Summary,
"rows": rows, "total": res.Total, "count": res.Count,
}}, nil
case "apply":
req, err := svc.ReadTransferToken(p.Token)
if err != nil {
return refusal("apply", "token", phrase("golem15.seo::lang.transfer.token_invalid", nil)), nil
}
res, err := svc.Import(ctx, req)
if err != nil {
return failed("apply", err)
}
return pact.AdminActionResult{
Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.transfer.applied", res.Count, nil),
Data: map[string]any{"op": "apply", "applied": true, "count": res.Count, "batch_id": res.BatchID},
}, nil
}
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.transfer.unreadable"}}}
}
var (
_ pact.AdminController = transferAdminController{}
_ pact.AdminPermissioned = transferAdminController{}
_ pact.AdminRecordSource = transferAdminController{}
_ pact.AdminSingleton = transferAdminController{}
_ pact.FormBeforeUpdate = transferAdminController{}
_ pact.HasAdminActions = transferAdminController{}
_ pact.AdminClientAssets = transferAdminController{}
)