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
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
"git.golem15.com/golem15/summercms/phrasebook"
|
"git.golem15.com/golem15/summercms/phrasebook"
|
||||||
|
"git.golem15.com/golem15/summercms/towel"
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
"github.com/goccy/go-yaml/ast"
|
"github.com/goccy/go-yaml/ast"
|
||||||
)
|
)
|
||||||
@@ -98,6 +99,9 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
|||||||
if fields == nil {
|
if fields == nil {
|
||||||
fields = []FormField{}
|
fields = []FormField{}
|
||||||
}
|
}
|
||||||
|
if err := requireDropdownProvider(ctl, fields); err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
|
||||||
|
}
|
||||||
return &FormSchema{
|
return &FormSchema{
|
||||||
Name: doc.Name,
|
Name: doc.Name,
|
||||||
ModelClass: doc.ModelClass,
|
ModelClass: doc.ModelClass,
|
||||||
@@ -105,6 +109,54 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
|
|||||||
}, nil
|
}, 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.
|
// CanonicalJSON is the stable, locale-neutral form contract.
|
||||||
func (s *FormSchema) CanonicalJSON() ([]byte, error) {
|
func (s *FormSchema) CanonicalJSON() ([]byte, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -117,6 +169,98 @@ func (s *FormSchema) CanonicalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(out)
|
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) {
|
func decodeFields(raw []byte) ([]FormField, error) {
|
||||||
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||||
var doc formFieldsFile
|
var doc formFieldsFile
|
||||||
@@ -458,24 +602,6 @@ func normalizeYAMLError(err error) error {
|
|||||||
return err
|
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) {
|
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
|
||||||
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
|
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
|
||||||
if dir == "." || strings.HasPrefix(dir, "..") {
|
if dir == "." || strings.HasPrefix(dir, "..") {
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
|
|||||||
return nil
|
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,
|
// Get returns the translation for key using the request locale when present,
|
||||||
// otherwise the configured app locale.
|
// otherwise the configured app locale.
|
||||||
func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string {
|
func (t *Translator) Get(ctx context.Context, key string, params map[string]string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user