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 }) }