From 1d72e832f1cea6ce1ffd726d9d7ddb6cfc4b340d Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 3 Jul 2026 19:56:55 +0300 Subject: [PATCH 1/2] feat(i18n): generated humanized + pluralized entity-name translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar / list titles / detail panels show pluralized humanized entity names ('Sales Invoices') and the forms singular ones ('Sales Invoice'), but the generated catalog carried only one ambiguous key per entity holding the RAW name - so the names could not be translated properly (a single key cannot be both 'Фактура за продажба' and 'Фактури за продажба'). - generate (translate action): every entity now emits BOTH display names into the project catalog (translations/en-US/.json): .t. = humanized singular (entityLabel) .t._plural = humanized pluralized (menuLabel) Models without baked labels (hand-authored .edm) get the same derivation via JS ports of IntentNaming.humanize/pluralize (incl. the UoM overrides). - Harmonia templates: every plural surface now binds the _plural key - the standalone sidebar entries, the manage/master list titles, the breadcrumb nav-key map, the document line-items title, and the detail-panel registry tkey (its label is the pluralized menuLabel). - shared application shell: each generated perspective contributes tkey (':.t._plural'); the shell sidebar, dashboard tiles and Settings list translate through it. The shell (which has no project namespace of its own) loads the contributing projects' catalogs on demand: new AppI18nAddNamespaces() in the shared i18n service fetches additional namespaces after init and re-renders already-bound labels through a reactive store version bump. Co-Authored-By: Claude Fable 5 --- .../shell/js/services/i18n.js | 66 +++++++++++++++---- .../META-INF/dirigible/application/index.html | 2 +- .../dirigible/application/js/appShell.js | 9 +++ .../application/views/_dashboard.html | 4 +- .../application/views/_settings.html | 2 +- .../document/document-view.html.template | 2 +- .../manage/list-view.html.template | 2 +- .../master/detail-register.js.template | 3 +- .../master/master-view.html.template | 2 +- .../ui/perspective/perspective.js.template | 3 + .../ui/shell/index.html.template | 4 +- .../template/generateUtils.js | 55 +++++++++++++++- 12 files changed, 129 insertions(+), 25 deletions(-) diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/i18n.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/i18n.js index ba24741936..86502606e6 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/i18n.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/i18n.js @@ -39,11 +39,15 @@ document.addEventListener('alpine:init', () => { Alpine.store('i18n', { ready: catalogsReady, + // Bumped whenever catalogs are added AFTER init (extra namespaces) so Alpine re-evaluates + // every T() binding that already rendered its fallback. + version: 0, // Translate a fully-qualified key ('ns:path'). In the DEFAULT language the baked English // fallback always wins (it is the same authored label, often prettier than the raw catalog // value); in any other language the key resolves against THAT language's own catalog only, // so an untranslated key degrades to the pretty English literal - never to a raw name. t(key, fallback, options) { + this.version; // reactive dependency - see above if (!key) return fallback !== undefined ? fallback : ''; if (this.ready && window.i18next && i18next.exists(key, Object.assign({ fallbackLng: false }, options))) { @@ -71,9 +75,26 @@ // The catalogs to request: the platform shell chrome ('application-core', shipped with the shared // runtime) plus the hosting app's own project namespace ('common' is always included by the - // service). The shared application shell has no project and uses only the shell catalog. + // service). The shared application shell has no project and uses only the shell catalog; the + // namespaces of the apps it aggregates are added on demand via AppI18nAddNamespaces below. const project = window.App && App.config && App.config.projectName; const namespaces = project ? ['application-core', project] : ['application-core']; + const loadedNamespaces = new Set(namespaces); + + const fetchCatalogs = async (requested) => { + const params = 'extensionPoints=platform-locales&extensionPoints=application-locales' + + requested.map(ns => '&namespaces=' + encodeURIComponent(ns)).join(''); + const response = await fetch(LOCALES_SERVICE + '?' + params, { + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!response.ok) throw new Error('locales service returned ' + response.status); + return response.json(); + }; + + // Resolved after init: false when the default language rendered from the baked literals (no + // catalogs, i18next not even loaded), true when the catalogs are live. + let initPromise; const init = async () => { try { @@ -81,18 +102,8 @@ try { return localStorage.getItem(STORAGE_KEY) || 'en'; } catch (e) { return 'en'; } })(); // The default language renders entirely from the baked-in literals - no catalogs needed. - if (DEFAULT_LOCALE.indexOf(code + '-') === 0) return; - const params = 'extensionPoints=platform-locales&extensionPoints=application-locales' - + namespaces.map(ns => '&namespaces=' + encodeURIComponent(ns)).join(''); - const [response] = await Promise.all([ - fetch(LOCALES_SERVICE + '?' + params, { - credentials: 'same-origin', - headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, - }), - loadScript(I18NEXT_WEBJAR), - ]); - if (!response.ok) throw new Error('locales service returned ' + response.status); - const data = await response.json(); + if (DEFAULT_LOCALE.indexOf(code + '-') === 0) return false; + const [data] = await Promise.all([fetchCatalogs(namespaces), loadScript(I18NEXT_WEBJAR)]); const locales = Array.isArray(data.locales) ? data.locales : []; // Resolve the 2-letter platform code against the registered locale ids (en -> en-US). const resolved = locales.find(l => l.id === code) || locales.find(l => (l.id || '').indexOf(code + '-') === 0); @@ -105,11 +116,38 @@ resources: data.translations || {}, }); markReady(); + return true; } catch (e) { // Untranslated fallbacks keep rendering - the page stays fully usable in English. console.error('i18n: catalogs unavailable, using built-in labels', e); + return false; } }; - init(); + initPromise = init(); + + // Load additional catalog namespaces AFTER init - the shared application shell calls this with + // the projects of the perspectives it aggregates (their generated entity/label keys live in each + // project's own namespace). No-op in the default language (baked literals) and for namespaces + // already loaded; already-rendered T() bindings re-evaluate through the store's version bump. + window.AppI18nAddNamespaces = async (extra) => { + try { + const active = await initPromise; + if (!active) return; + const missing = (extra || []).filter(ns => ns && !loadedNamespaces.has(ns)); + if (!missing.length) return; + missing.forEach(ns => loadedNamespaces.add(ns)); + const data = await fetchCatalogs(missing); + const translations = data.translations || {}; + for (const lng of Object.keys(translations)) { + for (const ns of Object.keys(translations[lng])) { + i18next.addResourceBundle(lng, ns, translations[lng][ns], true, true); + } + } + const store = window.Alpine && Alpine.store('i18n'); + if (store) store.version++; + } catch (e) { + console.error('i18n: additional catalogs unavailable', e); + } + }; })(); diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html index bad11f6396..9b89e31873 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html @@ -102,7 +102,7 @@ - + diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js index 6821c16982..afe9db8237 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js @@ -134,6 +134,15 @@ document.addEventListener('alpine:init', () => { } this.groups = appGroups; this.settingsItems = settings; + // Fire-and-forget: load the contributing apps' i18n catalogs so the sidebar / dashboard / + // Settings entries translate. Each perspective's tkey is ':' - the project + // is the catalog namespace, which the shell (having no project of its own) must add. + if (window.AppI18nAddNamespaces) { + const namespaces = [...new Set(all.flatMap(g => Array.isArray(g.items) ? g.items : [g]) + .map(it => (it.tkey || '').split(':')[0]) + .filter(ns => ns && ns !== 'application-core'))]; + AppI18nAddNamespaces(namespaces); + } // Fire-and-forget: load each entity's live record count for the dashboard KPI tiles. this.loadCounts(); // Fire-and-forget: scan which languages each embedded app provides translations for diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_dashboard.html b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_dashboard.html index 70cd776585..a0ad54a504 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_dashboard.html +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_dashboard.html @@ -23,7 +23,7 @@

- +
- +
diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_settings.html b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_settings.html index 0f1c19f7ea..bbf6b62c75 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_settings.html +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_settings.html @@ -22,7 +22,7 @@
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template index 764da389c3..cfdf2e3bf4 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template @@ -146,7 +146,7 @@
- ${documentItemsLabel} +
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/list-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/list-view.html.template index 127572878c..b9fe9b919e 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/list-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/list-view.html.template @@ -6,7 +6,7 @@
- +
- +
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/perspective.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/perspective.js.template index c89507825d..e8dda8eb84 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/perspective.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/perspective.js.template @@ -11,6 +11,9 @@ const perspectiveData = { id: '${projectName}-${name}', label: '${menuLabel}', + // i18n key of the (pluralized) label in the owning project's generated catalog; the shared shell + // translates the sidebar entry through it (loading the '${projectName}' namespace on demand). + tkey: '${projectName}:${tprefix}.t.${dataName}_plural', path: '/services/web/${projectName}/gen/${genFolderName}/index.html?embedded=true#/${name}', kind: '${type}', // Absolute /count of this entity's generated REST controller, so the shared shell dashboard can show diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template index bff07eb19e..e78f0331ed 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template @@ -102,7 +102,7 @@
  • #end @@ -367,7 +367,7 @@ // Route segment -> i18n catalog key (the shared shell translates breadcrumbs through these). window.__harmoniaNavKeys = { #foreach($entity in $models) - "${entity.name}": "$projectName:${tprefix}.t.${entity.dataName}", + "${entity.name}": "$projectName:${tprefix}.t.${entity.dataName}_plural", #end }; diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index ef5cfe68f4..acfe5f7ca9 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -17,6 +17,51 @@ function getTranslationId(str) { return `${str.replaceAll(' ', '').replaceAll('_', '').replaceAll('.', '').replaceAll(':', '')}`; } +// Humanize a PascalCase/camelCase identifier for display ("SalesInvoice" -> "Sales Invoice"). +// Mirrors org.eclipse.dirigible.components.intent.generator.IntentNaming.humanize, including its +// acronym overrides, so a hand-authored .edm (no entityLabel/menuLabel baked by the intent +// generator) still yields the same labels the intent path would. +const HUMANIZE_OVERRIDES = { 'uom': 'Unit of Measure' }; + +function humanizeName(name) { + if (!name) return ''; + const override = HUMANIZE_OVERRIDES[name.toLowerCase()]; + if (override) return override; + let out = ''; + for (let i = 0; i < name.length; i++) { + const c = name.charAt(i); + if (i > 0 && c >= 'A' && c <= 'Z' && !(name.charAt(i - 1) >= 'A' && name.charAt(i - 1) <= 'Z')) { + out += ' '; + } + out += i === 0 ? c.toUpperCase() : c; + } + return out; +} + +// Pluralize a humanized label's last word ("Sales Invoice" -> "Sales Invoices", "Country" -> +// "Countries"). Mirrors IntentNaming.pluralize, including its irregular overrides. +const PLURALIZE_OVERRIDES = { 'unit of measure': 'Units of Measure', 'uom': 'Units of Measure' }; + +function pluralizeLabel(label) { + if (!label) return ''; + const override = PLURALIZE_OVERRIDES[label.toLowerCase()]; + if (override) return override; + const sp = label.lastIndexOf(' '); + const head = sp >= 0 ? label.substring(0, sp + 1) : ''; + const last = sp >= 0 ? label.substring(sp + 1) : label; + if (!last) return label; + const lower = last.toLowerCase(); + let plural; + if (lower.length > 1 && lower.endsWith('y') && 'aeiou'.indexOf(lower.charAt(lower.length - 2)) < 0) { + plural = last.substring(0, last.length - 1) + 'ies'; + } else if (lower.endsWith('s') || lower.endsWith('x') || lower.endsWith('z') || lower.endsWith('ch') || lower.endsWith('sh')) { + plural = last + 'es'; + } else { + plural = last + 's'; + } + return head + plural; +} + function getTranslations(model) { let translations = {}; for (const [key, value] of Object.entries(model)) { @@ -682,7 +727,15 @@ export function generateFiles(model, parameters, templateSources) { if (model.entities) { for (let i = 0; i < model.entities.length; i++) { if (model.entities[i].dataName && model.entities[i].name) { - translations.t[model.entities[i].dataName] = model.entities[i].name; + // The entity's humanized singular ("Sales Invoice") and pluralized + // ("Sales Invoices") display names, per language: t. feeds the + // singular UI texts (form captions, "New X", "X #id"), t._plural + // the plural ones (sidebar entries, list/master titles, detail panels). + // The model's own entityLabel/menuLabel (EdmIntentGenerator) win; models + // without them (hand-authored .edm) get the same derivation applied here. + const singular = model.entities[i].entityLabel || humanizeName(model.entities[i].name); + translations.t[model.entities[i].dataName] = singular; + translations.t[`${model.entities[i].dataName}_plural`] = model.entities[i].menuLabel || pluralizeLabel(singular); } if (model.entities[i].properties) { properties(model.entities[i].properties); From 1488ed1ca001c9574109ff5cd1ba08047eb9ac42 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 3 Jul 2026 20:50:03 +0300 Subject: [PATCH 2/2] fix(harmonia): translate the master details-pane record title The master view's selected-record title concatenated the raw baked '${entityLabel} #id' literal - the one singular entity-name surface that bypassed T(). It now resolves through the same t. singular key as the other captions. Co-Authored-By: Claude Fable 5 --- .../ui/perspective/master/master-view.html.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/master-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/master-view.html.template index 34db355fed..4345d8ef17 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/master-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/master-view.html.template @@ -84,7 +84,7 @@
    - +