- phrasebook ships the backend::lang admin strings (pl, en) with CLDR plural maps, loads them as namespace backend, applies pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml) after every namespace, and fails activation when a backend key cannot convert to plural forms - Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps - Public GET /lang returns every backend::lang key for the request locale over the fallback locale, Cache-Control no-cache - config_list, config_form and config_relation accept a strict messages block; omitted keys take framework defaults, schemas serve every message as CLDR forms, and activation fails on a missing phrase key - toolbar.buttons is an ordered [create, delete] list; the Winter string form, duplicates, unknown actions and delete without showCheckboxes fail at boot, and create is dropped when the controller has no form - Form schema serves the raw Winter redirects; scaffold emits the list syntax; form and relation schema routes are typed in the admin OpenAPI
258 lines
9.0 KiB
Go
258 lines
9.0 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
|
|
"git.golem15.com/golem15/summercms/phrasebook"
|
|
)
|
|
|
|
// MessageForms is one controller message resolved to its CLDR plural forms
|
|
// (D-13). "other" is always present; the SPA picks a form with
|
|
// Intl.PluralRules and interpolates :count, :name and :term (D-24).
|
|
type MessageForms map[string]string
|
|
|
|
// MarshalJSON keeps an unset message an object, never null.
|
|
func (m MessageForms) MarshalJSON() ([]byte, error) {
|
|
if m == nil {
|
|
return []byte("{}"), nil
|
|
}
|
|
return json.Marshal(map[string]string(m))
|
|
}
|
|
|
|
// listMessageKeys is the config_list.yaml messages block: phrase keys.
|
|
type listMessageKeys struct {
|
|
RecordCount string `yaml:"recordCount"`
|
|
Create string `yaml:"create"`
|
|
SearchPrompt string `yaml:"searchPrompt"`
|
|
Empty string `yaml:"empty"`
|
|
EmptySearch string `yaml:"emptySearch"`
|
|
EmptySearchHint string `yaml:"emptySearchHint"`
|
|
Selected string `yaml:"selected"`
|
|
DeleteSelected string `yaml:"deleteSelected"`
|
|
DeleteConfirm string `yaml:"deleteConfirm"`
|
|
Deleted string `yaml:"deleted"`
|
|
}
|
|
|
|
// ListMessages is a list schema's copy, every key resolved (same field order
|
|
// as listMessageKeys).
|
|
type ListMessages struct {
|
|
RecordCount MessageForms `json:"recordCount"`
|
|
Create MessageForms `json:"create"`
|
|
SearchPrompt MessageForms `json:"searchPrompt"`
|
|
Empty MessageForms `json:"empty"`
|
|
EmptySearch MessageForms `json:"emptySearch"`
|
|
EmptySearchHint MessageForms `json:"emptySearchHint"`
|
|
Selected MessageForms `json:"selected"`
|
|
DeleteSelected MessageForms `json:"deleteSelected"`
|
|
DeleteConfirm MessageForms `json:"deleteConfirm"`
|
|
Deleted MessageForms `json:"deleted"`
|
|
}
|
|
|
|
// formMessageKeys is the config_form.yaml messages block: phrase keys.
|
|
type formMessageKeys struct {
|
|
Create string `yaml:"create"`
|
|
Update string `yaml:"update"`
|
|
Saved string `yaml:"saved"`
|
|
DeleteConfirm string `yaml:"deleteConfirm"`
|
|
Deleted string `yaml:"deleted"`
|
|
}
|
|
|
|
// FormMessages is a form's copy, every key resolved.
|
|
type FormMessages struct {
|
|
Create MessageForms `json:"create"`
|
|
Update MessageForms `json:"update"`
|
|
Saved MessageForms `json:"saved"`
|
|
DeleteConfirm MessageForms `json:"deleteConfirm"`
|
|
Deleted MessageForms `json:"deleted"`
|
|
}
|
|
|
|
// relationMessageKeys is one config_relation.yaml relation's messages block.
|
|
type relationMessageKeys struct {
|
|
Link string `yaml:"link"`
|
|
LinkHint string `yaml:"linkHint"`
|
|
CandidateSearch string `yaml:"candidateSearch"`
|
|
Linked string `yaml:"linked"`
|
|
UnlinkSelected string `yaml:"unlinkSelected"`
|
|
UnlinkConfirm string `yaml:"unlinkConfirm"`
|
|
Unlinked string `yaml:"unlinked"`
|
|
Empty string `yaml:"empty"`
|
|
}
|
|
|
|
// RelationMessages is a relation manager's copy, every key resolved.
|
|
type RelationMessages struct {
|
|
Link MessageForms `json:"link"`
|
|
LinkHint MessageForms `json:"linkHint"`
|
|
CandidateSearch MessageForms `json:"candidateSearch"`
|
|
Linked MessageForms `json:"linked"`
|
|
UnlinkSelected MessageForms `json:"unlinkSelected"`
|
|
UnlinkConfirm MessageForms `json:"unlinkConfirm"`
|
|
Unlinked MessageForms `json:"unlinked"`
|
|
Empty MessageForms `json:"empty"`
|
|
}
|
|
|
|
// Framework defaults (D-13): every omitted key falls back to backend::lang.
|
|
var (
|
|
listMessageDefaults = listMessageKeys{
|
|
RecordCount: "backend::lang.messages.list.record_count",
|
|
Create: "backend::lang.messages.list.create",
|
|
SearchPrompt: "backend::lang.list.search_prompt",
|
|
Empty: "backend::lang.list.no_records",
|
|
EmptySearch: "backend::lang.messages.list.empty_search",
|
|
EmptySearchHint: "backend::lang.messages.list.empty_search_hint",
|
|
Selected: "backend::lang.messages.list.selected",
|
|
DeleteSelected: "backend::lang.list.delete_selected",
|
|
DeleteConfirm: "backend::lang.messages.list.delete_confirm",
|
|
Deleted: "backend::lang.messages.list.deleted",
|
|
}
|
|
formMessageDefaults = formMessageKeys{
|
|
Create: "backend::lang.messages.form.create",
|
|
Update: "backend::lang.messages.form.update",
|
|
Saved: "backend::lang.messages.form.saved",
|
|
DeleteConfirm: "backend::lang.messages.form.delete_confirm",
|
|
Deleted: "backend::lang.messages.form.deleted",
|
|
}
|
|
relationMessageDefaults = relationMessageKeys{
|
|
Link: "backend::lang.messages.relation.link",
|
|
LinkHint: "backend::lang.messages.relation.link_hint",
|
|
CandidateSearch: "backend::lang.messages.relation.candidate_search",
|
|
Linked: "backend::lang.messages.relation.linked",
|
|
UnlinkSelected: "backend::lang.messages.relation.unlink_selected",
|
|
UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm",
|
|
Unlinked: "backend::lang.messages.relation.unlinked",
|
|
Empty: "backend::lang.messages.relation.empty",
|
|
}
|
|
)
|
|
|
|
// withDefaults fills every empty key of keys from defaults.
|
|
func withDefaults[K any](keys, defaults K) K {
|
|
out := keys
|
|
dst := reflect.ValueOf(&out).Elem()
|
|
src := reflect.ValueOf(defaults)
|
|
for i := 0; i < dst.NumField(); i++ {
|
|
if dst.Field(i).String() == "" {
|
|
dst.Field(i).SetString(src.Field(i).String())
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// messageKeyPairs lists (yaml name, phrase key) for every key, in field order.
|
|
func messageKeyPairs[K any](keys K) [][2]string {
|
|
v := reflect.ValueOf(keys)
|
|
t := v.Type()
|
|
out := make([][2]string, 0, t.NumField())
|
|
for i := 0; i < t.NumField(); i++ {
|
|
out = append(out, [2]string{t.Field(i).Tag.Get("yaml"), v.Field(i).String()})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// localizeMessages resolves each key of K into the same-index MessageForms
|
|
// field of M. Without a translator, or for a key that cannot convert, the
|
|
// form is {"other": text} where text is the translation or the key itself.
|
|
func localizeMessages[K any, M any](ctx context.Context, tr *phrasebook.Translator, keys K) M {
|
|
var out M
|
|
dst := reflect.ValueOf(&out).Elem()
|
|
src := reflect.ValueOf(keys)
|
|
locale := schemaLocale(ctx, tr)
|
|
for i := 0; i < src.NumField(); i++ {
|
|
key := src.Field(i).String()
|
|
dst.Field(i).Set(reflect.ValueOf(messageForms(tr, locale, key)))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func messageForms(tr *phrasebook.Translator, locale, key string) MessageForms {
|
|
if key == "" {
|
|
return MessageForms{"other": ""}
|
|
}
|
|
if tr == nil {
|
|
return MessageForms{"other": key}
|
|
}
|
|
if forms, ok := tr.Forms(locale, key); ok {
|
|
return MessageForms(forms)
|
|
}
|
|
return MessageForms{"other": tr.GetIn(locale, key, nil)}
|
|
}
|
|
|
|
// listMessageKeySet is the list's resolved key set: declared keys over the
|
|
// framework defaults, with searchPrompt and empty defaulting to the list's own
|
|
// toolbar.search.prompt and noRecordsMessage when those are set.
|
|
func (s *ListSchema) listMessageKeySet() listMessageKeys {
|
|
defaults := listMessageDefaults
|
|
if s.SearchPrompt != "" {
|
|
defaults.SearchPrompt = s.SearchPrompt
|
|
}
|
|
if s.NoRecordsMessage != "" {
|
|
defaults.Empty = s.NoRecordsMessage
|
|
}
|
|
return withDefaults(s.messageKeys, defaults)
|
|
}
|
|
|
|
// formMessageKeySet is the form's resolved key set; create defaults to the
|
|
// config_form.yaml name when it is set.
|
|
func (s *FormSchema) formMessageKeySet() formMessageKeys {
|
|
defaults := formMessageDefaults
|
|
if s.Name != "" {
|
|
defaults.Create = s.Name
|
|
}
|
|
return withDefaults(s.messageKeys, defaults)
|
|
}
|
|
|
|
func (s *RelationSchema) relationMessageKeySet() relationMessageKeys {
|
|
return withDefaults(s.messageKeys, relationMessageDefaults)
|
|
}
|
|
|
|
// validateMessageKeys fails activation when a controller's messages block (or
|
|
// a framework default) names a phrase key no locale defines (D-13). Defaults
|
|
// taken from the list's own search prompt, empty message or the form name are
|
|
// Winter strings that may be literal text, so they are not checked.
|
|
func validateMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
|
|
if reg == nil || tr == nil {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(reg.byID))
|
|
for id := range reg.byID {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
check := func(pluginID, id, file string, pairs [][2]string) error {
|
|
for _, pair := range pairs {
|
|
if pair[1] != "" && !tr.Has(pair[1]) {
|
|
return bootErr(pluginID, id, file, fmt.Errorf("messages.%s names missing phrase key %s", pair[0], pair[1]))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
for _, id := range ids {
|
|
cc := reg.byID[id]
|
|
if cc.List != nil {
|
|
if err := check(cc.PluginID, id, "config_list.yaml", messageKeyPairs(withDefaults(cc.List.messageKeys, listMessageDefaults))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if cc.Form != nil {
|
|
if err := check(cc.PluginID, id, "config_form.yaml", messageKeyPairs(withDefaults(cc.Form.messageKeys, formMessageDefaults))); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
names := make([]string, 0, len(cc.Relations))
|
|
for name := range cc.Relations {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
if schema := cc.Relations[name].Schema; schema != nil {
|
|
if err := check(cc.PluginID, id, "config_relation.yaml", messageKeyPairs(schema.relationMessageKeySet())); err != nil {
|
|
return fmt.Errorf("relation %s: %w", name, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|