Files
Jakub Zych ab3b219fec 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
2026-10-27 12:35:26 +01:00

62 lines
2.3 KiB
Go

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
}