feat: SEO change history
This commit is contained in:
@@ -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
241
controllers/history.go
Normal 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{}
|
||||
)
|
||||
10
controllers/history/_history_diff.htm
Normal file
10
controllers/history/_history_diff.htm
Normal 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 -}}
|
||||
11
controllers/history/config_filter.yaml
Normal file
11
controllers/history/config_filter.yaml
Normal 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
|
||||
8
controllers/history/config_form.yaml
Normal file
8
controllers/history/config_form.yaml
Normal 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
|
||||
22
controllers/history/config_list.yaml
Normal file
22
controllers/history/config_list.yaml
Normal 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
|
||||
@@ -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{}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user