feat: SEO change history
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user