Files
sm-seo-plugin/controllers/api/rules.go
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

180 lines
6.4 KiB
Go

package api
import (
"context"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// ListRulesInput is the query of GET /_seo/api/v1/rules.
type ListRulesInput struct {
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
Search string `json:"search,omitempty" query:"search" jsonschema:"text the address, title or description contains"`
Match string `json:"match,omitempty" query:"match" jsonschema:"exact or pattern"`
}
// RuleIDInput names one URL rule.
type RuleIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the URL rule id"`
}
// CreateRuleInput is the body of POST /_seo/api/v1/rules.
type CreateRuleInput struct {
URL string `json:"url" jsonschema:"the address: a path with an optional query; with match pattern * matches any text"`
Match *string `json:"match,omitempty" jsonschema:"exact (the default) or pattern"`
Title *string `json:"title,omitempty" jsonschema:"the SEO title"`
Description *string `json:"description,omitempty" jsonschema:"the meta description"`
Canonical *string `json:"canonical,omitempty" jsonschema:"the canonical URL"`
Robots *string `json:"robots,omitempty" jsonschema:"robots directives, for example noindex,follow"`
OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"`
OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"`
OGImage *string `json:"og_image,omitempty" jsonschema:"the social image"`
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
}
// UpdateRuleInput is PUT /_seo/api/v1/rules/{id}: only the fields sent are
// changed.
type UpdateRuleInput struct {
ID uint `json:"id" path:"id" jsonschema:"the URL rule id"`
URL *string `json:"url,omitempty" jsonschema:"the address"`
Match *string `json:"match,omitempty" jsonschema:"exact or pattern"`
Title *string `json:"title,omitempty" jsonschema:"the SEO title"`
Description *string `json:"description,omitempty" jsonschema:"the meta description"`
Canonical *string `json:"canonical,omitempty" jsonschema:"the canonical URL"`
Robots *string `json:"robots,omitempty" jsonschema:"robots directives"`
OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"`
OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"`
OGImage *string `json:"og_image,omitempty" jsonschema:"the social image"`
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
}
// RuleJSON is a rule in an answer.
func RuleJSON(r models.Rule) map[string]any {
out := classes.RuleValues(r)
out["id"] = r.ID
out["created_at"] = r.CreatedAt
out["updated_at"] = r.UpdatedAt
return out
}
// ListRules serves GET /_seo/api/v1/rules: URL rules by address, filtered
// by search text and match.
func ListRules(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
in := listParams(r)
q := db.Model(&models.Rule{}).Order("url ASC, id ASC")
if in.Search != "" {
p := like(in.Search)
q = q.Where("lower(url) LIKE ? OR lower(title) LIKE ? OR lower(description) LIKE ?", p, p, p)
}
if m := r.URL.Query().Get("match"); m != "" {
q = q.Where("match = ?", m)
}
var rows []models.Rule
total, err := paginate(q, in, &rows)
if err != nil {
return 0, nil, err
}
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
out = append(out, RuleJSON(row))
}
return http.StatusOK, listBody(out, in, total), nil
})
}
// ShowRule serves GET /_seo/api/v1/rules/{id}.
func ShowRule(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
rule, err := classes.LoadRule(db, id)
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": RuleJSON(rule)}, nil
})
}
// CreateRule serves POST /_seo/api/v1/rules: 201 with the rule, 422 for
// an invalid or taken address or invalid values.
func CreateRule(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var body classes.RuleFields
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
rule := models.Rule{Match: models.MatchExact}
body.Apply(&rule)
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
return svc.SaveRule(ctx, tx, &rule)
})
if err != nil {
return 0, nil, err
}
return http.StatusCreated, map[string]any{"data": RuleJSON(rule)}, nil
})
}
// UpdateRule serves PUT /_seo/api/v1/rules/{id}: the fields sent change.
func UpdateRule(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
var body classes.RuleFields
if err := decode(r, maxBody, &body); err != nil {
return 0, nil, err
}
var rule models.Rule
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
if rule, err = classes.LoadRule(tx, id); err != nil {
return err
}
body.Apply(&rule)
return svc.SaveRule(ctx, tx, &rule)
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": RuleJSON(rule)}, nil
})
}
// DeleteRule serves DELETE /_seo/api/v1/rules/{id}.
func DeleteRule(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
rule, err := classes.LoadRule(tx, id)
if err != nil {
return err
}
return svc.DeleteRule(ctx, tx, rule)
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"deleted": id}, nil
})
}