- 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
159 lines
5.3 KiB
Go
159 lines
5.3 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ListHistoryInput is the query of GET /_seo/api/v1/history.
|
|
type ListHistoryInput 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 description contains"`
|
|
Via string `json:"via,omitempty" query:"via" jsonschema:"admin, mcp, import, auto or undo"`
|
|
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"entry, rule, redirect, robots, settings or notfound"`
|
|
Batch string `json:"batch,omitempty" query:"batch" jsonschema:"a batch id: the changes of one batch or import"`
|
|
}
|
|
|
|
// HistoryIDInput names one history entry.
|
|
type HistoryIDInput struct {
|
|
ID uint `json:"id" path:"id" jsonschema:"the history entry id"`
|
|
}
|
|
|
|
// RevisionJSON is a history entry in an answer.
|
|
func RevisionJSON(r models.Revision) map[string]any {
|
|
batch := ""
|
|
if r.BatchID != nil {
|
|
batch = *r.BatchID
|
|
}
|
|
return map[string]any{
|
|
"id": r.ID, "created_at": r.CreatedAt, "author": r.AuthorLabel, "author_id": r.AuthorID,
|
|
"via": r.Via, "kind": r.SubjectKind, "subject": r.SubjectID, "what": r.What,
|
|
"before": r.Before, "after": r.After, "batch_id": batch, "undone_at": r.UndoneAt,
|
|
}
|
|
}
|
|
|
|
// ListHistory serves GET /_seo/api/v1/history: newest first, filtered by
|
|
// writer, subject kind, batch and description text (D-13).
|
|
func ListHistory(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.Revision{}).Order("id DESC")
|
|
if in.Search != "" {
|
|
q = q.Where("lower(what) LIKE ?", like(in.Search))
|
|
}
|
|
query := r.URL.Query()
|
|
if v := query.Get("via"); v != "" {
|
|
q = q.Where("via = ?", v)
|
|
}
|
|
if v := query.Get("kind"); v != "" {
|
|
q = q.Where("subject_kind = ?", v)
|
|
}
|
|
if v := query.Get("batch"); v != "" {
|
|
q = q.Where("batch_id = ?", v)
|
|
}
|
|
var rows []models.Revision
|
|
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, RevisionJSON(row))
|
|
}
|
|
return http.StatusOK, listBody(out, in, total), nil
|
|
})
|
|
}
|
|
|
|
// ShowHistory serves GET /_seo/api/v1/history/{id}.
|
|
func ShowHistory(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
|
|
}
|
|
var rows []models.Revision
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return 0, nil, classes.ErrNoRecord
|
|
}
|
|
return http.StatusOK, map[string]any{"data": RevisionJSON(rows[0])}, nil
|
|
})
|
|
}
|
|
|
|
// HistoryBatchInput names one batch.
|
|
type HistoryBatchInput struct {
|
|
Batch string `json:"batch" path:"batch" jsonschema:"the batch id"`
|
|
}
|
|
|
|
// undoAnswer maps an undo refusal to 409 with its code and message.
|
|
func undoAnswer(ctx context.Context, svc *classes.Service, err error) error {
|
|
var refusal *classes.UndoRefusal
|
|
if errors.As(err, &refusal) {
|
|
return &apiError{Status: http.StatusConflict, Body: map[string]any{
|
|
"error": refusal.Code,
|
|
"message": svc.PhraseCtx(ctx, refusal.Key, refusal.Params),
|
|
}}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// UndoChange serves POST /_seo/api/v1/history/{id}/undo (D-13): the values
|
|
// before the change are restored and the undo is recorded (via undo). It
|
|
// answers 409 undo_stale when a later change touched the same values,
|
|
// undo_loop when the restore would create a redirect loop, undo_gone when
|
|
// the record is gone and already_undone; nothing is undone then.
|
|
func UndoChange(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 ids []uint
|
|
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
|
var err error
|
|
ids, err = svc.Undo(ctx, tx, id)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return 0, nil, undoAnswer(ctx, svc, err)
|
|
}
|
|
return http.StatusOK, map[string]any{"undone": ids, "count": len(ids)}, nil
|
|
})
|
|
}
|
|
|
|
// UndoBatch serves POST /_seo/api/v1/history/batches/{batch}/undo: every
|
|
// change of the batch not undone yet is undone in one transaction, newest
|
|
// first, or none is (the same refusals as one change). 404 when nothing of
|
|
// the batch is left to undo.
|
|
func UndoBatch(service func() *classes.Service) http.HandlerFunc {
|
|
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
|
batch := r.PathValue("batch")
|
|
var ids []uint
|
|
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
|
var err error
|
|
ids, err = svc.UndoBatch(ctx, tx, batch)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return 0, nil, undoAnswer(ctx, svc, err)
|
|
}
|
|
return http.StatusOK, map[string]any{"undone": ids, "count": len(ids)}, nil
|
|
})
|
|
}
|