diff --git a/CLAUDE.md b/CLAUDE.md index 5997a4a6348..b15402087f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,7 +185,7 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **Detailed guide:** [`components/engine/engine-intent/CLAUDE.md`](components/engine/engine-intent/CLAUDE.md). Read it before changing anything under that module — it covers the editor-first architecture and altitude contract (model files only, never code), the YAML schema and its semantics (integer-only primary keys, `composition: true` to-one = DEPENDENT master-detail while `required` alone is just a NOT NULL FK, PascalCase property names with UPPER_SNAKE columns, decision `then`/`else`, intent-prefixed table names via `IntentNaming`), the `writeModelFile`-only write surface with the stale-output scrub, the wrong turns already made (wrong altitude, template-output paths, registry-relative vs repository-absolute paths, the `JsonHelper` Gson pitfall, **and the synchronizer-based first incarnation — do not reintroduce it**), and the follow-up list (chaining model-to-code via `.gen` descriptors, `/custom/` escape hatch). Process triggers (`trigger: { onCreate: }`) are wired: the EDM adds a `ProcessId` field + a `triggers` collection to the `.model`, and the `template-application-events-java` template generates a `gen/events/Trigger.java` listener that starts the process on create. That persisted `ProcessId` is in turn **consumed by the generated entity-view UI**: a shared `ProcessTasks` module (`components/resources/resources-dashboard/.../dashboard/services/process-tasks.js`) surfaces the record's actionable BPM user tasks inline via an `` directive (correlating `entity.ProcessId === task.processInstanceId`), wired into every generated view gated on a `hasProcess` flag; the task form completes via the permission-checked `/services/inbox/tasks/{id}` and self-closes (#6074). `IntentEngineIT` is the HTTP-only end-to-end test (~1 minute, no sync cycles). The editor's diagram pane is **mxGraph** (replacing Mermaid, which had unfixable light/dark theming bugs) with a fixed brand-colour palette that reads on both themes — see the module guide's "Intent Editor diagram = mxGraph" section before touching `editor-intent/js/editor.js`. -**Multi-model + layout additions (PRs [#6089](https://github.com/eclipse-dirigible/dirigible/pull/6089)-[#6092](https://github.com/eclipse-dirigible/dirigible/pull/6092)):** the DSL now supports building an app from **several intent models that reference each other cross-model** - a top-level `uses:` block names other models, and a relation gains an optional `model:` alias; a cross-model `manyToOne`/`oneToOne` is emitted as a read-only **PROJECTION** entity + integer FK + dropdown (the codbex cross-project pattern - no local table/DAO/controller for the target), resolved against the owner's already-generated `.model` (leaf-first generation; convention fallback otherwise). **n:m** is an explicit **intermediate entity** (composition to one side + `manyToOne` to the other, which may be cross-model, plus bridge fields like `amount`) - `manyToMany` is parsed but never materialized. New field attributes: `unique`, `precision`/`scale`, `calculatedOnCreate`/`calculatedOnUpdate` (a neutral arithmetic expression for numeric totals, else emitted verbatim into the runtime), `calculatedActionOnCreate`/`calculatedActionOnUpdate` (server-side call-out to a hand-written `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField`, invoked as `Beans.get(.class).calculate(entity)`, taking precedence over the expression — for logic too custom to model, e.g. number generation); field `readOnly: true` (not editable; rendered in the Harmonia form's read-only details block — Label:Value above the buttons — via `isReadOnlyProperty`; `ProcessId`/audit columns/`uuid` are auto-flagged read-only, `status`-style fields opt in); field `major: false` (kept off the entity **list** table — the model's `widgetIsMajor="false"` — still shown in forms + the record details pane; defaults true); entity `imports:` (Java `import` lines injected into the generated repository so a calculated action can be referenced by simple name — Base64-encoded into the `.model`'s `importsCode`, which the Java DAO template emits; the editor's entity-level Imports tab is the model-editor equivalent); entity `audit: true` (the four standard audit columns); entity `group:` (the perspective's nav-group id in the shared application shell). **Depends-On** is exposed as `dependsOn: { relation, valueFrom?, filterBy? }` on a to-one relation (cascading/narrowed dropdown) or a field (auto-populated value) — emitted as the EDM `widgetDependsOn*` attributes (the AngularJS stacks consume them as-is; the Harmonia runtime — form/document watchers + the metadata-driven item-dialog cascade — was added alongside); defaults are the respective primary keys, names are the target's authored property names, cross-model triggers/targets supported. **Multi-language data** (the TS-era `multilingual` port): entity `multilingual: true` → the schema layer generates a sibling `_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention) and the generated Java repository overlays translated values on every read for the request's `Accept-Language` (SDK `Translator`, name-based merge); top-level `languages: [en, bg]` feeds the Harmonia shell's **Region & Language** Settings entry (an Alpine `locale` store, localStorage `codbex.harmonia.language`, sent as `Accept-Language` by the shared fetch client — one flag drives UI, data, and the Print default); translations are authored as seeds with `language: bg`, and large data sets reference an authored CSV via seed `file: data/x.csv` (subfolder mandatory — root `.csv` is scrub-owned) instead of inline rows. A master owning an `*Item` composition child renders as the **document (header-items) layout** (`MANAGE_DOCUMENT` + `documentItemsEntity`, `uiDocumentModels`), with `aggregate: true` fields shown in the totals footer. `IntentNaming.upperSnake` collapses kebab/space/`.`/`/` separators so a hyphenated model name yields a valid SQL identifier (`sales-invoices` -> `SALES_INVOICES`). Worked example: `dirigiblelabs/sample-intent-multi-model` (six interdependent projects + a navigation-groups project). +**Multi-model + layout additions (PRs [#6089](https://github.com/eclipse-dirigible/dirigible/pull/6089)-[#6092](https://github.com/eclipse-dirigible/dirigible/pull/6092)):** the DSL now supports building an app from **several intent models that reference each other cross-model** - a top-level `uses:` block names other models, and a relation gains an optional `model:` alias; a cross-model `manyToOne`/`oneToOne` is emitted as a read-only **PROJECTION** entity + integer FK + dropdown (the codbex cross-project pattern - no local table/DAO/controller for the target), resolved against the owner's already-generated `.model` (leaf-first generation; convention fallback otherwise). **n:m** is an explicit **intermediate entity** (composition to one side + `manyToOne` to the other, which may be cross-model, plus bridge fields like `amount`) - `manyToMany` is parsed but never materialized. New field attributes: `unique`, `precision`/`scale`, `calculatedOnCreate`/`calculatedOnUpdate` (a neutral arithmetic expression for numeric totals, else emitted verbatim into the runtime), `calculatedActionOnCreate`/`calculatedActionOnUpdate` (server-side call-out to a hand-written `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField`, invoked as `Beans.get(.class).calculate(entity)`, taking precedence over the expression — for logic too custom to model, e.g. number generation); field `readOnly: true` (not editable; rendered in the Harmonia form's read-only details block — Label:Value above the buttons — via `isReadOnlyProperty`; `ProcessId`/audit columns/`uuid` are auto-flagged read-only, `status`-style fields opt in); field `major: false` (kept off the entity **list** table — the model's `widgetIsMajor="false"` — still shown in forms + the record details pane; defaults true); entity `imports:` (Java `import` lines injected into the generated repository so a calculated action can be referenced by simple name — Base64-encoded into the `.model`'s `importsCode`, which the Java DAO template emits; the editor's entity-level Imports tab is the model-editor equivalent); entity `audit: true` (the four standard audit columns); entity `group:` (the perspective's nav-group id in the shared application shell). **Depends-On** is exposed as `dependsOn: { relation, valueFrom?, filterBy? }` on a to-one relation (cascading/narrowed dropdown) or a field (auto-populated value) — emitted as the EDM `widgetDependsOn*` attributes (the AngularJS stacks consume them as-is; the Harmonia runtime — form/document watchers + the metadata-driven item-dialog cascade — was added alongside); defaults are the respective primary keys, names are the target's authored property names, cross-model triggers/targets supported. **Multi-language data** (the TS-era `multilingual` port): entity `multilingual: true` → the schema layer generates a sibling `
_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention) and the generated Java repository overlays translated values on every read for the request's `Accept-Language` (SDK `Translator`, name-based merge); the supported language set is a PLATFORM concern (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en,bg`) — the Harmonia **Region & Language** Settings entry always offers that set (an Alpine `locale` store, localStorage `codbex.harmonia.language`, sent as `Accept-Language` by the shared fetch client — one flag drives UI, data, and the Print default), while the top-level `languages: [en, bg]` only declares which languages the module PROVIDES translations for; the application shell warns about modules missing a platform language, and untranslated content falls back to the default; translations are authored as seeds with `language: bg`, and large data sets reference an authored CSV via seed `file: data/x.csv` (subfolder mandatory — root `.csv` is scrub-owned) instead of inline rows. A master owning an `*Item` composition child renders as the **document (header-items) layout** (`MANAGE_DOCUMENT` + `documentItemsEntity`, `uiDocumentModels`), with `aggregate: true` fields shown in the totals footer. `IntentNaming.upperSnake` collapses kebab/space/`.`/`/` separators so a hyphenated model name yields a valid SQL identifier (`sales-invoices` -> `SALES_INVOICES`). Worked example: `dirigiblelabs/sample-intent-multi-model` (six interdependent projects + a navigation-groups project). **The general platform line this enshrines:** authoring artifacts (`.edm`, `.model`, `.form`, `.report`, `.intent`) get **workspace editors + an explicit Generate**; only runtime artifacts (`.roles`, `.bpmn`, `.csvim`, `.table`, jobs, listeners, …) get **synchronizers**. Applying the synchronizer hammer to an authoring artifact generates into the registry where no modeler, Projects view, or template can use it — that mistake was made once and reverted; the inventory of synchronizers (grep `extends BaseSynchronizer`) deliberately contains no authoring formats. diff --git a/components/engine/engine-camel/src/generated/java/org/eclipse/dirigible/components/engine/camel/components/DirigibleJavaScriptEndpointUriFactory.java b/components/engine/engine-camel/src/generated/java/org/eclipse/dirigible/components/engine/camel/components/DirigibleJavaScriptEndpointUriFactory.java index 49c186a444e..9e688b89804 100644 --- a/components/engine/engine-camel/src/generated/java/org/eclipse/dirigible/components/engine/camel/components/DirigibleJavaScriptEndpointUriFactory.java +++ b/components/engine/engine-camel/src/generated/java/org/eclipse/dirigible/components/engine/camel/components/DirigibleJavaScriptEndpointUriFactory.java @@ -21,6 +21,7 @@ public class DirigibleJavaScriptEndpointUriFactory extends org.apache.camel.supp private static final Set PROPERTY_NAMES; private static final Set SECRET_PROPERTY_NAMES; + private static final Set ENDPOINT_IDENTITY_PROPERTY_NAMES; private static final Map MULTI_VALUE_PREFIXES; static { Set props = new HashSet<>(2); @@ -28,6 +29,7 @@ public class DirigibleJavaScriptEndpointUriFactory extends org.apache.camel.supp props.add("lazyStartProducer"); PROPERTY_NAMES = Collections.unmodifiableSet(props); SECRET_PROPERTY_NAMES = Collections.emptySet(); + ENDPOINT_IDENTITY_PROPERTY_NAMES = Collections.emptySet(); MULTI_VALUE_PREFIXES = Collections.emptyMap(); } @@ -58,6 +60,11 @@ public Set secretPropertyNames() { return SECRET_PROPERTY_NAMES; } + @Override + public Set endpointIdentityPropertyNames() { + return ENDPOINT_IDENTITY_PROPERTY_NAMES; + } + @Override public Map multiValuePrefixes() { return MULTI_VALUE_PREFIXES; diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 97b93d41e60..64f274db5cf 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -264,7 +264,7 @@ Semantics worth knowing: - **`init: ` on a to-one relation = the FK's DB-level default (`RelationIntent.init` → `dataDefaultValue` on the FK property, in both `relationProperty` and `crossModelRelationProperty`).** The relation analogue of a field's `defaultValue`; a new row gets this FK on insert when the column is left unset (e.g. `Status` defaults to the DRAFT seed, `PaymentMethod` to Bank, `SentMethod` to E-mail). **Use `init` for an INITIAL status, never a process step.** A `setRelationField` serviceTask that sets the status on process *start* races the trigger's `ProcessId` write-back: the generated `Trigger` loads the entity (status null), calls `Process.start`, then does `entity.ProcessId = id; updateWithoutEvent(entity)` — a **full-row** `super.update` with the stale pre-step copy — which clobbers the status the start-step just set (confirmed live: the invoice reached the Approve task but `Status` stayed null). A DB default is applied at insert, before the trigger reads the row, so it round-trips cleanly. `setRelationField` is correct only for *transitions* (after a user task / on a decision branch), where nothing else writes the row concurrently. - **`trigger: { onCreate|onUpdate|onDelete: , when: "" }` starts the process on the named `` lifecycle event** - fully wired (Java). Any of the three events is supported: `onCreate` binds the entity's base topic, `onUpdate`/`onDelete` the `-updated`/`-deleted` topics the Java DAO publishes (`TriggerSupport` + `EventBinding`); an optional `when` guard (a single `field ==|!= literal`, via `NotificationSupport.guard`) gates `Process.start`. Three parts: (1) the parser validates at most one event kind and that the target is a declared entity; (2) the EDM generator adds a `ProcessId` back-reference property (VARCHAR) to that entity and a `triggers` collection to the `.model` (`TriggerSupport` + `EdmIntentGenerator.buildTriggers`); (3) the **`template-application-events-java`** template (intent-driven, like the other language templates) reads that `triggers` collection and emits one **`gen/events/Trigger.java`** per trigger - a client-Java self-describing `MessageHandler` (a `@Component` whose `destination()` is the entity's per-operation topic via `topicSuffix` and whose `kind()` is `TOPIC`) that loads the entity, applies the `when` guard, calls `Process.start(, businessKey, )`, and writes the instance id back to `ProcessId` (so it starts at most once). The Java DAO template (`template-application-dao-java`) now publishes the create event (`Producer.sendToTopic('${projectName}-${perspectiveName}-${name}', json)`) the way the TS DAO does - that's the topic the handler binds to. `gen/events` is a sibling of `gen/`, so it survives the per-model regeneration wipe. The events template iterates the model's `triggers` via a new **`triggers` collection case in `service-generate/template/generateUtils.js`** (the engine's collection switch is hardcoded; the case has its own loop because triggers are not entity-shaped). The BPM **business key** defaults to the entity's primary key but is **configurable**: `trigger: { ..., businessKey: }` names which trigger-entity field becomes the started instance's business key (the listener still loads the entity by its PK via `findById`; only the business key differs — a separate `businessKeyProperty` in `.glue`). An optional `businessKeyStrategy: timestamp` mints a `yyyyMMddHHmmss` value into that field when it is blank and persists it via the listener's existing update — the simple "for now" generator and the **extension point** for richer pluggable number generators later (sequential, zero-padded, config-prefixed invoice numbers); the parser validates the field exists, the strategy is supported, and (for `timestamp`) the field is `string`/`text`. `TriggerSupport.triggerBusinessKey`/`triggerBusinessKeyStrategy` read them; `GlueIntentGenerator` emits `businessKeyProperty` + `generateBusinessKey`; `Trigger.java.template` renders the mint-if-blank block. `onSchedule` is still unmodelled. **Casing subtlety in the generated handler:** its `import gen..data..{Entity,Repository}` must use the **lowercased** Java package segment (`javaPerspective` = `sanitizeJavaIdentifier(perspective)`, matching the DAO/entity templates' `javaPerspectiveName` folder), while the `destination()` topic (`"--"`) keeps the **raw** perspective so it matches the topic the DAO publishes to (`${projectName}-${perspectiveName}-${name}`). The `triggers` collection case in `generateUtils.js` supplies both (`javaPerspective` for the import, `perspective` for the topic). Using the raw perspective in the import compiled on macOS (case-insensitive FS) but failed `javac` with "package gen.x.data.Member does not exist" because the entity files declare the lowercased package. - **`dependsOn` on a to-one relation or a field = the EDM Depends-On feature (cascading dropdowns + auto-populated fields).** `dependsOn: { relation: , valueFrom?: , filterBy?: }` — the widget reacts to the sibling trigger: the generated form loads the trigger's selected record, reads `valueFrom` (default: the trigger target's PK), then a **relation** re-filters its dropdown options where its own target's `filterBy` (default: that target's PK) equals the value (`POST /search` with an EQ condition; a single remaining option auto-selects), while a **field** copies the value (auto-population; `valueFrom` mandatory, `filterBy` rejected). Emitted by `EdmIntentGenerator.putDependsOn` as the four scalar `widgetDependsOn*` property attributes the AngularJS stacks already consume (so those work for free); the Harmonia runtime was added in the same pass (`form-page.js.template` per-property watcher + `applyDependsOn` methods covering manage/master-detail/allocation forms; `document-page.js.template` header watchers + a generic metadata-driven `applyDraftDependsOn` for the line-item dialog off `detail-register.js.template`'s `editColumns[].dependsOn`, with filtered options in a separate `draftOptions` store so the items table's label resolution keeps the full set; `parameterUtils.js` precomputes `widgetDependsOnControllerUrl` from the trigger sibling). `valueFrom`/`filterBy` use the target's **authored** property names (field lower-camel / relation as declared); same-model references are parse-validated, cross-model ones generation-validated against the resolved owner model (`CrossModelSupport.TargetInfo.propertyNames`). A `documentStatus` relation can neither declare nor trigger a dependsOn. Canonical cases (the `codbex-sample-model-depends-on` set): Country→City cascade (`filterBy` only), Product→UoM narrow-to-referenced (`valueFrom` only), Product→price auto-populate (field). -- **`multilingual: true` on an entity + `language:`/`file:` seeds + top-level `languages:` = the multi-language data stack.** A multilingual entity's translatable (string-typed) properties may carry per-language values in a sibling `
_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention). `EdmIntentGenerator` emits the EDM `multilingual="true"` entity attribute (the same one the EDM editor writes); the schema template generates the language table from it; the Java DAO template overrides every finder to overlay translations via the SDK `org.eclipse.dirigible.sdk.db.Translator` for the caller's `Accept-Language` (thread-bound `User.getLanguage()`; null → no-op, so listeners/jobs read base values). Translations are authored as **seeds with a `language: bg` code** → `CsvimIntentGenerator` writes them into `
_LANG` (`GUID` auto-numbered, `Language` constant; parser validates the entity is multilingual and row keys are `id` + string/text fields). **Large data sets stay out of the intent**: a seed may reference an authored CSV via `file: data/countries.csv` (exactly one of `file`/`rows`; the path MUST be in a subfolder — root-level `.csv` files are intent-owned and scrubbed) — only the `.csvim` is generated, pointing at the developer-owned file. Top-level `languages: [en, bg]` lands on the `.model` root → Harmonia `config.js` `languages` → the shell Settings page's **Region & Language** picker (mirroring the IDE's `settings-locale` view), backed by the shared `locale` Alpine store (localStorage `codbex.harmonia.language`) whose value the shared fetch client sends as `Accept-Language` on every call — one flag drives the backend translation, and the document Print flow prefers it too. Caveat (TS parity): editing a record while a non-base language is active saves the displayed (translated) values into the base table — translations are maintained via seeds/DB, not through the generated UI. +- **`multilingual: true` on an entity + `language:`/`file:` seeds + top-level `languages:` = the multi-language data stack.** A multilingual entity's translatable (string-typed) properties may carry per-language values in a sibling `
_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention). `EdmIntentGenerator` emits the EDM `multilingual="true"` entity attribute (the same one the EDM editor writes); the schema template generates the language table from it; the Java DAO template overrides every finder to overlay translations via the SDK `org.eclipse.dirigible.sdk.db.Translator` for the caller's `Accept-Language` (thread-bound `User.getLanguage()`; null → no-op, so listeners/jobs read base values). Translations are authored as **seeds with a `language: bg` code** → `CsvimIntentGenerator` writes them into `
_LANG` (`GUID` auto-numbered, `Language` constant; parser validates the entity is multilingual and row keys are `id` + string/text fields). **Large data sets stay out of the intent**: a seed may reference an authored CSV via `file: data/countries.csv` (exactly one of `file`/`rows`; the path MUST be in a subfolder — root-level `.csv` files are intent-owned and scrubbed) — only the `.csvim` is generated, pointing at the developer-owned file. Top-level `languages: [en, bg]` declares which languages this module PROVIDES translations for (landing on the `.model` root → Harmonia `config.js` `languages`) — it never defines what the stack supports: the **Region & Language** picker always offers the PLATFORM's set (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en,bg`, served by `platform-core/services/application-languages.js`), backed by the shared `locale` Alpine store (localStorage `codbex.harmonia.language`) whose value the shared fetch client sends as `Accept-Language` on every call — one flag drives the backend translation, and the document Print flow prefers it too. The application shell compares each app's provided set against the platform set and lists gaps as warnings in Settings; untranslated content falls back to the default language. Caveat (TS parity): editing a record while a non-base language is active saves the displayed (translated) values into the base table — translations are maintained via seeds/DB, not through the generated UI. - **The YAML `name:` field is the intent's identity for outputs.** `IntentNaming.baseName` prefers it over the artefact name derived from the file name (which is conventionally just `app` from `app.intent`); single-file outputs are `.edm` / `.model` / `.roles` and the table prefix is its upper-snake. - **Physical table names are intent-prefixed**: `_` upper-snake (`ORDERS_ORDER`), via `IntentNaming.tableName`, consistently across `.edm` `dataName`, `.report` `table` and `.csvim` `table`. This avoids SQL reserved words (`ORDER`, `USER`, ...) and cross-project collisions in a shared schema. If the downstream "Generate from EDM" wizard asks for a table prefix, intent projects must leave it empty - the prefix is already part of `dataName`. diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 2d5c27feef2..e2aa654fef8 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -161,11 +161,15 @@ composition is opt-in. may carry per-language values in a sibling `
_LANG` table (generated automatically by the schema layer: `GUID, Id, , Language`). Every read of the generated Java repository overlays the translated values for the caller's `Accept-Language` - the Harmonia shell's -Region & Language setting (fed by the top-level `languages:`) sends the user's choice on every call. -Author translations as seeds with a `language:` code (see seeds). Typical for nomenclatures: +Region & Language setting sends the user's choice on every call. The languages the STACK supports are +a platform concern (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en,bg`) - never defined per module. +The top-level `languages:` only declares which languages THIS module provides translations for; the +application shell warns about modules missing a platform language, and untranslated content falls +back to the default language. Author translations as seeds with a `language:` code (see seeds). +Typical for nomenclatures: ```yaml -languages: [en, bg] # top level: the data languages the app offers +languages: [en, bg] # top level: the languages this module PROVIDES translations for entities: - name: UoM kind: setting diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/app.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/app.js index 5542dc22e3f..445d77d42db 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/app.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/app.js @@ -9,6 +9,11 @@ * SPDX-FileCopyrightText: Eclipse Dirigible contributors * SPDX-License-Identifier: EPL-2.0 */ +// Safety net for apps generated before label i18n existed: their index.html does not load +// services/i18n.js, but the SHARED views (inbox/documents/reports) now bind labels through T(). +// The stub returns the English fallback; i18n.js overwrites it with the real translator when loaded. +window.T = window.T || ((key, fallback) => fallback !== undefined ? fallback : key); + window.App = { services: {}, routes: {}, diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js index bf439c1fe8a..ce2489a188c 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js @@ -103,10 +103,13 @@ document.addEventListener('alpine:init', () => { // Entity route: top is the entity name; label it the same way the sidebar does. const isList = segments.length === 1; - crumbs.push({ label: this.navLabel(this.navLabels[top] || top), route: isList ? null : '/' + top }); + const navKeys = window.__harmoniaNavKeys || {}; + crumbs.push({ label: window.T ? T(navKeys[top], this.navLabel(this.navLabels[top] || top)) : this.navLabel(this.navLabels[top] || top), route: isList ? null : '/' + top }); if (!isList) { const last = segments[segments.length - 1]; - const action = last === 'create' ? 'Create' : last === 'edit' ? 'Edit' : this.navLabel(last); + const action = last === 'create' ? (window.T ? T('application-core:shell.nav.create', 'Create') : 'Create') + : last === 'edit' ? (window.T ? T('application-core:shell.nav.edit', 'Edit') : 'Edit') + : this.navLabel(last); crumbs.push({ label: action, route: null }); } return crumbs; diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/settingsPage.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/settingsPage.js index 2132276cfca..e4982555427 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/settingsPage.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/pages/settingsPage.js @@ -24,7 +24,7 @@ document.addEventListener('alpine:init', () => { content: '', // the selected entity's manage-list fragment HTML (rendered via x-html) loading: false, error: null, - // Region & Language: the app's single language flag, mirrored from the locale store so the + // Region & Language: the platform's single language flag, mirrored from the locale store so the // picker's x-model has a plain component property; changes persist through the store (and take // effect on the next data load - the fetch client sends the value as Accept-Language). language: 'en', @@ -37,7 +37,7 @@ document.addEventListener('alpine:init', () => { } }, - // The offered data-language codes and their display names (delegates to the locale store). + // The platform's supported language codes and their display names (delegates to the locale store). languageOptions() { const locale = Alpine.store('locale'); if (!locale) return []; 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 new file mode 100644 index 00000000000..ba247419368 --- /dev/null +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/services/i18n.js @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors + * SPDX-License-Identifier: EPL-2.0 + */ +/* + * i18n — UI-label translations for the Harmonia stack, REUSING the platform's existing i18next + * machinery end-to-end: the i18next webjar (self-loaded below), the `locales.js` extension service + * that aggregates every project's `translations//*.json` catalogs from the registry, the + * `platform-locales` + `application-locales` extension points that register the available locales, + * and the very same catalogs/keys the `translate` generation action emits for the AngularJS stack. + * + * Generated views bind labels as `T(':.t.', 'English fallback')`. T() is + * reactive through the Alpine `i18n` store: pages render the English fallback instantly and swap + * to the translated labels the moment the catalogs finish loading. The current language is the + * platform-wide locale flag (localStorage `codbex.harmonia.language`, a 2-letter code) resolved + * against the registered locale ids (`en` -> `en-US`); anything a module has not translated falls + * back through i18next's `fallbackLng` to English, so partial catalogs degrade gracefully. + */ +(() => { + const I18NEXT_WEBJAR = '/webjars/i18next/dist/umd/i18next.min.js'; + const LOCALES_SERVICE = '/services/js/platform-core/extension-services/locales.js'; + const STORAGE_KEY = 'codbex.harmonia.language'; + const DEFAULT_LOCALE = 'en-US'; + + let catalogsReady = false; + + const markReady = () => { + catalogsReady = true; + if (window.Alpine && Alpine.store('i18n')) Alpine.store('i18n').ready = true; + }; + + document.addEventListener('alpine:init', () => { + Alpine.store('i18n', { + ready: catalogsReady, + // 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) { + if (!key) return fallback !== undefined ? fallback : ''; + if (this.ready && window.i18next + && i18next.exists(key, Object.assign({ fallbackLng: false }, options))) { + return i18next.t(key, options); + } + return fallback !== undefined ? fallback : key; + }, + }); + }, { once: true }); + + // The global the generated templates bind against (safe in Velocity-rendered files - no '$'). + window.T = (key, fallback, options) => { + const store = window.Alpine && Alpine.store('i18n'); + if (store) return store.t(key, fallback, options); + return fallback !== undefined ? fallback : key; + }; + + const loadScript = (src) => new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.onload = resolve; + script.onerror = () => reject(new Error('Failed to load ' + src)); + document.head.appendChild(script); + }); + + // 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. + const project = window.App && App.config && App.config.projectName; + const namespaces = project ? ['application-core', project] : ['application-core']; + + 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(); + 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); + const language = resolved ? resolved.id : DEFAULT_LOCALE; + await i18next.init({ + lng: language, + fallbackLng: DEFAULT_LOCALE, + defaultNS: 'common', + interpolation: { skipOnVariables: false }, + resources: data.translations || {}, + }); + markReady(); + } catch (e) { + // Untranslated fallbacks keep rendering - the page stays fully usable in English. + console.error('i18n: catalogs unavailable, using built-in labels', e); + } + }; + + init(); +})(); diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/locale.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/locale.js index 0f4808d9a86..bae25b14d40 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/locale.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/stores/locale.js @@ -10,30 +10,43 @@ * SPDX-License-Identifier: EPL-2.0 */ /* - * locale store — the app's single language flag (the Region & Language setting). A global store - * (like the theme) persisted per user in localStorage. The shared fetch client (services/api.js) - * sends the value as Accept-Language on every call, so the same flag drives BOTH the frontend and - * the backend: generated multilingual repositories overlay
_LANG values for it, and the - * document Print flow prefers it. The available codes come from the generated App.config.languages - * (the intent's `languages:`); a value outside that list falls back to the first entry. + * locale store — the PLATFORM's single language flag (the Region & Language setting). A global + * store (like the theme) persisted per user in localStorage. The shared fetch client + * (services/api.js) sends the value as Accept-Language on every call, so the same flag drives + * BOTH the frontend and the backend: generated multilingual repositories overlay
_LANG + * values for it, and the document Print flow prefers it. The offered codes are the platform's + * supported language set (DIRIGIBLE_APPLICATION_LANGUAGES, default en,bg) — individual modules + * never define what the stack supports; they only provide translations, and anything a module + * does not translate falls back to the base (first/default) language naturally. */ document.addEventListener('alpine:init', () => { const STORAGE_KEY = 'codbex.harmonia.language'; + const LANGUAGES_URL = '/services/js/platform-core/services/application-languages.js'; + Alpine.store('locale', { value: 'en', + // The platform's supported set; the served value replaces this default asynchronously. + supported: ['en', 'bg'], init() { - const configured = this.languages(); let saved = null; try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) { /* storage unavailable */ } - this.value = (saved && configured.includes(saved)) ? saved : (configured[0] || 'en'); + if (saved) this.value = saved; + // Fire-and-forget: refresh the platform set (DIRIGIBLE_APPLICATION_LANGUAGES); on failure the + // built-in default stands. A persisted value outside the set falls back to the first entry. + fetch(LANGUAGES_URL, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }) + .then((r) => r.ok ? r.json() : null) + .then((codes) => { + if (Array.isArray(codes) && codes.length) this.supported = codes; + if (!this.supported.includes(this.value)) this.value = this.supported[0]; + }) + .catch(() => { /* platform default stands */ }); }, - // The app's offered data-language codes (from config.js; defaults to just 'en'). + // The platform's supported data-language codes (never per-module). languages() { - const configured = (window.App && App.config && App.config.languages) || []; - return Array.isArray(configured) && configured.length ? configured : ['en']; + return this.supported; }, // Human-readable name for a code ('bg' -> 'Bulgarian'), falling back to the code itself. @@ -48,6 +61,9 @@ document.addEventListener('alpine:init', () => { if (!lang || lang === this.value) return; this.value = lang; try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* storage unavailable */ } + // Reload so everything follows at once: the i18n label catalogs re-init in the new language + // and every list/form re-fetches its data with the new Accept-Language. + window.location.reload(); }, }); }, { once: true }); diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_documents.html b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_documents.html index f9e8e809a05..80fae2452c5 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_documents.html +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_documents.html @@ -39,8 +39,8 @@
    -
  • Upload files
  • -
  • Upload zip contents
  • +
  • +
@@ -49,7 +49,7 @@
- +
@@ -63,7 +63,7 @@
-
Drop files here
+
@@ -76,7 +76,7 @@ - + @@ -97,11 +97,11 @@ @@ -141,7 +141,7 @@
-
File Preview
+
@@ -152,17 +152,17 @@
-

New folder

+

- - + +
- - + +
@@ -170,17 +170,17 @@
-

Rename

+

- +
- - + +
@@ -188,14 +188,14 @@
-

Delete

+

Delete ? This cannot be undone.

Delete items? This cannot be undone.

- - + +
diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_inbox.html b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_inbox.html index de4d7ada8ca..62b98641337 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_inbox.html +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_inbox.html @@ -16,7 +16,7 @@ dashboard Process Inbox redesigned in #6064/#6068). -->
- Inbox +
@@ -24,7 +24,7 @@ @click="toggleAuto()" :aria-label="autoRefresh ? 'Pause auto-refresh' : 'Resume auto-refresh'"> - +
@@ -34,7 +34,7 @@
- +
@@ -42,8 +42,8 @@
-
No tasks
-
You have no pending tasks.
+
+
@@ -75,8 +75,8 @@
-
No task selected
-
Select a task from the list to claim it or open its form.
+
+
@@ -93,18 +93,18 @@
-
Claim this task
-
This task is available to your group. Claim it to open and complete its form.
+
+
- +
-
This task has no form.
+
diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_notfound.html b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_notfound.html index b693664c655..4966b1c7df7 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_notfound.html +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_notfound.html @@ -18,8 +18,8 @@
-
Page not found
-
The page you are looking for does not exist.
+
+
diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_reports.html b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_reports.html index e821dd2b29f..eb432857845 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_reports.html +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_reports.html @@ -20,8 +20,8 @@
-
Select a report
-
Choose a report from the sidebar to view it here.
+
+
diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/bg-BG/shell.json b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/bg-BG/shell.json new file mode 100644 index 00000000000..b5422aa7aab --- /dev/null +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/bg-BG/shell.json @@ -0,0 +1,83 @@ +{ + "shell": { + "nav": { + "dashboard": "Табло", + "inbox": "Входящи", + "documents": "Документи", + "reports": "Справки", + "settings": "Настройки", + "application": "Приложение", + "applications": "Приложения", + "entities": "Обекти", + "other": "Други", + "create": "Създаване", + "edit": "Редактиране" + }, + "user": { + "user": "Потребител", + "logout": "Изход" + }, + "notifications": { + "title": "Известия", + "caughtUp": "Нямате нови известия." + }, + "dashboard": { + "title": "Табло", + "overview": "Преглед на публикуваните приложения." + }, + "inbox": { + "refresh": "Обнови", + "filterTasks": "Филтриране на задачи...", + "noTasks": "Няма задачи", + "noTasksHint": "Нямате чакащи задачи.", + "claimOpen": "Поеми и отвори", + "claimTask": "Поеми тази задача", + "noTaskSelected": "Няма избрана задача", + "selectTaskHint": "Изберете задача от списъка, за да я поемете или да отворите формуляра ѝ.", + "noForm": "Тази задача няма формуляр.", + "taskForm": "Формуляр на задачата", + "claimHint": "Тази задача е достъпна за вашата група. Поемете я, за да отворите и попълните формуляра ѝ." + }, + "documents": { + "newFolder": "Нова папка", + "uploadFiles": "Качване на файлове", + "uploadZip": "Качване на zip съдържание", + "download": "Изтегли", + "rename": "Преименувай", + "delete": "Изтрий", + "cancel": "Отказ", + "create": "Създай", + "copyLink": "Копирай връзка", + "dropFiles": "Пуснете файловете тук", + "filePreview": "Преглед на файл", + "folderName": "Име на папка", + "newName": "Ново име", + "name": "Име", + "enterFolderName": "Въведете име на папка...", + "findInFolder": "Търсене в папката" + }, + "reports": { + "select": "Изберете справка", + "selectHint": "Изберете справка от страничната лента, за да я видите тук." + }, + "notfound": { + "title": "Страницата не е намерена", + "hint": "Страницата, която търсите, не съществува." + }, + "settings": { + "selectSetting": "Изберете настройка", + "selectSettingHint": "Изберете конфигурационен обект отляво, за да го управлявате тук.", + "configFor": "Конфигурация и номенклатури за {{name}}.", + "configAll": "Конфигурация и номенклатури за всички приложения.", + "regionLanguage": "Регион и език", + "language": "Език", + "missingTranslations": "Липсващи преводи", + "missingWarning": "{{app}} не предоставя: {{missing}} — използва се езикът по подразбиране.", + "rlDescription": "Една настройка за цялата платформа: потребителският интерфейс, данните и печатът на документи следват избрания език. Съдържание, което даден модул още не е превел, се показва на езика по подразбиране." + }, + "apps": { + "loading": "Зареждане на приложенията...", + "empty": "Все още няма публикувани приложения." + } + } +} \ No newline at end of file diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/en-US/shell.json b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/en-US/shell.json new file mode 100644 index 00000000000..19e5234a49c --- /dev/null +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/translations/en-US/shell.json @@ -0,0 +1,83 @@ +{ + "shell": { + "nav": { + "dashboard": "Dashboard", + "inbox": "Inbox", + "documents": "Documents", + "reports": "Reports", + "settings": "Settings", + "application": "Application", + "applications": "Applications", + "entities": "Entities", + "other": "Other", + "create": "Create", + "edit": "Edit" + }, + "user": { + "user": "User", + "logout": "Log out" + }, + "notifications": { + "title": "Notifications", + "caughtUp": "You're all caught up." + }, + "dashboard": { + "title": "Dashboard", + "overview": "Overview of your published applications." + }, + "inbox": { + "refresh": "Refresh", + "filterTasks": "Filter tasks...", + "noTasks": "No tasks", + "noTasksHint": "You have no pending tasks.", + "claimOpen": "Claim & open", + "claimTask": "Claim this task", + "noTaskSelected": "No task selected", + "selectTaskHint": "Select a task from the list to claim it or open its form.", + "noForm": "This task has no form.", + "taskForm": "Task form", + "claimHint": "This task is available to your group. Claim it to open and complete its form." + }, + "documents": { + "newFolder": "New folder", + "uploadFiles": "Upload files", + "uploadZip": "Upload zip contents", + "download": "Download", + "rename": "Rename", + "delete": "Delete", + "cancel": "Cancel", + "create": "Create", + "copyLink": "Copy link", + "dropFiles": "Drop files here", + "filePreview": "File Preview", + "folderName": "Folder name", + "newName": "New name", + "name": "Name", + "enterFolderName": "Enter folder name...", + "findInFolder": "Find in folder" + }, + "reports": { + "select": "Select a report", + "selectHint": "Choose a report from the sidebar to view it here." + }, + "notfound": { + "title": "Page not found", + "hint": "The page you are looking for does not exist." + }, + "settings": { + "selectSetting": "Select a setting", + "selectSettingHint": "Choose a configuration entity on the left to manage it here.", + "configFor": "Configuration & nomenclature for {{name}}.", + "configAll": "Configuration & nomenclature across all applications.", + "regionLanguage": "Region & Language", + "language": "Language", + "missingTranslations": "Missing translations", + "missingWarning": "{{app}} does not provide: {{missing}} - falls back to the default language.", + "rlDescription": "One preference for the whole stack: the user interface, the data and document printing all follow the selected language. Content a module has not translated yet falls back to the default language." + }, + "apps": { + "loading": "Loading applications...", + "empty": "No applications published yet." + } + } +} \ No newline at end of file diff --git a/components/resources/platform-core/src/main/resources/META-INF/dirigible/platform-core/services/application-languages.js b/components/resources/platform-core/src/main/resources/META-INF/dirigible/platform-core/services/application-languages.js new file mode 100644 index 00000000000..c9713bcbbd8 --- /dev/null +++ b/components/resources/platform-core/src/main/resources/META-INF/dirigible/platform-core/services/application-languages.js @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors + * SPDX-License-Identifier: EPL-2.0 + */ +/* + * The PLATFORM's supported language set (DIRIGIBLE_APPLICATION_LANGUAGES, default "en,bg"). + * The Region & Language picker in every Harmonia shell offers exactly this set; individual + * modules never define what languages the stack supports - they only provide translations, + * falling back to the first (default) language for anything missing. + */ +import { configurations } from "@aerokit/sdk/core"; +import { response } from "@aerokit/sdk/http"; + +const configured = configurations.get("DIRIGIBLE_APPLICATION_LANGUAGES", "en,bg"); +const languages = configured.split(",") + .map(code => code.trim()) + .filter(code => code.length > 0); + +response.setContentType("application/json"); +response.println(JSON.stringify(languages.length > 0 ? languages : ["en"])); +response.flush(); +response.close(); 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 4375c50971a..bad11f6396d 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 @@ -57,29 +57,29 @@
- Applications +
-
Application
+
@@ -87,13 +87,13 @@
-
Loading applications...
-
No applications published yet.
+
+
Name
    -
  • Copy link
  • -
  • Rename
  • -
  • Download
  • +
  • +
  • +
  • -
  • Delete
  • +