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:
199
controllers/transfer.go
Normal file
199
controllers/transfer.go
Normal 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{}
|
||||
)
|
||||
Reference in New Issue
Block a user