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/<locale>/<namespace>/<group>.yaml) after every namespace, and fails activation when a backend key cannot convert to plural forms - Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps - Public GET /lang returns every backend::lang key for the request locale over the fallback locale, Cache-Control no-cache - config_list, config_form and config_relation accept a strict messages block; omitted keys take framework defaults, schemas serve every message as CLDR forms, and activation fails on a missing phrase key - toolbar.buttons is an ordered [create, delete] list; the Winter string form, duplicates, unknown actions and delete without showCheckboxes fail at boot, and create is dropped when the controller has no form - Form schema serves the raw Winter redirects; scaffold emits the list syntax; form and relation schema routes are typed in the admin OpenAPI
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
164
admin/src/api/schema.d.ts
vendored
164
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
24
cabana/lang.go
Normal file
24
cabana/lang.go
Normal file
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
257
cabana/messages.go
Normal file
257
cabana/messages.go
Normal file
@@ -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
|
||||
}
|
||||
354
cabana/messages_test.go
Normal file
354
cabana/messages_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)...),
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/<locale>/<namespace>/<group>.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
|
||||
|
||||
119
phrasebook/backend/lang/en/lang.yaml
Normal file
119
phrasebook/backend/lang/en/lang.yaml
Normal file
@@ -0,0 +1,119 @@
|
||||
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
|
||||
# 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.
|
||||
135
phrasebook/backend/lang/pl/lang.yaml
Normal file
135
phrasebook/backend/lang/pl/lang.yaml
Normal file
@@ -0,0 +1,135 @@
|
||||
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
|
||||
# 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.
|
||||
@@ -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/<locale>/lang.yaml so its keys resolve as backend::lang.<group>.<key>.
|
||||
func backendLang() fs.FS {
|
||||
sub, err := fs.Sub(backendLangFS, "backend")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
@@ -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/<locale>/<namespace>/<group>.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/<locale>/<namespace>/<group>.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, "/")
|
||||
|
||||
199
phrasebook/phase10_test.go
Normal file
199
phrasebook/phase10_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user