From da8828ef387abeade23ecb4f75427e8143d9830b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 18:21:59 +0200 Subject: [PATCH] feat(09-03): localize form schemas and resolve dropdown options - Cached schemas stay source-key IR and each response carries its own meta.locale - YAML option maps keep declaration order and scalar type; method options call DropdownOptions and fail boot without a provider --- cabana/form_schema.go | 162 ++++++++++++++++++++++++++++++++++----- phrasebook/translator.go | 8 ++ 2 files changed, 152 insertions(+), 18 deletions(-) diff --git a/cabana/form_schema.go b/cabana/form_schema.go index 08374ee..eeb19db 100644 --- a/cabana/form_schema.go +++ b/cabana/form_schema.go @@ -14,6 +14,7 @@ import ( "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/towel" "github.com/goccy/go-yaml" "github.com/goccy/go-yaml/ast" ) @@ -98,6 +99,9 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc if fields == nil { fields = []FormField{} } + if err := requireDropdownProvider(ctl, fields); err != nil { + return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err) + } return &FormSchema{ Name: doc.Name, ModelClass: doc.ModelClass, @@ -105,6 +109,54 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc }, nil } +// Localize copies the cached schema and resolves display strings for one request. +// It does not mutate s. Option values keep the scalar captured at boot. +func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) { + if s == nil { + return nil, fmt.Errorf("cabana: nil form schema") + } + if ctx == nil { + ctx = context.Background() + } + locale := schemaLocale(ctx, tr) + ctx = towel.WithLocale(ctx, locale) + fields := make([]FormField, len(s.Fields)) + for i, src := range s.Fields { + field := src + field.Label = translateKey(ctx, tr, src.Label) + field.Comment = translateKey(ctx, tr, src.Comment) + field.Tab = translateKey(ctx, tr, src.Tab) + field.EmptyOption = translateKey(ctx, tr, src.EmptyOption) + options, err := localizeOptions(ctx, tr, src, provider) + if err != nil { + return nil, err + } + field.Options = options + fields[i] = field + } + if fields == nil { + fields = []FormField{} + } + return &FormView{ + Name: translateKey(ctx, tr, s.Name), + ModelClass: s.ModelClass, + Fields: fields, + Meta: FormMeta{Locale: locale}, + }, nil +} + +// CanonicalJSON is one localized schema response. +func (v *FormView) CanonicalJSON() ([]byte, error) { + if v == nil { + return nil, fmt.Errorf("cabana: nil form view") + } + out := *v + if out.Fields == nil { + out.Fields = []FormField{} + } + return json.Marshal(out) +} + // CanonicalJSON is the stable, locale-neutral form contract. func (s *FormSchema) CanonicalJSON() ([]byte, error) { if s == nil { @@ -117,6 +169,98 @@ func (s *FormSchema) CanonicalJSON() ([]byte, error) { return json.Marshal(out) } +func requireDropdownProvider(ctl pact.AdminController, fields []FormField) error { + var method string + for _, field := range fields { + if field.optionsMethod != "" { + method = field.optionsMethod + break + } + } + if method == "" || dropdownProvider(ctl) != nil { + return nil + } + return fmt.Errorf("dropdown method %s requires DropdownOptions", method) +} + +func dropdownProvider(ctl pact.AdminController) pact.DropdownOptionsProvider { + if ctl == nil { + return nil + } + if p, ok := ctl.(pact.DropdownOptionsProvider); ok && p != nil { + return p + } + src, ok := ctl.(pact.AdminRecordSource) + if !ok || src == nil { + return nil + } + rec := src.NewRecord() + p, ok := rec.(pact.DropdownOptionsProvider) + if !ok || p == nil { + return nil + } + return p +} + +func schemaLocale(ctx context.Context, tr *phrasebook.Translator) string { + if loc, ok := towel.Locale(ctx); ok { + if primary := primaryLanguage(loc); primary != "" { + return primary + } + } + if tr != nil { + if loc := strings.TrimSpace(tr.Locale()); loc != "" { + return loc + } + } + return "en" +} + +func primaryLanguage(header string) string { + header = strings.TrimSpace(header) + if header == "" { + return "" + } + if i := strings.IndexAny(header, ",;"); i >= 0 { + header = strings.TrimSpace(header[:i]) + } + return header +} + +func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string { + if key == "" || tr == nil { + return key + } + return tr.Get(ctx, key, nil) +} + +func localizeOptions(ctx context.Context, tr *phrasebook.Translator, src FormField, provider pact.DropdownOptionsProvider) ([]FormOption, error) { + if src.optionsMethod != "" { + if provider == nil { + return nil, fmt.Errorf("cabana: dropdown method %s requires DropdownOptions", src.optionsMethod) + } + provided := provider.DropdownOptions(src.Name) + opts := make([]FormOption, len(provided)) + for i, opt := range provided { + raw, err := json.Marshal(opt.Value) + if err != nil { + return nil, err + } + opts[i] = FormOption{Value: jsonScalar{raw: raw}, Label: translateKey(ctx, tr, opt.Label)} + } + return opts, nil + } + if len(src.Options) == 0 { + return nil, nil + } + opts := make([]FormOption, len(src.Options)) + for i, opt := range src.Options { + opt.Label = translateKey(ctx, tr, opt.Label) + opts[i] = opt + } + return opts, nil +} + func decodeFields(raw []byte) ([]FormField, error) { dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) var doc formFieldsFile @@ -458,24 +602,6 @@ func normalizeYAMLError(err error) error { return err } -// Localize resolves display strings for one request. It does not yet. -func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) { - if s == nil || ctx == nil { - return nil, fmt.Errorf("cabana: form localization is not implemented") - } - _ = tr - _ = provider - return nil, fmt.Errorf("cabana: form localization is not implemented") -} - -// CanonicalJSON will emit one localized response. It does not yet. -func (v *FormView) CanonicalJSON() ([]byte, error) { - if v == nil { - return nil, fmt.Errorf("cabana: form localization is not implemented") - } - return nil, fmt.Errorf("cabana: form localization is not implemented") -} - func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) { dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") if dir == "." || strings.HasPrefix(dir, "..") { diff --git a/phrasebook/translator.go b/phrasebook/translator.go index ddafbc8..2d1a240 100644 --- a/phrasebook/translator.go +++ b/phrasebook/translator.go @@ -116,6 +116,14 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error return nil } +// Locale returns the configured app locale. Request locales stay on the context. +func (t *Translator) Locale() string { + if t == nil || strings.TrimSpace(t.locale) == "" { + return defaultLocale + } + return t.locale +} + // Get returns the translation for key using the request locale when present, // otherwise the configured app locale. func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string {