feat: SEO change history
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -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
260
classes/history.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
91
models/revision.go
Normal 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")
|
||||
}
|
||||
28
models/revision/columns.yaml
Normal file
28
models/revision/columns.yaml
Normal 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
|
||||
28
models/revision/fields.yaml
Normal file
28
models/revision/fields.yaml
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
49
updates/202610270002_create_golem15_seo_revisions.go
Normal file
49
updates/202610270002_create_golem15_seo_revisions.go
Normal 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...)
|
||||
}
|
||||
Reference in New Issue
Block a user