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 "" 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()) } }