Commit 01c71a69128090cab03ae92d8965f48c707ad32c
1 parent
57297bcb
feat(i18n): P1.6 — ICU4J translator + per-plug-in locale chain
The eighth cross-cutting platform service is live: plug-ins and PBCs
now have a real Translator and LocaleProvider instead of the
UnsupportedOperationException stubs that have shipped since v0.5.
What landed
-----------
* New Gradle subproject `platform/platform-i18n` (13 modules total).
* `IcuTranslator` — backed by ICU4J's MessageFormat (named placeholders,
plurals, gender, locale-aware number/date/currency formatting), the
format every modern translation tool speaks natively. JDK ResourceBundle
handles per-locale fallback (zh_CN → zh → root).
* The translator takes a list of `BundleLocation(classLoader, baseName)`
pairs and tries them in order. For the **core** translator the chain
is just `[(host, "messages")]`; for a **per-plug-in** translator
constructed by VibeErpPluginManager it's
`[(pluginClassLoader, "META-INF/vibe-erp/i18n/messages"), (host, "messages")]`
so plug-in keys override host keys, but plug-ins still inherit
shared keys like `errors.not_found`.
* Critical detail: the plug-in baseName uses a path the host does NOT
publish, because PF4J's `PluginClassLoader` is parent-first — a
`getResource("messages.properties")` against the plug-in classloader
would find the HOST bundle through the parent chain, defeating the
per-plug-in override entirely. Naming the plug-in resource somewhere
the host doesn't claim sidesteps the trap.
* The translator disables `ResourceBundle.Control`'s automatic JVM-default
locale fallback. The default control walks `requested → root → JVM
default → root` which would silently serve German strings to a
Japanese-locale request just because the German bundle exists. The
fallback chain stops at root within a bundle, then moves to the next
bundle location, then returns the key string itself.
* `RequestLocaleProvider` reads the active HTTP request's
Accept-Language via `RequestContextHolder` + the servlet container's
`getLocale()`. Outside an HTTP request (background jobs, workflow
tasks, MCP agents) it falls back to the configured default locale
(`vibeerp.i18n.defaultLocale`, default `en`). Importantly, when an
HTTP request HAS no Accept-Language header it ALSO falls back to the
configured default — never to the JVM's locale.
* `I18nConfiguration` exposes `coreTranslator` and `coreLocaleProvider`
beans. Per-plug-in translators are NOT beans — they're constructed
imperatively per plug-in start in VibeErpPluginManager because each
needs its own classloader at the front of the resolution chain.
* `DefaultPluginContext` now wires `translator` and `localeProvider`
for real instead of throwing `UnsupportedOperationException`.
Bundles
-------
* Core: `platform-i18n/src/main/resources/messages.properties` (English),
`messages_zh_CN.properties` (Simplified Chinese), `messages_de.properties`
(German). Six common keys (errors, ok/cancel/save/delete) and an ICU
plural example for `counts.items`. Java 9+ JEP 226 reads .properties
files as UTF-8 by default, so Chinese characters are written directly
rather than as `\\uXXXX` escapes.
* Reference plug-in: moved from the broken `i18n/messages_en-US.properties`
/ `messages_zh-CN.properties` (wrong path, hyphen-locale filenames
ResourceBundle ignores) to the canonical
`META-INF/vibe-erp/i18n/messages.properties` /
`messages_zh_CN.properties` paths with underscore locale tags.
Added a new `printingshop.plate.created` key with an ICU plural for
`ink_count` to demonstrate non-trivial argument substitution.
End-to-end smoke test
---------------------
Reset Postgres, booted the app, hit POST /api/v1/plugins/printing-shop/plates
with three different Accept-Language headers:
* (no header) → "Plate 'PLATE-001' created with no inks." (en-US, plug-in base bundle)
* `Accept-Language: zh-CN` → "已创建印版 'PLATE-002' (无油墨)。" (zh-CN, plug-in zh_CN bundle)
* `Accept-Language: de` → "Plate 'PLATE-003' created with no inks." (de, but the plug-in
ships no German bundle so it falls back to the plug-in base
bundle — correct, the key is plug-in-specific)
Regression: identity, catalog, partners, and `GET /plates` all still
HTTP 200 after the i18n wiring change.
Build
-----
* `./gradlew build`: 13 subprojects, 118 unit tests (was 107 / 12),
all green. The 11 new tests cover ICU plural rendering, named-arg
substitution, locale fallback (zh_CN → root, ja → root via NO_FALLBACK),
cross-classloader override (a real JAR built in /tmp at test time),
and RequestLocaleProvider's three resolution paths
(no request → default; Accept-Language present → request locale;
request without Accept-Language → default, NOT JVM locale).
* The architectural rule still enforced: platform-plugins now imports
platform-i18n, which is a platform-* dependency (allowed), not a
pbc-* dependency (forbidden).
What was deferred
-----------------
* User-preferred locale from the authenticated user's profile row is
NOT in the resolution chain yet — the `LocaleProvider` interface
leaves room for it but the implementation only consults
Accept-Language and the configured default. Adding it slots in
between request and default without changing the api.v1 surface.
* The metadata translation overrides table (`metadata__translation`)
is also deferred — the `Translator` JavaDoc mentions it as the
first lookup source, but right now keys come from .properties files
only. Once Tier 1 customisation lands (P3.x), key users will be able
to override any string from the SPA without touching code.
Showing
24 changed files
with
692 additions
and
65 deletions
CLAUDE.md
| ... | ... | @@ -95,9 +95,9 @@ plugins (incl. ref) depend on: api/api-v1 only |
| 95 | 95 | |
| 96 | 96 | **Foundation complete; first business surface in place.** As of the latest commit: |
| 97 | 97 | |
| 98 | -- **12 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. | |
| 99 | -- **107 unit tests across 12 modules**, all green. `./gradlew build` is the canonical full build. | |
| 100 | -- **All 7 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader (P1.5), plus the audit/principal context bridge. | |
| 98 | +- **13 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. | |
| 99 | +- **118 unit tests across 13 modules**, all green. `./gradlew build` is the canonical full build. | |
| 100 | +- **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader (P1.5), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. | |
| 101 | 101 | - **3 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`) — they validate the recipe every future PBC clones across three different aggregate shapes (single entity, two-entity catalog, parent-with-children partners+addresses+contacts). |
| 102 | 102 | - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. |
| 103 | 103 | - **Package root** is `org.vibeerp`. | ... | ... |
PROGRESS.md
| ... | ... | @@ -10,19 +10,19 @@ |
| 10 | 10 | |
| 11 | 11 | | | | |
| 12 | 12 | |---|---| |
| 13 | -| **Latest version** | v0.7 (post-P5.2) | | |
| 14 | -| **Latest commit** | `3e40cae feat(pbc): P5.2 — pbc-partners (customers, suppliers, addresses, contacts)` | | |
| 13 | +| **Latest version** | v0.8 (post-P1.6) | | |
| 14 | +| **Latest commit** | `feat(i18n): P1.6 — ICU4J translator + per-plug-in locale chain` | | |
| 15 | 15 | | **Repo** | https://github.com/reporkey/vibe-erp | |
| 16 | -| **Modules** | 12 | | |
| 17 | -| **Unit tests** | 107, all green | | |
| 18 | -| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres | | |
| 16 | +| **Modules** | 13 | | |
| 17 | +| **Unit tests** | 118, all green | | |
| 18 | +| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales | | |
| 19 | 19 | | **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) | |
| 20 | -| **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata) | | |
| 20 | +| **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | |
| 21 | 21 | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | |
| 22 | 22 | |
| 23 | 23 | ## Current stage |
| 24 | 24 | |
| 25 | -**Foundation complete; business surface area growing.** All seven cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader). Three real PBCs (identity, catalog, partners) validate the modular-monolith template against three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain through the api.v1 typed-SQL surface, registers its own HTTP endpoints, and would be rejected at install time if it tried to import any internal framework class. | |
| 25 | +**Foundation complete; business surface area growing; i18n online.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader, ICU4J translator). Three real PBCs (identity, catalog, partners) validate the modular-monolith template against three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain through the api.v1 typed-SQL surface, registers its own HTTP endpoints, ships its own message bundles, returns localised strings in three locales, and would be rejected at install time if it tried to import any internal framework class. | |
| 26 | 26 | |
| 27 | 27 | The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), the metadata-driven custom fields and forms layer (Tier 1 customization), and eventually the React SPA. |
| 28 | 28 | |
| ... | ... | @@ -30,7 +30,7 @@ The next phase continues **building business surface area**: more PBCs (inventor |
| 30 | 30 | |
| 31 | 31 | The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar. |
| 32 | 32 | |
| 33 | -That target breaks down into roughly 30 work units across 8 phases. About **16 are done** as of today. Below is the full list with status. | |
| 33 | +That target breaks down into roughly 30 work units across 8 phases. About **17 are done** as of today. Below is the full list with status. | |
| 34 | 34 | |
| 35 | 35 | ### Phase 1 — Platform completion (foundation) |
| 36 | 36 | |
| ... | ... | @@ -41,7 +41,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **16 a |
| 41 | 41 | | P1.3 | Plug-in lifecycle: HTTP endpoints, DefaultPluginContext, dispatcher | ✅ DONE — `20d7ddc` | |
| 42 | 42 | | P1.4 | Plug-in Liquibase application + `PluginJdbc` | ✅ DONE — `7af11f2` | |
| 43 | 43 | | P1.5 | Metadata store seeding | ✅ DONE — `1ead32d` | |
| 44 | -| P1.6 | ICU4J `Translator` implementation + locale resolution | ⏳ Next priority | | |
| 44 | +| P1.6 | ICU4J `Translator` implementation + locale resolution | ✅ DONE — `<this commit>` | | |
| 45 | 45 | | P1.7 | Event bus + transactional outbox | ✅ DONE — `c2f2314` | |
| 46 | 46 | | P1.8 | JasperReports integration | 🔜 Pending | |
| 47 | 47 | | P1.9 | File store (local + S3) | 🔜 Pending | |
| ... | ... | @@ -126,6 +126,7 @@ These are the cross-cutting platform services already wired into the running fra |
| 126 | 126 | | **Plug-in DB schemas** (P1.4) | `platform-plugins` | Each plug-in ships its own `META-INF/vibe-erp/db/changelog.xml`. The host's `PluginLiquibaseRunner` applies it against the shared host datasource at plug-in start. Plug-ins query their own tables via the api.v1 `PluginJdbc` typed-SQL surface — no Spring or Hibernate types ever leak. | |
| 127 | 127 | | **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. | |
| 128 | 128 | | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. | |
| 129 | +| **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. | | |
| 129 | 130 | | **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners` | Three real PBCs prove the recipe across three aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. | |
| 130 | 131 | |
| 131 | 132 | ## What the reference plug-in proves end-to-end |
| ... | ... | @@ -162,7 +163,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, |
| 162 | 163 | ## What's not yet live (the deferred list) |
| 163 | 164 | |
| 164 | 165 | - **Workflow engine.** No Flowable yet. The api.v1 `TaskHandler` interface exists; the runtime that calls it doesn't. |
| 165 | -- **i18n / Translator.** Plug-ins that try to use `context.translator` get an `UnsupportedOperationException` — the stub points at P1.6. | |
| 166 | 166 | - **Custom fields.** The `ext jsonb` columns exist on every entity; the metadata describing them exists; the validator that enforces them on save does not (P3.4). |
| 167 | 167 | - **Forms.** No JSON Schema form renderer (server or client). No form designer. |
| 168 | 168 | - **Reports.** No JasperReports. |
| ... | ... | @@ -208,6 +208,7 @@ platform/platform-persistence Audit base, JPA entities, PrincipalContext |
| 208 | 208 | platform/platform-security JWT issuer/verifier, Spring Security config, password encoder |
| 209 | 209 | platform/platform-events EventBus impl, transactional outbox, poller |
| 210 | 210 | platform/platform-metadata MetadataLoader, MetadataController |
| 211 | +platform/platform-i18n IcuTranslator, RequestLocaleProvider (P1.6) | |
| 211 | 212 | platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint dispatcher, PluginJdbc |
| 212 | 213 | |
| 213 | 214 | pbc/pbc-identity User entity end-to-end + auth + bootstrap admin |
| ... | ... | @@ -221,7 +222,7 @@ reference-customer/plugin-printing-shop |
| 221 | 222 | distribution Bootable Spring Boot fat-jar assembly |
| 222 | 223 | ``` |
| 223 | 224 | |
| 224 | -12 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time. | |
| 225 | +13 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time. | |
| 225 | 226 | |
| 226 | 227 | ## Where to look next |
| 227 | 228 | ... | ... |
README.md
| ... | ... | @@ -77,7 +77,7 @@ vibe-erp/ |
| 77 | 77 | ## Building |
| 78 | 78 | |
| 79 | 79 | ```bash |
| 80 | -# Build everything (compiles 12 modules, runs 107 unit tests) | |
| 80 | +# Build everything (compiles 13 modules, runs 118 unit tests) | |
| 81 | 81 | ./gradlew build |
| 82 | 82 | |
| 83 | 83 | # Bring up Postgres + the reference plug-in JAR |
| ... | ... | @@ -92,14 +92,14 @@ The bootstrap admin password is printed to the application logs on first boot. A |
| 92 | 92 | |
| 93 | 93 | ## Status |
| 94 | 94 | |
| 95 | -**Current stage: foundation complete; business surface area growing.** All seven cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, and the metadata seeder. Three real PBCs (identity + catalog + partners) validate the modular-monolith template across three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain via REST, and would be rejected at install time if it tried to import any internal framework class. | |
| 95 | +**Current stage: foundation complete; business surface area growing; i18n online.** All eight cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, the metadata seeder, and the ICU4J translator with per-plug-in locale chain. Three real PBCs (identity + catalog + partners) validate the modular-monolith template across three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain via REST, ships its own message bundles, returns localised strings in three locales, and would be rejected at install time if it tried to import any internal framework class. | |
| 96 | 96 | |
| 97 | 97 | | | | |
| 98 | 98 | |---|---| |
| 99 | -| Modules | 12 | | |
| 100 | -| Unit tests | 107, all green | | |
| 99 | +| Modules | 13 | | |
| 100 | +| Unit tests | 118, all green | | |
| 101 | 101 | | Real PBCs | 3 of 10 | |
| 102 | -| Cross-cutting services live | 7 | | |
| 102 | +| Cross-cutting services live | 8 | | |
| 103 | 103 | | Plug-ins serving HTTP | 1 (reference printing-shop) | |
| 104 | 104 | | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | |
| 105 | 105 | ... | ... |
distribution/build.gradle.kts
| ... | ... | @@ -25,6 +25,7 @@ dependencies { |
| 25 | 25 | implementation(project(":platform:platform-security")) |
| 26 | 26 | implementation(project(":platform:platform-events")) |
| 27 | 27 | implementation(project(":platform:platform-metadata")) |
| 28 | + implementation(project(":platform:platform-i18n")) | |
| 28 | 29 | implementation(project(":pbc:pbc-identity")) |
| 29 | 30 | implementation(project(":pbc:pbc-catalog")) |
| 30 | 31 | implementation(project(":pbc:pbc-partners")) | ... | ... |
platform/platform-i18n/build.gradle.kts
0 → 100644
| 1 | +plugins { | |
| 2 | + alias(libs.plugins.kotlin.jvm) | |
| 3 | + alias(libs.plugins.kotlin.spring) | |
| 4 | + alias(libs.plugins.spring.dependency.management) | |
| 5 | +} | |
| 6 | + | |
| 7 | +description = "vibe_erp i18n — ICU4J translator + Accept-Language locale resolution. INTERNAL." | |
| 8 | + | |
| 9 | +java { | |
| 10 | + toolchain { | |
| 11 | + languageVersion.set(JavaLanguageVersion.of(21)) | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 15 | +kotlin { | |
| 16 | + jvmToolchain(21) | |
| 17 | + compilerOptions { | |
| 18 | + freeCompilerArgs.add("-Xjsr305=strict") | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +dependencies { | |
| 23 | + api(project(":api:api-v1")) | |
| 24 | + | |
| 25 | + implementation(libs.kotlin.stdlib) | |
| 26 | + implementation(libs.kotlin.reflect) | |
| 27 | + | |
| 28 | + implementation(libs.spring.boot.starter) | |
| 29 | + implementation(libs.spring.boot.starter.web) // for RequestContextHolder + Accept-Language parsing | |
| 30 | + implementation(libs.icu4j) | |
| 31 | + | |
| 32 | + testImplementation(libs.spring.boot.starter.test) | |
| 33 | + testImplementation(libs.junit.jupiter) | |
| 34 | + testImplementation(libs.assertk) | |
| 35 | + testImplementation(libs.mockk) | |
| 36 | +} | |
| 37 | + | |
| 38 | +tasks.test { | |
| 39 | + useJUnitPlatform() | |
| 40 | +} | ... | ... |
platform/platform-i18n/src/main/kotlin/org/vibeerp/platform/i18n/I18nConfiguration.kt
0 → 100644
| 1 | +package org.vibeerp.platform.i18n | |
| 2 | + | |
| 3 | +import org.springframework.boot.context.properties.ConfigurationProperties | |
| 4 | +import org.springframework.boot.context.properties.EnableConfigurationProperties | |
| 5 | +import org.springframework.context.annotation.Bean | |
| 6 | +import org.springframework.context.annotation.Configuration | |
| 7 | +import org.vibeerp.api.v1.i18n.LocaleProvider | |
| 8 | +import org.vibeerp.api.v1.i18n.Translator | |
| 9 | +import java.util.Locale | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Spring wiring for the i18n module. | |
| 13 | + * | |
| 14 | + * Exposes two beans that PBCs and the plug-in host can inject: | |
| 15 | + * | |
| 16 | + * - **`coreTranslator`** ([Translator]) — looks at the HOST classpath | |
| 17 | + * only. Used by core PBCs whose translation keys live in | |
| 18 | + * `messages_<locale>.properties` on the host classpath. | |
| 19 | + * - **`coreLocaleProvider`** ([LocaleProvider]) — request-scoped | |
| 20 | + * locale resolution with a configured default fallback. | |
| 21 | + * | |
| 22 | + * **Per-plug-in translators are NOT beans.** They are constructed | |
| 23 | + * imperatively by `VibeErpPluginManager` when each plug-in starts, | |
| 24 | + * because they need the plug-in's own classloader at the front of the | |
| 25 | + * resolution chain. There can be many of them at runtime — one per | |
| 26 | + * loaded plug-in. The host's [IcuTranslator] still serves the core PBC | |
| 27 | + * keys, and per-plug-in instances exist alongside it. | |
| 28 | + */ | |
| 29 | +@Configuration | |
| 30 | +@EnableConfigurationProperties(I18nProperties::class) | |
| 31 | +class I18nConfiguration { | |
| 32 | + | |
| 33 | + @Bean | |
| 34 | + fun coreTranslator(): Translator = | |
| 35 | + IcuTranslator( | |
| 36 | + locations = listOf( | |
| 37 | + IcuTranslator.BundleLocation( | |
| 38 | + classLoader = I18nConfiguration::class.java.classLoader, | |
| 39 | + baseName = IcuTranslator.CORE_BASE_NAME, | |
| 40 | + ), | |
| 41 | + ), | |
| 42 | + ) | |
| 43 | + | |
| 44 | + @Bean | |
| 45 | + fun coreLocaleProvider(properties: I18nProperties): LocaleProvider = | |
| 46 | + RequestLocaleProvider(defaultLocale = Locale.forLanguageTag(properties.defaultLocale)) | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * Configuration properties for the i18n layer. | |
| 51 | + * | |
| 52 | + * The default locale is **English** because the framework is sold | |
| 53 | + * worldwide and English is the most-translated source locale (see | |
| 54 | + * CLAUDE.md guardrail #6: "the reference docs being in Chinese does | |
| 55 | + * NOT mean Chinese is the primary or default locale; it is just one | |
| 56 | + * supported locale among many"). Operators who want a different | |
| 57 | + * default set `vibeerp.i18n.defaultLocale=zh-CN` in application.yaml. | |
| 58 | + * | |
| 59 | + * The format is BCP-47 language tags ("en", "zh-CN", "de-DE", | |
| 60 | + * "pt-BR"), parsed via [java.util.Locale.forLanguageTag] which is the | |
| 61 | + * standard the SPA, the OpenAPI client, and every modern HTTP | |
| 62 | + * Accept-Language header speak. | |
| 63 | + */ | |
| 64 | +@ConfigurationProperties(prefix = "vibeerp.i18n") | |
| 65 | +data class I18nProperties( | |
| 66 | + var defaultLocale: String = "en", | |
| 67 | +) | ... | ... |
platform/platform-i18n/src/main/kotlin/org/vibeerp/platform/i18n/IcuTranslator.kt
0 → 100644
| 1 | +package org.vibeerp.platform.i18n | |
| 2 | + | |
| 3 | +import com.ibm.icu.text.MessageFormat | |
| 4 | +import org.slf4j.LoggerFactory | |
| 5 | +import org.vibeerp.api.v1.i18n.MessageKey | |
| 6 | +import org.vibeerp.api.v1.i18n.Translator | |
| 7 | +import java.util.Locale | |
| 8 | +import java.util.MissingResourceException | |
| 9 | +import java.util.ResourceBundle | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * The framework's [Translator] implementation, backed by: | |
| 13 | + * - **JDK [ResourceBundle]** for locating `<baseName>_<locale>.properties` | |
| 14 | + * files on a chain of class loaders (locale fallback to base bundle is | |
| 15 | + * handled by the JDK). | |
| 16 | + * - **ICU4J [com.ibm.icu.text.MessageFormat]** for actual formatting, | |
| 17 | + * so plurals (`{count, plural, ...}`), gender, number/date/currency, | |
| 18 | + * and language-aware ordering all work without the caller doing | |
| 19 | + * anything special. | |
| 20 | + * | |
| 21 | + * **Why ICU4J's MessageFormat instead of the JDK's `java.text.MessageFormat`:** | |
| 22 | + * The JDK class supports positional placeholders only (`{0}`, `{1}`) and | |
| 23 | + * has anaemic plural / gender support. ICU4J supports named placeholders | |
| 24 | + * (`{user}`, `{count, plural, one {# item} other {# items}}`) which is the | |
| 25 | + * format every modern translation tool (Crowdin, Phrase, Lokalise, Tolgee) | |
| 26 | + * speaks natively. CLAUDE.md guardrail #6 requires the framework to be | |
| 27 | + * sellable worldwide; that means picking a translation format the world's | |
| 28 | + * translation tooling already understands. | |
| 29 | + * | |
| 30 | + * **Bundle locations and the parent-first classloader trap.** | |
| 31 | + * The constructor takes a list of [BundleLocation] entries — each one a | |
| 32 | + * `(classLoader, baseName)` pair — tried in order. For the **core** | |
| 33 | + * translator the chain is just `[(host, "messages")]`. For a | |
| 34 | + * **per-plug-in** translator it is | |
| 35 | + * `[(pluginClassLoader, "META-INF/vibe-erp/i18n/messages"), (host, "messages")]`. | |
| 36 | + * | |
| 37 | + * The plug-in entry uses a deliberately host-free path | |
| 38 | + * (`META-INF/vibe-erp/i18n/messages`) instead of the host's own | |
| 39 | + * `messages` baseName. That matters because PF4J's `PluginClassLoader` | |
| 40 | + * is parent-first: a `getResource("messages.properties")` call against | |
| 41 | + * a plug-in classloader would find the HOST's bundle through the | |
| 42 | + * parent chain, completely defeating the per-plug-in override. By | |
| 43 | + * naming the plug-in resource somewhere the host does NOT publish, the | |
| 44 | + * lookup is guaranteed to come from inside the plug-in JAR alone. | |
| 45 | + * | |
| 46 | + * **Locale fallback.** [ResourceBundle.getBundle] walks the locale | |
| 47 | + * candidate chain itself (e.g. `zh_CN` → `zh` → root). On top of that, | |
| 48 | + * if a key is missing from EVERY bundle on EVERY location for EVERY | |
| 49 | + * candidate locale, the translator returns the key string itself — | |
| 50 | + * never `null`, never an empty string. The contract is "make the | |
| 51 | + * failure visible in the UI rather than silently swallowed", which is | |
| 52 | + * the rule on [Translator.translate]. | |
| 53 | + * | |
| 54 | + * Reference: implementation plan unit P1.6. | |
| 55 | + */ | |
| 56 | +class IcuTranslator( | |
| 57 | + private val locations: List<BundleLocation>, | |
| 58 | +) : Translator { | |
| 59 | + | |
| 60 | + init { | |
| 61 | + require(locations.isNotEmpty()) { "IcuTranslator needs at least one bundle location" } | |
| 62 | + } | |
| 63 | + | |
| 64 | + /** | |
| 65 | + * One pair of `(classLoader, baseName)`. The translator tries | |
| 66 | + * each in order; the first that contains the requested key wins. | |
| 67 | + */ | |
| 68 | + data class BundleLocation( | |
| 69 | + val classLoader: ClassLoader, | |
| 70 | + val baseName: String, | |
| 71 | + ) | |
| 72 | + | |
| 73 | + private val log = LoggerFactory.getLogger(IcuTranslator::class.java) | |
| 74 | + | |
| 75 | + override fun translate(key: MessageKey, locale: Locale, args: Map<String, Any>): String { | |
| 76 | + val pattern = lookupPattern(key.key, locale) | |
| 77 | + ?: run { | |
| 78 | + // Visible failure mode: return the key so UI shows | |
| 79 | + // "printingshop.plate.created" instead of empty space. | |
| 80 | + log.debug("translation miss for key='{}' locale={}", key.key, locale) | |
| 81 | + return key.key | |
| 82 | + } | |
| 83 | + if (args.isEmpty()) return pattern | |
| 84 | + return try { | |
| 85 | + // ICU4J's MessageFormat is the named-arg variant; if a key | |
| 86 | + // doesn't include any placeholders this is still safe. | |
| 87 | + MessageFormat(pattern, locale).format(args) | |
| 88 | + } catch (ex: IllegalArgumentException) { | |
| 89 | + // A malformed pattern (translator typo) shouldn't take down | |
| 90 | + // a request — log it and return the raw pattern instead so | |
| 91 | + // the UI shows something. The translator's job is to never | |
| 92 | + // throw at call sites. | |
| 93 | + log.warn("ICU4J MessageFormat failed for key='{}' locale={}: {}", key.key, locale, ex.message) | |
| 94 | + pattern | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * Walk the bundle locations and return the first matching pattern. | |
| 100 | + * The JDK's [ResourceBundle.getBundle] handles `zh_CN → zh → root` | |
| 101 | + * fallback within a single bundle; we add the cross-location | |
| 102 | + * dimension on top. | |
| 103 | + */ | |
| 104 | + private fun lookupPattern(key: String, locale: Locale): String? { | |
| 105 | + for ((cl, baseName) in locations) { | |
| 106 | + val pattern = try { | |
| 107 | + val bundle = ResourceBundle.getBundle(baseName, locale, cl, NO_FALLBACK_TO_DEFAULT) | |
| 108 | + bundle.takeIf { it.containsKey(key) }?.getString(key) | |
| 109 | + } catch (ex: MissingResourceException) { | |
| 110 | + // Bundle not found at this location; try the next one. | |
| 111 | + null | |
| 112 | + } | |
| 113 | + if (pattern != null) return pattern | |
| 114 | + } | |
| 115 | + return null | |
| 116 | + } | |
| 117 | + | |
| 118 | + companion object { | |
| 119 | + /** | |
| 120 | + * The convention for **core** message bundles: `messages.properties`, | |
| 121 | + * `messages_zh_CN.properties`, `messages_de.properties`, etc., at | |
| 122 | + * the host classpath root. | |
| 123 | + */ | |
| 124 | + const val CORE_BASE_NAME: String = "messages" | |
| 125 | + | |
| 126 | + /** | |
| 127 | + * The convention for **plug-in** message bundles. Plug-ins ship | |
| 128 | + * their `messages*.properties` files inside their JAR at | |
| 129 | + * `META-INF/vibe-erp/i18n/`. This path is deliberately one the | |
| 130 | + * host does not claim, so the parent-first PF4J classloader | |
| 131 | + * cannot return a host bundle by mistake. | |
| 132 | + */ | |
| 133 | + const val PLUGIN_BASE_NAME: String = "META-INF/vibe-erp/i18n/messages" | |
| 134 | + | |
| 135 | + /** | |
| 136 | + * A [ResourceBundle.Control] that disables the JDK's automatic | |
| 137 | + * "fall back to the runtime default locale" step. | |
| 138 | + * | |
| 139 | + * The default control, after walking `requestedLocale → its | |
| 140 | + * parents → ROOT`, also tries `defaultLocale → its parents → | |
| 141 | + * ROOT`. That means a JVM running on `LANG=de_DE` will silently | |
| 142 | + * serve German strings to a request that asked for Japanese, | |
| 143 | + * just because the German bundle exists. We never want that — | |
| 144 | + * the framework's fallback chain is the explicit one we built, | |
| 145 | + * ending in the requested locale's bundle, then ROOT, then | |
| 146 | + * (cross-location) the next entry, then the key itself. No | |
| 147 | + * implicit JVM-default detour. | |
| 148 | + */ | |
| 149 | + private val NO_FALLBACK_TO_DEFAULT: ResourceBundle.Control = object : ResourceBundle.Control() { | |
| 150 | + override fun getFallbackLocale(baseName: String?, locale: Locale?): Locale? = null | |
| 151 | + } | |
| 152 | + } | |
| 153 | +} | ... | ... |
platform/platform-i18n/src/main/kotlin/org/vibeerp/platform/i18n/RequestLocaleProvider.kt
0 → 100644
| 1 | +package org.vibeerp.platform.i18n | |
| 2 | + | |
| 3 | +import org.springframework.web.context.request.RequestContextHolder | |
| 4 | +import org.springframework.web.context.request.ServletRequestAttributes | |
| 5 | +import org.vibeerp.api.v1.i18n.LocaleProvider | |
| 6 | +import java.util.Locale | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * The framework's [LocaleProvider] implementation. | |
| 10 | + * | |
| 11 | + * Resolution chain (first match wins): | |
| 12 | + * | |
| 13 | + * 1. **Active HTTP request's `Accept-Language` header**, parsed via | |
| 14 | + * Spring's [jakarta.servlet.http.HttpServletRequest.getLocale]. The | |
| 15 | + * servlet container handles q-value sorting and language tag | |
| 16 | + * parsing for us — we don't reimplement RFC 4647. | |
| 17 | + * 2. **Configured platform default** (`vibeerp.i18n.defaultLocale` in | |
| 18 | + * application.yaml, default `en`). This kicks in for background | |
| 19 | + * jobs, scheduled tasks, workflow steps, MCP agents, system | |
| 20 | + * principals — anywhere there is no active HTTP request. | |
| 21 | + * | |
| 22 | + * Future precedence steps that are NOT in v1 but the contract leaves | |
| 23 | + * room for: (a) authenticated user's preferred locale from their | |
| 24 | + * profile row; (b) per-instance "shop locale" override stored in the | |
| 25 | + * metadata config table. Both can be added without changing the | |
| 26 | + * `LocaleProvider` interface — they slot in between the request and | |
| 27 | + * the configured default. | |
| 28 | + * | |
| 29 | + * **Why not Spring's `LocaleContextHolder`:** that holder is wired by | |
| 30 | + * Spring MVC's `LocaleResolver` chain, which has its own opinions about | |
| 31 | + * how locales are resolved (cookies, session, fixed). We deliberately | |
| 32 | + * sidestep all of that and read the request directly so the chain is | |
| 33 | + * the framework's, not Spring MVC's. The choice of resolution rules is | |
| 34 | + * a vibe_erp guardrail-level decision (CLAUDE.md #6), not a Spring | |
| 35 | + * configuration question. | |
| 36 | + */ | |
| 37 | +class RequestLocaleProvider( | |
| 38 | + private val defaultLocale: Locale, | |
| 39 | +) : LocaleProvider { | |
| 40 | + | |
| 41 | + override fun current(): Locale { | |
| 42 | + val attrs = RequestContextHolder.getRequestAttributes() | |
| 43 | + if (attrs is ServletRequestAttributes) { | |
| 44 | + // The servlet container parses Accept-Language for us; | |
| 45 | + // when the header is absent it returns the JVM default, | |
| 46 | + // which is wrong for us, so we have to detect that case. | |
| 47 | + val request = attrs.request | |
| 48 | + val header = request.getHeader("Accept-Language") | |
| 49 | + if (!header.isNullOrBlank()) { | |
| 50 | + return request.locale ?: defaultLocale | |
| 51 | + } | |
| 52 | + } | |
| 53 | + return defaultLocale | |
| 54 | + } | |
| 55 | +} | ... | ... |
platform/platform-i18n/src/main/resources/messages.properties
0 → 100644
| 1 | +# vibe_erp core message bundle — English (the source locale). | |
| 2 | +# | |
| 3 | +# This is the FALLBACK bundle. ResourceBundle resolves keys via | |
| 4 | +# requested-locale → its parents → ROOT (this file). The framework's | |
| 5 | +# default locale (vibeerp.i18n.defaultLocale) is also `en` out of the | |
| 6 | +# box, so most operators will see these strings unless they explicitly | |
| 7 | +# pick a different locale via Accept-Language or the configuration. | |
| 8 | +# | |
| 9 | +# Naming convention: `<area>.<resource>.<message>`. Enforced at runtime | |
| 10 | +# by org.vibeerp.api.v1.i18n.MessageKey's regex. | |
| 11 | + | |
| 12 | +# ─── Common ─────────────────────────────────────────────────────────── | |
| 13 | +common.ok = OK | |
| 14 | +common.cancel = Cancel | |
| 15 | +common.save = Save | |
| 16 | +common.delete = Delete | |
| 17 | + | |
| 18 | +# ─── Errors ─────────────────────────────────────────────────────────── | |
| 19 | +errors.not_found = The requested item was not found. | |
| 20 | +errors.duplicate_code = The code ''{code}'' is already in use. | |
| 21 | +errors.invalid_argument = Invalid input: {detail} | |
| 22 | +errors.unauthorised = You are not authorised to perform this action. | |
| 23 | + | |
| 24 | +# ─── Counts (ICU plural) ───────────────────────────────────────────── | |
| 25 | +counts.items = {count, plural, =0 {no items} one {1 item} other {# items}} | ... | ... |
platform/platform-i18n/src/main/resources/messages_de.properties
0 → 100644
| 1 | +# vibe_erp Kernnachrichtenbundle — Deutsch. | |
| 2 | + | |
| 3 | +common.ok = OK | |
| 4 | +common.cancel = Abbrechen | |
| 5 | +common.save = Speichern | |
| 6 | +common.delete = Löschen | |
| 7 | + | |
| 8 | +errors.not_found = Das angeforderte Element wurde nicht gefunden. | |
| 9 | +errors.duplicate_code = Der Code ''{code}'' ist bereits vergeben. | |
| 10 | +errors.invalid_argument = Ungültige Eingabe: {detail} | |
| 11 | +errors.unauthorised = Sie sind nicht berechtigt, diese Aktion auszuführen. | |
| 12 | + | |
| 13 | +counts.items = {count, plural, =0 {keine Einträge} one {1 Eintrag} other {# Einträge}} | ... | ... |
platform/platform-i18n/src/main/resources/messages_zh_CN.properties
0 → 100644
| 1 | +# vibe_erp 核心消息包 — 简体中文。 | |
| 2 | +# | |
| 3 | +# Java 9+ reads .properties files as UTF-8 by default (JEP 226), so | |
| 4 | +# Chinese characters are written here directly rather than as \uXXXX | |
| 5 | +# escapes. This file is read via java.util.ResourceBundle, which | |
| 6 | +# follows the same convention. | |
| 7 | + | |
| 8 | +common.ok = 确定 | |
| 9 | +common.cancel = 取消 | |
| 10 | +common.save = 保存 | |
| 11 | +common.delete = 删除 | |
| 12 | + | |
| 13 | +errors.not_found = 未找到所请求的项目。 | |
| 14 | +errors.duplicate_code = 编码 ''{code}'' 已被使用。 | |
| 15 | +errors.invalid_argument = 输入无效: {detail} | |
| 16 | +errors.unauthorised = 您无权执行此操作。 | |
| 17 | + | |
| 18 | +counts.items = {count, plural, =0 {无项目} other {# 个项目}} | ... | ... |
platform/platform-i18n/src/test/kotlin/org/vibeerp/platform/i18n/IcuTranslatorTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.i18n | |
| 2 | + | |
| 3 | +import assertk.assertThat | |
| 4 | +import assertk.assertions.isEqualTo | |
| 5 | +import org.junit.jupiter.api.Test | |
| 6 | +import org.vibeerp.api.v1.i18n.MessageKey | |
| 7 | +import java.net.URLClassLoader | |
| 8 | +import java.nio.file.Files | |
| 9 | +import java.util.Locale | |
| 10 | +import java.util.jar.Attributes | |
| 11 | +import java.util.jar.JarOutputStream | |
| 12 | +import java.util.jar.Manifest | |
| 13 | +import java.util.zip.ZipEntry | |
| 14 | + | |
| 15 | +class IcuTranslatorTest { | |
| 16 | + | |
| 17 | + private val hostCl: ClassLoader = IcuTranslatorTest::class.java.classLoader | |
| 18 | + | |
| 19 | + /** | |
| 20 | + * Two test message bundles ship with `src/test/resources` so the | |
| 21 | + * test classloader picks them up automatically: | |
| 22 | + * - `test-messages.properties` — base | |
| 23 | + * - `test-messages_zh_CN.properties` — zh-CN variant | |
| 24 | + */ | |
| 25 | + private fun coreOnly() = IcuTranslator( | |
| 26 | + locations = listOf(IcuTranslator.BundleLocation(hostCl, "test-messages")), | |
| 27 | + ) | |
| 28 | + | |
| 29 | + @Test | |
| 30 | + fun `plain key lookup returns the base bundle string`() { | |
| 31 | + val t = coreOnly() | |
| 32 | + val out = t.translate(MessageKey("test.simple.greeting"), Locale.ENGLISH) | |
| 33 | + assertThat(out).isEqualTo("Hello, world") | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Test | |
| 37 | + fun `ICU named-arg substitution works`() { | |
| 38 | + val t = coreOnly() | |
| 39 | + val out = t.translate( | |
| 40 | + key = MessageKey("test.greeting.named"), | |
| 41 | + locale = Locale.ENGLISH, | |
| 42 | + args = mapOf("user" to "Marie"), | |
| 43 | + ) | |
| 44 | + assertThat(out).isEqualTo("Hello, Marie!") | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + fun `ICU plural one variant`() { | |
| 49 | + val t = coreOnly() | |
| 50 | + val out = t.translate( | |
| 51 | + key = MessageKey("test.counts.items"), | |
| 52 | + locale = Locale.ENGLISH, | |
| 53 | + args = mapOf("count" to 1), | |
| 54 | + ) | |
| 55 | + assertThat(out).isEqualTo("1 item") | |
| 56 | + } | |
| 57 | + | |
| 58 | + @Test | |
| 59 | + fun `ICU plural other variant`() { | |
| 60 | + val t = coreOnly() | |
| 61 | + val out = t.translate( | |
| 62 | + key = MessageKey("test.counts.items"), | |
| 63 | + locale = Locale.ENGLISH, | |
| 64 | + args = mapOf("count" to 5), | |
| 65 | + ) | |
| 66 | + assertThat(out).isEqualTo("5 items") | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Test | |
| 70 | + fun `zh_CN locale picks the localised bundle`() { | |
| 71 | + val t = coreOnly() | |
| 72 | + val out = t.translate(MessageKey("test.simple.greeting"), Locale.SIMPLIFIED_CHINESE) | |
| 73 | + assertThat(out).isEqualTo("你好,世界") | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + fun `key missing from every bundle returns the key itself`() { | |
| 78 | + val t = coreOnly() | |
| 79 | + val out = t.translate(MessageKey("test.does_not.exist"), Locale.ENGLISH) | |
| 80 | + assertThat(out).isEqualTo("test.does_not.exist") | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + fun `unknown locale falls back to base bundle, not JVM default`() { | |
| 85 | + // The framework's NO_FALLBACK_TO_DEFAULT control means a | |
| 86 | + // request for Japanese (which has no test-messages_ja.properties) | |
| 87 | + // resolves via the explicit chain: ja → ROOT (the base | |
| 88 | + // test-messages.properties), NOT via the JVM default locale. | |
| 89 | + val t = coreOnly() | |
| 90 | + val out = t.translate(MessageKey("test.simple.greeting"), Locale.JAPANESE) | |
| 91 | + assertThat(out).isEqualTo("Hello, world") | |
| 92 | + } | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + fun `plug-in bundle in JAR overrides core bundle for matching key`() { | |
| 96 | + // Build a tiny JAR with a META-INF/vibe-erp/i18n/messages.properties | |
| 97 | + // entry that overrides 'test.simple.greeting'. The plug-in | |
| 98 | + // location is tried first, so the override wins. | |
| 99 | + val jarPath = Files.createTempFile("plugin-i18n-test-", ".jar") | |
| 100 | + JarOutputStream(Files.newOutputStream(jarPath), Manifest().also { | |
| 101 | + it.mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" | |
| 102 | + }).use { jos -> | |
| 103 | + jos.putNextEntry(ZipEntry("META-INF/vibe-erp/i18n/messages.properties")) | |
| 104 | + jos.write("test.simple.greeting=Plug-in override\n".toByteArray(Charsets.UTF_8)) | |
| 105 | + jos.closeEntry() | |
| 106 | + } | |
| 107 | + | |
| 108 | + URLClassLoader(arrayOf(jarPath.toUri().toURL()), hostCl).use { pluginCl -> | |
| 109 | + val t = IcuTranslator( | |
| 110 | + locations = listOf( | |
| 111 | + IcuTranslator.BundleLocation(pluginCl, IcuTranslator.PLUGIN_BASE_NAME), | |
| 112 | + IcuTranslator.BundleLocation(hostCl, "test-messages"), | |
| 113 | + ), | |
| 114 | + ) | |
| 115 | + | |
| 116 | + assertThat(t.translate(MessageKey("test.simple.greeting"), Locale.ENGLISH)) | |
| 117 | + .isEqualTo("Plug-in override") | |
| 118 | + | |
| 119 | + // Keys NOT in the plug-in bundle still fall back to the | |
| 120 | + // host bundle — verifying the chain works in both directions. | |
| 121 | + assertThat(t.translate(MessageKey("test.greeting.named"), Locale.ENGLISH, mapOf("user" to "Bob"))) | |
| 122 | + .isEqualTo("Hello, Bob!") | |
| 123 | + } | |
| 124 | + | |
| 125 | + Files.deleteIfExists(jarPath) | |
| 126 | + } | |
| 127 | +} | ... | ... |
platform/platform-i18n/src/test/kotlin/org/vibeerp/platform/i18n/RequestLocaleProviderTest.kt
0 → 100644
| 1 | +package org.vibeerp.platform.i18n | |
| 2 | + | |
| 3 | +import assertk.assertThat | |
| 4 | +import assertk.assertions.isEqualTo | |
| 5 | +import jakarta.servlet.http.HttpServletRequest | |
| 6 | +import io.mockk.every | |
| 7 | +import io.mockk.mockk | |
| 8 | +import org.junit.jupiter.api.AfterEach | |
| 9 | +import org.junit.jupiter.api.Test | |
| 10 | +import org.springframework.web.context.request.RequestContextHolder | |
| 11 | +import org.springframework.web.context.request.ServletRequestAttributes | |
| 12 | +import java.util.Locale | |
| 13 | + | |
| 14 | +class RequestLocaleProviderTest { | |
| 15 | + | |
| 16 | + @AfterEach | |
| 17 | + fun clear() { | |
| 18 | + RequestContextHolder.resetRequestAttributes() | |
| 19 | + } | |
| 20 | + | |
| 21 | + @Test | |
| 22 | + fun `outside an HTTP request returns the configured default`() { | |
| 23 | + val provider = RequestLocaleProvider(defaultLocale = Locale.GERMAN) | |
| 24 | + assertThat(provider.current()).isEqualTo(Locale.GERMAN) | |
| 25 | + } | |
| 26 | + | |
| 27 | + @Test | |
| 28 | + fun `Accept-Language header parsed via servlet container takes precedence`() { | |
| 29 | + val request = mockk<HttpServletRequest>(relaxed = true) | |
| 30 | + every { request.getHeader("Accept-Language") } returns "zh-CN,en;q=0.5" | |
| 31 | + every { request.locale } returns Locale.SIMPLIFIED_CHINESE | |
| 32 | + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) | |
| 33 | + | |
| 34 | + val provider = RequestLocaleProvider(defaultLocale = Locale.ENGLISH) | |
| 35 | + assertThat(provider.current()).isEqualTo(Locale.SIMPLIFIED_CHINESE) | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Test | |
| 39 | + fun `request without Accept-Language falls back to default, not the JVM locale`() { | |
| 40 | + // The servlet container's getLocale() defaults to the JVM | |
| 41 | + // locale when no Accept-Language is present, which is wrong | |
| 42 | + // for us. The provider must check the header first and | |
| 43 | + // fall back to the configured default — never to the JVM | |
| 44 | + // default. This test makes that contract explicit. | |
| 45 | + val request = mockk<HttpServletRequest>(relaxed = true) | |
| 46 | + every { request.getHeader("Accept-Language") } returns null | |
| 47 | + // request.locale would be Locale.getDefault() here; the | |
| 48 | + // provider must NOT consult it. | |
| 49 | + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) | |
| 50 | + | |
| 51 | + val provider = RequestLocaleProvider(defaultLocale = Locale.JAPANESE) | |
| 52 | + assertThat(provider.current()).isEqualTo(Locale.JAPANESE) | |
| 53 | + } | |
| 54 | +} | ... | ... |
platform/platform-i18n/src/test/resources/test-messages.properties
0 → 100644
platform/platform-i18n/src/test/resources/test-messages_zh_CN.properties
0 → 100644
| 1 | +test.simple.greeting = 你好,世界 | ... | ... |
platform/platform-plugins/build.gradle.kts
| ... | ... | @@ -26,6 +26,7 @@ dependencies { |
| 26 | 26 | implementation(libs.jackson.module.kotlin) |
| 27 | 27 | implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext |
| 28 | 28 | implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...) |
| 29 | + implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider | |
| 29 | 30 | |
| 30 | 31 | implementation(libs.spring.boot.starter) |
| 31 | 32 | implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher | ... | ... |
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt
| ... | ... | @@ -15,24 +15,29 @@ import org.vibeerp.api.v1.security.PermissionCheck |
| 15 | 15 | /** |
| 16 | 16 | * The host's [PluginContext] implementation. |
| 17 | 17 | * |
| 18 | - * v0.5 wires three of the eight services for real: | |
| 19 | - * • [logger] — backed by SLF4J, every line tagged with the plug-in id | |
| 20 | - * • [endpoints] — backed by the per-plug-in scoped registrar | |
| 18 | + * Wired services as of v0.7: | |
| 19 | + * • [logger] — backed by SLF4J, every line tagged with the plug-in id (P1.3) | |
| 20 | + * • [endpoints] — backed by the per-plug-in scoped registrar (P1.3) | |
| 21 | + * • [eventBus] — real in-process bus + transactional outbox (P1.7) | |
| 22 | + * • [jdbc] — typed SQL against the host datasource (P1.4) | |
| 23 | + * • [translator] — ICU4J-backed, plug-in-classloader-first (P1.6) | |
| 24 | + * • [localeProvider] — request Accept-Language with platform default fallback (P1.6) | |
| 21 | 25 | * • everything else — throws `UnsupportedOperationException` with a |
| 22 | 26 | * message pointing at the implementation plan unit that will land it |
| 23 | 27 | * |
| 24 | - * Why throw rather than provide stub no-ops: a plug-in that calls | |
| 25 | - * `context.translator.translate(...)` and silently gets back an empty | |
| 26 | - * string, or `context.eventBus.publish(...)` that silently drops the | |
| 27 | - * event, would create extremely subtle bugs that only surface much | |
| 28 | - * later. A loud `UnsupportedOperationException` at first call surfaces | |
| 29 | - * the gap immediately, in dev, on a live request, with a clear message | |
| 30 | - * saying "this lands in P1.x". | |
| 28 | + * Why throw rather than provide stub no-ops on the remaining gaps: a | |
| 29 | + * plug-in that calls `context.permissionCheck.has(...)` and silently | |
| 30 | + * gets `true`, or `context.transaction.run(...)` that silently no-ops, | |
| 31 | + * would create subtle bugs that only surface much later. A loud | |
| 32 | + * `UnsupportedOperationException` at first call surfaces the gap | |
| 33 | + * immediately, in dev, on a live request, with a clear message saying | |
| 34 | + * "this lands in P1.x". | |
| 31 | 35 | * |
| 32 | 36 | * Per-plug-in: every loaded plug-in gets its own [DefaultPluginContext] |
| 33 | - * instance because the [logger] and [endpoints] are scoped to the | |
| 34 | - * plug-in id. Sharing would let one plug-in's logs masquerade as | |
| 35 | - * another's. | |
| 37 | + * instance because the [logger], [endpoints], and [translator] are | |
| 38 | + * scoped to the plug-in id (so the translator can resolve the plug-in's | |
| 39 | + * own `messages_<locale>.properties` ahead of the host's). Sharing | |
| 40 | + * would let one plug-in's keys collide with another's. | |
| 36 | 41 | */ |
| 37 | 42 | internal class DefaultPluginContext( |
| 38 | 43 | pluginId: String, |
| ... | ... | @@ -40,6 +45,8 @@ internal class DefaultPluginContext( |
| 40 | 45 | delegateLogger: Logger, |
| 41 | 46 | private val sharedEventBus: EventBus, |
| 42 | 47 | private val sharedJdbc: PluginJdbc, |
| 48 | + private val pluginTranslator: Translator, | |
| 49 | + private val sharedLocaleProvider: LocaleProvider, | |
| 43 | 50 | ) : PluginContext { |
| 44 | 51 | |
| 45 | 52 | override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger) |
| ... | ... | @@ -70,6 +77,25 @@ internal class DefaultPluginContext( |
| 70 | 77 | */ |
| 71 | 78 | override val jdbc: PluginJdbc = sharedJdbc |
| 72 | 79 | |
| 80 | + /** | |
| 81 | + * Per-plug-in ICU4J translator, wired in P1.6. The classloader | |
| 82 | + * chain is `[pluginClassLoader, hostClassLoader]` so a key defined | |
| 83 | + * in the plug-in's own `messages_<locale>.properties` resolves | |
| 84 | + * before the host's version. Falls back to the host bundle for | |
| 85 | + * shared keys (e.g. `errors.notFound`). | |
| 86 | + */ | |
| 87 | + override val translator: Translator = pluginTranslator | |
| 88 | + | |
| 89 | + /** | |
| 90 | + * Shared host [LocaleProvider]. The "current locale" decision is | |
| 91 | + * the host's responsibility — plug-ins should never reach into HTTP | |
| 92 | + * headers themselves because that breaks for non-HTTP callers | |
| 93 | + * (workflow tasks, scheduled jobs, MCP agents). The provider | |
| 94 | + * resolves the active request's `Accept-Language` and falls back | |
| 95 | + * to `vibeerp.i18n.defaultLocale` outside an HTTP request. | |
| 96 | + */ | |
| 97 | + override val localeProvider: LocaleProvider = sharedLocaleProvider | |
| 98 | + | |
| 73 | 99 | // ─── Not yet implemented ─────────────────────────────────────── |
| 74 | 100 | |
| 75 | 101 | override val transaction: Transaction |
| ... | ... | @@ -77,16 +103,6 @@ internal class DefaultPluginContext( |
| 77 | 103 | "PluginContext.transaction is not yet implemented; lands alongside the plug-in JPA story" |
| 78 | 104 | ) |
| 79 | 105 | |
| 80 | - override val translator: Translator | |
| 81 | - get() = throw UnsupportedOperationException( | |
| 82 | - "PluginContext.translator is not yet implemented; lands in P1.6 (ICU4J translator)" | |
| 83 | - ) | |
| 84 | - | |
| 85 | - override val localeProvider: LocaleProvider | |
| 86 | - get() = throw UnsupportedOperationException( | |
| 87 | - "PluginContext.localeProvider is not yet implemented; lands in P1.6" | |
| 88 | - ) | |
| 89 | - | |
| 90 | 106 | override val permissionCheck: PermissionCheck |
| 91 | 107 | get() = throw UnsupportedOperationException( |
| 92 | 108 | "PluginContext.permissionCheck is not yet implemented; lands in P4.3 (real permissions)" | ... | ... |
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
| ... | ... | @@ -8,7 +8,9 @@ import org.springframework.beans.factory.InitializingBean |
| 8 | 8 | import org.springframework.boot.context.properties.ConfigurationProperties |
| 9 | 9 | import org.springframework.stereotype.Component |
| 10 | 10 | import org.vibeerp.api.v1.event.EventBus |
| 11 | +import org.vibeerp.api.v1.i18n.LocaleProvider | |
| 11 | 12 | import org.vibeerp.api.v1.plugin.PluginJdbc |
| 13 | +import org.vibeerp.platform.i18n.IcuTranslator | |
| 12 | 14 | import org.vibeerp.platform.metadata.MetadataLoader |
| 13 | 15 | import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry |
| 14 | 16 | import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar |
| ... | ... | @@ -58,6 +60,7 @@ class VibeErpPluginManager( |
| 58 | 60 | private val linter: PluginLinter, |
| 59 | 61 | private val liquibaseRunner: PluginLiquibaseRunner, |
| 60 | 62 | private val metadataLoader: MetadataLoader, |
| 63 | + private val localeProvider: LocaleProvider, | |
| 61 | 64 | ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { |
| 62 | 65 | |
| 63 | 66 | private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) |
| ... | ... | @@ -195,12 +198,32 @@ class VibeErpPluginManager( |
| 195 | 198 | ) |
| 196 | 199 | return |
| 197 | 200 | } |
| 201 | + // Per-plug-in translator: bundle location chain puts the plug-in's | |
| 202 | + // own META-INF/vibe-erp/i18n/messages_<locale>.properties ahead | |
| 203 | + // of the host's messages.properties, so a key like | |
| 204 | + // 'printingshop.plate.created' resolves from inside the plug-in | |
| 205 | + // JAR while shared keys (errors.not_found, etc.) still fall | |
| 206 | + // through to the host bundles. | |
| 207 | + // | |
| 208 | + // The plug-in baseName deliberately uses a path the host does | |
| 209 | + // NOT publish (META-INF/vibe-erp/i18n/messages) — see the | |
| 210 | + // "parent-first classloader trap" rationale on IcuTranslator. | |
| 211 | + val pluginClassLoader: ClassLoader = wrapper.pluginClassLoader | |
| 212 | + val hostClassLoader: ClassLoader = VibeErpPluginManager::class.java.classLoader | |
| 213 | + val pluginTranslator = IcuTranslator( | |
| 214 | + locations = listOf( | |
| 215 | + IcuTranslator.BundleLocation(pluginClassLoader, IcuTranslator.PLUGIN_BASE_NAME), | |
| 216 | + IcuTranslator.BundleLocation(hostClassLoader, IcuTranslator.CORE_BASE_NAME), | |
| 217 | + ), | |
| 218 | + ) | |
| 198 | 219 | val context = DefaultPluginContext( |
| 199 | 220 | pluginId = pluginId, |
| 200 | 221 | sharedRegistrar = ScopedPluginEndpointRegistrar(endpointRegistry, pluginId), |
| 201 | 222 | delegateLogger = LoggerFactory.getLogger("plugin.$pluginId"), |
| 202 | 223 | sharedEventBus = eventBus, |
| 203 | 224 | sharedJdbc = jdbc, |
| 225 | + pluginTranslator = pluginTranslator, | |
| 226 | + sharedLocaleProvider = localeProvider, | |
| 204 | 227 | ) |
| 205 | 228 | try { |
| 206 | 229 | vibeErpPlugin.start(context) | ... | ... |
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt
| 1 | 1 | package org.vibeerp.reference.printingshop |
| 2 | 2 | |
| 3 | 3 | import org.pf4j.PluginWrapper |
| 4 | +import org.vibeerp.api.v1.i18n.MessageKey | |
| 4 | 5 | import org.vibeerp.api.v1.plugin.HttpMethod |
| 5 | 6 | import org.vibeerp.api.v1.plugin.PluginContext |
| 6 | 7 | import org.vibeerp.api.v1.plugin.PluginRequest |
| ... | ... | @@ -172,6 +173,21 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP |
| 172 | 173 | "now" to java.sql.Timestamp.from(Instant.now()), |
| 173 | 174 | ), |
| 174 | 175 | ) |
| 176 | + // Demonstrate the i18n surface (P1.6). The message is | |
| 177 | + // looked up via the per-plug-in IcuTranslator wired in | |
| 178 | + // DefaultPluginContext: the plug-in's own | |
| 179 | + // META-INF/vibe-erp/i18n/messages_<locale>.properties | |
| 180 | + // takes precedence, falling back to the host bundles for | |
| 181 | + // shared keys. The current locale comes from the request's | |
| 182 | + // Accept-Language header via the framework's | |
| 183 | + // RequestLocaleProvider — the plug-in never reads HTTP | |
| 184 | + // headers itself, which would break for non-HTTP callers. | |
| 185 | + val locale = context.localeProvider.current() | |
| 186 | + val message = context.translator.translate( | |
| 187 | + key = MessageKey("printingshop.plate.created"), | |
| 188 | + locale = locale, | |
| 189 | + args = mapOf("number" to code, "ink_count" to 0), | |
| 190 | + ) | |
| 175 | 191 | PluginResponse( |
| 176 | 192 | status = 201, |
| 177 | 193 | body = mapOf( |
| ... | ... | @@ -181,6 +197,8 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP |
| 181 | 197 | "widthMm" to widthMm, |
| 182 | 198 | "heightMm" to heightMm, |
| 183 | 199 | "status" to "DRAFT", |
| 200 | + "message" to message, | |
| 201 | + "locale" to locale.toLanguageTag(), | |
| 184 | 202 | ), |
| 185 | 203 | ) |
| 186 | 204 | } | ... | ... |
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/i18n/messages.properties
0 → 100644
| 1 | +# Reference printing-shop plug-in — base (English) message bundle. | |
| 2 | +# | |
| 3 | +# Loaded by the per-plug-in IcuTranslator constructed in | |
| 4 | +# VibeErpPluginManager. The base name is META-INF/vibe-erp/i18n/messages | |
| 5 | +# (a path the host does NOT publish) so PF4J's parent-first | |
| 6 | +# PluginClassLoader cannot return the host bundle by mistake. | |
| 7 | +# | |
| 8 | +# Naming convention: <plugin-id>.<area>.<message>. The plug-in id | |
| 9 | +# prefix stops two plug-ins from accidentally colliding on a generic | |
| 10 | +# key like "form.title". | |
| 11 | + | |
| 12 | +printingshop.plate.title = Plate | |
| 13 | +printingshop.plate.create = Create plate | |
| 14 | +printingshop.plate.created = Plate ''{number}'' created with {ink_count, plural, =0 {no inks} one {1 ink} other {# inks}}. | |
| 15 | +printingshop.plate.approve = Approve plate | |
| 16 | +printingshop.job.title = Job card | |
| 17 | +printingshop.job.dispatch = Dispatch to press | |
| 18 | +printingshop.workflow.quote_to_job_card.name = Quote to job card | ... | ... |
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/i18n/messages_zh_CN.properties
0 → 100644
| 1 | +# 参考印刷厂插件 — 简体中文消息包。 | |
| 2 | +# UTF-8 encoded; Java 9+ ResourceBundle reads .properties files as | |
| 3 | +# UTF-8 by default (JEP 226). | |
| 4 | + | |
| 5 | +printingshop.plate.title = 印版 | |
| 6 | +printingshop.plate.create = 新建印版 | |
| 7 | +printingshop.plate.created = 已创建印版 ''{number}'' ({ink_count, plural, =0 {无油墨} other {# 种油墨}})。 | |
| 8 | +printingshop.plate.approve = 审批印版 | |
| 9 | +printingshop.job.title = 工单 | |
| 10 | +printingshop.job.dispatch = 派工至印刷机 | |
| 11 | +printingshop.workflow.quote_to_job_card.name = 报价转工单 | ... | ... |
reference-customer/plugin-printing-shop/src/main/resources/i18n/messages_en-US.properties deleted
| 1 | -# Reference printing-shop plug-in — English (US) message bundle. | |
| 2 | -# | |
| 3 | -# Keys follow the convention <plugin-id>.<area>.<message> declared in | |
| 4 | -# CLAUDE.md guardrail #6 / architecture spec section 7. The plug-in id | |
| 5 | -# prefix is critical: it stops two plug-ins from accidentally colliding | |
| 6 | -# on a generic key like "form.title". | |
| 7 | - | |
| 8 | -printingshop.plate.title=Plate | |
| 9 | -printingshop.plate.create=Create plate | |
| 10 | -printingshop.plate.approve=Approve plate | |
| 11 | -printingshop.job.title=Job card | |
| 12 | -printingshop.job.dispatch=Dispatch to press | |
| 13 | -printingshop.workflow.quoteToJobCard.name=Quote to job card |
reference-customer/plugin-printing-shop/src/main/resources/i18n/messages_zh-CN.properties deleted
| 1 | -# Reference printing-shop plug-in — Simplified Chinese message bundle. | |
| 2 | -# UTF-8 encoded; Spring's MessageSource is configured to read .properties | |
| 3 | -# files as UTF-8 in api.v1 / platform-i18n. | |
| 4 | - | |
| 5 | -printingshop.plate.title=印版 | |
| 6 | -printingshop.plate.create=新建印版 | |
| 7 | -printingshop.plate.approve=审批印版 | |
| 8 | -printingshop.job.title=工单 | |
| 9 | -printingshop.job.dispatch=派工至印刷机 | |
| 10 | -printingshop.workflow.quoteToJobCard.name=报价转工单 |
settings.gradle.kts
| ... | ... | @@ -39,6 +39,9 @@ project(":platform:platform-events").projectDir = file("platform/platform-events |
| 39 | 39 | include(":platform:platform-metadata") |
| 40 | 40 | project(":platform:platform-metadata").projectDir = file("platform/platform-metadata") |
| 41 | 41 | |
| 42 | +include(":platform:platform-i18n") | |
| 43 | +project(":platform:platform-i18n").projectDir = file("platform/platform-i18n") | |
| 44 | + | |
| 42 | 45 | // ─── Packaged Business Capabilities (core PBCs) ───────────────────── |
| 43 | 46 | include(":pbc:pbc-identity") |
| 44 | 47 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") | ... | ... |