Commit 01c71a69128090cab03ae92d8965f48c707ad32c

Authored by zichun
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
  1 +# Test bundle for IcuTranslatorTest. Lives in platform-i18n test
  2 +# resources only — never shipped in any production JAR.
  3 +test.simple.greeting = Hello, world
  4 +test.greeting.named = Hello, {user}!
  5 +test.counts.items = {count, plural, one {1 item} other {# items}}
... ...
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(&quot;:platform:platform-events&quot;).projectDir = file(&quot;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")
... ...