package cabana import ( "context" "fmt" "gorm.io/gorm" ) const ( mlTextFieldType = "mltext" mlMarkdownFieldType = "mlmarkdown" ) // TranslationWriter persists non-default locale values during a cabana save. // The interface lives in the framework so cabana never imports a plugin; a // host publishes an adapter at Boot. There is no standalone translate-write // HTTP endpoint: writes run only inside a permissioned, scoped host save // transaction after the host row has a primary key. 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 } // 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 }