Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down Expand Up @@ -71,28 +75,35 @@

// 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 {
const code = (() => {
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);
Expand All @@ -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);
}
};
})();
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
<template x-if="isSvgIcon(item.icon)"><svg x-h-icon :data-link="item.icon" class="size-4" role="presentation"></svg></template>
<template x-if="isImageIcon(item.icon)"><img :src="item.icon" alt="" class="size-4" /></template>
<template x-if="!isSvgIcon(item.icon) && !isImageIcon(item.icon)"><i role="img" :data-lucide="item.icon || 'box'"></i></template>
<span x-text="item.label"></span>
<span x-text="item.tkey ? T(item.tkey, item.label) : item.label"></span>
</button>
</li>
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<project>:<path>' - 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ <h1 class="text-2xl font-bold" x-text="T('application-core:shell.nav.dashboard',
<template x-for="group in groups" :key="group.id">
<div class="vbox gap-3" x-show="group.items.some(i => i.kind === 'PRIMARY')">
<div class="hbox items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
<i role="img" data-lucide="layout-grid" class="size-4"></i><span x-text="group.label"></span>
<i role="img" data-lucide="layout-grid" class="size-4"></i><span x-text="group.tkey ? T(group.tkey, group.label) : group.label"></span>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<template x-for="item in group.items.filter(i => i.kind === 'PRIMARY')" :key="item.id">
Expand All @@ -34,7 +34,7 @@ <h1 class="text-2xl font-bold" x-text="T('application-core:shell.nav.dashboard',
<template x-if="isSvgIcon(item.icon)"><svg x-h-icon :data-link="item.icon" class="size-4" role="presentation"></svg></template>
<template x-if="isImageIcon(item.icon)"><img :src="item.icon" alt="" class="size-4" /></template>
<template x-if="!isSvgIcon(item.icon) && !isImageIcon(item.icon)"><i role="img" :data-lucide="item.icon || 'box'" class="size-4"></i></template>
<span x-text="item.label"></span>
<span x-text="item.tkey ? T(item.tkey, item.label) : item.label"></span>
</div>
</div>
<div x-h-card-content>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<button x-h-button data-variant="outline" class="justify-start"
:data-state="isSettingActive(item) ? 'selected' : ''" @click="selectSetting(item)">
<i role="img" data-lucide="settings"></i>
<span x-text="item.label"></span>
<span x-text="item.tkey ? T(item.tkey, item.label) : item.label"></span>
</button>
</template>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
<!-- ===== Line items (read-only table; add/edit via dialog) — only once the document exists ===== -->
<div class="vbox gap-2 w-full" x-show="itemsEnabled">
<div x-h-toolbar data-variant="transparent">
<span x-h-toolbar-title class="shrink-0">${documentItemsLabel}</span>
<span x-h-toolbar-title class="shrink-0" x-text="itemsDef ? T(itemsDef.tkey, '${documentItemsLabel}') : '${documentItemsLabel}'"></span>
<div x-h-toolbar-spacer></div>
<button type="button" x-h-button data-variant="primary" data-size="sm" x-show="!isPreview" @click="openRowDialog(null)"><i role="img" data-lucide="plus"></i><span x-text="T('$projectName:${tprefix}.defaults.add', 'Add')"></span></button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<div x-data="${name}ManageListPage" class="vbox size-full">

<div x-h-toolbar data-variant="transparent">
<span x-h-toolbar-title class="shrink-0" x-text="T('$projectName:${tprefix}.t.${dataName}', '${menuLabel}')"></span>
<span x-h-toolbar-title class="shrink-0" x-text="T('$projectName:${tprefix}.t.${dataName}_plural', '${menuLabel}')"></span>
<div x-h-toolbar-spacer></div>
<div x-h-input-group data-size="sm">
<input x-h-input.group type="text" :placeholder="T('$projectName:${tprefix}.messages.inputSearch', 'Search ${menuLabel}...', { name: T('$projectName:${tprefix}.t.${dataName}', '${entityLabel}') })"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
App.registerDetail('${masterEntity}', {
entity: '${name}',
label: '${menuLabel}',
tkey: '$projectName:${tprefix}.t.${dataName}',
// The label is the pluralized humanized name ("Loans"), so its key is the _plural variant.
tkey: '$projectName:${tprefix}.t.${dataName}_plural',
apiPath: '/${javaPerspectiveName}/${name}Controller',
masterEntityId: '${masterEntityId}',
primaryKey: '${primaryKeysString}',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<div x-data="${name}MasterPage" class="vbox size-full">

<div x-h-toolbar data-variant="transparent">
<span x-h-toolbar-title class="shrink-0" x-text="T('$projectName:${tprefix}.t.${dataName}', '${menuLabel}')"></span>
<span x-h-toolbar-title class="shrink-0" x-text="T('$projectName:${tprefix}.t.${dataName}_plural', '${menuLabel}')"></span>
<div x-h-toolbar-spacer></div>
<div x-h-input-group data-size="sm">
<input x-h-input.group type="text" :placeholder="T('$projectName:${tprefix}.messages.inputSearch', 'Search ${name}...', { name: T('$projectName:${tprefix}.t.${dataName}', '${name}') })" x-model="searchTerm" @input="refreshIcons()" />
Expand Down Expand Up @@ -84,7 +84,7 @@

<div x-show="selectedId" class="vbox gap-4 p-4 overflow-auto">
<div x-h-toolbar data-variant="transparent" data-size="sm">
<span x-h-toolbar-title class="shrink-0" x-text="'${entityLabel} #' + selectedId"></span>
<span x-h-toolbar-title class="shrink-0" x-text="T('$projectName:${tprefix}.t.${dataName}', '${entityLabel}') + ' #' + selectedId"></span>
<div x-h-toolbar-spacer></div>
<button x-h-button data-variant="outline" data-size="sm" @click="previewMaster(selected)"><i role="img" data-lucide="eye"></i><span x-text="T('$projectName:${tprefix}.defaults.preview', 'Preview')"></span></button>
<button x-h-button data-variant="outline" data-size="sm" @click="editMaster(selected)"><i role="img" data-lucide="pencil"></i><span x-text="T('$projectName:${tprefix}.defaults.edit', 'Edit')"></span></button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
<li x-h-sidebar-menu-item>
<button x-h-sidebar-menu-button :data-active="isActive('/${entity.name}')" @click="navigate('/${entity.name}')">
<i role="img" data-lucide="#if($entity.iconName && $entity.iconName != '')${entity.iconName}#{else}list#end"></i>
<span x-text="T('$projectName:${tprefix}.t.${entity.dataName}', navLabel('#if($entity.menuLabel && $entity.menuLabel != '')${entity.menuLabel}#{else}${entity.name}#end'))"></span>
<span x-text="T('$projectName:${tprefix}.t.${entity.dataName}_plural', navLabel('#if($entity.menuLabel && $entity.menuLabel != '')${entity.menuLabel}#{else}${entity.name}#end'))"></span>
</button>
</li>
#end
Expand Down Expand Up @@ -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
};
</script>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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.<dataName> feeds the
// singular UI texts (form captions, "New X", "X #id"), t.<dataName>_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);
Expand Down
Loading