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

@@ -24,6 +24,13 @@ func (*Plugin) Navigation() []pact.NavigationItem {
Permissions: []string{controllers.PermissionManage},
Controller: controllers.RulesControllerID,
},
{
Code: "history",
Label: "golem15.seo::lang.nav.history",
Icon: "clock",
Permissions: []string{controllers.PermissionManage},
Controller: controllers.HistoryControllerID,
},
},
}}
}

View File

@@ -129,7 +129,7 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
for locale, e := range existing {
before[locale] = e
}
urls := s.recordURLs(ctx, in.Type.Key, in.ID)
described := s.recordLocales(ctx, in.Type.Key, in.ID)
touched := map[string]models.Entry{}
for column, perLocale := range in.Values {
for locale, value := range perLocale {
@@ -153,8 +153,8 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
continue
}
e.TypeKey = in.Type.Key
if u, ok := urls[locale]; ok {
e.URL = u
if l, ok := described[locale]; ok {
e.URL = l.URL
}
if e.ID == 0 {
if err := db.Create(&e).Error; err != nil {
@@ -164,10 +164,37 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (
return nil, nil, err
}
after[locale] = e
var old map[string]any
if stored {
old = entryValues(existing[locale])
}
if err := s.Record(ctx, db, Change{
Kind: models.SubjectEntry,
SubjectID: EntrySubject(modelType, in.ID, locale),
What: s.recordWhat(in.Type, in.ID, described[locale], locale),
Before: old,
After: entryValues(e),
}); err != nil {
return nil, nil, err
}
}
return before, after, nil
}
// recordWhat is the history description of a record's SEO in a locale:
// "SEO of <type> “<title>” (<locale>)".
func (s *Service) recordWhat(typ pact.MenuItemType, id string, l pact.ContentLocale, locale string) string {
title := l.Title
if strings.TrimSpace(title) == "" {
title = "#" + id
}
return s.Phrase("golem15.seo::lang.history.what_record", map[string]string{
"type": s.Phrase(typ.Label, nil),
"title": title,
"locale": locale,
})
}
// entryEmpty reports whether no editable column of e carries a value.
func entryEmpty(e models.Entry) bool {
for _, column := range EntryColumns {
@@ -178,10 +205,10 @@ func entryEmpty(e models.Entry) bool {
return len(e.Data) == 0
}
// recordURLs returns the record's public path per locale through its
// content descriptor; an error or a missing registry answers none.
func (s *Service) recordURLs(ctx context.Context, key, id string) map[string]string {
out := map[string]string{}
// recordLocales describes the record per locale through its content
// descriptor; an error or a missing registry answers none.
func (s *Service) recordLocales(ctx context.Context, key, id string) map[string]pact.ContentLocale {
out := map[string]pact.ContentLocale{}
reg := s.Registry()
if reg == nil {
return out
@@ -191,7 +218,7 @@ func (s *Service) recordURLs(ctx context.Context, key, id string) map[string]str
return out
}
for _, l := range rec.Locales {
out[l.Locale] = l.URL
out[l.Locale] = l
}
return out
}

260
classes/history.go Normal file
View File

@@ -0,0 +1,260 @@
package classes
import (
"context"
"database/sql/driver"
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
type viaKey struct{}
// WithVia marks ctx as a write through via (import, auto, undo), which
// history records instead of admin or mcp.
func WithVia(ctx context.Context, via string) context.Context {
return context.WithValue(ctx, viaKey{}, via)
}
// ViaOf is the writer history records for ctx: a WithVia mark, else mcp
// when an MCP dispatch for the backend guard carries the request
// (see mcpPrincipal), else admin.
func ViaOf(ctx context.Context) string {
if v, ok := ctx.Value(viaKey{}).(string); ok && v != "" {
return v
}
if _, ok := mcpPrincipal(ctx); ok {
return models.ViaMCP
}
return models.ViaAdmin
}
// mcpPrincipal returns the principal of an MCP dispatch for the backend
// guard, which the MCP module attaches to the requests it builds.
func mcpPrincipal(ctx context.Context) (*bouncer.Principal, bool) {
d, ok := bouncer.Dispatched(ctx, "backend")
if !ok {
return nil, false
}
return d.Principal, true
}
// Change is one changed object for history. Before and After hold the
// object's values; Record keeps only the fields whose value changed. A
// created object has a nil Before, a deleted one a nil After.
type Change struct {
Kind string
SubjectID string
What string
Before map[string]any
After map[string]any
}
// diffValues keeps the keys whose value differs between before and after;
// nil before or after keeps every key of the other side.
func diffValues(before, after map[string]any) (b, a map[string]any) {
if before == nil || after == nil {
return before, after
}
b, a = map[string]any{}, map[string]any{}
keys := map[string]bool{}
for k := range before {
keys[k] = true
}
for k := range after {
keys[k] = true
}
for k := range keys {
bv, av := before[k], after[k]
if !reflect.DeepEqual(plainValue(bv), plainValue(av)) {
b[k], a[k] = plainValue(bv), plainValue(av)
}
}
return b, a
}
// plainValue turns a model value into a JSON-friendly one: pointers are
// dereferenced, times formatted, and SQL valuers (jsonable columns) read
// through their stored JSON.
func plainValue(v any) any {
switch val := v.(type) {
case nil:
return nil
case string, bool, float64, int, int64, uint, uint64:
return val
case time.Time:
return val.UTC().Format(time.RFC3339)
case *time.Time:
if val == nil {
return nil
}
return val.UTC().Format(time.RFC3339)
case json.Number:
return val.String()
case driver.Valuer:
raw, err := val.Value()
if err != nil || raw == nil {
return nil
}
if s, ok := raw.(string); ok {
var decoded any
if json.Unmarshal([]byte(s), &decoded) == nil {
return decoded
}
return s
}
return plainValue(raw)
}
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return nil
}
return plainValue(rv.Elem().Interface())
}
raw, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v)
}
var decoded any
if json.Unmarshal(raw, &decoded) != nil {
return fmt.Sprint(v)
}
return decoded
}
// ChangesValues turns a save's record changes into before and after maps:
// the attribute name, with ".<locale>" for a translated value.
func ChangesValues(changes []pact.RecordChange, created bool) (before, after map[string]any) {
after = map[string]any{}
if !created {
before = map[string]any{}
}
for _, c := range changes {
key := c.Attribute
if c.Locale != "" {
key += "." + c.Locale
}
if before != nil {
before[key] = plainValue(c.Old)
}
after[key] = plainValue(c.New)
}
return before, after
}
// author returns the author of a write: the backend principal's id and a
// label (the administrator's name or login; "MCP: <label>" through MCP).
func (s *Service) author(ctx context.Context, db *gorm.DB, via string) (*uint, string) {
p, ok := mcpPrincipal(ctx)
if !ok {
p, _ = bouncer.User(ctx)
}
if p == nil || p.ID == 0 {
return nil, ""
}
id := p.ID
var row struct {
FirstName string
LastName string
Login string
}
label := fmt.Sprintf("#%d", id)
if err := db.Raw(`SELECT first_name, last_name, login FROM backend_users WHERE id = ?`, id).Scan(&row).Error; err == nil {
if name := strings.TrimSpace(row.FirstName + " " + row.LastName); name != "" {
label = name
} else if row.Login != "" {
label = row.Login
}
}
if via == models.ViaMCP {
label = "MCP: " + label
}
return &id, label
}
// Record appends one history row per change whose values differ, inside
// db (the caller's transaction): author and via from ctx, What as given,
// before and after of the changed fields only.
func (s *Service) Record(ctx context.Context, db *gorm.DB, changes ...Change) error {
via := ViaOf(ctx)
var authorID *uint
var authorLabel string
authored := false
for _, c := range changes {
before, after := diffValues(c.Before, c.After)
if c.Before != nil && c.After != nil && len(after) == 0 {
continue
}
if !authored {
authorID, authorLabel = s.author(ctx, db, via)
authored = true
}
row := models.Revision{
AuthorID: authorID,
AuthorLabel: authorLabel,
Via: via,
SubjectKind: c.Kind,
SubjectID: c.SubjectID,
What: c.What,
Before: models.JSONMap(before),
After: models.JSONMap(after),
CreatedAt: time.Now().UTC(),
}
if err := db.Create(&row).Error; err != nil {
return err
}
}
return nil
}
// translator returns the application translator, nil when none.
func (s *Service) translator() *phrasebook.Translator {
if s == nil || s.app == nil {
return nil
}
tr, _ := s.app.Lookup[*phrasebook.Translator]()
return tr
}
// Phrase renders a phrase key with params in the application's default
// admin locale (history descriptions are stored as text, so the list can
// search them).
func (s *Service) Phrase(key string, params map[string]string) string {
tr := s.translator()
if tr == nil {
return key
}
return tr.GetIn(tr.Locale(), key, params)
}
// EntrySubject is the subject id of a record's entry in a locale.
func EntrySubject(modelType, recordID, locale string) string {
return modelType + "|" + recordID + "|" + locale
}
// entryValues are the editable values of an entry for history.
func entryValues(e models.Entry) map[string]any {
out := map[string]any{}
for _, column := range EntryColumns {
out[column] = entryValue(e, column)
}
return out
}
// RuleValues are a rule's values for history.
func RuleValues(r models.Rule) map[string]any {
out := map[string]any{"url": r.URL, "match": r.Match, "note": r.Note}
for _, p := range models.RuleProperties {
out[p.Column] = r.Value(p.Column)
}
return out
}

View File

@@ -24,16 +24,13 @@ type Service struct {
app *backpack.App
}
// NewService returns the service of a booted application. It needs the
// application's database; the signpost registry is optional (without it no
// content type takes part).
// NewService returns the service of a booted application. The database
// and the signpost registry are looked up when used, so a binary that runs
// a console command without a database still boots.
func NewService(app *backpack.App) (*Service, error) {
if app == nil {
return nil, ErrNotBooted
}
if db, ok := app.Lookup[*gorm.DB](); !ok || db == nil {
return nil, errors.New("database is not configured")
}
return &Service{app: app}, nil
}

View File

@@ -41,3 +41,13 @@ func (s *Service) SaveTypeSetting(_ context.Context, db *gorm.DB, row models.Typ
DoUpdates: clause.AssignmentColumns([]string{"inject", "title_template", "description_template", "robots"}),
}).Create(&row).Error
}
// TypeSettingValues are a type settings row's values for history.
func TypeSettingValues(row models.TypeSetting) map[string]any {
return map[string]any{
"inject": row.Inject,
"title_template": row.TitleTemplate,
"description_template": row.DescriptionTemplate,
"robots": row.Robots,
}
}

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{}
)

View File

@@ -7,6 +7,7 @@ import (
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/controllers"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
)
@@ -36,9 +37,37 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
if form := p.typeSettingsTabs(types); form != nil {
out = append(out, pact.AdminActionExtension{Controller: controllers.SettingsControllerID, Form: form})
}
out = append(out, pact.AdminActionExtension{Controller: controllers.RulesControllerID, Form: p.ruleHistory()})
return out
}
// ruleHistory is a field-less extension on the plugin's own rules
// controller: its Save sees the save's changes inside the save transaction
// and records them in history (deletes are recorded by the controller).
func (p *Plugin) ruleHistory() *pact.AdminFormExtension {
return &pact.AdminFormExtension{
Permissions: []string{controllers.PermissionManage},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
svc := p.Service()
if svc == nil {
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
}
before, after := classes.ChangesValues(in.Changes, in.Created)
url := ""
if r, ok := in.Record.(*models.Rule); ok && r != nil {
url = r.URL
}
return pact.AdminFormSaveResult{}, svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectRule,
SubjectID: in.ID,
What: svc.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": url}),
Before: before,
After: after,
})
},
}
}
func (p *Plugin) registry() *signpost.Registry {
if p == nil || p.app == nil {
return nil

View File

@@ -108,18 +108,23 @@ func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) {
})
}
// typeSettingsTabs is the form extension on the SEO settings controller
// that adds the per-type tabs; nil when no content type is registered.
// typeSettingsTabs is the plugin's form extension on its own settings
// controller: it adds the per-type tabs (none when no content type is
// registered) and records every settings save in history.
func (p *Plugin) typeSettingsTabs(types []pact.MenuItemType) *pact.AdminFormExtension {
if len(types) == 0 {
return nil
}
doc, err := typeSettingsDocument(types)
if err != nil {
pluginLog.Error("golem15.seo: per-type settings tabs", "error", err)
return nil
var doc []byte
if len(types) > 0 {
var err error
if doc, err = typeSettingsDocument(types); err != nil {
pluginLog.Error("golem15.seo: per-type settings tabs", "error", err)
return nil
}
}
fields := typeSettingsFields(types)
labels := map[string]string{}
for _, typ := range types {
labels[typ.Key] = typ.Label
}
return &pact.AdminFormExtension{
FieldsYAML: doc,
Permissions: []string{controllers.PermissionManage},
@@ -139,7 +144,21 @@ func (p *Plugin) typeSettingsTabs(types []pact.MenuItemType) *pact.AdminFormExte
return out, nil
},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
return pact.AdminFormSaveResult{}, p.saveTypeSettings(ctx, in, fields)
svc := p.Service()
if svc == nil {
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
}
before, after := classes.ChangesValues(in.Changes, in.Created)
if err := svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectSettings,
SubjectID: in.ID,
What: svc.Phrase("golem15.seo::lang.history.what_settings", nil),
Before: before,
After: after,
}); err != nil {
return pact.AdminFormSaveResult{}, err
}
return pact.AdminFormSaveResult{}, p.saveTypeSettings(ctx, in, fields, labels)
},
}
}
@@ -161,7 +180,7 @@ func typeSettingValue(row models.TypeSetting, column string) any {
// saveTypeSettings stores the submitted per-type values through the
// service; a template with an unknown placeholder refuses the save.
func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fields []typeField) error {
func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fields []typeField, labels map[string]string) error {
svc := p.Service()
if svc == nil {
return classes.ErrNotBooted
@@ -208,10 +227,23 @@ func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fi
if len(refusals) > 0 {
return refusals
}
for _, row := range changed {
for key, row := range changed {
old, ok := rows[key]
if !ok {
old = models.DefaultTypeSetting(key)
}
if err := svc.SaveTypeSetting(ctx, in.DB, row); err != nil {
return err
}
if err := svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectSettings,
SubjectID: "type:" + key,
What: svc.Phrase("golem15.seo::lang.history.what_settings", nil) + " – " + svc.Phrase(labels[key], nil),
Before: classes.TypeSettingValues(old),
After: classes.TypeSettingValues(row),
}); err != nil {
return err
}
}
return nil
}

View File

@@ -3,6 +3,7 @@ plugin:
description: Search engine metadata for every content type, URL rules and global SEO settings.
nav:
seo: SEO
history: History
rules: URL rules
permissions:
tab: SEO
@@ -113,3 +114,36 @@ rules:
delete_selected_confirm:
one: Delete :count URL rule? You can undo it in History.
other: Delete :count URL rules? You can undo it in History.
history:
list_title: History
name: Change
when: When
who: Who
via: Via
what: What
change: Change
batch: Batch
via_admin: Admin
via_mcp: MCP
via_import: Import
via_auto: Automatic
via_undo: Undo
what_record: SEO of :type “:title” (:locale)
what_rule: URL rule “:url”
what_redirect: Redirect “:url”
what_robots: robots.txt
what_settings: SEO settings
change_cell:
one: ':field: :before → :after'
other: ':count fields changed'
created: (created)
deleted: (deleted)
state_undone: Undone
filter_via: Via
filter_when: When
filter_who: Who
preview: Change details
diff_field: Field
diff_before: Before
diff_after: After
list_empty: No SEO changes recorded yet. Every change from the admin, MCP or an import appears here and can be undone.

View File

@@ -3,6 +3,7 @@ plugin:
description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO.
nav:
seo: SEO
history: Historia zmian
rules: Reguły adresów
permissions:
tab: SEO
@@ -115,3 +116,38 @@ rules:
few: Usunąć :count reguły adresów? Możesz to cofnąć w Historii zmian.
many: Usunąć :count reguł adresów? Możesz to cofnąć w Historii zmian.
other: Usunąć :count reguły adresu? Możesz to cofnąć w Historii zmian.
history:
list_title: Historia zmian
name: Zmiana
when: Kiedy
who: Kto
via: Skąd
what: Czego dotyczy
change: Zmiana
batch: Partia
via_admin: Panel
via_mcp: MCP
via_import: Import
via_auto: Automatycznie
via_undo: Cofnięcie
what_record: 'SEO: :type „:title” (:locale)'
what_rule: Reguła adresu „:url”
what_redirect: Przekierowanie „:url”
what_robots: robots.txt
what_settings: Ustawienia SEO
change_cell:
one: ':field: :before → :after'
few: Zmieniono :count pola
many: Zmieniono :count pól
other: Zmieniono :count pola
created: (utworzono)
deleted: (usunięto)
state_undone: Cofnięte
filter_via: Skąd
filter_when: Kiedy
filter_who: Kto
preview: Szczegóły zmiany
diff_field: Pole
diff_before: Przed
diff_after: Po
list_empty: Nie zarejestrowano jeszcze zmian SEO. Każda zmiana z panelu, MCP lub importu pojawi się tutaj i będzie można ją cofnąć.

91
models/revision.go Normal file
View File

@@ -0,0 +1,91 @@
package models
import (
"time"
"gorm.io/gorm"
)
// The writers of a history entry (D-13).
const (
ViaAdmin = "admin"
ViaMCP = "mcp"
ViaImport = "import"
ViaAuto = "auto"
ViaUndo = "undo"
)
// The subjects of a history entry.
const (
SubjectEntry = "entry"
SubjectRule = "rule"
SubjectRedirect = "redirect"
SubjectRobots = "robots"
SubjectSettings = "settings"
SubjectNotFound = "notfound"
)
// Revision is one recorded SEO change on golem15_seo_revisions (D-13): who
// made it (AuthorID and AuthorLabel, the label kept when the administrator
// is gone), through what (Via), on which object (SubjectKind and
// SubjectID: an entry as "<model type>|<record id>|<locale>", a rule by
// id, settings as "1" or "type:<key>"), a readable description (What) and
// the changed fields only, before and after. A created object has no
// Before and a deleted one no After. BatchID groups the changes of one
// batch; UndoneAt marks an undone change.
type Revision struct {
ID uint `gorm:"column:id;primaryKey"`
BatchID *string `gorm:"column:batch_id"`
AuthorID *uint `gorm:"column:author_id"`
AuthorLabel string `gorm:"column:author_label"`
Via string `gorm:"column:via"`
SubjectKind string `gorm:"column:subject_kind"`
SubjectID string `gorm:"column:subject_id"`
What string `gorm:"column:what"`
Before JSONMap `gorm:"column:before"`
After JSONMap `gorm:"column:after"`
UndoneAt *time.Time `gorm:"column:undone_at"`
UndoneByID *uint `gorm:"column:undone_by_id"`
CreatedAt time.Time `gorm:"column:created_at"`
// Change is the list's summary of the changed fields and ViaLabel the
// via column's label, filled by the history controller.
Change string `gorm:"-:all;column:change"`
ViaLabel string `gorm:"-:all;column:via_label"`
Batch string `gorm:"-:all;column:batch"`
}
func (Revision) TableName() string { return "golem15_seo_revisions" }
// Fillable is empty: history is written by the service only.
func (Revision) Fillable() []string { return []string{} }
// Rules is empty: history is never edited.
func (Revision) Rules() map[string]string { return map[string]string{} }
// FilterVia is the history list's via filter scope.
const FilterVia = "FilterVia"
// Vias lists the writers in display order.
var Vias = []string{ViaAdmin, ViaMCP, ViaImport, ViaAuto, ViaUndo}
func init() {
Register(Revision{})
}
// FilterScopes lists the history list's filter scopes.
func (Revision) FilterScopes() []string { return []string{FilterVia} }
// FilterScope narrows the history list to one writer; an unknown value
// matches nothing.
func (Revision) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != FilterVia {
return db
}
s, _ := value.(string)
for _, v := range Vias {
if v == s {
return db.Where("golem15_seo_revisions.via = ?", s)
}
}
return db.Where("1 = 0")
}

View File

@@ -0,0 +1,28 @@
# History list columns (UI-SPEC C12). via_label, change and batch are
# filled by the controller's ListExtendRecords.
columns:
created_at:
label: golem15.seo::lang.history.when
type: datetime
sortable: true
author_label:
label: golem15.seo::lang.history.who
type: text
searchable: true
via_label:
label: golem15.seo::lang.history.via
type: text
sortable: false
what:
label: golem15.seo::lang.history.what
type: text
searchable: true
change:
label: golem15.seo::lang.history.change
type: text
sortable: false
batch:
label: golem15.seo::lang.history.batch
type: text
sortable: false
invisible: true

View File

@@ -0,0 +1,28 @@
# History preview (UI-SPEC C12): read-only fields, then the P5 diff.
fields:
created_at:
label: golem15.seo::lang.history.when
type: text
span: left
context: preview
author_label:
label: golem15.seo::lang.history.who
type: text
span: right
context: preview
via:
label: golem15.seo::lang.history.via
type: text
span: left
context: preview
what:
label: golem15.seo::lang.history.what
type: text
span: right
context: preview
_history_diff:
label: golem15.seo::lang.history.change
type: partial
path: history_diff
span: full
context: preview

View File

@@ -1,12 +1,16 @@
package seo_test
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
seo "git.golem15.com/golem15/sm-seo-plugin"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// seoTabLabel is the English label of seo.TabSEO.
@@ -124,3 +128,96 @@ func TestSEOTabPermission(t *testing.T) {
t.Fatalf("editor show carries SEO values: %s", show.Body.String())
}
}
// revisionRow is a history row as the tests read it.
type revisionRow struct {
Via string
SubjectKind string
SubjectID string
What string
AuthorLabel string
Before string
After string
}
func (e *testEnv) revisions(t *testing.T, kind string) []revisionRow {
t.Helper()
var rows []revisionRow
if err := e.db.Raw(`SELECT via, subject_kind, subject_id, what, author_label, COALESCE(before::text, '') AS before, COALESCE(after::text, '') AS after
FROM golem15_seo_revisions WHERE subject_kind = ? ORDER BY id`, kind).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// TestHistoryRecordsAdminWrite checks D-13 for admin writes: an SEO tab
// save writes one revision with via admin, the author and the changed
// field before and after; an unchanged save writes none; rule create,
// update and delete and a settings save are recorded too, and the history
// list and preview serve them.
func TestHistoryRecordsAdminWrite(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "First"}})
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{
"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Second"},
})
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{
"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Second"},
})
rows := env.revisions(t, "entry")
if len(rows) != 2 {
t.Fatalf("entry revisions = %+v, want create and one change", rows)
}
last := rows[1]
if last.Via != "admin" || last.SubjectID != pagesModel+"|"+id+"|en" || !strings.Contains(last.What, "About") || !strings.Contains(last.What, "(en)") || last.AuthorLabel == "" {
t.Fatalf("revision = %+v", last)
}
if !jsonContains(last.Before, `"title": "First"`) || !jsonContains(last.After, `"title": "Second"`) || strings.Contains(last.After, "description") {
t.Fatalf("revision before/after = %s / %s, want the title only", last.Before, last.After)
}
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/landing", "match": "exact", "title": "Landing"})
var created struct {
Data struct {
ID json.Number `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
ruleID := created.Data.ID.String()
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/rules/"+ruleID), token, map[string]any{"url": "/landing", "match": "exact", "title": "Landing page"})
env.expect(t, http.StatusOK, http.MethodDelete, adminAPI("/golem15/seo/rules/"+ruleID), token, nil)
rules := env.revisions(t, "rule")
if len(rules) != 3 || rules[0].Before != "{}" || !jsonContains(rules[1].After, `"title": "Landing page"`) || rules[2].After != "{}" || !jsonContains(rules[2].Before, `"url": "/landing"`) {
t.Fatalf("rule revisions = %+v", rules)
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"twitter_site": "@acme", "type_acme_page_robots": "noindex,follow"})
settings := env.revisions(t, "settings")
if len(settings) != 2 || !jsonContains(settings[0].After, `"twitter_site": "@acme"`) || settings[1].SubjectID != "type:"+pagesTypeKey {
t.Fatalf("settings revisions = %+v", settings)
}
list := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/history"), token, nil)
if !strings.Contains(list.Body.String(), "SEO title: First → Second") || !strings.Contains(list.Body.String(), `"via_label":"Admin"`) {
t.Fatalf("history list = %s", list.Body.String())
}
var firstID uint
if err := env.db.Raw(`SELECT id FROM golem15_seo_revisions WHERE subject_kind = 'entry' ORDER BY id DESC LIMIT 1`).Scan(&firstID).Error; err != nil {
t.Fatal(err)
}
partial := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(fmt.Sprintf("/golem15/seo/history/partials/history_diff?id=%d", firstID)), token, nil)
if !strings.Contains(partial.Body.String(), "Second") {
t.Fatalf("history diff partial = %s", partial.Body.String())
}
mcp := bouncer.WithDispatch(t.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: &bouncer.Principal{ID: 1, Backend: true}})
if got := classes.ViaOf(mcp); got != "mcp" {
t.Fatalf("ViaOf(MCP dispatch) = %q, want mcp", got)
}
if got := classes.ViaOf(classes.WithVia(mcp, "import")); got != "import" {
t.Fatalf("ViaOf(WithVia import) = %q", got)
}
}

View File

@@ -0,0 +1,49 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// createRevisions creates golem15_seo_revisions: the SEO change history
// (D-13) with author, via, subject, a readable description and the changed
// fields before and after; undo marks a change undone.
var createRevisions = []*gormigrate.Migration{
{
ID: "202610270002_create_golem15_seo_revisions",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{
`CREATE TABLE golem15_seo_revisions (
id BIGSERIAL PRIMARY KEY,
batch_id TEXT NULL,
author_id BIGINT NULL,
author_label TEXT NOT NULL DEFAULT '',
via TEXT NOT NULL,
subject_kind TEXT NOT NULL,
subject_id TEXT NOT NULL DEFAULT '',
what TEXT NOT NULL DEFAULT '',
before jsonb NULL,
after jsonb NULL,
undone_at TIMESTAMP(0) NULL,
undone_by_id BIGINT NULL,
created_at TIMESTAMP(0) NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX golem15_seo_revisions_subject ON golem15_seo_revisions (subject_kind, subject_id)`,
`CREATE INDEX golem15_seo_revisions_created ON golem15_seo_revisions (created_at DESC)`,
`CREATE INDEX golem15_seo_revisions_batch ON golem15_seo_revisions (batch_id) WHERE batch_id IS NOT NULL`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_revisions`).Error
},
},
}
func init() {
Register(createRevisions...)
}