- 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
128 lines
4.9 KiB
Go
128 lines
4.9 KiB
Go
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
|
|
})
|
|
}
|