Files
summercms/modules/cabana/field_ml.go
Jakub Zych 3596df4ff5 feat(cabana): add mltextarea field type
- mltextarea compiles (with size), binds as a writable scalar and goes
  through the mltext lift, translation write and hydrate paths
- preset on or from an mltextarea field stays refused
- tests: compile, Postgres round trip over a nullable host column with
  multi-line text, invalid locale maps, preset refusals
2026-10-06 21:30:56 +02:00

254 lines
7.9 KiB
Go

package cabana
import (
"context"
"fmt"
"reflect"
"gorm.io/gorm"
)
const (
mlTextFieldType = "mltext"
mlTextareaFieldType = "mltextarea"
mlMarkdownFieldType = "mlmarkdown"
)
// TranslationWriter persists non-default locale values during a cabana save
// and reads exact stored translations for Show/save hydration. The interface
// lives in the framework so cabana never imports a plugin; a host publishes
// an adapter at Boot. There is no standalone translate-read or translate-write
// HTTP endpoint: writes run only inside a permissioned, scoped host save
// transaction after the host row has a primary key, and reads run on the
// same adapter after projectFullRecord.
type TranslationWriter interface {
// DefaultLocale is the host-column locale.
DefaultLocale(ctx context.Context, tx *gorm.DB) (string, error)
// EnabledLocales are the codes a save body may name, including the default.
EnabledLocales(ctx context.Context, tx *gorm.DB) ([]string, error)
// WriteTranslated stores field in locale for model. locale is never the
// default; cabana already filled the host scalar.
WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error
// TranslatedExact returns the stored value for field in locale. ok is
// false when the non-default key is missing. It must not apply D-11
// fallback: empty admin values stay empty. The default locale still
// reads the host column.
TranslatedExact(ctx context.Context, tx *gorm.DB, model any, field, locale string) (string, bool, error)
}
// mlValue is one multilingual field lifted from a save body.
type mlValue struct {
field string
values map[string]string
}
// mlFieldType reports whether typ is a multilingual field type whose value is
// a locale map: mltext, mltextarea or mlmarkdown.
func mlFieldType(typ string) bool {
switch typ {
case mlTextFieldType, mlTextareaFieldType, mlMarkdownFieldType:
return true
}
return false
}
// liftMLValues takes declared mltext/mltextarea/mlmarkdown maps out of a save body
// before ProjectWritableFields drops nested values. Only fields present in
// the body whose context allows op are lifted. A value must be a JSON object
// of enabled locale code to string; unknown locales, extra keys, and
// non-string values are validation_failed on the field. The default-locale
// string is written back onto body so Fill sees a host scalar.
func liftMLValues(ctx context.Context, cc *CompiledController, body map[string]any, op string, writer TranslationWriter, db *gorm.DB) ([]mlValue, error) {
if cc == nil || cc.Form == nil || body == nil {
return nil, nil
}
var names []string
for _, field := range cc.Form.Fields {
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
continue
}
if _, present := body[field.Name]; present {
names = append(names, field.Name)
}
}
if len(names) == 0 {
return nil, nil
}
details := map[string]any{}
needWriter := false
for _, name := range names {
if _, isMap := body[name].(map[string]any); isMap {
needWriter = true
break
}
}
var (
defaultLocale string
enabled map[string]struct{}
)
if needWriter {
if writer == nil {
for _, name := range names {
if _, isMap := body[name].(map[string]any); isMap {
details[name] = []string{"The " + name + " field cannot be translated."}
}
}
return nil, &ValidationError{Details: details}
}
code, err := writer.DefaultLocale(ctx, db)
if err != nil || code == "" {
return nil, fmt.Errorf("cabana: default locale: %w", err)
}
defaultLocale = code
codes, err := writer.EnabledLocales(ctx, db)
if err != nil {
return nil, fmt.Errorf("cabana: enabled locales: %w", err)
}
enabled = make(map[string]struct{}, len(codes))
for _, loc := range codes {
enabled[loc] = struct{}{}
}
}
var out []mlValue
for _, name := range names {
raw := body[name]
object, isMap := raw.(map[string]any)
if !isMap {
if nestedValue(raw) {
details[name] = []string{"The " + name + " field must be an object of locale codes to text."}
}
continue
}
values := make(map[string]string, len(object))
ok := true
for locale, item := range object {
if _, known := enabled[locale]; !known {
ok = false
break
}
text, isString := item.(string)
if !isString {
ok = false
break
}
values[locale] = text
}
if !ok {
details[name] = []string{"The " + name + " field must be an object of locale codes to text."}
continue
}
if _, hasDefault := values[defaultLocale]; !hasDefault {
details[name] = []string{"The " + name + " field must include the default locale."}
continue
}
body[name] = values[defaultLocale]
out = append(out, mlValue{field: name, values: values})
}
if len(details) > 0 {
return nil, &ValidationError{Details: details}
}
return out, nil
}
// applyMLTranslations writes non-default locale values through the published
// TranslationWriter. It runs after the host row write so the model has a
// primary key, and only with the tx of the host save.
func applyMLTranslations(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, translations []mlValue) error {
if writer == nil || len(translations) == 0 {
return nil
}
defaultLocale, err := writer.DefaultLocale(ctx, tx)
if err != nil {
return fmt.Errorf("cabana: default locale: %w", err)
}
for _, item := range translations {
for locale, value := range item.values {
if locale == defaultLocale {
continue
}
if err := writer.WriteTranslated(ctx, tx, model, item.field, locale, value); err != nil {
return err
}
}
}
return nil
}
// hydrateMLRecord replaces host scalars of declared mltext/mltextarea/mlmarkdown
// fields in a Show/save RecordResult.Data map with a locale→text map for every
// enabled code. The default locale is the projected host column; other codes
// come from TranslatedExact; a missing non-default code is an empty string,
// never D-11 fallback. It is a no-op when writer is nil and is not used from
// list row projection.
func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, writer TranslationWriter, model any, data map[string]any, op string) error {
if writer == nil || cc == nil || cc.Form == nil || data == nil {
return nil
}
var names []string
for _, field := range cc.Form.Fields {
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
continue
}
names = append(names, field.Name)
}
if len(names) == 0 {
return nil
}
defaultLocale, err := writer.DefaultLocale(ctx, tx)
if err != nil || defaultLocale == "" {
return fmt.Errorf("cabana: default locale: %w", err)
}
codes, err := writer.EnabledLocales(ctx, tx)
if err != nil {
return fmt.Errorf("cabana: enabled locales: %w", err)
}
for _, name := range names {
host := hostScalarString(data[name])
out := make(map[string]string, len(codes))
for _, code := range codes {
if code == defaultLocale {
out[code] = host
continue
}
text, ok, err := writer.TranslatedExact(ctx, tx, model, name, code)
if err != nil {
return err
}
if !ok {
out[code] = ""
continue
}
out[code] = text
}
data[name] = out
}
return nil
}
// hostScalarString renders a projected host column as the default-locale text.
// Nullable pointer columns (*string, *int, **string, ...) are dereferenced at
// any depth and a nil pointer becomes the empty string, so neither "<nil>" nor
// a pointer address reaches the admin form. String kinds return their raw text
// (bypassing any String method), byte slices are decoded as text and every
// other kind is formatted from the dereferenced value.
func hostScalarString(v any) string {
if v == nil {
return ""
}
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return ""
}
rv = rv.Elem()
}
switch {
case rv.Kind() == reflect.String:
return rv.String()
case rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8:
return string(rv.Bytes())
default:
return fmt.Sprint(rv.Interface())
}
}