package cabana import ( "context" "encoding/json" "fmt" "reflect" "sort" "strings" "git.golem15.com/golem15/summercms/modules/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"` // The child create, update and delete copy and the pivot copy (12.2). Create string `yaml:"create"` CreateTitle string `yaml:"createTitle"` UpdateTitle string `yaml:"updateTitle"` PreviewTitle string `yaml:"previewTitle"` Created string `yaml:"created"` Updated string `yaml:"updated"` DeleteSelected string `yaml:"deleteSelected"` DeleteConfirm string `yaml:"deleteConfirm"` DeleteOneConfirm string `yaml:"deleteOneConfirm"` Deleted string `yaml:"deleted"` PivotTitle string `yaml:"pivotTitle"` PivotSaved string `yaml:"pivotSaved"` EditPivot string `yaml:"editPivot"` CreateSubmit string `yaml:"createSubmit"` UpdateSubmit string `yaml:"updateSubmit"` PivotSubmit string `yaml:"pivotSubmit"` LinkSubmit string `yaml:"linkSubmit"` } // 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"` Create MessageForms `json:"create"` CreateTitle MessageForms `json:"createTitle"` UpdateTitle MessageForms `json:"updateTitle"` PreviewTitle MessageForms `json:"previewTitle"` Created MessageForms `json:"created"` Updated MessageForms `json:"updated"` DeleteSelected MessageForms `json:"deleteSelected"` DeleteConfirm MessageForms `json:"deleteConfirm"` DeleteOneConfirm MessageForms `json:"deleteOneConfirm"` Deleted MessageForms `json:"deleted"` PivotTitle MessageForms `json:"pivotTitle"` PivotSaved MessageForms `json:"pivotSaved"` EditPivot MessageForms `json:"editPivot"` CreateSubmit MessageForms `json:"createSubmit"` UpdateSubmit MessageForms `json:"updateSubmit"` PivotSubmit MessageForms `json:"pivotSubmit"` LinkSubmit MessageForms `json:"linkSubmit"` } // 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", Create: "backend::lang.messages.relation.create", CreateTitle: "backend::lang.messages.relation.create_title", UpdateTitle: "backend::lang.messages.relation.update_title", PreviewTitle: "backend::lang.messages.relation.preview_title", Created: "backend::lang.messages.relation.created", Updated: "backend::lang.messages.relation.updated", DeleteSelected: "backend::lang.messages.relation.delete_selected", DeleteConfirm: "backend::lang.messages.relation.delete_confirm", DeleteOneConfirm: "backend::lang.messages.relation.delete_one_confirm", Deleted: "backend::lang.messages.relation.deleted", PivotTitle: "backend::lang.messages.relation.pivot_title", PivotSaved: "backend::lang.messages.relation.pivot_saved", EditPivot: "backend::lang.messages.relation.edit_pivot", CreateSubmit: "backend::lang.messages.relation.create_submit", UpdateSubmit: "backend::lang.messages.relation.update_submit", PivotSubmit: "backend::lang.messages.relation.pivot_submit", LinkSubmit: "backend::lang.messages.relation.link_submit", } ) // 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 } } actionNames := make([]string, 0, len(cc.Actions)) for name := range cc.Actions { actionNames = append(actionNames, name) } sort.Strings(actionNames) for _, name := range actionNames { // A label with a namespace separator is a phrase key and must // resolve; anything else is literal button text. if label := cc.Actions[name].Label; strings.Contains(label, "::") && !tr.Has(label) { return fmt.Errorf("cabana: admin controller %s/%s: action %s label names missing phrase key %s", cc.PluginID, id, name, label) } } 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 }