Files
sm-seo-plugin/controllers/history.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

322 lines
10 KiB
Go

package controllers
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// HistoryControllerID is the history screen (UI-SPEC C12).
const HistoryControllerID = "golem15.seo.history"
// errHistoryReadOnly refuses writes through the history form.
var errHistoryReadOnly = errors.New("golem15.seo: history is read-only")
// The history preview's record actions (UI-SPEC C12).
const (
HistoryActionUndo = "undo"
HistoryActionUndoBatch = "undo_batch"
)
// historyAdminController lists and previews the SEO change history
// (D-13). History is written by the plugin's service only: the form saves
// and deletes nothing. The preview undoes one change or a whole batch.
type historyAdminController struct {
app func() *backpack.App
}
func (historyAdminController) ID() string { return HistoryControllerID }
func (historyAdminController) ModelName() string { return `Golem15\SEO\Models\Revision` }
func (historyAdminController) ConfigDir() string { return "controllers/history" }
func (historyAdminController) NewRecord() any { return &models.Revision{} }
func (historyAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// FormBeforeCreate refuses: history is written by the service only.
func (historyAdminController) FormBeforeCreate(context.Context, any) error {
return errHistoryReadOnly
}
// FormBeforeUpdate refuses: history is never edited.
func (historyAdminController) FormBeforeUpdate(context.Context, any) error {
return errHistoryReadOnly
}
// FormBeforeDelete refuses: history is never deleted from the admin.
func (historyAdminController) FormBeforeDelete(context.Context, any) error {
return errHistoryReadOnly
}
// viaLabel is the phrase key of a writer.
func viaLabel(via string) string {
return "golem15.seo::lang.history.via_" + via
}
// fieldLabel is the label of a changed field: the SEO tab's and the rule
// form's phrase for known columns ("title.pl" keeps its locale), else the
// field name.
func fieldLabel(ctx context.Context, tr phraser, key string) string {
base, locale, _ := strings.Cut(key, ".")
label := key
for _, p := range models.RuleProperties {
if p.Column == base {
label = tr(ctx, p.Label, nil)
if locale != "" {
label += " (" + locale + ")"
}
break
}
}
return label
}
// phraser translates a key with params.
type phraser func(ctx context.Context, key string, params map[string]string) string
// displayValue is a stored history value as text.
func displayValue(v any) string {
switch val := v.(type) {
case nil:
return ""
case string:
return val
case bool:
if val {
return "true"
}
return "false"
}
raw, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v)
}
return string(raw)
}
// changedKeys lists the keys of a revision's before and after in order.
func changedKeys(r *models.Revision) []string {
keys := map[string]bool{}
for k := range r.Before {
keys[k] = true
}
for k := range r.After {
keys[k] = true
}
out := make([]string, 0, len(keys))
for k := range keys {
out = append(out, k)
}
sort.Strings(out)
return out
}
// ListExtendRecords fills the via label, the one-line change summary and
// the short batch id of each row.
func (c historyAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
tr := translator(c.app)
phrase := func(ctx context.Context, key string, params map[string]string) string {
return Phrase(ctx, tr, key, params)
}
for _, record := range records {
r, ok := record.(*models.Revision)
if !ok || r == nil {
continue
}
r.ViaLabel = phrase(ctx, viaLabel(r.Via), nil)
if r.BatchID != nil {
r.Batch = *r.BatchID
if len(r.Batch) > 8 {
r.Batch = r.Batch[:8]
}
}
keys := changedKeys(r)
switch {
case len(r.Before) == 0 && len(r.After) > 0 && r.SubjectKind != models.SubjectSettings:
r.Change = phrase(ctx, "golem15.seo::lang.history.created", nil)
case len(r.After) == 0 && len(r.Before) > 0:
r.Change = phrase(ctx, "golem15.seo::lang.history.deleted", nil)
case len(keys) == 1:
if tr != nil {
r.Change = tr.Choice(ctx, "golem15.seo::lang.history.change_cell", 1, map[string]string{
"field": fieldLabel(ctx, phrase, keys[0]),
"before": displayValue(r.Before[keys[0]]),
"after": displayValue(r.After[keys[0]]),
})
}
default:
if tr != nil {
r.Change = tr.Choice(ctx, "golem15.seo::lang.history.change_cell", len(keys), map[string]string{
"count": fmt.Sprint(len(keys)),
})
}
}
}
return nil
}
// ListRowStates marks undone changes disabled (label history.state_undone).
func (historyAdminController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
out := make([][]pact.RowState, len(records))
for i, record := range records {
if r, ok := record.(*models.Revision); ok && r != nil && r.UndoneAt != nil {
out[i] = []pact.RowState{pact.RowStateDisabled}
}
}
return out, nil
}
// FilterOptions serves the via filter's choices.
func (historyAdminController) FilterOptions(scope string) []pact.Option {
if scope != models.FilterVia {
return nil
}
out := make([]pact.Option, 0, len(models.Vias))
for _, v := range models.Vias {
out = append(out, pact.Option{Value: v, Label: viaLabel(v)})
}
return out
}
// diffRow is one row of the P5 history diff.
type diffRow struct {
Field string
Before string
After string
Mono bool
Created bool
Deleted bool
}
// diffView is the view model of the P5 history diff partial.
type diffView struct {
Rows []diffRow
}
// PartialData serves the P5 diff of a change: one row per changed field,
// URL values in monospace, created and deleted markers.
func (c historyAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
if name != "history_diff" {
return nil, nil
}
r, ok := record.(*models.Revision)
if !ok || r == nil {
return diffView{}, nil
}
tr := translator(c.app)
phrase := func(ctx context.Context, key string, params map[string]string) string {
return Phrase(ctx, tr, key, params)
}
view := diffView{}
for _, key := range changedKeys(r) {
base, _, _ := strings.Cut(key, ".")
_, inBefore := r.Before[key]
_, inAfter := r.After[key]
view.Rows = append(view.Rows, diffRow{
Field: fieldLabel(ctx, phrase, key),
Before: displayValue(r.Before[key]),
After: displayValue(r.After[key]),
Mono: base == "url" || base == "canonical" || base == "og_image" || base == "default_og_image",
Created: !inBefore || len(r.Before) == 0,
Deleted: !inAfter || len(r.After) == 0,
})
}
return view, nil
}
// AdminRecordActions are the preview's undo actions (UI-SPEC C12): undo
// applies while the change is not undone; undo_batch while the change
// belongs to a batch with changes not undone. Both confirm without the
// danger variant (an undo is itself undoable); a refusal (stale, loop,
// gone) is a 403 whose message the admin shows as a toast.
func (c historyAdminController) AdminRecordActions() []pact.AdminRecordAction {
return []pact.AdminRecordAction{
{
Name: HistoryActionUndo,
Label: "golem15.seo::lang.history.undo",
Confirm: "golem15.seo::lang.history.undo_confirm",
Permissions: []string{PermissionManage},
Applies: func(_ context.Context, record any) (bool, error) {
r, ok := record.(*models.Revision)
return ok && r != nil && classes.Undoable(*r), nil
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return c.undo(ctx, in.Record, false)
},
},
{
Name: HistoryActionUndoBatch,
Label: "golem15.seo::lang.history.undo_batch",
Confirm: "golem15.seo::lang.history.undo_batch_confirm",
Permissions: []string{PermissionManage},
Applies: func(ctx context.Context, record any) (bool, error) {
r, ok := record.(*models.Revision)
if !ok || r == nil || r.BatchID == nil || *r.BatchID == "" {
return false, nil
}
db, err := database(ctx, c.app)
if err != nil {
return false, err
}
n, err := classes.BatchSize(db, *r.BatchID)
return n > 0, err
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return c.undo(ctx, in.Record, true)
},
},
}
}
// undo runs an undo inside the action's transaction.
func (c historyAdminController) undo(ctx context.Context, record any, batch bool) (pact.AdminRecordActionResult, error) {
r, ok := record.(*models.Revision)
svc, err := classes.NewService(appOf(c.app))
if !ok || r == nil || err != nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
done := "golem15.seo::lang.history.undone"
if batch && r.BatchID != nil {
_, err = svc.UndoBatch(ctx, db, *r.BatchID)
done = "golem15.seo::lang.history.batch_undone"
} else {
_, err = svc.Undo(ctx, db, r.ID)
}
var refusal *classes.UndoRefusal
if errors.As(err, &refusal) {
return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{Message: svc.PhraseCtx(ctx, refusal.Key, refusal.Params)}
}
if err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: done}, nil
}
var (
_ pact.HasAdminRecordActions = historyAdminController{}
_ pact.AdminController = historyAdminController{}
_ pact.AdminPermissioned = historyAdminController{}
_ pact.AdminRecordSource = historyAdminController{}
_ pact.FormBeforeCreate = historyAdminController{}
_ pact.FormBeforeUpdate = historyAdminController{}
_ pact.FormBeforeDelete = historyAdminController{}
_ pact.ListExtendRecords = historyAdminController{}
_ pact.ListRowStates = historyAdminController{}
_ pact.FilterOptions = historyAdminController{}
_ pact.AdminPartialData = historyAdminController{}
)