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
This commit is contained in:
540
classes/undo.go
Normal file
540
classes/undo.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The undo refusals (D-13, T-32-27).
|
||||
const (
|
||||
UndoStale = "undo_stale"
|
||||
UndoLoop = "undo_loop"
|
||||
UndoGone = "undo_gone"
|
||||
UndoDone = "already_undone"
|
||||
UndoUnsupported = "undo_unsupported"
|
||||
)
|
||||
|
||||
// UndoRefusal refuses an undo: Code is one of the Undo* constants, Key the
|
||||
// phrase key of its message and Params the message's parameters. Nothing
|
||||
// was undone.
|
||||
type UndoRefusal struct {
|
||||
Code string
|
||||
Key string
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
func (e *UndoRefusal) Error() string { return "golem15.seo: " + e.Code }
|
||||
|
||||
// LoadRevision returns history entry id; ErrNoRecord when there is none.
|
||||
func LoadRevision(db *gorm.DB, id uint) (models.Revision, error) {
|
||||
var rows []models.Revision
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Revision{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.Revision{}, ErrNoRecord
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// Undoable reports whether a history entry can be offered an undo: not
|
||||
// undone yet and of a kind undo restores.
|
||||
func Undoable(r models.Revision) bool {
|
||||
if r.UndoneAt != nil {
|
||||
return false
|
||||
}
|
||||
switch r.SubjectKind {
|
||||
case models.SubjectEntry, models.SubjectRule, models.SubjectRedirect, models.SubjectRobots, models.SubjectSettings, models.SubjectNotFound:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Undo restores the values before history entry id inside tx (D-13): the
|
||||
// undo is itself recorded (via undo) and the entry is marked undone. It
|
||||
// is refused with an *UndoRefusal when the entry is undone already, when a
|
||||
// later change touched the same values (undo_stale, naming who and when),
|
||||
// when the restore would create a redirect loop (undo_loop) or when the
|
||||
// record it belongs to is gone (undo_gone).
|
||||
func (s *Service) Undo(ctx context.Context, tx *gorm.DB, id uint) ([]uint, error) {
|
||||
rev, err := LoadRevision(tx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rev.UndoneAt != nil {
|
||||
return nil, &UndoRefusal{Code: UndoDone, Key: "golem15.seo::lang.history.undo_done"}
|
||||
}
|
||||
if err := s.undoOne(WithVia(ctx, models.ViaUndo), tx, rev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []uint{rev.ID}, nil
|
||||
}
|
||||
|
||||
// UndoBatch undoes every change of batch that is not undone yet, newest
|
||||
// first, inside tx: all of them or none (D-13). ErrNoRecord when the batch
|
||||
// has nothing left to undo.
|
||||
func (s *Service) UndoBatch(ctx context.Context, tx *gorm.DB, batch string) ([]uint, error) {
|
||||
var revs []models.Revision
|
||||
if err := tx.Where("batch_id = ? AND undone_at IS NULL", batch).Order("id DESC").Find(&revs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(revs) == 0 {
|
||||
return nil, ErrNoRecord
|
||||
}
|
||||
ctx = WithVia(ctx, models.ViaUndo)
|
||||
ids := make([]uint, 0, len(revs))
|
||||
for _, rev := range revs {
|
||||
if err := s.undoOne(ctx, tx, rev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, rev.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// BatchSize counts the changes of batch that are not undone yet.
|
||||
func BatchSize(db *gorm.DB, batch string) (int64, error) {
|
||||
var n int64
|
||||
err := db.Model(&models.Revision{}).Where("batch_id = ? AND undone_at IS NULL", batch).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// jsonNorm brings a value to its JSON form, so a stored history value
|
||||
// and a current one compare equal when they encode the same.
|
||||
func jsonNorm(v any) any {
|
||||
raw, err := json.Marshal(plainValue(v))
|
||||
if err != nil {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
var out any
|
||||
if json.Unmarshal(raw, &out) != nil {
|
||||
return string(raw)
|
||||
}
|
||||
if s, ok := out.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameValue reports whether two values encode the same; nil and "" are
|
||||
// the same empty value.
|
||||
func sameValue(a, b any) bool {
|
||||
na, nb := jsonNorm(a), jsonNorm(b)
|
||||
if na == nil {
|
||||
na = ""
|
||||
}
|
||||
if nb == nil {
|
||||
nb = ""
|
||||
}
|
||||
return reflect.DeepEqual(na, nb)
|
||||
}
|
||||
|
||||
// subjectState is the current values of a history subject; exists is
|
||||
// false when the object is gone.
|
||||
type subjectState struct {
|
||||
values map[string]any
|
||||
exists bool
|
||||
// recordGone is true for an entry whose record no longer exists.
|
||||
recordGone bool
|
||||
typ pact.MenuItemType
|
||||
model string
|
||||
recordID string
|
||||
locale string
|
||||
}
|
||||
|
||||
// currentState reads the current values of rev's subject.
|
||||
func (s *Service) currentState(ctx context.Context, tx *gorm.DB, rev models.Revision) (subjectState, error) {
|
||||
var st subjectState
|
||||
switch rev.SubjectKind {
|
||||
case models.SubjectEntry:
|
||||
parts := strings.SplitN(rev.SubjectID, "|", 3)
|
||||
if len(parts) != 3 {
|
||||
return st, &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
|
||||
}
|
||||
st.model, st.recordID, st.locale = parts[0], parts[1], parts[2]
|
||||
reg := s.Registry()
|
||||
typ, ok := pact.MenuItemType{}, false
|
||||
if reg != nil {
|
||||
typ, ok = reg.ContentTypeForModel(st.model)
|
||||
}
|
||||
if !ok || typ.Content == nil {
|
||||
st.recordGone = true
|
||||
return st, nil
|
||||
}
|
||||
st.typ = typ
|
||||
rec, err := reg.Describe(ctx, typ.Key, st.recordID, s.UnprefixedLocale())
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
if rec == nil {
|
||||
st.recordGone = true
|
||||
return st, nil
|
||||
}
|
||||
e, found, err := LoadEntry(tx, st.model, st.recordID, st.locale)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.exists = true
|
||||
st.values = map[string]any{}
|
||||
for _, c := range EntryColumns {
|
||||
v := ""
|
||||
if found {
|
||||
v = EntryValue(e, c)
|
||||
}
|
||||
st.values[c] = v
|
||||
}
|
||||
case models.SubjectRule:
|
||||
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
|
||||
r, err := LoadRule(tx, uint(id))
|
||||
if errors.Is(err, ErrNoRecord) {
|
||||
return st, nil
|
||||
}
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.exists, st.values = true, RuleValues(r)
|
||||
case models.SubjectRedirect:
|
||||
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
|
||||
r, err := LoadRedirect(tx, uint(id))
|
||||
if errors.Is(err, ErrNoRecord) {
|
||||
return st, nil
|
||||
}
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.exists, st.values = true, RedirectValues(r)
|
||||
case models.SubjectRobots:
|
||||
r, err := LoadRobots(tx)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.exists, st.values = true, RobotsValues(r)
|
||||
case models.SubjectSettings:
|
||||
if key, ok := strings.CutPrefix(rev.SubjectID, "type:"); ok {
|
||||
ts, err := LoadTypeSetting(tx, key)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.exists, st.values = true, TypeSettingValues(ts)
|
||||
} else {
|
||||
row, err := LoadSettings(tx)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.exists, st.values = true, SettingsValues(row)
|
||||
}
|
||||
case models.SubjectNotFound:
|
||||
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
|
||||
n, err := LoadNotFound(tx, uint(id))
|
||||
if errors.Is(err, ErrNoRecord) {
|
||||
return st, nil
|
||||
}
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.exists, st.values = true, NotFoundValues(n)
|
||||
default:
|
||||
return st, &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// staleRefusal names the newest later change of field on rev's subject.
|
||||
func (s *Service) staleRefusal(ctx context.Context, tx *gorm.DB, rev models.Revision, field string) error {
|
||||
author, when := "—", ""
|
||||
var later []models.Revision
|
||||
if err := tx.Where("subject_kind = ? AND subject_id = ? AND id > ?", rev.SubjectKind, rev.SubjectID, rev.ID).Order("id DESC").Find(&later).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range later {
|
||||
if _, ok := l.After[field]; ok || len(l.After) == 0 {
|
||||
if l.AuthorLabel != "" {
|
||||
author = l.AuthorLabel
|
||||
}
|
||||
when = l.CreatedAt.UTC().Format("2006-01-02 15:04")
|
||||
break
|
||||
}
|
||||
}
|
||||
return &UndoRefusal{Code: UndoStale, Key: "golem15.seo::lang.history.undo_stale", Params: map[string]string{
|
||||
"field": s.fieldLabel(ctx, field), "author": author, "time": when,
|
||||
}}
|
||||
}
|
||||
|
||||
// fieldLabel is the label of a history field in the request locale.
|
||||
func (s *Service) fieldLabel(ctx context.Context, key string) string {
|
||||
base, locale, _ := strings.Cut(key, ".")
|
||||
for _, p := range models.RuleProperties {
|
||||
if p.Column == base {
|
||||
label := s.PhraseCtx(ctx, p.Label, nil)
|
||||
if locale != "" {
|
||||
label += " (" + locale + ")"
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// undoOne restores the values before rev and marks rev undone.
|
||||
func (s *Service) undoOne(ctx context.Context, tx *gorm.DB, rev models.Revision) error {
|
||||
st, err := s.currentState(ctx, tx, rev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rev.SubjectKind == models.SubjectEntry && st.recordGone {
|
||||
label := s.PhraseCtx(ctx, "golem15.seo::lang.history.record", nil)
|
||||
if st.typ.Label != "" {
|
||||
label = s.PhraseCtx(ctx, st.typ.Label, nil)
|
||||
}
|
||||
return &UndoRefusal{Code: UndoGone, Key: "golem15.seo::lang.history.undo_gone", Params: map[string]string{"type": label}}
|
||||
}
|
||||
created := len(rev.Before) == 0 && len(rev.After) > 0
|
||||
deleted := len(rev.After) == 0 && len(rev.Before) > 0
|
||||
if deleted {
|
||||
if st.exists && rev.SubjectKind != models.SubjectEntry {
|
||||
return s.staleRefusal(ctx, tx, rev, firstKey(rev.Before))
|
||||
}
|
||||
} else {
|
||||
if !st.exists {
|
||||
return &UndoRefusal{Code: UndoGone, Key: "golem15.seo::lang.history.undo_gone", Params: map[string]string{"type": s.PhraseCtx(ctx, kindLabels[rev.SubjectKind], nil)}}
|
||||
}
|
||||
keys := make([]string, 0, len(rev.After))
|
||||
for k := range rev.After {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
cur, ok := st.values[k]
|
||||
if !ok {
|
||||
return &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
|
||||
}
|
||||
if !sameValue(cur, rev.After[k]) {
|
||||
return s.staleRefusal(ctx, tx, rev, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.restore(ctx, tx, rev, st, created, deleted); err != nil {
|
||||
var loop *LoopError
|
||||
if errors.As(err, &loop) {
|
||||
return &UndoRefusal{Code: UndoLoop, Key: "golem15.seo::lang.history.undo_loop", Params: map[string]string{"path": loop.Check.PathText()}}
|
||||
}
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"undone_at": now}
|
||||
if p, ok := bouncer.User(ctx); ok && p.ID != 0 {
|
||||
updates["undone_by_id"] = p.ID
|
||||
} else if p, ok := mcpPrincipal(ctx); ok && p.ID != 0 {
|
||||
updates["undone_by_id"] = p.ID
|
||||
}
|
||||
return tx.Model(&models.Revision{}).Where("id = ?", rev.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// kindLabels are the phrase keys of the history subjects in the undo_gone
|
||||
// refusal.
|
||||
var kindLabels = map[string]string{
|
||||
models.SubjectEntry: "golem15.seo::lang.history.record",
|
||||
models.SubjectRule: "golem15.seo::lang.history.kind_rule",
|
||||
models.SubjectRedirect: "golem15.seo::lang.history.kind_redirect",
|
||||
models.SubjectRobots: "golem15.seo::lang.history.kind_robots",
|
||||
models.SubjectSettings: "golem15.seo::lang.history.kind_settings",
|
||||
models.SubjectNotFound: "golem15.seo::lang.history.kind_notfound",
|
||||
}
|
||||
|
||||
// firstKey is the first key of m in order, "" for none.
|
||||
func firstKey(m map[string]any) string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
// text reads a history value as text.
|
||||
func text(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
// restore writes the values before rev through the service, so the undo
|
||||
// is checked (loops) and recorded like any write.
|
||||
func (s *Service) restore(ctx context.Context, tx *gorm.DB, rev models.Revision, st subjectState, created, deleted bool) error {
|
||||
switch rev.SubjectKind {
|
||||
case models.SubjectEntry:
|
||||
values := RecordValues{}
|
||||
for k := range rev.After {
|
||||
values[k] = map[string]string{st.locale: text(rev.Before[k])}
|
||||
}
|
||||
if deleted {
|
||||
for k, v := range rev.Before {
|
||||
values[k] = map[string]string{st.locale: text(v)}
|
||||
}
|
||||
}
|
||||
_, _, err := s.SaveRecord(ctx, tx, RecordWrite{Type: st.typ, ID: st.recordID, Values: values})
|
||||
return err
|
||||
case models.SubjectRule:
|
||||
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
|
||||
switch {
|
||||
case created:
|
||||
r, err := LoadRule(tx, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.DeleteRule(ctx, tx, r)
|
||||
case deleted:
|
||||
r := models.Rule{}
|
||||
for k, v := range rev.Before {
|
||||
SetRuleValue(&r, k, text(v))
|
||||
}
|
||||
return s.SaveRule(ctx, tx, &r)
|
||||
}
|
||||
r, err := LoadRule(tx, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k := range rev.After {
|
||||
SetRuleValue(&r, k, text(rev.Before[k]))
|
||||
}
|
||||
return s.SaveRule(ctx, tx, &r)
|
||||
case models.SubjectRedirect:
|
||||
id, _ := strconv.ParseUint(rev.SubjectID, 10, 64)
|
||||
switch {
|
||||
case created:
|
||||
r, err := LoadRedirect(tx, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.DeleteRedirect(ctx, tx, r)
|
||||
case deleted:
|
||||
r := models.NewRedirect()
|
||||
for k, v := range rev.Before {
|
||||
setRedirectValue(&r, k, v)
|
||||
}
|
||||
_, err := s.SaveRedirect(ctx, tx, &r)
|
||||
return err
|
||||
}
|
||||
r, err := LoadRedirect(tx, uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k := range rev.After {
|
||||
setRedirectValue(&r, k, rev.Before[k])
|
||||
}
|
||||
_, err = s.SaveRedirect(ctx, tx, &r)
|
||||
return err
|
||||
case models.SubjectRobots:
|
||||
var w RobotsWrite
|
||||
if v, ok := rev.Before["content"]; ok {
|
||||
content := text(v)
|
||||
w.Content = &content
|
||||
}
|
||||
if bots, ok := rev.Before["bots"].(map[string]any); ok {
|
||||
w.Bots = map[string]bool{}
|
||||
for token, on := range bots {
|
||||
b, _ := on.(bool)
|
||||
w.Bots[token] = b
|
||||
}
|
||||
}
|
||||
_, _, err := s.SaveRobots(ctx, tx, w)
|
||||
return err
|
||||
case models.SubjectSettings:
|
||||
if key, ok := strings.CutPrefix(rev.SubjectID, "type:"); ok {
|
||||
w := TypeSettingWrite{}
|
||||
for k := range rev.After {
|
||||
v := rev.Before[k]
|
||||
switch k {
|
||||
case "inject":
|
||||
b, _ := v.(bool)
|
||||
w.Inject = &b
|
||||
case "title_template":
|
||||
t := text(v)
|
||||
w.TitleTemplate = &t
|
||||
case "description_template":
|
||||
t := text(v)
|
||||
w.DescriptionTemplate = &t
|
||||
case "robots":
|
||||
t := text(v)
|
||||
w.Robots = &t
|
||||
}
|
||||
}
|
||||
_, err := s.SaveSettings(ctx, tx, SettingsWrite{Types: map[string]TypeSettingWrite{key: w}})
|
||||
return err
|
||||
}
|
||||
values := map[string]any{}
|
||||
for k := range rev.After {
|
||||
values[k] = rev.Before[k]
|
||||
}
|
||||
_, err := s.SaveSettings(ctx, tx, SettingsWrite{Values: values})
|
||||
return err
|
||||
case models.SubjectNotFound:
|
||||
if !deleted {
|
||||
return &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
row := models.NotFound{URL: text(rev.Before["url"]), Referer: text(rev.Before["referer"]), FirstSeen: &now, LastHit: &now}
|
||||
if h, ok := rev.Before["hits"].(float64); ok {
|
||||
row.Hits = int64(h)
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Record(ctx, tx, Change{
|
||||
Kind: models.SubjectNotFound,
|
||||
SubjectID: strconv.FormatUint(uint64(row.ID), 10),
|
||||
What: s.Phrase("golem15.seo::lang.history.what_notfound", map[string]string{"url": row.URL}),
|
||||
After: NotFoundValues(row),
|
||||
})
|
||||
}
|
||||
return &UndoRefusal{Code: UndoUnsupported, Key: "golem15.seo::lang.history.undo_unsupported"}
|
||||
}
|
||||
|
||||
// setRedirectValue writes one history value onto a redirect.
|
||||
func setRedirectValue(r *models.Redirect, key string, v any) {
|
||||
switch key {
|
||||
case "source":
|
||||
r.Source = text(v)
|
||||
case "match":
|
||||
r.Match = text(v)
|
||||
case "target":
|
||||
r.Target = text(v)
|
||||
case "status":
|
||||
if n, err := strconv.Atoi(text(v)); err == nil {
|
||||
r.Status = models.RedirectStatus(n)
|
||||
}
|
||||
case "query":
|
||||
r.Query = text(v)
|
||||
case "ignore_case":
|
||||
r.IgnoreCase, _ = v.(bool)
|
||||
case "trailing_slash":
|
||||
r.TrailingSlash, _ = v.(bool)
|
||||
case "origin":
|
||||
r.Origin = text(v)
|
||||
case "note":
|
||||
r.Note = text(v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user