Form schema meta lists enabled locales, Show/save expand mltext maps without D-11 fallback, and the SPA seeds and adopt-merges every locale. Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
7.0 KiB
Go
233 lines
7.0 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
mlTextFieldType = "mltext"
|
|
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
|
|
}
|
|
|
|
func mlFieldType(typ string) bool {
|
|
return typ == mlTextFieldType || typ == mlMarkdownFieldType
|
|
}
|
|
|
|
// liftMLValues takes declared mltext/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/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
|
|
}
|
|
|
|
func hostScalarString(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case []byte:
|
|
return string(t)
|
|
default:
|
|
return fmt.Sprint(t)
|
|
}
|
|
}
|