feat: SEO change history

This commit is contained in:
2026-10-27 10:45:30 +01:00
parent 9f1082d62d
commit e8a6b9fc77
21 changed files with 1070 additions and 26 deletions

View File

@@ -30,6 +30,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
settingsAdminController{app: app},
rulesAdminController{app: app},
historyAdminController{app: app},
}
}

241
controllers/history.go Normal file
View File

@@ -0,0 +1,241 @@
package controllers
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"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")
// 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. Undo arrives as record actions with the management
// API.
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
}
var (
_ 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{}
)

View File

@@ -0,0 +1,10 @@
{{- if .Data.Rows -}}
<table class="summer-table">
<thead><tr><th>{{ trans "golem15.seo::lang.history.diff_field" }}</th><th>{{ trans "golem15.seo::lang.history.diff_before" }}</th><th>{{ trans "golem15.seo::lang.history.diff_after" }}</th></tr></thead>
<tbody>
{{- range .Data.Rows -}}
<tr><td>{{ .Field }}</td><td>{{ if and .Created (not .Before) }}<span class="summer-table__sub">{{ trans "golem15.seo::lang.history.created" }}</span>{{ else if .Mono }}<code>{{ .Before }}</code>{{ else }}{{ .Before }}{{ end }}</td><td>{{ if and .Deleted (not .After) }}<span class="summer-table__sub">{{ trans "golem15.seo::lang.history.deleted" }}</span>{{ else if .Mono }}<code>{{ .After }}</code>{{ else }}{{ .After }}{{ end }}</td></tr>
{{- end -}}
</tbody>
</table>
{{- end -}}

View File

@@ -0,0 +1,11 @@
# History filters (UI-SPEC C12): writer and date.
scopes:
via:
label: golem15.seo::lang.history.filter_via
modelClass: Golem15\SEO\Models\Revision
nameFrom: via
scope: FilterVia
created_at:
label: golem15.seo::lang.history.filter_when
type: daterange
column: created_at

View File

@@ -0,0 +1,8 @@
# History preview (UI-SPEC C12): read-only with the P5 diff.
name: golem15.seo::lang.history.name
form: ~/plugins/golem15/seo/models/revision/fields.yaml
modelClass: Golem15\SEO\Models\Revision
defaultRedirect: golem15/seo/history
messages:
preview: golem15.seo::lang.history.preview

View File

@@ -0,0 +1,22 @@
# History list (UI-SPEC C12): newest first, read-only, undone rows
# disabled.
list: ~/plugins/golem15/seo/models/revision/columns.yaml
modelClass: Golem15\SEO\Models\Revision
title: golem15.seo::lang.history.list_title
recordUrl: golem15/seo/history/preview/:id
noRecordsMessage: golem15.seo::lang.history.list_empty
recordsPerPage: 50
showSetup: true
filter: config_filter.yaml
defaultSort:
column: created_at
direction: desc
toolbar:
search:
prompt: backend::lang.list.search_prompt
messages:
empty: golem15.seo::lang.history.list_empty
rowStateDisabled: golem15.seo::lang.history.state_undone

View File

@@ -2,8 +2,10 @@ package controllers
import (
"context"
"strconv"
"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/pact"
@@ -48,6 +50,29 @@ func (c rulesAdminController) check(ctx context.Context, model any) error {
return problemsError(ctx, translator(c.app), r.Problems(db))
}
// FormAfterDelete records the deleted rule in history inside the delete's
// transaction, so its values can be restored.
func (c rulesAdminController) FormAfterDelete(ctx context.Context, model any) error {
r, ok := model.(*models.Rule)
if !ok || r == nil {
return nil
}
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return err
}
db, err := database(ctx, c.app)
if err != nil {
return err
}
return svc.Record(ctx, db, classes.Change{
Kind: models.SubjectRule,
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
What: svc.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": r.URL}),
Before: classes.RuleValues(*r),
})
}
// ListExtendRecords fills the overrides column: the translated labels of
// the properties each rule sets, comma-joined.
func (c rulesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
@@ -87,4 +112,5 @@ var (
_ pact.FormBeforeUpdate = rulesAdminController{}
_ pact.ListExtendRecords = rulesAdminController{}
_ pact.FilterOptions = rulesAdminController{}
_ pact.FormAfterDelete = rulesAdminController{}
)