Files
summercms/modules/cabana/field_ml.go
Jakub Zych e7423bd21f feat(32-01): form extension placement rules, boot errors and owner-aware branches
- placement: tabs before/after tab anchors, anchor cursor for plugin
  order, own-field anchors sorted with cycle detection, Requires() rule,
  anchor tab inheritance, Hide with reference checks, zone refusal (D-101)
- boot errors name plugin, controller and offending name; fileupload is
  refused (D-100); localized tab label collisions checked in Activate
- extension widgets dispatch to AdminFormExtension.Actions, partials
  render from the contributor's Dir through PartialData, plugin field
  types decode for Save and load after Load, translatable values reach
  Save as locale maps and never the owner's translations
- Permissions and Visible apply to schema, show, save, widgets and
  partials; AdminFormErrors answer 422, Save notices join meta.notices
2026-10-26 22:00:51 +01:00

264 lines
8.5 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
}
// isMLField reports whether a form field's value is a locale map stored
// through the translation writer: a built-in ML type (mlFieldType) or a
// translatable plugin field type (pact.FormFieldType.Translatable).
func isMLField(field FormField) bool {
return mlFieldType(field.Type) || (field.fieldType != nil && field.fieldType.def.Translatable)
}
// 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 {
// A form extension's translatable field is lifted for its own Save
// (liftExtensionValues), never into the owner's translations.
if !isMLField(field) || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
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 {
// A form extension serves its own values (hydrateFormExtensions).
if !isMLField(field) || !contextAllows(cc, field.Name, op) || cc.extensionOf(field.Name) != nil {
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())
}
}