From c87148a34f5b82940410caf9c3541b7bd2f3f4ff Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 27 Sep 2026 16:16:32 +0200 Subject: [PATCH] feat(10-02): backend strings, controller messages and declarative toolbar - phrasebook ships the backend::lang admin strings (pl, en) with CLDR plural maps, loads them as namespace backend, applies pact.HasLangOverrides trees (lang///.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 --- admin/openapi/admin.json | 450 ++++++++++++++++++++++++++- admin/src/api/schema.d.ts | 164 +++++++++- cabana/admin_openapi.go | 18 +- cabana/form_schema.go | 30 +- cabana/http.go | 9 + cabana/lang.go | 24 ++ cabana/list_schema.go | 74 ++++- cabana/list_schema_test.go | 20 +- cabana/messages.go | 257 +++++++++++++++ cabana/messages_test.go | 354 +++++++++++++++++++++ cabana/phase09_contract_test.go | 5 +- cabana/registry.go | 14 + cabana/relation.go | 26 +- cabana/schema_types.go | 39 ++- cabana/security_coverage_test.go | 1 + internal/build/build_test.go | 2 + internal/build/stubs/artifacts.tmpl | 5 +- pact/capabilities.go | 9 + phrasebook/backend/lang/en/lang.yaml | 119 +++++++ phrasebook/backend/lang/pl/lang.yaml | 135 ++++++++ phrasebook/lang.go | 18 +- phrasebook/loader.go | 104 ++++++- phrasebook/phase10_test.go | 199 ++++++++++++ phrasebook/translator.go | 117 +++++++ 24 files changed, 2148 insertions(+), 45 deletions(-) create mode 100644 cabana/lang.go create mode 100644 cabana/messages.go create mode 100644 cabana/messages_test.go create mode 100644 phrasebook/backend/lang/en/lang.yaml create mode 100644 phrasebook/backend/lang/pl/lang.yaml create mode 100644 phrasebook/phase10_test.go diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index b1eecce..a4fec17 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -154,6 +154,36 @@ ], "type": "object" }, + "cabana.Envelope-cabana_FormView": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.FormView" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, + "cabana.Envelope-cabana_LangBundle": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.LangBundle" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_ListSchema": { "properties": { "data": { @@ -169,6 +199,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_RelationSchema": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.RelationSchema" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.ErrorBody": { "properties": { "code": { @@ -200,6 +245,99 @@ ], "type": "object" }, + "cabana.FormField": { + "properties": { + "attributes": { + "additionalProperties": { + "$ref": "#/components/schemas/cabana.jsonScalar" + }, + "type": "object" + }, + "comment": { + "type": "string" + }, + "context": { + "$ref": "#/components/schemas/cabana.fieldContext" + }, + "default": { + "$ref": "#/components/schemas/cabana.jsonScalar" + }, + "emptyOption": { + "type": "string" + }, + "label": { + "type": "string" + }, + "multiple": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "nameFrom": { + "type": "string" + }, + "options": { + "items": { + "$ref": "#/components/schemas/cabana.FormOption" + }, + "type": "array" + }, + "readOnly": { + "type": "boolean" + }, + "relation": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "size": { + "type": "string" + }, + "span": { + "type": "string" + }, + "tab": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "cabana.FormMessages": { + "properties": { + "create": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleteConfirm": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleted": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "saved": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "update": { + "$ref": "#/components/schemas/cabana.MessageForms" + } + }, + "required": [ + "create", + "deleteConfirm", + "deleted", + "saved", + "update" + ], + "type": "object" + }, "cabana.FormMeta": { "properties": { "locale": { @@ -226,6 +364,88 @@ ], "type": "object" }, + "cabana.FormRedirect": { + "properties": { + "redirect": { + "type": "string" + }, + "redirectClose": { + "type": "string" + } + }, + "required": [ + "redirect", + "redirectClose" + ], + "type": "object" + }, + "cabana.FormRedirects": { + "properties": { + "create": { + "$ref": "#/components/schemas/cabana.FormRedirect" + }, + "default": { + "type": "string" + }, + "update": { + "$ref": "#/components/schemas/cabana.FormRedirect" + } + }, + "required": [ + "create", + "default", + "update" + ], + "type": "object" + }, + "cabana.FormView": { + "properties": { + "fields": { + "items": { + "$ref": "#/components/schemas/cabana.FormField" + }, + "type": "array" + }, + "messages": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.FormMessages" + } + ], + "description": "Messages is the form's copy resolved in the request locale (D-13)." + }, + "meta": { + "$ref": "#/components/schemas/cabana.FormMeta" + }, + "modelClass": { + "type": "string" + }, + "name": { + "type": "string" + }, + "redirects": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.FormRedirects" + } + ], + "description": "Redirects are the raw Winter config_form.yaml targets; the SPA maps\nthem onto its routes." + } + }, + "required": [ + "fields", + "messages", + "meta", + "redirects" + ], + "type": "object" + }, + "cabana.LangBundle": { + "additionalProperties": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "type": "object" + }, "cabana.ListColumn": { "properties": { "key": { @@ -337,6 +557,53 @@ ], "type": "object" }, + "cabana.ListMessages": { + "properties": { + "create": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleteConfirm": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleteSelected": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "deleted": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "empty": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "emptySearch": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "emptySearchHint": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "recordCount": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "searchPrompt": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "selected": { + "$ref": "#/components/schemas/cabana.MessageForms" + } + }, + "required": [ + "create", + "deleteConfirm", + "deleteSelected", + "deleted", + "empty", + "emptySearch", + "emptySearchHint", + "recordCount", + "searchPrompt", + "selected" + ], + "type": "object" + }, "cabana.ListMeta": { "properties": { "last_page": { @@ -383,6 +650,14 @@ }, "type": "array" }, + "messages": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.ListMessages" + } + ], + "description": "Messages is the list's copy (D-13). The cached schema carries each\nphrase key as its own form; a response resolves them in its locale." + }, "meta": { "$ref": "#/components/schemas/cabana.FormMeta" }, @@ -442,6 +717,7 @@ "bulkActions", "columns", "filters", + "messages", "perPageOptions", "recordsPerPage", "rowActions", @@ -469,6 +745,12 @@ ], "type": "object" }, + "cabana.MessageForms": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "cabana.NavigationEntry": { "properties": { "code": { @@ -535,6 +817,82 @@ ], "type": "object" }, + "cabana.RelationColumn": { + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "searchable": { + "type": "boolean" + }, + "sortable": { + "type": "boolean" + } + }, + "required": [ + "key", + "label", + "searchable", + "sortable" + ], + "type": "object" + }, + "cabana.RelationList": { + "properties": { + "columns": { + "items": { + "$ref": "#/components/schemas/cabana.RelationColumn" + }, + "type": "array" + } + }, + "required": [ + "columns" + ], + "type": "object" + }, + "cabana.RelationMessages": { + "properties": { + "candidateSearch": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "empty": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "link": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "linkHint": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "linked": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "unlinkConfirm": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "unlinkSelected": { + "$ref": "#/components/schemas/cabana.MessageForms" + }, + "unlinked": { + "$ref": "#/components/schemas/cabana.MessageForms" + } + }, + "required": [ + "candidateSearch", + "empty", + "link", + "linkHint", + "linked", + "unlinkConfirm", + "unlinkSelected", + "unlinked" + ], + "type": "object" + }, "cabana.RelationOption": { "properties": { "label": { @@ -550,6 +908,60 @@ ], "type": "object" }, + "cabana.RelationPanel": { + "properties": { + "list": { + "$ref": "#/components/schemas/cabana.RelationList" + }, + "showSearch": { + "type": "boolean" + }, + "toolbarButtons": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "list", + "showSearch", + "toolbarButtons" + ], + "type": "object" + }, + "cabana.RelationSchema": { + "properties": { + "label": { + "type": "string" + }, + "manage": { + "$ref": "#/components/schemas/cabana.RelationPanel" + }, + "messages": { + "allOf": [ + { + "$ref": "#/components/schemas/cabana.RelationMessages" + } + ], + "description": "Messages is the relation manager's copy (D-13); the cached schema\ncarries each phrase key as its own form." + }, + "name": { + "type": "string" + }, + "view": { + "$ref": "#/components/schemas/cabana.RelationPanel" + } + }, + "required": [ + "label", + "manage", + "messages", + "name", + "view" + ], + "type": "object" + }, "cabana.RowAction": { "properties": { "label": { @@ -600,6 +1012,19 @@ }, "type": "object" }, + "cabana.fieldContext": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "cabana.jsonScalar": { "nullable": true, "oneOf": [ @@ -803,6 +1228,27 @@ ] } }, + "/lang": { + "get": { + "description": "Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_LangBundle" + } + } + }, + "description": "OK" + } + }, + "summary": "Admin UI strings", + "tags": [ + "admin" + ] + } + }, "/navigation": { "get": { "responses": { @@ -1544,7 +1990,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_FormView" } } }, @@ -1721,7 +2167,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/cabana.SuccessEnvelope" + "$ref": "#/components/schemas/cabana.Envelope-cabana_RelationSchema" } } }, diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index c176724..697d04c 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -204,6 +204,45 @@ export interface paths { patch?: never; trace?: never; }; + "/lang": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Admin UI strings + * @description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_LangBundle"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/navigation": { parameters: { query?: never; @@ -823,7 +862,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_FormView"]; }; }; /** @description Unauthorized */ @@ -965,7 +1004,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["cabana.SuccessEnvelope"]; + "application/json": components["schemas"]["cabana.Envelope-cabana_RelationSchema"]; }; }; /** @description Unauthorized */ @@ -1523,10 +1562,22 @@ export interface components { data: components["schemas"]["cabana.AdminProfile"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_FormView": { + data: components["schemas"]["cabana.FormView"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; + "cabana.Envelope-cabana_LangBundle": { + data: components["schemas"]["cabana.LangBundle"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_ListSchema": { data: components["schemas"]["cabana.ListSchema"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_RelationSchema": { + data: components["schemas"]["cabana.RelationSchema"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.ErrorBody": { code: string; details: { @@ -1537,6 +1588,34 @@ export interface components { "cabana.ErrorEnvelope": { error: components["schemas"]["cabana.ErrorBody"]; }; + "cabana.FormField": { + attributes?: { + [key: string]: components["schemas"]["cabana.jsonScalar"]; + }; + comment?: string; + context?: components["schemas"]["cabana.fieldContext"]; + default?: components["schemas"]["cabana.jsonScalar"]; + emptyOption?: string; + label?: string; + multiple?: boolean; + name: string; + nameFrom?: string; + options?: components["schemas"]["cabana.FormOption"][]; + readOnly?: boolean; + relation?: string; + required?: boolean; + size?: string; + span?: string; + tab?: string; + type: string; + }; + "cabana.FormMessages": { + create: components["schemas"]["cabana.MessageForms"]; + deleteConfirm: components["schemas"]["cabana.MessageForms"]; + deleted: components["schemas"]["cabana.MessageForms"]; + saved: components["schemas"]["cabana.MessageForms"]; + update: components["schemas"]["cabana.MessageForms"]; + }; "cabana.FormMeta": { locale: string; }; @@ -1544,6 +1623,31 @@ export interface components { label: string; value: components["schemas"]["cabana.jsonScalar"]; }; + "cabana.FormRedirect": { + redirect: string; + redirectClose: string; + }; + "cabana.FormRedirects": { + create: components["schemas"]["cabana.FormRedirect"]; + default: string; + update: components["schemas"]["cabana.FormRedirect"]; + }; + "cabana.FormView": { + fields: components["schemas"]["cabana.FormField"][]; + /** @description Messages is the form's copy resolved in the request locale (D-13). */ + messages: components["schemas"]["cabana.FormMessages"]; + meta: components["schemas"]["cabana.FormMeta"]; + modelClass?: string; + name?: string; + /** + * @description Redirects are the raw Winter config_form.yaml targets; the SPA maps + * them onto its routes. + */ + redirects: components["schemas"]["cabana.FormRedirects"]; + }; + "cabana.LangBundle": { + [key: string]: components["schemas"]["cabana.MessageForms"]; + }; "cabana.ListColumn": { key: string; label: string; @@ -1575,6 +1679,18 @@ export interface components { trueValue?: components["schemas"]["cabana.jsonScalar"]; type: string; }; + "cabana.ListMessages": { + create: components["schemas"]["cabana.MessageForms"]; + deleteConfirm: components["schemas"]["cabana.MessageForms"]; + deleteSelected: components["schemas"]["cabana.MessageForms"]; + deleted: components["schemas"]["cabana.MessageForms"]; + empty: components["schemas"]["cabana.MessageForms"]; + emptySearch: components["schemas"]["cabana.MessageForms"]; + emptySearchHint: components["schemas"]["cabana.MessageForms"]; + recordCount: components["schemas"]["cabana.MessageForms"]; + searchPrompt: components["schemas"]["cabana.MessageForms"]; + selected: components["schemas"]["cabana.MessageForms"]; + }; "cabana.ListMeta": { last_page: number; page: number; @@ -1586,6 +1702,11 @@ export interface components { columns: components["schemas"]["cabana.ListColumn"][]; defaultSort?: components["schemas"]["cabana.ListSort"]; filters: components["schemas"]["cabana.ListFilter"][]; + /** + * @description Messages is the list's copy (D-13). The cached schema carries each + * phrase key as its own form; a response resolves them in its locale. + */ + messages: components["schemas"]["cabana.ListMessages"]; meta?: components["schemas"]["cabana.FormMeta"]; modelClass?: string; noRecordsMessage?: string; @@ -1606,6 +1727,9 @@ export interface components { column: string; direction: string; }; + "cabana.MessageForms": { + [key: string]: string; + }; "cabana.NavigationEntry": { code: string; controller: string; @@ -1623,10 +1747,45 @@ export interface components { [key: string]: components["schemas"]["cabana.RelationOption"][]; }; }; + "cabana.RelationColumn": { + key: string; + label: string; + searchable: boolean; + sortable: boolean; + }; + "cabana.RelationList": { + columns: components["schemas"]["cabana.RelationColumn"][]; + }; + "cabana.RelationMessages": { + candidateSearch: components["schemas"]["cabana.MessageForms"]; + empty: components["schemas"]["cabana.MessageForms"]; + link: components["schemas"]["cabana.MessageForms"]; + linkHint: components["schemas"]["cabana.MessageForms"]; + linked: components["schemas"]["cabana.MessageForms"]; + unlinkConfirm: components["schemas"]["cabana.MessageForms"]; + unlinkSelected: components["schemas"]["cabana.MessageForms"]; + unlinked: components["schemas"]["cabana.MessageForms"]; + }; "cabana.RelationOption": { label: string; value: number; }; + "cabana.RelationPanel": { + list: components["schemas"]["cabana.RelationList"]; + showSearch: boolean; + toolbarButtons: string[]; + }; + "cabana.RelationSchema": { + label: string; + manage: components["schemas"]["cabana.RelationPanel"]; + /** + * @description Messages is the relation manager's copy (D-13); the cached schema + * carries each phrase key as its own form. + */ + messages: components["schemas"]["cabana.RelationMessages"]; + name: string; + view: components["schemas"]["cabana.RelationPanel"]; + }; "cabana.RowAction": { label?: string; name: string; @@ -1643,6 +1802,7 @@ export interface components { per_page?: number; total?: number; }; + "cabana.fieldContext": string | string[]; "cabana.jsonScalar": (string | number | boolean) | null; }; responses: never; diff --git a/cabana/admin_openapi.go b/cabana/admin_openapi.go index 35b4720..104c3cd 100644 --- a/cabana/admin_openapi.go +++ b/cabana/admin_openapi.go @@ -119,6 +119,20 @@ func AdminLogin() {} // @Router /auth/refresh [post] func AdminRefresh() {} +// LangBundle is the public string bundle: full backend::lang key to CLDR +// forms (D-20). +type LangBundle map[string]MessageForms + +// AdminLang documents GET /lang. +// +// @Summary Admin UI strings +// @Description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to. +// @Tags admin +// @Produce json +// @Success 200 {object} Envelope[LangBundle] +// @Router /lang [get] +func AdminLang() {} + // AdminLogout documents POST /auth/logout. // // @Summary Admin logout @@ -233,7 +247,7 @@ func AdminListSchema() {} // @Param vendor path string true "Vendor" // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[FormView] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope @@ -250,7 +264,7 @@ func AdminFormSchema() {} // @Param plugin path string true "Plugin" // @Param controller path string true "Controller" // @Param name path string true "Relation name" -// @Success 200 {object} SuccessEnvelope +// @Success 200 {object} Envelope[RelationSchema] // @Failure 401 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope diff --git a/cabana/form_schema.go b/cabana/form_schema.go index eeb19db..c678c75 100644 --- a/cabana/form_schema.go +++ b/cabana/form_schema.go @@ -38,12 +38,13 @@ var ( ) type formConfigDocument struct { - Name string `yaml:"name"` - Form string `yaml:"form"` - ModelClass string `yaml:"modelClass"` - DefaultRedirect string `yaml:"defaultRedirect"` - Create *formRedirects `yaml:"create"` - Update *formRedirects `yaml:"update"` + Name string `yaml:"name"` + Form string `yaml:"form"` + ModelClass string `yaml:"modelClass"` + DefaultRedirect string `yaml:"defaultRedirect"` + Create *formRedirects `yaml:"create"` + Update *formRedirects `yaml:"update"` + Messages *formMessageKeys `yaml:"messages"` } type formRedirects struct { @@ -102,11 +103,22 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc if err := requireDropdownProvider(ctl, fields); err != nil { return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err) } - return &FormSchema{ + schema := &FormSchema{ Name: doc.Name, ModelClass: doc.ModelClass, Fields: fields, - }, nil + redirects: FormRedirects{Default: doc.DefaultRedirect}, + } + if doc.Messages != nil { + schema.messageKeys = *doc.Messages + } + if doc.Create != nil { + schema.redirects.Create = FormRedirect{Redirect: doc.Create.Redirect, RedirectClose: doc.Create.RedirectClose} + } + if doc.Update != nil { + schema.redirects.Update = FormRedirect{Redirect: doc.Update.Redirect, RedirectClose: doc.Update.RedirectClose} + } + return schema, nil } // Localize copies the cached schema and resolves display strings for one request. @@ -141,6 +153,8 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr Name: translateKey(ctx, tr, s.Name), ModelClass: s.ModelClass, Fields: fields, + Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()), + Redirects: s.redirects, Meta: FormMeta{Locale: locale}, }, nil } diff --git a/cabana/http.go b/cabana/http.go index ec6f82e..dad0b6d 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -96,6 +96,11 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) { if app == nil { return nil, errors.New("cabana: app is nil") } + if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil { + if err := validateMessageKeys(reg, tr); err != nil { + return nil, err + } + } guards, ok := app.Lookup[*bouncer.Registry]() if !ok || guards == nil { guards = bouncer.NewRegistry() @@ -183,6 +188,10 @@ func (s *service) mount(r pact.Router) { g.Post("/login", s.login, throttle) g.Post("/refresh", requireAjax(s.refresh)) }) + // The string bundle is public: the login screen needs it before auth. + r.GroupRaw(api, nil, func(g pact.Router) { + g.Get("/lang", s.langBundle) + }) r.GroupRaw(api, []string{"backend"}, func(g pact.Router) { g.Post("/auth/logout", requireAjax(s.logout)) g.Get("/auth/me", s.me) diff --git a/cabana/lang.go b/cabana/lang.go new file mode 100644 index 0000000..65fbb00 --- /dev/null +++ b/cabana/lang.go @@ -0,0 +1,24 @@ +package cabana + +import ( + "net/http" +) + +// adminLangPrefix is the only namespace the public bundle serves (D-20). +const adminLangPrefix = "backend::lang." + +// langBundle serves GET {prefix}/api/v1/lang: every backend::lang key as CLDR +// forms for the request locale, with the fallback locale's keys underneath. +// It is public (the login screen needs it) and never includes another +// namespace. +func (s *service) langBundle(w http.ResponseWriter, r *http.Request) { + tr := s.translator() + locale := schemaLocale(r.Context(), tr) + data := map[string]map[string]string{} + if tr != nil { + locale = tr.Resolved(locale, adminLangPrefix) + data = tr.Bundle(locale, adminLangPrefix) + } + w.Header().Set("Cache-Control", "no-cache") + WriteData(w, http.StatusOK, data, map[string]any{"locale": locale}) +} diff --git a/cabana/list_schema.go b/cabana/list_schema.go index 195d7dd..49d2d5c 100644 --- a/cabana/list_schema.go +++ b/cabana/list_schema.go @@ -13,6 +13,7 @@ import ( "git.golem15.com/golem15/summercms/phrasebook" "git.golem15.com/golem15/summercms/towel" "github.com/goccy/go-yaml" + "github.com/goccy/go-yaml/ast" "gorm.io/gorm" ) @@ -37,6 +38,7 @@ type listDocument struct { DefaultSort *listSortDocument `yaml:"defaultSort"` Toolbar *listToolbar `yaml:"toolbar"` Filter string `yaml:"filter"` + Messages *listMessageKeys `yaml:"messages"` } type listSortDocument struct { @@ -45,7 +47,7 @@ type listSortDocument struct { } type listToolbar struct { - Buttons string `yaml:"buttons"` + Buttons toolbarButtons `yaml:"buttons"` Search *struct { Prompt string `yaml:"prompt"` Mode string `yaml:"mode"` @@ -123,7 +125,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode)) } } - buttons, err := compileToolbarButtons(doc.Toolbar) + buttons, err := compileToolbarButtons(doc.Toolbar, doc.ShowCheckboxes) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } @@ -154,7 +156,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc return nil, err } } - return &ListSchema{ + schema := &ListSchema{ Title: doc.Title, ModelClass: doc.ModelClass, RecordURL: doc.RecordURL, @@ -173,7 +175,13 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc Filters: filters, RowActions: rowActions, BulkActions: bulk, - }, nil + } + if doc.Messages != nil { + schema.messageKeys = *doc.Messages + } + keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, schema.listMessageKeySet()) + schema.Messages = &keys + return schema, nil } func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) { @@ -270,16 +278,58 @@ func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) { return options, nil } -func compileToolbarButtons(toolbar *listToolbar) ([]string, error) { - if toolbar == nil || strings.TrimSpace(toolbar.Buttons) == "" { +// toolbarButtons is the declarative toolbar.buttons list (D-14): built-in +// actions in display order. A scalar (Winter's partial name) is rejected +// with a pointer at the list syntax. +type toolbarButtons struct { + items []string +} + +// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1. +var toolbarActions = map[string]struct{}{"create": {}, "delete": {}} + +func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error { + node = unwrapNode(node) + switch n := node.(type) { + case nil, *ast.NullNode: + b.items = nil + return nil + case *ast.SequenceNode: + values := sequenceValues(n) + items := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, item := range values { + action, err := nodeString(unwrapNode(item)) + if err != nil { + return fmt.Errorf("toolbar.buttons entries must be action names (create, delete)") + } + if _, ok := toolbarActions[action]; !ok { + return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action) + } + if _, dup := seen[action]; dup { + return fmt.Errorf("toolbar.buttons: duplicate action %s", action) + } + seen[action] = struct{}{} + items = append(items, action) + } + b.items = items + return nil + default: + return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete); the Winter partial %q is not supported", nodeText(node)) + } +} + +func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) { + if toolbar == nil || len(toolbar.Buttons.items) == 0 { return []string{}, nil } - switch toolbar.Buttons { - case "list_toolbar": - return []string{"create"}, nil - default: - return nil, fmt.Errorf("unsupported action %s", toolbar.Buttons) + out := append([]string(nil), toolbar.Buttons.items...) + for _, action := range out { + if action == "delete" && !showCheckboxes { + return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true") + } } + return out, nil } func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) { @@ -391,6 +441,8 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (* out.Filters = localizeListFilters(ctx, tr, s.Filters) out.RowActions = localizeRowActions(ctx, tr, s.RowActions) out.BulkActions = localizeBulkActions(ctx, tr, s.BulkActions) + messages := localizeMessages[listMessageKeys, ListMessages](ctx, tr, s.listMessageKeySet()) + out.Messages = &messages out.Meta = &FormMeta{Locale: locale} return &out, nil } diff --git a/cabana/list_schema_test.go b/cabana/list_schema_test.go index b916ec0..c013c9b 100644 --- a/cabana/list_schema_test.go +++ b/cabana/list_schema_test.go @@ -32,12 +32,16 @@ defaultSort: column: name direction: asc toolbar: - buttons: list_toolbar + buttons: [create, delete] search: prompt: backend::lang.list.search_prompt ` -const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}]}` +const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create","delete"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}],` + defaultListMessagesJSON + `}` + +// defaultListMessagesJSON is a compiled list's messages block when the YAML +// declares none: every key is a framework default phrase key (D-13). +const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"}}` func TestListSchemaCompile(t *testing.T) { columns := readListFixture(t, "testdata/list/all_columns.yaml") @@ -52,7 +56,7 @@ func TestListSchemaCompile(t *testing.T) { for _, want := range []string{ `"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`, `"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`, - `"perPageOptions":[20,50,100]`, `"toolbarButtons":["create"]`, `"filters":[]`, + `"perPageOptions":[20,50,100]`, `"toolbarButtons":["create","delete"]`, `"filters":[]`, } { if !strings.Contains(got, want) { t.Fatalf("compiled list missing %s:\n%s", want, got) @@ -68,7 +72,7 @@ noRecordsMessage: backend::lang.list.no_records recordsPerPage: 20 showCheckboxes: true toolbar: - buttons: list_toolbar + buttons: [create, delete] search: prompt: backend::lang.list.search_prompt ` @@ -98,7 +102,7 @@ modelClass: Widget recordsPerPage: 20 ` got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n") - want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[]}` + want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` if got != want { t.Fatalf("empty list =\n%s\nwant\n%s", got, want) } @@ -115,7 +119,7 @@ modelClass: Widget recordsPerPage: 20 ` got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n") - want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[]}` + want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` if got != want { t.Fatalf("single list =\n%s\nwant\n%s", got, want) } @@ -217,7 +221,7 @@ func TestListSchemaRejects(t *testing.T) { }, { name: "unsupported action", - config: strings.Replace(allColumnsConfig, "buttons: list_toolbar", "buttons: drop_database", 1), + config: strings.Replace(allColumnsConfig, "buttons: [create, delete]", "buttons: [create, drop_database]", 1), columns: columns, want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "drop_database"}, }, @@ -307,7 +311,7 @@ const filterColumns = `columns: searchable: true ` -const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[]}` +const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}` func TestListSchemaFilter(t *testing.T) { filters := readListFixture(t, "testdata/list/all_filters.yaml") diff --git a/cabana/messages.go b/cabana/messages.go new file mode 100644 index 0000000..cc4a5aa --- /dev/null +++ b/cabana/messages.go @@ -0,0 +1,257 @@ +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 +} diff --git a/cabana/messages_test.go b/cabana/messages_test.go new file mode 100644 index 0000000..c5cafc9 --- /dev/null +++ b/cabana/messages_test.go @@ -0,0 +1,354 @@ +package cabana + +import ( + "context" + "encoding/json" + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/phrasebook" + "git.golem15.com/golem15/summercms/towel" +) + +type langPlugin struct { + id string + lang fs.FS +} + +func (p langPlugin) ID() string { return p.id } +func (p langPlugin) LangFS() fs.FS { return p.lang } + +// messagesTranslator activates phrasebook with the framework backend strings +// and an acme.demo catalog. +func messagesTranslator(t *testing.T) *phrasebook.Translator { + t.Helper() + app := backpack.New(nil) + demo := fstest.MapFS{ + "lang/pl/lang.yaml": {Data: []byte(`widgets: + count: + one: ":count widżet" + few: ":count widżety" + many: ":count widżetów" + other: ":count widżetu" + create: Nowy widżet + saved: Widżet „:name” zapisany + link: Dodaj edytora +`)}, + "lang/en/lang.yaml": {Data: []byte(`widgets: + count: + one: ":count widget" + other: ":count widgets" + create: New widget + saved: Widget “:name” saved + link: Add editor +`)}, + } + if err := phrasebook.Activate(app, []langPlugin{{id: "acme.demo", lang: demo}}); err != nil { + t.Fatal(err) + } + tr, ok := app.Lookup[*phrasebook.Translator]() + if !ok || tr == nil { + t.Fatal("translator missing") + } + return tr +} + +const messagesListConfig = `modelClass: Widget +list: ~/plugins/acme/demo/models/widget/columns.yaml +recordsPerPage: 20 +showCheckboxes: true +toolbar: + buttons: [create, delete] + search: + prompt: backend::lang.list.search_prompt +messages: + recordCount: acme.demo::lang.widgets.count + create: acme.demo::lang.widgets.create +` + +func localeCtx(locale string) context.Context { + return towel.WithLocale(context.Background(), locale) +} + +func TestPhase10Messages(t *testing.T) { + tr := messagesTranslator(t) + schema, err := CompileList("acme.demo", schemaController{}, listFS(messagesListConfig, "columns: {}\n")) + if err != nil { + t.Fatal(err) + } + + pl, err := schema.Localize(localeCtx("pl"), tr) + if err != nil { + t.Fatal(err) + } + m := pl.Messages + if m == nil { + t.Fatal("localized list has no messages") + } + if len(m.RecordCount) != 4 || m.RecordCount["few"] != ":count widżety" || m.RecordCount["many"] != ":count widżetów" { + t.Fatalf("plural recordCount=%v", m.RecordCount) + } + if m.Create["other"] != "Nowy widżet" { + t.Fatalf("plugin create=%v", m.Create) + } + for name, got := range map[string]MessageForms{ + "deleteConfirm": m.DeleteConfirm, + "searchPrompt": m.SearchPrompt, + "empty": m.Empty, + "deleteSelected": m.DeleteSelected, + "selected": m.Selected, + } { + if got["other"] == "" || strings.Contains(got["other"], "::") { + t.Fatalf("default %s did not resolve: %v", name, got) + } + } + if m.DeleteConfirm["other"] != "Usunąć zaznaczone (:count)?" || m.SearchPrompt["other"] != "Szukaj…" { + t.Fatalf("framework defaults: deleteConfirm=%v searchPrompt=%v", m.DeleteConfirm, m.SearchPrompt) + } + if len(m.Deleted) != 4 || m.Deleted["one"] != "Usunięto :count rekord" { + t.Fatalf("default plural deleted=%v", m.Deleted) + } + en, err := schema.Localize(localeCtx("en"), tr) + if err != nil { + t.Fatal(err) + } + if en.Messages.RecordCount["one"] != ":count widget" || len(en.Messages.RecordCount) != 2 || en.Messages.Create["other"] != "New widget" { + t.Fatalf("en messages=%+v", en.Messages) + } + raw, err := json.Marshal(pl) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"messages":{"recordCount":{`) { + t.Fatalf("served list JSON has no messages: %s", raw) + } + if schema.Messages.RecordCount["other"] != "acme.demo::lang.widgets.count" { + t.Fatalf("cached schema lost its keys: %v", schema.Messages.RecordCount) + } + + t.Run("form", func(t *testing.T) { + config := `name: acme.demo::lang.widgets.create +form: ~/plugins/acme/demo/models/widget/fields.yaml +modelClass: Widget +defaultRedirect: acme/demo/widgets +create: + redirect: acme/demo/widgets/update/:id + redirectClose: acme/demo/widgets +update: + redirect: acme/demo/widgets + redirectClose: acme/demo/widgets +messages: + saved: acme.demo::lang.widgets.saved +` + form, err := CompileForm("acme.demo", schemaController{}, formFS(config, "fields:\n name:\n type: text\n")) + if err != nil { + t.Fatal(err) + } + view, err := form.Localize(localeCtx("pl"), tr, nil) + if err != nil { + t.Fatal(err) + } + if view.Messages.Saved["other"] != "Widżet „:name” zapisany" || view.Messages.Create["other"] != "Nowy widżet" || view.Messages.Update["other"] != "Edycja rekordu" { + t.Fatalf("form messages=%+v", view.Messages) + } + want := FormRedirects{Default: "acme/demo/widgets", + Create: FormRedirect{Redirect: "acme/demo/widgets/update/:id", RedirectClose: "acme/demo/widgets"}, + Update: FormRedirect{Redirect: "acme/demo/widgets", RedirectClose: "acme/demo/widgets"}} + if view.Redirects != want { + t.Fatalf("redirects=%+v", view.Redirects) + } + raw, err := view.CanonicalJSON() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"redirects":{"default":"acme/demo/widgets"`) || !strings.Contains(string(raw), `"saved":{"other":`) { + t.Fatalf("form view JSON=%s", raw) + } + }) + + t.Run("relation", func(t *testing.T) { + yaml := strings.Replace(validRelationYAML, "editors:\n label: demo.editors\n", "editors:\n label: demo.editors\n messages:\n link: acme.demo::lang.widgets.link\n", 1) + ctl := relationTestController{} + fsys := relationTestFS(yaml) + form, err := CompileForm("acme.demo", ctl, fsys) + if err != nil { + t.Fatal(err) + } + relations, err := compileRelations("acme.demo", ctl, fsys, form) + if err != nil { + t.Fatal(err) + } + view := relations["editors"].Schema.Localize(localeCtx("pl"), tr) + if view.Messages.Link["other"] != "Dodaj edytora" || len(view.Messages.Linked) != 4 || view.Messages.Linked["few"] != "Dołączono :count rekordy" { + t.Fatalf("relation messages=%+v", view.Messages) + } + }) + + t.Run("unknown key fails at boot", func(t *testing.T) { + list := strings.Replace(messagesListConfig, " create: acme.demo::lang.widgets.create\n", " create: acme.demo::lang.widgets.create\n bogusKey: x\n", 1) + if _, err := CompileList("acme.demo", schemaController{}, listFS(list, "columns: {}\n")); err == nil || !strings.Contains(err.Error(), "bogusKey") || !strings.Contains(err.Error(), "config_list.yaml") { + t.Fatalf("list err=%v", err) + } + form := "form: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\nmessages:\n toast: x\n" + if _, err := CompileForm("acme.demo", schemaController{}, formFS(form, "fields: {}\n")); err == nil || !strings.Contains(err.Error(), "toast") { + t.Fatalf("form err=%v", err) + } + yaml := strings.Replace(validRelationYAML, " label: demo.editors\n", " label: demo.editors\n messages:\n bogusKey: x\n", 1) + ctl := relationTestController{} + fsys := relationTestFS(yaml) + compiled, _ := CompileForm("acme.demo", ctl, fsys) + if _, err := compileRelations("acme.demo", ctl, fsys, compiled); err == nil || !strings.Contains(err.Error(), "bogusKey") { + t.Fatalf("relation err=%v", err) + } + }) + + t.Run("missing phrase key fails at boot", func(t *testing.T) { + list := strings.Replace(messagesListConfig, "acme.demo::lang.widgets.count", "acme.demo::lang.widgets.missing", 1) + reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(list, "columns: {}\n")}, ctl: schemaController{}}}) + if err != nil { + t.Fatal(err) + } + err = validateMessageKeys(reg, tr) + if err == nil || !strings.Contains(err.Error(), "messages.recordCount") || !strings.Contains(err.Error(), "acme.demo::lang.widgets.missing") || !strings.Contains(err.Error(), "acme.demo.widgets") { + t.Fatalf("err=%v", err) + } + ok, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(messagesListConfig, "columns: {}\n")}, ctl: schemaController{}}}) + if err != nil { + t.Fatal(err) + } + if err := validateMessageKeys(ok, tr); err != nil { + t.Fatalf("declared keys failed: %v", err) + } + if err := validateMessageKeys(reg, nil); err != nil { + t.Fatalf("no translator must skip the check: %v", err) + } + }) +} + +func TestPhase10Toolbar(t *testing.T) { + base := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\nshowCheckboxes: true\ntoolbar:\n buttons: BUTTONS\n" + compile := func(buttons string, checkboxes bool) (*ListSchema, error) { + config := strings.Replace(base, "BUTTONS", buttons, 1) + if !checkboxes { + config = strings.Replace(config, "showCheckboxes: true\n", "", 1) + } + return CompileList("acme.demo", schemaController{}, listFS(config, "columns: {}\n")) + } + for _, tc := range []struct { + buttons string + want []string + }{ + {"[create, delete]", []string{"create", "delete"}}, + {"[delete, create]", []string{"delete", "create"}}, + {"[create]", []string{"create"}}, + {"[]", []string{}}, + {"\n - delete\n - create", []string{"delete", "create"}}, + } { + schema, err := compile(tc.buttons, true) + if err != nil { + t.Fatalf("%s: %v", tc.buttons, err) + } + if strings.Join(schema.ToolbarButtons, ",") != strings.Join(tc.want, ",") { + t.Fatalf("%s compiled %v want %v", tc.buttons, schema.ToolbarButtons, tc.want) + } + } + for _, tc := range []struct { + buttons string + checkboxes bool + want string + }{ + {"list_toolbar", true, "toolbar.buttons must be a list"}, + {"list_toolbar", true, `"list_toolbar" is not supported`}, + {"[create, delete]", false, "showCheckboxes"}, + {"[create, create]", true, "duplicate action create"}, + {"[create, export]", true, "unsupported action export"}, + {"[{create: true}]", true, "toolbar.buttons"}, + } { + _, err := compile(tc.buttons, tc.checkboxes) + if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.widgets") { + t.Fatalf("%s err=%v want %q", tc.buttons, err, tc.want) + } + } + + // A controller without a compiled form cannot create: create is dropped. + reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(strings.Replace(base, "BUTTONS", "[create, delete]", 1), "columns: {}\n")}, ctl: schemaController{}}}) + if err != nil { + t.Fatal(err) + } + cc, _ := reg.Get("acme.demo.widgets") + if strings.Join(cc.List.ToolbarButtons, ",") != "delete" { + t.Fatalf("form-less toolbar=%v", cc.List.ToolbarButtons) + } +} + +func TestPhase10Bundle(t *testing.T) { + tr := messagesTranslator(t) + app := backpack.New(nil) + if err := app.Publish(tr); err != nil { + t.Fatal(err) + } + svc := &service{app: app} + call := func(locale string) (map[string]map[string]string, string, *httptest.ResponseRecorder) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil) + if locale != "" { + req = req.WithContext(towel.WithLocale(req.Context(), locale)) + } + rec := httptest.NewRecorder() + svc.langBundle(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data map[string]map[string]string `json:"data"` + Meta struct { + Locale string `json:"locale"` + } `json:"meta"` + } + dec := json.NewDecoder(strings.NewReader(rec.Body.String())) + dec.DisallowUnknownFields() + if err := dec.Decode(&body); err != nil { + t.Fatal(err) + } + return body.Data, body.Meta.Locale, rec + } + pl, locale, rec := call("pl") + if locale != "pl" || rec.Header().Get("Cache-Control") != "no-cache" { + t.Fatalf("locale=%q cache=%q", locale, rec.Header().Get("Cache-Control")) + } + if pl["backend::lang.form.save"]["other"] != "Zapisz" || len(pl["backend::lang.list.results"]) != 4 { + t.Fatalf("pl bundle save=%v results=%v", pl["backend::lang.form.save"], pl["backend::lang.list.results"]) + } + if len(pl) < 50 { + t.Fatalf("pl bundle has only %d keys", len(pl)) + } + for key := range pl { + if !strings.HasPrefix(key, "backend::lang.") { + t.Fatalf("bundle leaked a key outside backend::lang: %s", key) + } + } + if strings.Contains(rec.Body.String(), "acme.demo::") || strings.Contains(rec.Body.String(), "lagoon::") { + t.Fatalf("bundle leaked another namespace: %s", rec.Body.String()) + } + en, locale, _ := call("en-GB") + if locale != "en" || en["backend::lang.form.save"]["other"] != "Save" { + t.Fatalf("en-GB locale=%q save=%v", locale, en["backend::lang.form.save"]) + } + fallback, locale, _ := call("de") + if locale != "en" || fallback["backend::lang.form.save"]["other"] != "Save" { + t.Fatalf("de falls back to en: locale=%q save=%v", locale, fallback["backend::lang.form.save"]) + } + // No translator: an empty bundle, still an envelope. + empty := &service{app: backpack.New(nil)} + req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil) + out := httptest.NewRecorder() + empty.langBundle(out, req) + if out.Code != http.StatusOK || !strings.Contains(out.Body.String(), `"data":{}`) { + t.Fatalf("no translator status=%d body=%s", out.Code, out.Body.String()) + } +} diff --git a/cabana/phase09_contract_test.go b/cabana/phase09_contract_test.go index 3c21e95..4eab561 100644 --- a/cabana/phase09_contract_test.go +++ b/cabana/phase09_contract_test.go @@ -71,8 +71,9 @@ func TestPhase09ContractInventory(t *testing.T) { if !ok200 && !ok201 { t.Fatalf("%s has no 200 or 201 response", key) } - if public[key] { - if _, ok := op.Responses["401"]; !ok { + if route.public { + // Login and refresh answer 401; the public string bundle does not. + if _, ok := op.Responses["401"]; public[key] && !ok { t.Fatalf("%s has no 401 response", key) } continue diff --git a/cabana/registry.go b/cabana/registry.go index 3c4d8c0..4fbb679 100644 --- a/cabana/registry.go +++ b/cabana/registry.go @@ -77,6 +77,10 @@ func compileRegistry(items []controllerRef) (*Registry, error) { if err != nil { return nil, err } + if form == nil && list != nil { + // Without a compiled form there is nothing to create (D-14). + list.ToolbarButtons = withoutAction(list.ToolbarButtons, "create") + } compiled := &CompiledController{ PluginID: item.plugin.ID(), Controller: item.ctl, @@ -93,6 +97,16 @@ func compileRegistry(items []controllerRef) (*Registry, error) { return &Registry{byID: byID}, nil } +func withoutAction(actions []string, drop string) []string { + out := make([]string, 0, len(actions)) + for _, action := range actions { + if action != drop { + out = append(out, action) + } + } + return out +} + func compileContributions(reg *Registry, plugins []party.Plugin) error { if reg == nil { return fmt.Errorf("cabana: registry is nil") diff --git a/cabana/relation.go b/cabana/relation.go index 98f6e68..335f7f0 100644 --- a/cabana/relation.go +++ b/cabana/relation.go @@ -61,6 +61,11 @@ type RelationSchema struct { Label string `json:"label"` View RelationPanel `json:"view"` Manage RelationPanel `json:"manage"` + // Messages is the relation manager's copy (D-13); the cached schema + // carries each phrase key as its own form. + Messages *RelationMessages `json:"messages"` + + messageKeys relationMessageKeys } // CompiledRelation combines trusted YAML with model-owned metadata. @@ -100,6 +105,10 @@ type RelationMutationResult struct { type RelationService struct{ DB *gorm.DB } func (s RelationSchema) MarshalJSON() ([]byte, error) { + if s.Messages == nil { + keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, s.relationMessageKeySet()) + s.Messages = &keys + } type alias RelationSchema out := alias(s) ensureRelationArrays(&out.View) @@ -125,6 +134,8 @@ func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator out.Label = translateKey(ctx, tr, s.Label) out.View = localizeRelationPanel(ctx, tr, s.View) out.Manage = localizeRelationPanel(ctx, tr, s.Manage) + messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet()) + out.Messages = &messages return &out } @@ -144,9 +155,10 @@ type relationRoot struct { } type relationDocument struct { - Label string `yaml:"label"` - View relationPanelDocument `yaml:"view"` - Manage relationPanelDocument `yaml:"manage"` + Label string `yaml:"label"` + View relationPanelDocument `yaml:"view"` + Manage relationPanelDocument `yaml:"manage"` + Messages *relationMessageKeys `yaml:"messages"` } type relationPanelDocument struct { @@ -236,8 +248,14 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for } orderRelationColumns(raw, &view) orderRelationColumns(raw, &manage) + schema := &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage} + if doc.Messages != nil { + schema.messageKeys = *doc.Messages + } + keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet()) + schema.Messages = &keys out[name] = &CompiledRelation{ - Schema: &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage}, + Schema: schema, Contract: contract, RequiredPermissions: append([]string(nil), requiredOf(ctl)...), } diff --git a/cabana/schema_types.go b/cabana/schema_types.go index 07b6bbe..fae8aeb 100644 --- a/cabana/schema_types.go +++ b/cabana/schema_types.go @@ -1,6 +1,9 @@ package cabana -import "encoding/json" +import ( + "context" + "encoding/json" +) // ListSort is the compiled default sort. Direction is exactly asc or desc. type ListSort struct { @@ -68,11 +71,20 @@ type ListSchema struct { Filters []ListFilter `json:"filters"` RowActions []RowAction `json:"rowActions"` BulkActions []BulkAction `json:"bulkActions"` - Meta *FormMeta `json:"meta,omitempty"` + // Messages is the list's copy (D-13). The cached schema carries each + // phrase key as its own form; a response resolves them in its locale. + Messages *ListMessages `json:"messages"` + Meta *FormMeta `json:"meta,omitempty"` + + messageKeys listMessageKeys } // MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null. func (s ListSchema) MarshalJSON() ([]byte, error) { + if s.Messages == nil { + keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, s.listMessageKeySet()) + s.Messages = &keys + } type alias ListSchema out := alias(s) if out.PerPageOptions == nil { @@ -102,6 +114,9 @@ type FormSchema struct { Name string `json:"name,omitempty"` ModelClass string `json:"modelClass,omitempty"` Fields []FormField `json:"fields"` + + messageKeys formMessageKeys + redirects FormRedirects } // FormView is one request's localized form, including the locale actually used. @@ -109,7 +124,25 @@ type FormView struct { Name string `json:"name,omitempty"` ModelClass string `json:"modelClass,omitempty"` Fields []FormField `json:"fields"` - Meta FormMeta `json:"meta"` + // Messages is the form's copy resolved in the request locale (D-13). + Messages FormMessages `json:"messages"` + // Redirects are the raw Winter config_form.yaml targets; the SPA maps + // them onto its routes. + Redirects FormRedirects `json:"redirects"` + Meta FormMeta `json:"meta"` +} + +// FormRedirects are config_form.yaml defaultRedirect, create.* and update.*. +type FormRedirects struct { + Default string `json:"default"` + Create FormRedirect `json:"create"` + Update FormRedirect `json:"update"` +} + +// FormRedirect is one Winter redirect pair. +type FormRedirect struct { + Redirect string `json:"redirect"` + RedirectClose string `json:"redirectClose"` } // FormMeta reports the locale selected for a schema response. diff --git a/cabana/security_coverage_test.go b/cabana/security_coverage_test.go index 1f3eec6..dc56ad9 100644 --- a/cabana/security_coverage_test.go +++ b/cabana/security_coverage_test.go @@ -34,6 +34,7 @@ const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name var phase09Routes = []adminRoute{ {key: "POST /auth/login", public: true}, {key: "POST /auth/refresh", public: true}, + {key: "GET /lang", public: true}, {key: "POST /auth/logout"}, {key: "GET /auth/me"}, {key: "GET /navigation"}, diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 7f85be6..c1a0b09 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -978,6 +978,8 @@ func TestMakeAdminControllerLayout(t *testing.T) { for _, text := range []string{ "modelClass: Albums", "list: ~/plugins/golem15/demo/models/albums/columns.yaml", + "buttons: [create]", + "prompt: backend::lang.list.search_prompt", } { if !bytes.Contains(listSrc, []byte(text)) { t.Fatalf("config_list.yaml missing %s:\n%s", text, listSrc) diff --git a/internal/build/stubs/artifacts.tmpl b/internal/build/stubs/artifacts.tmpl index a62cb19..968c839 100644 --- a/internal/build/stubs/artifacts.tmpl +++ b/internal/build/stubs/artifacts.tmpl @@ -123,7 +123,10 @@ update: modelClass: {{.Ident}} title: {{.Ident}} recordsPerPage: 20 -showSearch: true +toolbar: + buttons: [create] + search: + prompt: backend::lang.list.search_prompt {{end}} {{define "fields.yaml"}}fields: diff --git a/pact/capabilities.go b/pact/capabilities.go index aa139d2..2962ed3 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -268,6 +268,15 @@ type HasLang interface { LangFS() fs.FS } +// HasLangOverrides is implemented by plugins (usually the app plugin) that +// replace or add translations of any loaded namespace, including the +// framework's backend::lang admin strings, and may add locales (D-20). +// Layout: lang///.yaml, e.g. +// lang/pl/backend/lang.yaml. +type HasLangOverrides interface { + LangOverridesFS() fs.FS +} + // HasMailTemplates is implemented by plugins that ship Winter-shaped mail // templates and layout aliases. MailTemplatesFS contains views/mail assets; // MailTemplates lists dotted template names; MailLayouts maps a short layout diff --git a/phrasebook/backend/lang/en/lang.yaml b/phrasebook/backend/lang/en/lang.yaml new file mode 100644 index 0000000..5888299 --- /dev/null +++ b/phrasebook/backend/lang/en/lang.yaml @@ -0,0 +1,119 @@ +# Framework admin strings (D-20), resolved as backend::lang... +# English equivalents of the Polish copy; plural texts are CLDR maps (one, +# other), never pipe strings. Placeholders use :count, :name, :term (D-24). +auth: + title: Welcome back + login: Login or email + password: Password + invalid: Invalid login details + submit: Sign in + logout: Sign out + session_expired: Your session has expired. Please sign in again. +nav: + plugins: Plugins + sections: Sections + breadcrumbs: Breadcrumbs + empty: No sections are available. + settings: Settings + collapse: Collapse menu + expand: Expand menu + user_menu: User menu +page: + not_found: Page not found. +list: + search_prompt: Search… + no_records: There are no records in this view. + no_results: 0 results + loading: Loading… + load_failed: The list could not be loaded. + delete_selected: Delete selected + prev_page: Previous + next_page: Next + records_per_page: Per page + column_switch_true: "Yes" + column_switch_false: "No" + column_created: Created + column_updated: Updated + pagination_range: ":from–:to of :total" + results: + one: ":count result" + other: ":count results" + empty_value: "—" + clear_search: Clear search + select_all: Select all on this page + select_row: Select row + sort_ascending: Sort ascending + sort_descending: Sort descending +form: + save: Save + save_and_close: Save and close + cancel: Cancel + delete: Delete + none: — none — + update: Edit + create: Create + return_to_list: Back to list + close: Close + confirm: Confirm + saving: Saving… + deleting: Deleting… + add: Add + remove_item: "Remove: :name" + add_item: Add… + select_placeholder: Select… + loading_options: Loading… + no_options: No results + unsupported_field: "Unsupported field type: :type" + error_title: Could not save. + error_fields: + one: "Fix :count field marked below." + other: "Fix :count fields marked below." + error_generic: The changes could not be saved. Please try again. + unsaved_confirm: You have unsaved changes. Discard them? + tab_errors: + one: ":count error" + other: ":count errors" +relation: + add: Add + link: Link + unlink: Unlink + remove: Remove + search: Search… + add_count: "Add (:count)" + candidates_range: ":from–:to of :total" +settings: + title: Settings + empty: No settings are available. + saved: Settings saved +messages: + list: + record_count: + one: ":count record" + other: ":count records" + create: New record + empty_search: Nothing found + empty_search_hint: No record matches “:term”. Try another search. + selected: ":count selected" + delete_confirm: "Delete the selected (:count)?" + deleted: + one: "Deleted :count record" + other: "Deleted :count records" + form: + create: New record + update: Edit record + saved: Saved + delete_confirm: Delete this record? + deleted: Record deleted + relation: + link: Add + link_hint: Choose the records to link. + candidate_search: Search… + linked: + one: "Linked :count record" + other: "Linked :count records" + unlink_selected: Unlink selected + unlink_confirm: "Unlink the selected (:count)?" + unlinked: + one: "Unlinked :count record" + other: "Unlinked :count records" + empty: No linked records. diff --git a/phrasebook/backend/lang/pl/lang.yaml b/phrasebook/backend/lang/pl/lang.yaml new file mode 100644 index 0000000..5391a1e --- /dev/null +++ b/phrasebook/backend/lang/pl/lang.yaml @@ -0,0 +1,135 @@ +# Framework admin strings (D-20), resolved as backend::lang... +# Winter backend key names are kept where Winter has them; values follow the +# admin design copy (D-06). Plural texts are CLDR maps (one, few, many, +# other), never pipe strings, so every key converts to the SPA's plural forms. +# Placeholders use :count, :name, :term and friends (D-24). +auth: + title: Witaj ponownie + login: Login lub e-mail + password: Hasło + invalid: Nieprawidłowe dane logowania + submit: Zaloguj + logout: Wyloguj + session_expired: Sesja wygasła. Zaloguj się ponownie. +nav: + plugins: Wtyczki + sections: Sekcje + breadcrumbs: Ścieżka nawigacji + empty: Brak dostępnych sekcji. + settings: Ustawienia + collapse: Zwiń menu + expand: Rozwiń menu + user_menu: Menu użytkownika +page: + not_found: Nie znaleziono strony. +list: + search_prompt: Szukaj… + no_records: Brak rekordów w tym widoku. + no_results: 0 wyników + loading: Wczytywanie… + load_failed: Nie udało się wczytać listy. + delete_selected: Usuń zaznaczone + prev_page: Poprzednia + next_page: Następna + records_per_page: Na stronę + column_switch_true: Tak + column_switch_false: Nie + column_created: Utworzono + column_updated: Zaktualizowano + pagination_range: ":from–:to z :total" + results: + one: ":count wynik" + few: ":count wyniki" + many: ":count wyników" + other: ":count wyniku" + empty_value: "—" + clear_search: Wyczyść wyszukiwanie + select_all: Zaznacz wszystkie na stronie + select_row: Zaznacz wiersz + sort_ascending: Sortuj rosnąco + sort_descending: Sortuj malejąco +form: + save: Zapisz + save_and_close: Zapisz i zamknij + cancel: Anuluj + delete: Usuń + none: — brak — + update: Edytuj + create: Utwórz + return_to_list: Wróć do listy + close: Zamknij + confirm: Potwierdź + saving: Zapisywanie… + deleting: Usuwanie… + add: Dodaj + remove_item: "Usuń: :name" + add_item: Dodaj… + select_placeholder: Wybierz… + loading_options: Wczytywanie… + no_options: Brak wyników + unsupported_field: "Nieobsługiwany typ pola: :type" + error_title: Nie udało się zapisać. + error_fields: + one: "Popraw :count pole oznaczone poniżej." + few: "Popraw :count pola oznaczone poniżej." + many: "Popraw :count pól oznaczonych poniżej." + other: "Popraw :count pola oznaczonego poniżej." + error_generic: Nie udało się zapisać zmian. Spróbuj ponownie. + unsaved_confirm: Masz niezapisane zmiany. Porzucić je? + tab_errors: + one: ":count błąd" + few: ":count błędy" + many: ":count błędów" + other: ":count błędu" +relation: + add: Dodaj + link: Dołącz + unlink: Odłącz + remove: Usuń + search: Szukaj… + add_count: "Dodaj (:count)" + candidates_range: ":from–:to z :total" +settings: + title: Ustawienia + empty: Brak dostępnych ustawień. + saved: Zapisano ustawienia +messages: + list: + record_count: + one: ":count rekord" + few: ":count rekordy" + many: ":count rekordów" + other: ":count rekordu" + create: Nowy rekord + empty_search: Nic nie znaleziono + empty_search_hint: Żaden rekord nie pasuje do frazy „:term”. Spróbuj innej frazy. + selected: "Zaznaczono :count" + delete_confirm: "Usunąć zaznaczone (:count)?" + deleted: + one: "Usunięto :count rekord" + few: "Usunięto :count rekordy" + many: "Usunięto :count rekordów" + other: "Usunięto :count rekordu" + form: + create: Nowy rekord + update: Edycja rekordu + saved: Zapisano + delete_confirm: Usunąć ten rekord? + deleted: Usunięto rekord + relation: + link: Dodaj + link_hint: Wybierz rekordy, które chcesz dołączyć. + candidate_search: Szukaj… + linked: + one: "Dołączono :count rekord" + few: "Dołączono :count rekordy" + many: "Dołączono :count rekordów" + other: "Dołączono :count rekordu" + unlink_selected: Odłącz zaznaczone + unlink_confirm: "Odłączyć zaznaczone (:count)?" + unlinked: + one: "Odłączono :count rekord" + few: "Odłączono :count rekordy" + many: "Odłączono :count rekordów" + other: "Odłączono :count rekordu" + empty: Brak powiązanych rekordów. diff --git a/phrasebook/lang.go b/phrasebook/lang.go index 6cdd4ab..88bd6f8 100644 --- a/phrasebook/lang.go +++ b/phrasebook/lang.go @@ -1,6 +1,22 @@ package phrasebook -import "embed" +import ( + "embed" + "io/fs" +) //go:embed lang var systemLangFS embed.FS + +//go:embed backend/lang +var backendLangFS embed.FS + +// backendLang is the framework admin string tree (D-20), laid out as +// lang//lang.yaml so its keys resolve as backend::lang... +func backendLang() fs.FS { + sub, err := fs.Sub(backendLangFS, "backend") + if err != nil { + panic(err) + } + return sub +} diff --git a/phrasebook/loader.go b/phrasebook/loader.go index 190bfc8..2e690cd 100644 --- a/phrasebook/loader.go +++ b/phrasebook/loader.go @@ -19,6 +19,8 @@ type Catalog struct { messages map[string]map[string]entry // namespace (plugin ID) -> owning plugin ID namespaces map[string]string + // replace lets put overwrite an existing key; only Override sets it. + replace bool } type entry struct { @@ -221,7 +223,7 @@ func (c *Catalog) put(pluginID, file, locale, key string, e entry) error { byKey = make(map[string]entry) c.messages[locale] = byKey } - if _, exists := byKey[key]; exists { + if _, exists := byKey[key]; exists && !c.replace { return catalogErrorf(pluginID, file, key, "duplicate key") } byKey[key] = e @@ -240,6 +242,106 @@ func (c *Catalog) lookup(locale, key string) (entry, bool) { return e, ok } +// Override applies owner's override tree, laid out as +// lang///.yaml, over the loaded catalog. It may +// replace existing keys of a loaded namespace and add locales; a malformed +// path or an unknown namespace fails. +func (c *Catalog) Override(owner string, fsys fs.FS) error { + if c == nil { + return fmt.Errorf("phrasebook: catalog is nil") + } + if fsys == nil { + return nil + } + var files []string + err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + files = append(files, p) + } + return nil + }) + if err != nil { + return catalogErrorf(owner, "", "", "walk lang overrides: %s", err.Error()) + } + sort.Strings(files) + c.replace = true + defer func() { c.replace = false }() + for _, file := range files { + locale, namespace, group, err := parseOverridePath(file) + if err != nil { + return catalogErrorf(owner, file, "", "%s", err.Error()) + } + if _, loaded := c.namespaces[namespace]; !loaded { + return catalogErrorf(owner, file, "", "override names unknown namespace %s", namespace) + } + raw, err := fs.ReadFile(fsys, file) + if err != nil { + return catalogErrorf(owner, file, "", "read: %s", err.Error()) + } + if err := c.loadFile(namespace, file, locale, group, raw); err != nil { + return fmt.Errorf("phrasebook: override by %s: %w", owner, err) + } + } + return nil +} + +func parseOverridePath(p string) (locale, namespace, group string, err error) { + bad := fmt.Errorf("malformed override path (want lang///.yaml)") + if strings.Contains(p, "\\") || path.Clean(p) != p { + return "", "", "", bad + } + parts := strings.Split(p, "/") + if len(parts) != 4 || parts[0] != "lang" || !strings.HasSuffix(parts[3], ".yaml") { + return "", "", "", bad + } + locale, namespace, group = parts[1], parts[2], strings.TrimSuffix(parts[3], ".yaml") + if !validLocale(locale) || !validNamespace(namespace) || !validGroup(group) { + return "", "", "", bad + } + return locale, namespace, group, nil +} + +func validNamespace(s string) bool { + if s == "" || s == "." || s == ".." || strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") { + return false + } + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' { + continue + } + return false + } + return true +} + +// checkForms fails when a key under prefix cannot convert to CLDR forms, so +// strings served to the admin SPA never need Laravel exact or range pipes. +func (c *Catalog) checkForms(prefix string) error { + locales := make([]string, 0, len(c.messages)) + for locale := range c.messages { + locales = append(locales, locale) + } + sort.Strings(locales) + for _, locale := range locales { + keys := make([]string, 0) + for key := range c.messages[locale] { + if strings.HasPrefix(key, prefix) { + keys = append(keys, key) + } + } + sort.Strings(keys) + for _, key := range keys { + if _, ok := entryForms(c.messages[locale][key]); !ok { + return fmt.Errorf("phrasebook: %s key %s cannot convert to CLDR plural forms (use a plural map, not an exact or range pipe)", locale, key) + } + } + } + return nil +} + func parseLangPath(p string) (locale, group string, err error) { p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/")) p = strings.TrimPrefix(p, "/") diff --git a/phrasebook/phase10_test.go b/phrasebook/phase10_test.go new file mode 100644 index 0000000..5bb3933 --- /dev/null +++ b/phrasebook/phase10_test.go @@ -0,0 +1,199 @@ +package phrasebook + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/backpack" +) + +func TestPhase10Forms(t *testing.T) { + cat := NewCatalog() + if err := cat.Load("acme.demo", fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte(`plain: Hello :name +plural: + one: ":count apple" + other: ":count apples" +pipe: ":count apple|:count apples" +exact: "{0} No apples|[1,*] :count apples" +`)}, + "lang/pl/lang.yaml": {Data: []byte(`pipe: ":count jabłko|:count jabłka|:count jabłek|:count jabłka" +`)}, + }); err != nil { + t.Fatal(err) + } + tr := NewTranslator(cat, Options{Locale: "en", Fallback: "en"}) + + check := func(locale, key string, want map[string]string) { + t.Helper() + got, ok := tr.Forms(locale, key) + if !ok { + t.Fatalf("%s %s: not convertible", locale, key) + } + if len(got) != len(want) { + t.Fatalf("%s %s = %v want %v", locale, key, got, want) + } + for cat, text := range want { + if got[cat] != text { + t.Fatalf("%s %s = %v want %v", locale, key, got, want) + } + } + } + check("en", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"}) + check("en", "acme.demo::lang.plural", map[string]string{"one": ":count apple", "other": ":count apples"}) + check("en", "acme.demo::lang.pipe", map[string]string{"one": ":count apple", "other": ":count apples"}) + check("pl", "acme.demo::lang.pipe", map[string]string{"one": ":count jabłko", "few": ":count jabłka", "many": ":count jabłek", "other": ":count jabłka"}) + // pl falls back to en for a key it does not define. + check("pl", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"}) + + if forms, ok := tr.Forms("en", "acme.demo::lang.exact"); ok { + t.Fatalf("exact/range pipe converted to %v", forms) + } + if forms, ok := tr.Forms("en", "acme.demo::lang.missing"); ok { + t.Fatalf("missing key converted to %v", forms) + } + + got, ok := tr.Forms("en", "acme.demo::lang.plural") + if !ok { + t.Fatal("plural not convertible") + } + got["one"] = "mutated" + if again, _ := tr.Forms("en", "acme.demo::lang.plural"); again["one"] != ":count apple" { + t.Fatal("Forms returned the catalog's own map") + } +} + +type overridePlugin struct { + id string + lang fs.FS + overrides fs.FS +} + +func (p overridePlugin) ID() string { return p.id } +func (p overridePlugin) LangFS() fs.FS { return p.lang } +func (p overridePlugin) LangOverridesFS() fs.FS { return p.overrides } + +func activateWith(t *testing.T, plugins ...overridePlugin) (*Translator, error) { + t.Helper() + app := backpack.New(nil) + if err := Activate(app, plugins); err != nil { + return nil, err + } + tr, ok := app.Lookup[*Translator]() + if !ok || tr == nil { + t.Fatal("translator was not published") + } + return tr, nil +} + +func TestPhase10LangOverride(t *testing.T) { + demo := fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("title: Demo\n")}} + tr, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: fstest.MapFS{ + "lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Zachowaj\n extra: Dodatkowy\n")}, + "lang/de/backend/lang.yaml": {Data: []byte("form:\n save: Speichern\n")}, + "lang/en/acme.demo/lang.yaml": {Data: []byte("title: Demo app\n")}, + }}) + if err != nil { + t.Fatalf("activate: %v", err) + } + for _, tc := range []struct{ locale, key, want string }{ + {"pl", "backend::lang.form.save", "Zachowaj"}, + {"pl", "backend::lang.form.extra", "Dodatkowy"}, + {"pl", "backend::lang.form.cancel", "Anuluj"}, + {"de", "backend::lang.form.save", "Speichern"}, + {"en", "backend::lang.form.save", "Save"}, + {"en", "acme.demo::lang.title", "Demo app"}, + } { + if got := tr.GetIn(tc.locale, tc.key, nil); got != tc.want { + t.Fatalf("%s %s = %q want %q", tc.locale, tc.key, got, tc.want) + } + } + if bundle := tr.Bundle("de", "backend::lang."); bundle["backend::lang.form.save"]["other"] != "Speichern" || bundle["backend::lang.form.cancel"]["other"] != "Cancel" { + t.Fatalf("de bundle did not merge the fallback: save=%v cancel=%v", bundle["backend::lang.form.save"], bundle["backend::lang.form.cancel"]) + } + + for _, tc := range []struct { + name string + overrides fstest.MapFS + want []string + }{ + {"missing namespace segment", fstest.MapFS{"lang/pl/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}}, + {"not yaml", fstest.MapFS{"lang/pl/backend/lang.txt": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}}, + {"too deep", fstest.MapFS{"lang/pl/backend/sub/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}}, + {"unknown namespace", fstest.MapFS{"lang/pl/acme.missing/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "acme.missing"}}, + {"unconvertible backend key", fstest.MapFS{"lang/en/backend/lang.yaml": {Data: []byte("list:\n results: \"{0} No results|[1,*] :count results\"\n")}}, []string{"backend::lang.list.results", "CLDR"}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: tc.overrides}) + if err == nil { + t.Fatal("activation accepted a bad override") + } + for _, want := range tc.want { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err=%v missing %q", err, want) + } + } + }) + } +} + +var spaKey = regexp.MustCompile(`backend::lang\.[A-Za-z0-9_.]*[A-Za-z0-9_]`) + +// TestPhase10SPAKeysResolve walks the admin SPA sources and resolves every +// backend::lang key literal in pl and en, each without falling back to the +// other locale. +func TestPhase10SPAKeysResolve(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("caller") + } + root := filepath.Join(filepath.Dir(file), "..", "admin", "src") + keys := map[string]bool{} + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !(strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".vue")) { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + for _, key := range spaKey.FindAllString(string(raw), -1) { + keys[key] = true + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(keys) < 10 { + t.Fatalf("found only %d backend::lang keys under %s", len(keys), root) + } + sorted := make([]string, 0, len(keys)) + for key := range keys { + sorted = append(sorted, key) + } + sort.Strings(sorted) + cat := NewCatalog() + if err := cat.Load("backend", backendLang()); err != nil { + t.Fatal(err) + } + for _, locale := range []string{"pl", "en"} { + tr := NewTranslator(cat, Options{Locale: locale, Fallback: locale}) + for _, key := range sorted { + forms, ok := tr.Forms(locale, key) + if !ok || forms["other"] == "" { + t.Errorf("%s: %s does not resolve", locale, key) + } + } + } +} diff --git a/phrasebook/translator.go b/phrasebook/translator.go index 2d1a240..5491683 100644 --- a/phrasebook/translator.go +++ b/phrasebook/translator.go @@ -91,6 +91,9 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error if err := cat.Load("lagoon", systemLangFS); err != nil { return err } + if err := cat.Load("backend", backendLang()); err != nil { + return err + } for _, p := range plugins { hl, ok := any(p).(pact.HasLang) if !ok { @@ -100,6 +103,21 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error return err } } + // Overrides run after every namespace is loaded (D-20): a project can + // replace framework or plugin strings and add locales without a rebuild + // of the admin SPA. + for _, p := range plugins { + ho, ok := any(p).(pact.HasLangOverrides) + if !ok { + continue + } + if err := cat.Override(p.ID(), ho.LangOverridesFS()); err != nil { + return err + } + } + if err := cat.checkForms("backend::"); err != nil { + return err + } opts := Options{Locale: defaultLocale, Fallback: defaultFallback} if app.Config != nil { if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" { @@ -460,3 +478,102 @@ func ucfirst(s string) string { } return string(unicode.ToUpper(r)) + s[size:] } + +// Forms returns key's text as CLDR plural forms for the SPA (D-13, D-20): a +// plain text is {"other": text}, a YAML plural map is copied, and a pipe of +// category forms maps by category. Exact and range pipes ({0}, [2,*]) have +// no Intl.PluralRules equivalent and report false, as does a missing key. +// Placeholders are left for the client to interpolate. +func (t *Translator) Forms(locale, key string) (map[string]string, bool) { + if t == nil { + return nil, false + } + e, _, ok := t.find(locale, key) + if !ok { + return nil, false + } + return entryForms(e) +} + +// Bundle returns every key starting with prefix as CLDR forms, keys of the +// requested locale over those of its fallback chain. Keys that cannot convert +// are left out. +func (t *Translator) Bundle(locale, prefix string) map[string]map[string]string { + out := map[string]map[string]string{} + if t == nil || t.cat == nil { + return out + } + chain := fallbackChain(locale, t.fallbackLocale()) + for i := len(chain) - 1; i >= 0; i-- { + for key, e := range t.cat.messages[chain[i]] { + if !strings.HasPrefix(key, prefix) { + continue + } + if forms, ok := entryForms(e); ok { + out[key] = forms + } + } + } + return out +} + +// Resolved returns the first locale of locale's fallback chain that the +// catalog has any key for under prefix: the locale a Bundle is mostly in. +func (t *Translator) Resolved(locale, prefix string) string { + if t == nil || t.cat == nil { + return locale + } + for _, step := range fallbackChain(locale, t.fallbackLocale()) { + for key := range t.cat.messages[step] { + if strings.HasPrefix(key, prefix) { + return step + } + } + } + return t.fallbackLocale() +} + +// Has reports whether any locale of the catalog defines key. +func (t *Translator) Has(key string) bool { + if t == nil || t.cat == nil { + return false + } + for _, byKey := range t.cat.messages { + if _, ok := byKey[key]; ok { + return true + } + } + return false +} + +func (t *Translator) fallbackLocale() string { + if t == nil || strings.TrimSpace(t.fallback) == "" { + return defaultFallback + } + return t.fallback +} + +func entryForms(e entry) (map[string]string, bool) { + switch { + case len(e.pipes) > 0: + out := make(map[string]string, len(e.pipes)) + for _, part := range e.pipes { + if part.exact != nil || part.lo != nil || part.cat == "" { + return nil, false + } + out[part.cat] = part.text + } + if _, ok := out["other"]; !ok { + return nil, false + } + return out, true + case e.plurals != nil: + out := make(map[string]string, len(e.plurals)) + for cat, text := range e.plurals { + out[cat] = text + } + return out, true + default: + return map[string]string{"other": e.text}, true + } +}