- 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
62 lines
2.3 KiB
Go
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
|
|
}
|