diff --git a/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/security/User.java b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/security/User.java index f2a51358356..750dbde6c46 100644 --- a/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/security/User.java +++ b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/security/User.java @@ -70,6 +70,26 @@ public static String getLanguage() { return UserFacade.getLanguage(); } + /** + * Binds a language to the current thread, preferred over the request's Accept-Language by every + * read that resolves the language through {@link #getLanguage()} - the multilingual translation + * overlay in particular. Intended for in-process renders that run outside an HTTP request (a + * document snapshot, a mailed PDF attachment): the caller MUST clear it in a finally via + * {@link #clearLanguage()}. + * + * @param language the language to bind (a null or blank value leaves the override unset) + */ + public static void setLanguage(String language) { + UserFacade.setLanguage(language); + } + + /** + * Clears the thread-bound language override set by {@link #setLanguage(String)}. + */ + public static void clearLanguage() { + UserFacade.clearLanguage(); + } + public static Integer getTimeout() { return UserFacade.getTimeout(); } diff --git a/components/api/api-security/src/main/java/org/eclipse/dirigible/components/api/security/UserFacade.java b/components/api/api-security/src/main/java/org/eclipse/dirigible/components/api/security/UserFacade.java index b07ea8fd46e..d0ea5866a04 100644 --- a/components/api/api-security/src/main/java/org/eclipse/dirigible/components/api/security/UserFacade.java +++ b/components/api/api-security/src/main/java/org/eclipse/dirigible/components/api/security/UserFacade.java @@ -62,6 +62,15 @@ public class UserFacade { /** The Constant ANY_LANGUAGE. */ private static final String ANY_LANGUAGE = "*"; + /** + * A thread-bound language override, preferred over the request's Accept-Language header. It exists + * for in-process renders that run outside any HTTP request (a BPM service task minting a document + * snapshot, a mail listener attaching a rendered PDF): they know the language the artefact must be + * produced in and set it here so the multilingual overlay resolves data in that same language. + * Always cleared in a finally by the caller, so pooled worker threads never leak it. + */ + private static final ThreadLocal LANGUAGE_OVERRIDE = new ThreadLocal<>(); + /** The Constant logger. */ private static final Logger logger = LoggerFactory.getLogger(UserFacade.class); @@ -381,6 +390,10 @@ public static String getInvocationCount() { * @return the language */ public static String getLanguage() { + String override = LANGUAGE_OVERRIDE.get(); + if (override != null && !override.isBlank()) { + return override; + } if (HttpRequestFacade.isValid()) { String language = HttpRequestFacade.getHeader(LANGUAGE_HEADER); if (language == null || language.isEmpty()) { @@ -398,6 +411,28 @@ public static String getLanguage() { return null; } + /** + * Binds a language to the current thread, preferred by {@link #getLanguage()} over the request's + * Accept-Language header. Intended for in-process renders that run outside an HTTP request; the + * caller MUST clear it in a finally via {@link #clearLanguage()}. + * + * @param language the language to bind (a null or blank value leaves the override unset) + */ + public static void setLanguage(String language) { + if (language == null || language.isBlank()) { + LANGUAGE_OVERRIDE.remove(); + } else { + LANGUAGE_OVERRIDE.set(language); + } + } + + /** + * Clears the thread-bound language override set by {@link #setLanguage(String)}. + */ + public static void clearLanguage() { + LANGUAGE_OVERRIDE.remove(); + } + public static Collection getUserRoles() { Authentication authentication = SecurityContextHolder.getContext() .getAuthentication(); diff --git a/components/api/api-security/src/test/java/org/eclipse/dirigible/components/api/security/UserFacadeLanguageOverrideTest.java b/components/api/api-security/src/test/java/org/eclipse/dirigible/components/api/security/UserFacadeLanguageOverrideTest.java new file mode 100644 index 00000000000..15169d52b85 --- /dev/null +++ b/components/api/api-security/src/test/java/org/eclipse/dirigible/components/api/security/UserFacadeLanguageOverrideTest.java @@ -0,0 +1,59 @@ +/* + * 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 + */ +package org.eclipse.dirigible.components.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * The thread-bound language override that lets an in-process render (a document snapshot, a mailed + * PDF attachment) resolve the multilingual overlay in the language the artefact is produced in, + * where there is no request to carry Accept-Language (#6947). + */ +class UserFacadeLanguageOverrideTest { + + @AfterEach + void clearTheOverride() { + UserFacade.clearLanguage(); + } + + @Test + void the_override_is_returned_over_the_absent_request_language() { + UserFacade.setLanguage("bg"); + + assertThat(UserFacade.getLanguage()).isEqualTo("bg"); + } + + @Test + void clearing_the_override_falls_back_to_the_request_language() { + UserFacade.setLanguage("bg"); + UserFacade.clearLanguage(); + + // No valid HTTP request in a plain unit test, so with the override cleared the language is + // unresolved. + assertThat(UserFacade.getLanguage()).isNull(); + } + + @Test + void a_null_language_leaves_the_override_unset() { + UserFacade.setLanguage(null); + + assertThat(UserFacade.getLanguage()).isNull(); + } + + @Test + void a_blank_language_leaves_the_override_unset() { + UserFacade.setLanguage(" "); + + assertThat(UserFacade.getLanguage()).isNull(); + } +} diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index dea26cf1f11..f51c5e10f8a 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -625,7 +625,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / - **Dashboard widgets: report-attached KPIs (`reports[].widget`) + custom widgets (top-level `widgets:`) + Report Editor authoring.** Meaningful KPI tiles ("Overdue Invoices", "Revenue this month") declared on reports instead of the raw per-entity count tiles — see the `reports[].widget` and `widgets:` semantics bullets above for the full contracts. New `WidgetIntent` (kind count/value/list, value/at/label/icon/limit) + `validateReportWidget`; `ReportIntentGenerator` resolves value→`valueColumn`/`valueType`/`pattern` and `at` pins→column aliases (+`bucket`/`token`) into a `widget` block on the `.report`; `CustomWidgetIntent` + `validateWidgets` + `EdmIntentGenerator.buildCustomWidgets` for the REST-kpi/page escape hatch on the `.model` root; `EdmIntentGenerator` sets `.model` `dashboardKpis`; the Harmonia shell template suppresses entity-tile baking on that flag and renders the tiles (count/value number tiles, list mini-tables, custom kpi/page tiles) from the shared reports store, which gained `loadWidgetValue` (apiBase derivation from the page path, client-side `now` resolution, 403→hidden) — plus report/widget label i18n end-to-end (report-template translate action + `-report` catalogs + `displayLabel`/`widgetLabel` store methods used by the sidebar/dashboard/breadcrumb). The Web IDE `editor-report` gained the "Dashboard Widget" panel + General Description/dashboard fields. Covered by `IntentParserTest`/`ReportIntentGeneratorTest`/`EdmIntentGeneratorTest` + `IntentEngineIT` (`report_widget_generates_the_kpi_block_and_replaces_entity_tiles`); verified live on the `kpidemo` project (count=2 overdue, month-pinned sum, top-3 list over the generated report controllers). -- **Print always renders LIVE; the stored Snapshot is first-class on its own surface (supersedes the #6359 stored-copy redirect).** The #6359 guardrail made the Print button serve the highest-Version stored Snapshot instead of rendering - which in production read as "the bg template is ignored" (an ISSUED invoice printed the English copy with no language question) and hid the whole language flow behind the mint. The redirect was REMOVED from both Harmonia print flows (`document-page` and `manage/form-page`): Print now always runs the dynamic flow - fetch the CMS languages, one prints directly, several pop the picker. The immutable issued copy stays one click away on the SAME page: the read-only Snapshot files panel (the `files.readOnly` def that distinguishes a generated Snapshot from a user Attachment) serves every stored version with a per-version inline **Open** (the shared `detailPanel.openHref` - the download route with `?disposition=inline`, which the generated controller honors) next to Download. A live re-render after master-data changes is acceptable BECAUSE the copy that IS the record remains first-class. The **mint language** is a knob on the `function: Snapshot` child - `language: ` literal or `languageFrom: .` (cross-model targets resolve like every other cross-model reference; a bad path fails the glue pass loudly) - falling back to the first entry of the tenant-resolved application language set via `sdk.print.Print.defaultLanguage()` at mint time; the notify block's `attach: print` gained the same `languageFrom` next to its existing `language:`, with the same fallback (NotifySupport pre-renders a Java expression + an `attachLanguageSource` load, the expansions convention). Nothing hardcodes `"en"` anymore. +- **Print always renders LIVE; the stored Snapshot is first-class on its own surface (supersedes the #6359 stored-copy redirect).** The #6359 guardrail made the Print button serve the highest-Version stored Snapshot instead of rendering - which in production read as "the bg template is ignored" (an ISSUED invoice printed the English copy with no language question) and hid the whole language flow behind the mint. The redirect was REMOVED from both Harmonia print flows (`document-page` and `manage/form-page`): Print now always runs the dynamic flow - fetch the CMS languages, one prints directly, several pop the picker. The immutable issued copy stays one click away on the SAME page: the read-only Snapshot files panel (the `files.readOnly` def that distinguishes a generated Snapshot from a user Attachment) serves every stored version with a per-version inline **Open** (the shared `detailPanel.openHref` - the download route with `?disposition=inline`, which the generated controller honors) next to Download. A live re-render after master-data changes is acceptable BECAUSE the copy that IS the record remains first-class. The **mint language** is a knob on the `function: Snapshot` child - `language: ` literal or `languageFrom: .` (cross-model targets resolve like every other cross-model reference; a bad path fails the glue pass loudly) - falling back to the first entry of the tenant-resolved application language set via `sdk.print.Print.defaultLanguage()` at mint time; the notify block's `attach: print` gained the same `languageFrom` next to its existing `language:`, with the same fallback (NotifySupport pre-renders a Java expression + an `attachLanguageSource` load, the expansions convention). Nothing hardcodes `"en"` anymore. **The mint language names the TEMPLATE and the DATA both (#6947).** A server-side render (the snapshot delegate, a `notify` `attach: print`/`recordPrint`/`report`) picks its `.print` template by that language, but the `{document, items}` payload is built by calling the generated `PrintFeeder` (or, for `attach: report`, the report repository) IN-PROCESS - and the multilingual overlay a generated repository applies reads `sdk.security.User.getLanguage()`, i.e. the request's `Accept-Language`, of which a BPM service task has none. So the values fell back to the default language while the template rendered in the mint language (a bg invoice reading "Bank transfer"/"ISSUED"). The four templates (`Snapshot`, `Send`, `Notification`, each attach branch) now wrap the feed/render in `User.setLanguage(language)` ... `finally { User.clearLanguage(); }` - a thread-bound override in `UserFacade` that `getLanguage()` prefers over the (absent) header, cleared in the finally so a pooled worker thread never leaks it. The feeder's own `@Get("/{id}")` HTTP path is untouched (the browser still carries `Accept-Language`, which #6946 pins to the chosen print language). Covered by `UserFacadeLanguageOverrideTest` (the override) and `IntentEmissionCoverageIT` (each attach branch emits the set/clear around its render). - **One declarative `fileName:` names both server-side renders (#6899).** The two PDF renders named their files with hardcoded, mutually inconsistent expressions: the snapshot mint used `" v.pdf"` - the numeric **primary key**, even though the mint runs after the `number:` stamp - while `attach: print` already used the document number. One document reached the archive and the customer's inbox under two different names, and neither was configurable. Now a **`fileName:`** pattern is authorable on the `function: Snapshot` child and inside a `notify` block (with `attach:` only - a plain-text message has no file to name): literals plus `{token}` interpolations, no expression language. `FileNameSupport` translates it once into a Java expression + the one-hop relation loads it reads; `{field}` / `{relation.field}` use the **same path vocabulary and authored names** a notify subject does, `{field:pattern}` formats a `date`/`timestamp` through a `DateTimeFormatter` pattern, `{A|B}` renders the first non-blank operand, and `{Version}` (snapshot only) places the copy's version - a pattern without it gets `_v` appended, because two versions must never share a name. Interpolated **values** are sanitized at run time by the SDK's `sdk.print.FileNames` (trim, whitespace → one `_`, path/control characters stripped, **non-ASCII deliberately kept** - a local-language document legitimately carries a non-Latin name, and keeping names Latin is an application data convention, not the platform's guess); the literal separators are the author's and are emitted verbatim. Three points worth remembering: (a) the file name's relation loads are **merged into the notify block's own loads**, deduped by local, because both name the local after the relation and declaring it twice would not compile - so a pattern may reference a relation the message text never mentions; (b) `attach: recordPrint` renders the anchor **once, before the per-row loop**, where those locals do not exist, so a relation hop is refused there (only fields of the anchor, exactly like the `record.` scope's one-field rule); (c) the Snapshot delegate now **always loads its master** (the name is a property of the document, not of the copy) and returns early when it is gone. **Absent a pattern the snapshot default CHANGES** from the primary-key form to the same number-or-id expression the mail uses, plus the version - a deliberate, called-out behavior change that finally makes the two agree; already-stored copies are untouched. An unknown field/relation, an unbalanced brace, a format on a non-date field, an invalid `DateTimeFormatter` pattern and a pattern that interpolates nothing are all **validation errors** - a token that silently rendered empty would produce archive names nobody can tell apart, which is the failure this replaces. Covered by `FileNamesTest` (the run-time sanitizer), `FileNameIntentTest` (every rejection), `GlueFileNameTest` (the emitted expressions, the merged loads, and both defaults agreeing), `IntentEngineIT` (`OrderCopy`'s date-modifier + relation-hop pattern in the generated mint) and `IntentEmissionCoverageIT` (`SendBill`'s `{A|B}` pattern - which the client-Java compile of that project also proves compiles). - **A notify block can mail a parameterized REPORT (`attach: { report, bind }`, #6931).** `attach: print` was document-only by design, so the standard AR **customer statement** - a period's rows per customer, mailed monthly - had no way out of the system while its little brother the per-invoice dunning reminder already did. The map form of `attach` names a declared report and binds its `parameters:` (#6911) from the recipient row, which is exactly what scopes the report to that recipient. Four things decided here, each of them a way the mail is quietly WRONG rather than broken: (a) **`attach` is now polymorphic**, so `NotificationIntent.attach` is typed `Object` (a `String` field would make plain Gson throw on the map) and `getAttach()` reports only the KIND - every existing reader compares kinds, so nothing else moved; the map's own vocabulary is closed through `UnknownKeyValidator.MAP_KEYS`, `bind:` staying opaque because its keys are the named report's parameters. (b) **A parameter declaring an `initial` must be bound.** A parameter is bound on every call and an unbound one rides its `initial` - one fixed slice for every recipient, the "whole ledger to one customer" shape, and nothing in a rendered report says whose it is; a parameter WITHOUT an `initial` is precisely one whose comparison has a neutral any-value default (#6911's rule read backwards), so omitting it legitimately means the whole range. That is why the test for requiredness is `initial`, not the op: no re-resolution of the target, no double-reported issue. (c) **An unresolvable report attachment drops the mail rather than degrading to plain text** - the bindings ARE the scoping, so a mail sent without them would carry the wrong rows, which is worse than not sending. (d) **The render reuses the whole print path** (`sdk.print.Print.render(, ...)` resolves a CMS template BY NAME, so a report name works exactly as an entity name does) through a scaffold `ReportPrintTemplate` writes to `doc/Templates//Print/en/standard.print` - written from the columns `ReportIntentGenerator` **just resolved**, not re-derived, because a column alias is what the query actually SELECTs and a second derivation would drift into placeholders that render empty; written once and developer-owned, and only for reports something actually mails. The bound values double as the rendered header, since a table of rows never states which slice it is. One run-time detail worth keeping: the generated code passes each bound value through a `reportValue(Object)` helper that stringifies it, because the report repository JSON-encodes its parameters and the named-parameter binder accepts only a JSON primitive - a raw `LocalDate` would arrive as an object and be rejected; null stays null, which is what makes a nullable source column fall back to the `initial` instead of failing the send. Covered by `NotifyAttachReportTest` (the glue at two call sites, the merged relation load, every parse rule), `ReportPrintTemplateTest` (the scaffold binds the report's own aliases) and the `orders.glue` render fixture in `ModelGenerationIT` (the branch's Java shape). diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template index d95b98e3096..e8bd0847625 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template @@ -90,12 +90,23 @@ public class ${className}Notification implements MessageHandler { ${attachLanguageTargetEntity}Entity attachLanguageSource = entity.${attachLanguageFkProperty} == null ? null : new ${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); #end + String language = ${attachLanguageExpression}; Map document = new HashMap(); document.put("type", "attachment"); document.put("contentType", "application/pdf"); document.put("fileName", ${attachFileNameExpression}); - document.put("data", org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", ${attachLanguageExpression}, - new ${attachEntity}PrintFeeder().feed(entity.${attachKeyProperty}))); + // Bind the render language to the thread so the feeder's multilingual overlay resolves nomenclature + // values in the SAME language as the template - there is no request here to carry Accept-Language + // (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + data = org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", language, + new ${attachEntity}PrintFeeder().feed(entity.${attachKeyProperty})); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); parts.add(document); #elseif($attach == "report") // attach: { report, bind } - run the declared report scoped to THIS recipient and attach the @@ -105,22 +116,33 @@ public class ${className}Notification implements MessageHandler { ${attachLanguageTargetEntity}Entity attachLanguageSource = entity.${attachLanguageFkProperty} == null ? null : new ${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); #end + String language = ${attachLanguageExpression}; Map reportFilter = new HashMap<>(); #foreach($binding in $attachReportBindings) reportFilter.put("${binding.parameter}", reportValue(${binding.expression})); #end - Map reportData = new HashMap<>(); - // The bound values double as the rendered header, so the PDF states which slice it is - a - // table of rows never does. - reportData.put("document", reportFilter); - reportData.put("items", new gen.${attachReportGenFolder}.data.${attachReportPerspective}.${attachReport}Repository() - .findAll(null, null, reportFilter)); Map document = new HashMap(); document.put("type", "attachment"); document.put("contentType", "application/pdf"); document.put("fileName", ${attachFileNameExpression}); - document.put("data", org.eclipse.dirigible.sdk.print.Print.render("${attachReport}", ${attachLanguageExpression}, - org.eclipse.dirigible.sdk.utils.Json.stringify(reportData))); + // Bind the render language to the thread so the report query's :language overlay resolves the + // translatable dimensions in the SAME language as the template - there is no request here to carry + // Accept-Language (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + Map reportData = new HashMap<>(); + // The bound values double as the rendered header, so the PDF states which slice it is - a + // table of rows never does. + reportData.put("document", reportFilter); + reportData.put("items", new gen.${attachReportGenFolder}.data.${attachReportPerspective}.${attachReport}Repository() + .findAll(null, null, reportFilter)); + data = org.eclipse.dirigible.sdk.print.Print.render("${attachReport}", language, + org.eclipse.dirigible.sdk.utils.Json.stringify(reportData)); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); parts.add(document); #end String from = Configurations.get("DIRIGIBLE_MAIL_SENDER", "noreply@dirigible.io"); diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Send.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Send.java.template index d7cd8a47e76..e714a74ea4a 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Send.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Send.java.template @@ -135,12 +135,23 @@ public class ${className} implements JavaDelegate { ${attachLanguageTargetEntity}Entity attachLanguageSource = source.${attachLanguageFkProperty} == null ? null : new ${attachLanguageTargetEntity}Repository().findById(source.${attachLanguageFkProperty}); #end + String language = ${attachLanguageExpression}; Map document = new HashMap(); document.put("type", "attachment"); document.put("contentType", "application/pdf"); document.put("fileName", ${attachFileNameExpression}); - document.put("data", org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", ${attachLanguageExpression}, - new ${attachEntity}PrintFeeder().feed(source.${attachKeyProperty}))); + // Bind the render language to the thread so the feeder's multilingual overlay resolves nomenclature + // values in the SAME language as the template - there is no request here to carry Accept-Language + // (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + data = org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", language, + new ${attachEntity}PrintFeeder().feed(source.${attachKeyProperty})); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); return document; } #end @@ -191,12 +202,23 @@ public class ${className} implements JavaDelegate { ${attachLanguageTargetEntity}Entity attachLanguageSource = entity.${attachLanguageFkProperty} == null ? null : new ${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); #end + String language = ${attachLanguageExpression}; Map document = new HashMap(); document.put("type", "attachment"); document.put("contentType", "application/pdf"); document.put("fileName", ${attachFileNameExpression}); - document.put("data", org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", ${attachLanguageExpression}, - new ${attachEntity}PrintFeeder().feed(entity.${attachKeyProperty}))); + // Bind the render language to the thread so the feeder's multilingual overlay resolves nomenclature + // values in the SAME language as the template - there is no request here to carry Accept-Language + // (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + data = org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", language, + new ${attachEntity}PrintFeeder().feed(entity.${attachKeyProperty})); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); parts.add(document); #elseif($attach == "recordPrint") // The record's document, rendered once for every recipient of this fan-out. @@ -209,22 +231,33 @@ public class ${className} implements JavaDelegate { ${attachLanguageTargetEntity}Entity attachLanguageSource = entity.${attachLanguageFkProperty} == null ? null : new ${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); #end + String language = ${attachLanguageExpression}; Map reportFilter = new HashMap<>(); #foreach($binding in $attachReportBindings) reportFilter.put("${binding.parameter}", reportValue(${binding.expression})); #end - Map reportData = new HashMap<>(); - // The bound values double as the rendered header, so the PDF states which slice it is - a - // table of rows never does. - reportData.put("document", reportFilter); - reportData.put("items", new gen.${attachReportGenFolder}.data.${attachReportPerspective}.${attachReport}Repository() - .findAll(null, null, reportFilter)); Map document = new HashMap(); document.put("type", "attachment"); document.put("contentType", "application/pdf"); document.put("fileName", ${attachFileNameExpression}); - document.put("data", org.eclipse.dirigible.sdk.print.Print.render("${attachReport}", ${attachLanguageExpression}, - org.eclipse.dirigible.sdk.utils.Json.stringify(reportData))); + // Bind the render language to the thread so the report query's :language overlay resolves the + // translatable dimensions in the SAME language as the template - there is no request here to carry + // Accept-Language (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + Map reportData = new HashMap<>(); + // The bound values double as the rendered header, so the PDF states which slice it is - a + // table of rows never does. + reportData.put("document", reportFilter); + reportData.put("items", new gen.${attachReportGenFolder}.data.${attachReportPerspective}.${attachReport}Repository() + .findAll(null, null, reportFilter)); + data = org.eclipse.dirigible.sdk.print.Print.render("${attachReport}", language, + org.eclipse.dirigible.sdk.utils.Json.stringify(reportData)); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); parts.add(document); #end String from = Configurations.get("DIRIGIBLE_MAIL_SENDER", "noreply@dirigible.io"); diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Snapshot.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Snapshot.java.template index cebf244324b..7193677ea26 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Snapshot.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Snapshot.java.template @@ -57,7 +57,17 @@ public class ${master}SnapshotGenerator implements JavaDelegate { String language = ${languageExpression}; // Assemble the { document, items } payload the print template binds, then render server-side. - String payload = new ${master}PrintFeeder().feed(id); + // Bind the render language to the thread so the feeder's multilingual overlay resolves nomenclature + // values (Status, Payment Method, ...) in the SAME language as the template - there is no request + // here, so Accept-Language cannot carry it (dirigible #6947). Cleared in the finally so the pooled + // BPM worker thread never leaks it. + String payload; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + payload = new ${master}PrintFeeder().feed(id); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } byte[] pdf = org.eclipse.dirigible.sdk.print.Print.render("${master}", language, payload); ${snapshotEntity}Repository repository = new ${snapshotEntity}Repository(); diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template index 38af411d0c5..c1916f57f35 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template @@ -137,12 +137,23 @@ public class ${className}Transition { source.${attachLanguageFkProperty} == null ? null : new gen.${attachLanguageJavaGenFolder}.data.${attachLanguageJavaTargetPerspective}.${attachLanguageTargetEntity}Repository().findById(source.${attachLanguageFkProperty}); #end + String language = ${attachLanguageExpression}; java.util.Map document = new java.util.HashMap(); document.put("type", "attachment"); document.put("contentType", "application/pdf"); document.put("fileName", ${attachFileNameExpression}); - document.put("data", org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", ${attachLanguageExpression}, - new ${attachEntity}PrintFeeder().feed(source.${attachKeyProperty}))); + // Bind the render language to the thread so the feeder's multilingual overlay resolves nomenclature + // values in the SAME language as the template - there is no request here to carry Accept-Language + // (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + data = org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", language, + new ${attachEntity}PrintFeeder().feed(source.${attachKeyProperty})); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); return document; } #end @@ -197,12 +208,23 @@ public class ${className}Transition { entity.${attachLanguageFkProperty} == null ? null : new gen.${attachLanguageJavaGenFolder}.data.${attachLanguageJavaTargetPerspective}.${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); #end + String language = ${attachLanguageExpression}; java.util.Map document = new java.util.HashMap(); document.put("type", "attachment"); document.put("contentType", "application/pdf"); document.put("fileName", ${attachFileNameExpression}); - document.put("data", org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", ${attachLanguageExpression}, - new ${attachEntity}PrintFeeder().feed(entity.${attachKeyProperty}))); + // Bind the render language to the thread so the feeder's multilingual overlay resolves nomenclature + // values in the SAME language as the template - there is no request here to carry Accept-Language + // (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + data = org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", language, + new ${attachEntity}PrintFeeder().feed(entity.${attachKeyProperty})); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); parts.add(document); #elseif($attach == "recordPrint") // attach: recordPrint - the transitioned record's own document, rendered once above and @@ -217,22 +239,33 @@ public class ${className}Transition { entity.${attachLanguageFkProperty} == null ? null : new gen.${attachLanguageJavaGenFolder}.data.${attachLanguageJavaTargetPerspective}.${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); #end + String language = ${attachLanguageExpression}; java.util.Map reportFilter = new java.util.HashMap<>(); #foreach($binding in $attachReportBindings) reportFilter.put("${binding.parameter}", reportValue(${binding.expression})); #end - java.util.Map reportData = new java.util.HashMap<>(); - // The bound values double as the rendered header, so the PDF states which slice it is - a - // table of rows never does. - reportData.put("document", reportFilter); - reportData.put("items", new gen.${attachReportGenFolder}.data.${attachReportPerspective}.${attachReport}Repository() - .findAll(null, null, reportFilter)); java.util.Map document = new java.util.HashMap(); document.put("type", "attachment"); document.put("contentType", "application/pdf"); document.put("fileName", ${attachFileNameExpression}); - document.put("data", org.eclipse.dirigible.sdk.print.Print.render("${attachReport}", ${attachLanguageExpression}, - org.eclipse.dirigible.sdk.utils.Json.stringify(reportData))); + // Bind the render language to the thread so the report query's :language overlay resolves the + // translatable dimensions in the SAME language as the template - there is no request here to carry + // Accept-Language (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + java.util.Map reportData = new java.util.HashMap<>(); + // The bound values double as the rendered header, so the PDF states which slice it is - a + // table of rows never does. + reportData.put("document", reportFilter); + reportData.put("items", new gen.${attachReportGenFolder}.data.${attachReportPerspective}.${attachReport}Repository() + .findAll(null, null, reportFilter)); + data = org.eclipse.dirigible.sdk.print.Print.render("${attachReport}", language, + org.eclipse.dirigible.sdk.utils.Json.stringify(reportData)); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); parts.add(document); #end String from = org.eclipse.dirigible.sdk.core.Configurations.get("DIRIGIBLE_MAIL_SENDER", "noreply@dirigible.io"); diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 5d99d4ab86f..e3f03ddefb8 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -2613,6 +2613,11 @@ private void assertEmission() { "attach: print must emit a PDF attachment part"); assertTrue(sendBill.contains("Print.render(\"Bill\",") && sendBill.contains("new BillPrintFeeder().feed(entity.Id)"), "the attachment must be the generated feeder's payload rendered by the server-side print engine"); + // #6947: there is no request here to carry Accept-Language, so the render binds the language to + // the thread around the feeder call - otherwise the feeder's multilingual overlay resolves + // nomenclature values in the default language while the template renders in the chosen one. + assertTrue(sendBill.contains("User.setLanguage(language)") && sendBill.contains("User.clearLanguage()"), + "the render must bind the render language to the thread so the overlay resolves values in it: " + sendBill); // The render language is never hardcoded: languageFrom: Person.locale loads the counterparty // and reads it off the record, falling back to the first entry of the tenant-resolved // application language set when the chain is null or blank. @@ -2687,6 +2692,10 @@ private void assertEmission() { "the report's rows are the print payload's items, got: " + billSend); assertTrue(billSend.contains("Print.render(\"ClaimsByUnit\""), "the render must resolve the REPORT's print template by the report's name, got: " + billSend); + // #6947: the report query's :language overlay reads the thread language, so the render binds it + // around the report findAll + render (no request here to carry Accept-Language). + assertTrue(billSend.contains("User.setLanguage(language)") && billSend.contains("User.clearLanguage()"), + "the report render must bind the render language to the thread so its :language overlay resolves in it: " + billSend); // The bound values double as the rendered header, so the PDF states which slice it is. assertTrue(billSend.contains("reportData.put(\"document\", reportFilter)"), "the bound parameters must be the rendered document context, got: " + billSend); @@ -2713,6 +2722,10 @@ private void assertEmission() { "the rendered document must be the ANCHOR record's, fed with the anchor's key: " + shareBill); assertEquals(1, shareBill.split("Print\\.render\\(", -1).length - 1, "one document for the whole fan-out means exactly one render call"); + // #6947: the once-before-the-loop render binds the language to the thread so the feeder overlay + // resolves nomenclature values in the render language (no request here to carry Accept-Language). + assertTrue(shareBill.contains("User.setLanguage(language)") && shareBill.contains("User.clearLanguage()"), + "the fan-out's single render must bind the render language to the thread for the overlay: " + shareBill); assertTrue(shareBill.contains("private boolean send(BillEntity source, BillRecipientEntity entity, Map document)"), "the per-row send must take the anchor (a placeholder quotes it) and the rendered document"); assertTrue(shareBill.contains("(Person == null ? null : Person.Email)"),