package classes import ( "context" "database/sql/driver" "encoding/json" "fmt" "reflect" "strings" "sync" "time" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cooler" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "gorm.io/gorm" ) type viaKey struct{} type batchKey struct{} // WithBatch marks ctx as part of batch id: every history row written // through it carries the batch id, so the batch can be listed and undone // as a whole (D-12, D-13). func WithBatch(ctx context.Context, id string) context.Context { return context.WithValue(ctx, batchKey{}, id) } // BatchOf is the batch id of ctx, "" outside a batch. func BatchOf(ctx context.Context) string { id, _ := ctx.Value(batchKey{}).(string) return id } // 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 "." 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: