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

61
controllers/api/batch.go Normal file
View File

@@ -0,0 +1,61 @@
package api
import (
"context"
"errors"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
)
// ApplyBatchInput is the body of POST /_seo/api/v1/batch.
type ApplyBatchInput struct {
DryRun bool `json:"dry_run,omitempty" jsonschema:"answer the diff and the conflicts without writing anything"`
Changes []classes.BatchChange `json:"changes" jsonschema:"the changes, applied in order: op set_seo (url, locale, values, force_rule), create_redirect and update_redirect (id, redirect), delete_redirect (id), create_rule and update_rule (id, rule), delete_rule (id)"`
}
// ApplyBatch serves POST /_seo/api/v1/batch (D-12, P-26): the changes run
// in one transaction. With dry_run the answer is every change's diff and
// the conflicts (redirect loops and chains, noindex on a published
// record's address, a canonical pointing to a redirected or noindex
// address) and nothing is written. Without it the batch is applied as a
// whole under one batch id, which history and undo_batch use. A refused
// change refuses the whole batch with 422: redirect_loop with the change
// and the loop's path, or batch_refused with the change's errors.
func ApplyBatch(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var in ApplyBatchInput
if err := decode(r, 4*maxBody, &in); err != nil {
return 0, nil, err
}
res, err := svc.RunBatch(ctx, in.Changes, classes.BatchOptions{DryRun: in.DryRun})
var refused *classes.BatchRefusedError
if errors.As(err, &refused) {
return 0, nil, &apiError{Status: http.StatusUnprocessableEntity, Body: BatchRefusal(refused.Result)}
}
if err != nil {
return 0, nil, err
}
return http.StatusOK, res, nil
})
}
// BatchRefusal is the 422 answer of a refused batch: the first refused
// change, its error, path or field errors, and every change's outcome.
func BatchRefusal(res classes.BatchResult) map[string]any {
item := res.Refused()
out := map[string]any{"error": "batch_refused", "changes": res.Changes}
if item == nil {
return out
}
out["change"] = item.Index
out["message"] = item.Message
if item.Error == classes.ConflictLoop {
out["error"] = "redirect_loop"
out["path"] = item.Path
}
if len(item.Errors) > 0 {
out["errors"] = item.Errors
}
return out
}

View File

@@ -2,10 +2,12 @@ package api
import (
"context"
"errors"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// ListHistoryInput is the query of GET /_seo/api/v1/history.
@@ -93,3 +95,64 @@ func ShowHistory(service func() *classes.Service) http.HandlerFunc {
return http.StatusOK, map[string]any{"data": RevisionJSON(rows[0])}, nil
})
}
// HistoryBatchInput names one batch.
type HistoryBatchInput struct {
Batch string `json:"batch" path:"batch" jsonschema:"the batch id"`
}
// undoAnswer maps an undo refusal to 409 with its code and message.
func undoAnswer(ctx context.Context, svc *classes.Service, err error) error {
var refusal *classes.UndoRefusal
if errors.As(err, &refusal) {
return &apiError{Status: http.StatusConflict, Body: map[string]any{
"error": refusal.Code,
"message": svc.PhraseCtx(ctx, refusal.Key, refusal.Params),
}}
}
return err
}
// UndoChange serves POST /_seo/api/v1/history/{id}/undo (D-13): the values
// before the change are restored and the undo is recorded (via undo). It
// answers 409 undo_stale when a later change touched the same values,
// undo_loop when the restore would create a redirect loop, undo_gone when
// the record is gone and already_undone; nothing is undone then.
func UndoChange(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
var ids []uint
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
ids, err = svc.Undo(ctx, tx, id)
return err
})
if err != nil {
return 0, nil, undoAnswer(ctx, svc, err)
}
return http.StatusOK, map[string]any{"undone": ids, "count": len(ids)}, nil
})
}
// UndoBatch serves POST /_seo/api/v1/history/batches/{batch}/undo: every
// change of the batch not undone yet is undone in one transaction, newest
// first, or none is (the same refusals as one change). 404 when nothing of
// the batch is left to undo.
func UndoBatch(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
batch := r.PathValue("batch")
var ids []uint
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
ids, err = svc.UndoBatch(ctx, tx, batch)
return err
})
if err != nil {
return 0, nil, undoAnswer(ctx, svc, err)
}
return http.StatusOK, map[string]any{"undone": ids, "count": len(ids)}, nil
})
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
@@ -51,47 +50,6 @@ type UpdateRedirectInput struct {
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
}
// RedirectBody is the decoded body of a redirect write; the batch and the
// import use it too.
type RedirectBody struct {
Source *string `json:"source,omitempty"`
Target *string `json:"target,omitempty"`
Status *int `json:"status,omitempty"`
Match *string `json:"match,omitempty"`
Query *string `json:"query,omitempty"`
IgnoreCase *bool `json:"ignore_case,omitempty"`
TrailingSlash *bool `json:"trailing_slash,omitempty"`
Note *string `json:"note,omitempty"`
}
// Apply writes the fields sent onto r.
func (b RedirectBody) Apply(r *models.Redirect) {
if b.Source != nil {
r.Source = strings.TrimSpace(*b.Source)
}
if b.Target != nil {
r.Target = strings.TrimSpace(*b.Target)
}
if b.Status != nil {
r.Status = models.RedirectStatus(*b.Status)
}
if b.Match != nil {
r.Match = strings.TrimSpace(*b.Match)
}
if b.Query != nil {
r.Query = strings.TrimSpace(*b.Query)
}
if b.IgnoreCase != nil {
r.IgnoreCase = *b.IgnoreCase
}
if b.TrailingSlash != nil {
r.TrailingSlash = *b.TrailingSlash
}
if b.Note != nil {
r.Note = strings.TrimSpace(*b.Note)
}
}
// RedirectJSON is a redirect in an answer.
func RedirectJSON(r models.Redirect) map[string]any {
out := classes.RedirectValues(r)
@@ -175,7 +133,7 @@ func ShowRedirect(service func() *classes.Service) http.HandlerFunc {
// validation_failed for invalid fields. The origin is mcp through MCP.
func CreateRedirect(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var body RedirectBody
var body classes.RedirectFields
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
@@ -203,7 +161,7 @@ func UpdateRedirect(service func() *classes.Service) http.HandlerFunc {
if err != nil {
return 0, nil, err
}
var body RedirectBody
var body classes.RedirectFields
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}

View File

@@ -3,7 +3,6 @@ package api
import (
"context"
"net/http"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
@@ -53,33 +52,6 @@ type UpdateRuleInput struct {
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
}
// ruleBody is the decoded body of a rule write.
type ruleBody struct {
URL *string `json:"url"`
Match *string `json:"match"`
Title *string `json:"title"`
Description *string `json:"description"`
Canonical *string `json:"canonical"`
Robots *string `json:"robots"`
OGTitle *string `json:"og_title"`
OGDescription *string `json:"og_description"`
OGImage *string `json:"og_image"`
Note *string `json:"note"`
}
// apply writes the fields sent onto r.
func (b ruleBody) apply(r *models.Rule) {
for column, v := range map[string]*string{
"url": b.URL, "match": b.Match, "title": b.Title, "description": b.Description,
"canonical": b.Canonical, "robots": b.Robots, "og_title": b.OGTitle,
"og_description": b.OGDescription, "og_image": b.OGImage, "note": b.Note,
} {
if v != nil {
classes.SetRuleValue(r, column, strings.TrimSpace(*v))
}
}
}
// RuleJSON is a rule in an answer.
func RuleJSON(r models.Rule) map[string]any {
out := classes.RuleValues(r)
@@ -142,12 +114,12 @@ func ShowRule(service func() *classes.Service) http.HandlerFunc {
// an invalid or taken address or invalid values.
func CreateRule(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var body ruleBody
var body classes.RuleFields
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
rule := models.Rule{Match: models.MatchExact}
body.apply(&rule)
body.Apply(&rule)
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
return svc.SaveRule(ctx, tx, &rule)
})
@@ -165,7 +137,7 @@ func UpdateRule(service func() *classes.Service) http.HandlerFunc {
if err != nil {
return 0, nil, err
}
var body ruleBody
var body classes.RuleFields
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
@@ -175,7 +147,7 @@ func UpdateRule(service func() *classes.Service) http.HandlerFunc {
if rule, err = classes.LoadRule(tx, id); err != nil {
return err
}
body.apply(&rule)
body.Apply(&rule)
return svc.SaveRule(ctx, tx, &rule)
})
if err != nil {

127
controllers/api/transfer.go Normal file
View File

@@ -0,0 +1,127 @@
package api
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
)
// ExportInput is the query of GET /_seo/api/v1/export.
type ExportInput struct {
Dataset string `json:"dataset" query:"dataset" jsonschema:"redirects, rules or records (the SEO of records, addressed by URL)"`
Delimiter string `json:"delimiter,omitempty" query:"delimiter" jsonschema:"the CSV delimiter: , or ; (the SEO settings' delimiter by default)"`
Page int `json:"page,omitempty" query:"page" jsonschema:"the page of 1000 rows, from 1"`
}
// ImportInput is the body of POST /_seo/api/v1/import.
type ImportInput struct {
Dataset string `json:"dataset" jsonschema:"redirects, rules or records"`
CSV string `json:"csv" jsonschema:"the file: a header row with the export's column names, then the rows; at most 900 KB per call"`
Delimiter string `json:"delimiter,omitempty" jsonschema:"the CSV delimiter: , or ; (detected from the header when omitted)"`
DryRun bool `json:"dry_run,omitempty" jsonschema:"preview the rows (new, changed, unchanged, refused, with notes) without writing anything"`
}
// mcpPreviewRows caps the preview rows an import answers through the API.
const mcpPreviewRows = 1000
// transferRefusal maps an import or export refusal: 422 with the code and
// the message (and the column of missing_column), 409 for a stale
// preview, 422 import_refused with the preview while a row is refused.
func transferRefusal(ctx context.Context, svc *classes.Service, err error) error {
var te *classes.TransferError
var refused *classes.ImportRefusedError
switch {
case errors.As(err, &te):
body := map[string]any{"error": te.Code, "message": svc.PhraseCtx(ctx, te.Key, te.Params)}
if c := te.Params["column"]; c != "" {
body["column"] = c
}
status := http.StatusUnprocessableEntity
if te.Code == "stale" {
status = http.StatusConflict
}
return &apiError{Status: status, Body: body}
case errors.As(err, &refused):
res := refused.Result
capRows(&res, mcpPreviewRows)
return &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{
"error": "import_refused", "message": svc.PhraseCtx(ctx, "golem15.seo::lang.transfer.refused_blocks", nil),
"summary": res.Summary, "rows": res.Rows, "total": res.Total,
}}
}
return err
}
// capRows keeps the first max preview rows.
func capRows(res *classes.ImportResult, max int) {
if len(res.Rows) > max {
res.Rows = res.Rows[:max]
}
}
// delimiterOf is the request's delimiter, else the settings' one.
func delimiterOf(ctx context.Context, svc *classes.Service, given string) string {
if d := strings.TrimSpace(given); d == "," || d == ";" {
return d
}
db, err := read(ctx, svc)
if err != nil {
return ","
}
row, err := classes.LoadSettings(db)
if err != nil || row.CSVDelimiter == "" {
return ","
}
return row.CSVDelimiter
}
// ExportCSV serves GET /_seo/api/v1/export?dataset=&delimiter=&page= (D-13,
// P-27): one page of 1000 rows of a dataset as CSV text, UTF-8 with a BOM,
// formula cells prefixed with an apostrophe, with the page count.
func ExportCSV(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
out, err := svc.Export(ctx, q.Get("dataset"), delimiterOf(ctx, svc, q.Get("delimiter")), page, classes.ExportPageRows)
if err != nil {
return 0, nil, transferRefusal(ctx, svc, err)
}
return http.StatusOK, out, nil
})
}
// ImportCSV serves POST /_seo/api/v1/import (D-13, P-27): with dry_run the
// preview of every row; without it the rows are applied as one batch (via
// import) or, while a row is refused (a loop, an invalid value), nothing
// is (422 import_refused). A file over 900 KB is 413; an unreadable file,
// a missing column or an unknown dataset 422.
func ImportCSV(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var in ImportInput
if err := decode(r, 2*maxBody, &in); err != nil {
return 0, nil, err
}
if len(in.CSV) > classes.MCPImportLimit {
return 0, nil, &apiError{Status: http.StatusRequestEntityTooLarge, Body: map[string]any{
"error": "too_large", "message": svc.PhraseCtx(ctx, "golem15.seo::lang.transfer.too_large_mcp", nil),
}}
}
delimiter := strings.TrimSpace(in.Delimiter)
if delimiter != "," && delimiter != ";" {
delimiter = ""
}
res, err := svc.Import(ctx, classes.ImportRequest{Dataset: in.Dataset, CSV: in.CSV, Delimiter: delimiter, DryRun: in.DryRun})
if err != nil {
return 0, nil, transferRefusal(ctx, svc, err)
}
capRows(&res, mcpPreviewRows)
return http.StatusOK, map[string]any{
"dry_run": in.DryRun, "applied": res.Applied, "batch_id": res.BatchID,
"summary": res.Summary, "rows": res.Rows, "total": res.Total, "count": res.Count,
}, nil
})
}