- 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
200 lines
7.3 KiB
Go
200 lines
7.3 KiB
Go
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{}
|
|
)
|