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:
61
controllers/api/batch.go
Normal file
61
controllers/api/batch.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user