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 @@
- +
- +
@@ -84,7 +84,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 1aa2802bd2..069d5b1f77 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 @@ -103,7 +103,7 @@
  • #end @@ -368,7 +368,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 a8cb51f4ed..a6e5093aec 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)) { @@ -686,7 +731,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);