- 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
322 lines
10 KiB
Go
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{}
|
|
)
|