You need to sign in before continuing.
  • 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.
    zichun authored
     
    Browse Code »