Files
sm-seo-plugin/classes/history.go
2026-10-27 10:45:30 +01:00

261 lines
6.8 KiB
Go

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
}