# Phase 14.2.1: Translate plugin - Research **Researched:** 2026-10-06 **Domain:** PHP-to-Go plugin port (WinterCMS Translate → compiled `sm-translate-plugin`) plus cabana ML field types **Confidence:** HIGH ## User Constraints (from CONTEXT.md) ### Locked Decisions ### PHP source of truth - **D-01:** The port contract is `github.com/golem15com/wn-translate-plugin` at git SHA `725d547` (2026-08-26), the pin currently checked out in Płytarium. That tree is newer than grzybyfunkcjonalne.pl (`6e0c146`). — **Reversibility:** costly — Locale tables, attribute storage and Translator behaviour are copied from this tree. - **D-02:** Freeze at `725d547`. Planner and researcher must not silently pick a newer `master`. — **Reversibility:** reversible — a later discussion can re-pin. - **D-03:** Downstream agents read the frozen source from `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate` (already at that SHA). Do not clone a second copy unless that path is missing or the SHA does not match. - **D-04:** This phase does not edit or PR `wn-translate-plugin`. An urgent PHP upgrade on a live Winter site before that site migrates is allowed as operations. If it lands before plan or execute, re-pin the SHA rather than mixing trees. ### What ships this phase - **D-05:** Lean only: Locale model + Locales admin (`golem15.translate.manage_locales`), and a TranslatableModel behavior Journal can implement. Messages catalogue, locale-picker components, AI/theme commands and message import/export are deferred. — **Reversibility:** reversible — later plans can add the rest without rewriting Locale. - **D-06:** Cabana gains ML field types (`mltext`, `mlmarkdown`) matching PHP's `ml*` widgets, so a Journal form stays one screen with a locale switch. Phase 15's markdown field (15 D-11) and these ML types must compose (`mlmarkdown` is markdown plus locale). — **Reversibility:** costly — new field types in the typed admin schema and generated TS types. - **D-07:** Port PHP `Translator` locale resolution: URL prefix / session / cookie / default. Do not reduce this phase to `Accept-Language` or a query param only. — **Reversibility:** costly — public blog URLs and the Journal API will depend on this order. - **D-08:** The plugin seeds English and Polish locales, matching Journal 15 D-06. ### Model API - **D-09:** A Go model declares translatable attributes with a `Translatable() []string` (or equivalent tagged list) the way PHP uses `$translatable`. Journal copies the PHP Post/Category lists. — **Reversibility:** costly — every translatable model in shared plugins will call this. - **D-10:** Storage uses the same tables and columns as this PHP pin (`golem15_translate_*` attributes/indexes/locales — researcher confirms exact names from the frozen updates). No JSON-column alternative. — **Reversibility:** one-way — Winter import and Journal cutover expect those tables. - **D-11:** A missing translation falls back to the default locale, as PHP does. - **D-12:** The exported API other plugins call this phase is the minimum: `WithLocale` on queries, get/set an attribute in a locale, and current locale from Translator. Do not 1:1 every PHP `TranslatableModel` / `Translator` method name unless research shows Journal needs it. ### Where it boots - **D-13:** The host is `sm-grzybyfunkcjonalne-app` (same as Phase 15 D-18). This phase mounts `sm-user-plugin` + `sm-translate-plugin`; Journal is added in Phase 15. — **Reversibility:** one-way — submodule layout lands in that app. - **D-14:** Local app checkout: `/media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/` (sibling of `summercms.go`). - **D-15:** The user creates the public Gitea remote `git@git.golem15.com:golem15/sm-translate-plugin.git` as a manual plan step (it does not exist yet). Module path `git.golem15.com/golem15/sm-translate-plugin`, package `translate`, plugin ID `golem15.translate`. - **D-16:** Local plugin checkout: `/media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/` (sibling of `summercms.go`). The host mounts it as a submodule at `plugins/golem15/translate` with a `go.work` replace. - **D-17:** Phase proof: the app boots with user+translate; Locales admin works; a fixture model can save and read `en`+`pl` through ML fields and `WithLocale`. ### the agent's Discretion - Exact `golem15_translate_*` table/column names and migration IDs, taken from the frozen PHP updates (D-10), not invented. - ML field YAML type names and SPA locale-switcher chrome, provided they follow cabana fail-loud rules and update the module README, `docs/`, admin OpenAPI, generated TS types and committed `dist/` in the same change. - How `mlmarkdown` shares implementation with Phase 15's markdown field (land the shared primitive here if Journal is otherwise blocked). - Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". - Run the security-review agent: locale switching on public requests and admin ML writes are authorization-adjacent. ### Deferred Ideas (OUT OF SCOPE) - Messages catalogue and `golem15.translate.manage_messages` admin. - Locale picker, alternate hreflang, and locale-suggestion-banner CMS components (Phase 16 views may need a successor). - AI/theme console commands and message import/export. - Remaining PHP locales beyond the `en`+`pl` seed (D-08 is seed rows, not phrasebook files). - Phase 15 should list **Depends on: Phase 14.2.1** in ROADMAP (`$gsd-phase --edit 15`); not done in this discussion. ### Reviewed Todos (not folded) - `sitemap-plugin-port.md`, `mailblocker-with-mailing.md`, and the other keyword matches from `todo.match-phase 14.2.1`: unrelated housekeeping. ## Project Constraints (from CLAUDE.md) - Lean planning: few larger plans; unit tests are always the last plan of a phase; earlier plans may smoke-test but must not be blocked on coverage. - Plan-count checkpoint before writing PLAN.md files. - Stdlib first (`net/http` ServeMux, `html/template`, `encoding/json`). Add a dependency only when this research or a phase decision names it. `go vet` and `go test ./...` green at every commit. - Compiled plugins only. No runtime plugin loading. - API parity is the acceptance test for ported contracts; do not "improve" shapes. - GORM on Postgres only; gormigrate (not goose/atlas); AutoMigrate is never the schema source (DATA-02). - Two-repo rule: `summercms.go` is framework only and knows nothing about Płytarium. This phase writes framework ML types in `summercms.go`, the plugin in `sm-translate-plugin`, and the proof host in `sm-grzybyfunkcjonalne-app`. Planning docs stay in `summercms.go/.planning`. - Per-request state (including locale) travels in `context.Context`; no package-level globals hold request state (KERN-07). - Framework READMEs never name a consuming application. Neutral examples (`blog`, `acme`). - A change to a module's exported API, config keys or CLI commands updates that module's `README.md` and the affected `docs/` pages in the same change. Cabana field types also update admin OpenAPI, generated TS types, and committed `boardwalk` `dist/`. - PHP originals are not changed. Core plugin contracts stay non-breaking. - Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits. ## Summary Phase 14.2.1 ports the lean core of `Golem15.Translate` at SHA `725d547ec839f02b5fdc0f0a6faaed601a414d50` so Journal (Phase 15) can keep translatable fields. The frozen tree is present at `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate` and matches the pin. Do not clone a second copy. **Primary recommendation:** Create `sm-translate-plugin` on the user-plugin mount pattern; squash PHP's rainlab→winter rename history into four final Postgres tables named `golem15_translate_*` (not PHP's `winter_translate_*`); export `Translatable() []string`, `WithLocale`, get/set-in-locale, and Translator current locale; land cabana `markdown` plus `mltext`/`mlmarkdown` in this phase so Phase 15 is not blocked; replace surf's Accept-Language-only house locale middleware with a backpack-published Translator when the plugin is mounted. PHP table names in this pin are `winter_translate_locales`, `winter_translate_attributes`, `winter_translate_indexes`, and `winter_translate_messages`. **Execution override (2026-10-06):** D-10 is locked to Go names `golem15_translate_*` with the PHP column/index shapes. Do not emit `winter_translate_*` in Go DDL. A Winter import will need a mapping later. ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Locale rows, attribute JSON, indexes, messages table DDL | Database / Storage | API / Backend | gormigrate owns schema; plugin models own rows | | Translatable get/set, default-locale fallback, `WithLocale` | API / Backend | Database / Storage | GORM hooks + attribute table; request locale from context | | Translator resolution (URL prefix / session / cookie flag / default) | API / Backend | — | Request middleware writes `towel.WithLocale`; no process-wide singleton | | Locales admin CRUD | API / Backend | Browser / Client | cabana JSON admin; SPA renders compiled schema | | `mltext` / `mlmarkdown` schema + save | API / Backend | Browser / Client | cabana fail-loud types; SPA locale switcher posts all locales | | Phrasebook UI strings (`golem15.translate::lang.*`) | API / Backend | Browser / Client | Distinct from model translations | | Proof host boot (user + translate) | API / Backend | — | `sm-grzybyfunkcjonalne-app` binary | ## Standard Stack No new libraries. Use the already-decided stack already in `summercms.go` `go.mod` and `sm-user-plugin`. ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Go | 1.27.0 | Language | Project pin; `go version go1.27.0-X:nodwarf5` on this machine | | GORM | v1.31.2 | Models | Decided; user plugin already requires it | | gorm.io/driver/postgres | v1.6.3 | Postgres | Decided | | go-gormigrate/gormigrate/v2 | v2.1.7 | Plugin migrations | Decided; user plugin uses this | | goldmark | v1.8.6 | Markdown primitive under `mlmarkdown` | Already a direct framework dep (`go.mod`); Phase 4 D-07 mail renderer. Do not add a second Markdown library. | | goccy/go-yaml | v1.19.2 | fields.yaml / columns.yaml | Cabana already parses with this | | Vue 3 + TypeScript | admin SPA | `mltext` / `mlmarkdown` controls | Existing `admin/` tree; types from OpenAPI | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | testcontainers-go + modules/postgres | v0.44.0 | Real Postgres for migration/behavior tests | Last plan; `-short` skip | | stretchr/testify | v1.12.1 | Assertions | Last plan only; keep `func Test...(*testing.T)` | | go-i18n/v2 | v2.6.1 | Phrasebook CLDR (already wired) | Plugin `HasLang` catalogs; not model translations | ### Alternatives Considered Do not use these. CONTEXT locked the storage, locale order, and YAML types. | Instead of | Could Use | Why rejected | |------------|-----------|--------------| | `golem15_translate_*` tables | JSON columns on host models | D-10 forbids it | | `golem15_translate_*` | PHP's `winter_translate_*` copied into Go | Execute lock 2026-10-06; Winter import is a later mapping | | URL/session/cookie/default | Accept-Language only | D-07 forbids reducing to it | | Cabana `mltext`/`mlmarkdown` | Plugin `type: widget` | Field types belong in cabana (12.1 pattern) | **Installation:** none. Plugin `go.mod` copies the user-plugin require set (GORM, gormigrate, summercms replace). Cabana ML types add no new Go module. **Version verification:** read `summercms.go/go.mod` and `sm-user-plugin/go.mod` this session. Goldmark `v1.8.6` is already a direct require. ## Package Legitimacy Audit This phase installs **no new external packages**. Goldmark, GORM, gormigrate, goccy/go-yaml, testcontainers and testify are already in the framework / user-plugin graphs. | Package | Registry | Verdict | Disposition | |---------|----------|---------|-------------| | (none new) | — | — | — | **Packages removed due to [SLOP] verdict:** none **Packages flagged as suspicious [SUS]:** none ## Verified PHP pin `git rev-parse HEAD` in `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate` is `725d547ec839f02b5fdc0f0a6faaed601a414d50` (2026-08-26). Matches D-01/D-02/D-03. Do not read a newer tree. ## 1. Tables, columns, indexes, migration IDs PHP created `rainlab_translate_*` then renamed to `winter_translate_*`. Go **squashes to the final names**. Do not replay the rainlab era. ### Final table names (use these) | Table | Model `$table` | Role this phase | |-------|----------------|-----------------| | `winter_translate_locales` | `"public $table = 'winter_translate_locales';"` [VERIFIED: `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/Locale.php:24`] | **Ship** Locale admin + seed | | `winter_translate_attributes` | `"public $table = 'winter_translate_attributes';"` [VERIFIED: `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/Attribute.php:15`] | **Ship** Translatable storage | | `winter_translate_indexes` | used as `'winter_translate_indexes'` [VERIFIED: `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/behaviors/TranslatableModel.php:74`] | **Ship** — Journal slugs use `index => true` | | `winter_translate_messages` | `"public $table = 'winter_translate_messages';"` [VERIFIED: `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/models/Message.php:20`] | **Create empty, do not break** — Messages admin is out of scope | Translator configuration check: `"Schema::hasTable('winter_translate_locales')"` [VERIFIED: `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate/classes/Translator.php:113`]. ### Columns (final, after all PHP updates) **`winter_translate_locales`** (create + `sort_order`): | Column | Source | |--------|--------| | `id` increments | `"$table->increments('id');"` [VERIFIED: `updates/v1.0.1/create_locales_table.php:14`] | | `code` string, indexed | `"$table->string('code')->index();"` [VERIFIED: `updates/v1.0.1/create_locales_table.php:15`] | | `name` string, indexed, nullable | `"$table->string('name')->index()->nullable();"` [VERIFIED: `updates/v1.0.1/create_locales_table.php:16`] | | `is_default` boolean default 0 | `"$table->boolean('is_default')->default(0);"` [VERIFIED: `updates/v1.0.1/create_locales_table.php:17`] | | `is_enabled` boolean default 0 | `"$table->boolean('is_enabled')->default(0);"` [VERIFIED: `updates/v1.0.1/create_locales_table.php:18`] | | `sort_order` integer default 0 | `"$table->integer('sort_order')->default(0);"` [VERIFIED: `updates/v1.3.1/add_sort_order.php:20`] | No timestamps on Locale (`"$this->timestamps = false;"` [VERIFIED: `models/Locale.php:43`]). **`winter_translate_attributes`:** `id`, `locale` (indexed), `model_id` (indexed, nullable string), `model_type` (indexed, nullable string), `attribute_data` (mediumText, nullable) [VERIFIED: `updates/v1.0.1/create_attributes_table.php:13-18`]. Storage of translations is a JSON object in `attribute_data`, one row per `(locale, model_id, model_type)` [VERIFIED: `behaviors/TranslatableModel.php:226-241`]. **`winter_translate_indexes`:** `id`, `locale` (indexed), `model_id` (indexed, nullable), `model_type` (indexed, nullable), `item` (nullable, indexed), `value` (mediumText, nullable) [VERIFIED: `updates/v1.2.4/create_indexes_table.php:13-19`]. Written when `$translatable` entry is `['slug', 'index' => true]`. **`winter_translate_messages` (must exist, unused this phase):** `id`, `code` (indexed, nullable), `message_data` (mediumText, nullable) [VERIFIED: `updates/v1.0.1/create_messages_table.php:13-16`]; plus `found` boolean default 1 [VERIFIED: `updates/v1.6.10/update_messages_table.php:16-19`]; plus `code_pre_2_1_0` string indexed nullable from 2.1.0 [VERIFIED: `updates/v2.1.0/migrate_message_code.php:16-19`]. Message `jsonable` is `message_data` [VERIFIED: `models/Message.php:38`]. ### PHP migration IDs (do not replay; squash) From `updates/version.yaml` [VERIFIED: `updates/version.yaml:1-155`]: | PHP version.yaml key | Files | |----------------------|-------| | `"1.0.1"` | `v1.0.1/create_messages_table.php`, `create_attributes_table.php`, `create_locales_table.php` | | `"1.2.4"` | `v1.2.4/create_indexes_table.php` | | `"1.3.1"` | `v1.3.1/add_sort_order.php`, `v1.3.1/seed_all_tables.php` | | `"1.6.6"` | `v1.6.6/migrate_morphed_attributes.php` | | `"1.6.7"` | `v1.6.7/migrate_morphed_indexes.php` | | `"1.6.10"` | `v1.6.10/update_messages_table.php` | | `"2.0.0"` | `v2.0.0/rename_tables.php` (`rainlab_translate_*` → `winter_translate_*`) | | `"2.0.1"` | `v2.0.1/rename_indexes.php` | | `"2.1.0"` | `v2.1.0/migrate_message_code.php` | | `"2.4.0"` | `v2.4.0/seed_additional_locales.php` | Go migration IDs: follow the user-plugin pattern `"202609170001_create_users"` [VERIFIED: `../fonoteka.go/plugins/golem15/user/updates/00_base.go:13`]. Suggested squash IDs (planner may adjust date prefix, not the table names): - `202610060001_create_golem15_translate_locales` - `202610060002_create_golem15_translate_attributes` - `202610060003_create_golem15_translate_indexes` - `202610060004_create_golem15_translate_messages` - `202610060005_seed_en_pl_locales` Postgres types: `SERIAL` PK, `TEXT` for strings, `BOOLEAN`, `INTEGER` for `sort_order`, `TEXT` for `attribute_data`/`message_data`/`value` (PHP mediumText). Add btree indexes on the PHP-indexed columns (`code`, `name`, `locale`, `model_id`, `model_type`, `item`). Do **not** run morph-map data migrations (1.6.6/1.6.7); there is no legacy rainlab data in a new Go database. Do **not** rename rainlab→winter at runtime. ## 2. Locale model, permissions, YAML, seed ### Permission `"golem15.translate.manage_locales"` [VERIFIED: `Plugin.php:71`] with tab `'golem15.translate::lang.plugin.tab'` and label `'golem15.translate::lang.plugin.manage_locales'` [VERIFIED: `Plugin.php:72-74`]. Developer role in PHP. Go: `pact.HasPermissions` with `Code: "golem15.translate.manage_locales"`, `Roles: []string{"developer"}`, labels via phrasebook — copy user plugin [VERIFIED: `../fonoteka.go/plugins/golem15/user/admin_permissions.go:14-21`]. Do **not** register `"golem15.translate.manage_messages"` [VERIFIED: `Plugin.php:76`] this phase (deferred). Locales controller: `"public $requiredPermissions = ['golem15.translate.manage_locales'];"` [VERIFIED: `controllers/Locales.php:21`]. ### Locale fields and fillable Fillable: `'code'`, `'name'`, `'is_enabled'` [VERIFIED: `models/Locale.php:37-41`]. `is_default` is **not** fillable; `makeDefault()` writes it [VERIFIED: `models/Locale.php:99-109`]. Rules: `'code' => 'required'`, `'name' => 'required'` [VERIFIED: `models/Locale.php:29-32`]. Guards: cannot delete default (`beforeDelete`) [VERIFIED: `models/Locale.php:77-81`]; cannot unset default (`beforeUpdate`) [VERIFIED: `models/Locale.php:84-92`]; cannot make a disabled locale default [VERIFIED: `models/Locale.php:101-103`]. Port these in Locale hooks / admin Form hooks. ### Admin YAML (port into embedded `AdminFS`) `controllers/locales/config_form.yaml`: `form: ~/plugins/golem15/translate/models/locale/fields.yaml`, `modelClass: Golem15\Translate\Models\Locale` [VERIFIED: `controllers/locales/config_form.yaml:5-7`]. `controllers/locales/config_list.yaml`: `list: ~/plugins/golem15/translate/models/locale/columns.yaml`, `title: golem15.translate::lang.locale.label_plural`, `recordsPerPage: 20`, `defaultSort.column: sort_order`, `defaultSort.direction: asc` [VERIFIED: `controllers/locales/config_list.yaml:6-34`]. PHP `recordOnClick` is Winter AJAX; Go uses cabana `recordUrl` to the update form. `models/locale/fields.yaml` fields: `name`, `code`, `is_enabled` (`type: checkbox`), `is_default` (`type: checkbox`) [VERIFIED: `models/locale/fields.yaml:6-24`]. `models/locale/columns.yaml`: `name` searchable, `code` searchable, `is_default` `type: switch`, `is_enabled` `type: switch` `invisible: true`, `sort_order` `type: number` `invisible: true` [VERIFIED: `models/locale/columns.yaml:6-26`]. Cabana list column types are `"text"`, `"datetime"`, `"switch"`, `"date"`, `"time"` [VERIFIED: `modules/cabana/list_schema.go:22-24`]. Map `number` → omit type (text) or drop `sort_order` from visible columns (already invisible). PHP ReorderController (`config_reorder.yaml` `nameFrom: name` [VERIFIED: `controllers/locales/config_reorder.yaml:8-12`]) has **no cabana analog**. Keep `sort_order` column, seed `en=1` `pl=2`, list `defaultSort` `sort_order` asc. Do not invent a drag-reorder UI this phase. PHP `registerSettings()` points at the Locales **list** controller [VERIFIED: `Plugin.php:90-98`]. Cabana `HasSettings` is a singleton settings screen [VERIFIED: `modules/pact/capabilities.go:402-404`]. Locales is CRUD. **Register it as `HasAdminControllers` + `HasNavigation`** like Users [VERIFIED: `../fonoteka.go/plugins/golem15/user/admin.go:33-37` and `admin_navigation.go:11-19`]. Navigation code e.g. `translate`, permission `golem15.translate.manage_locales`, controller id `golem15.translate.locales` (path `{backend.uri}/api/v1/golem15/translate/locales`). ### Phrasebook (UI strings, not model translations) Port `lang/en/lang.php` keys under `plugin.*` and `locale.*` into `lang/en/lang.yaml` and `lang/pl/lang.yaml` via `pact.HasLang` / `LangFS()` [VERIFIED user analog: `../fonoteka.go/plugins/golem15/user/plugin.go:180`]. PHP English: `'manage_locales' => 'Manage locales'`, `'label_plural' => 'Languages'`, `'title' => 'Manage languages'` [VERIFIED: `lang/en/lang.php:8-22`]. Do not load PHP `unsupported_lang/` (deferred remaining locales). ### Seed en + pl (D-08) English from `v1.3.1/seed_all_tables.php` if count === 0: `'code' => 'en'`, `'name' => 'English'`, `'is_default' => true`, `'is_enabled' => true` [VERIFIED: `updates/v1.3.1/seed_all_tables.php:16-22`]. Polish from `v2.4.0/seed_additional_locales.php`: `'code' => 'pl'`, `'name' => 'Polski'`, `'is_default' => false`, `'is_enabled' => true`, `'sort_order' => 2` [VERIFIED: `updates/v2.4.0/seed_additional_locales.php:13-20`]. The same seeder also inserts `'de'` / `'Deutsch'` / `sort_order => 3` [VERIFIED: `updates/v2.4.0/seed_additional_locales.php:21-27`]. **Do not seed `de`** (D-08 is en+pl only; remaining locales deferred). Set `en` `sort_order` to `1`. Idempotent: insert by `code` if missing, matching PHP `$exists` check [VERIFIED: `updates/v2.4.0/seed_additional_locales.php:33-36`]. ## 3. TranslatableModel behavior and minimum Go API ### PHP storage model (port this, not Eloquent magic) - Default locale values live on the **host model's own columns**. `isTranslatable` is false when context equals default [VERIFIED: `classes/TranslatableBehavior.php:141-144`]. - Other locales: JSON in `winter_translate_attributes.attribute_data` keyed by attribute name [VERIFIED: `behaviors/TranslatableModel.php:224-243`]. - Missing key + `$translatableUseFallback = true` (default): return default-locale column [VERIFIED: `classes/TranslatableBehavior.php:211-216`]. - `setTranslatableUseFallback(false)` returns empty/null [VERIFIED: `tests/unit/behaviors/TranslatableModelTest.php:80-82`]. - Indexed attributes also write `winter_translate_indexes` (`item` = attribute, `value` = translated string) [VERIFIED: `behaviors/TranslatableModel.php:251-294`]. - `model_type` is `getMorphClass()` [VERIFIED: `behaviors/TranslatableModel.php:345-348`]. Fixture models may use their Go type string. Phase 15 Journal import from PHP must later use PHP class strings (`Golem15\Journal\Models\Post`); document `MorphName() string` on the translatable interface so Journal can pin that. PHP declaration Journal will copy: DATA_k7m2p9qx_START ``` public $translatable = [ 'title', 'content', 'content_html', 'excerpt', 'metadata', ['slug', 'index' => true], ]; ``` DATA_k7m2p9qx_END [VERIFIED: `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php:58-65`] Category: `'name'`, `'description'`, `['slug', 'index' => true]` [VERIFIED: `journal/models/Category.php:36-40`]. ### Minimum exported Go API (D-09, D-12) Do not 1:1 every PHP method. Journal needs: | Go API | PHP analog | Why | |--------|------------|-----| | `Translatable() []string` on the model | `$translatable` names (ignore options in the slice) | D-09 | | `TranslatableIndexes() []string` or options on the same method | `['slug', 'index' => true]` | Journal slug lookups | | `MorphName() string` | `getMorphClass()` | attribute `model_type` | | `WithLocale(ctx, db, locale) *gorm.DB` (and/or context locale) | `translateContext` / `lang()` + `transWhere` | D-12 queries | | `Translated(ctx, model, field, locale) (value, ok)` | `getAttributeTranslated($key, $locale)` | get in a locale | | `SetTranslated(ctx, model, field, locale, value)` | `setAttributeTranslated($key, $value, $locale)` | set in a locale | | `Translator.Locale(ctx) string` | `Translator::instance()->getLocale()` | current locale | PHP query scopes `scopeTransWhere` / `scopeTransOrderBy` join `winter_translate_indexes` [VERIFIED: `behaviors/TranslatableModel.php:89-137`]. Journal public posts-by-slug in a locale needs `WithLocale` + index lookup (fallback to the host `slug` column when no index row, same as PHP `else { $query->where($index, ...) }` [VERIFIED: `behaviors/TranslatableModel.php:104-108`]). **Do not port** this phase: `addTranslatableAttributes`, CMS page/url behaviors, eager `translations` relation name as a required public API, Redis cache keys `translation:%s:%s:%s` [VERIFIED: `behaviors/TranslatableModel.php:312-336`]. Cache is optional; correctness first. **KERN-07:** PHP `Translator` is a Singleton [VERIFIED: `classes/Translator.php:21`]. Go must **not** store active locale on a process-wide struct. Put it on `context.Context` via existing `towel.WithLocale` / `towel.Locale` [VERIFIED: `modules/towel/context.go:55-62`]. A Translator service may be published on backpack; its methods take `ctx`. Phrasebook `towel.WithLocale` is **UI locale**. Model `WithLocale` is **content locale**. They usually match after Translator runs, but the Translatable helpers must take an explicit locale argument so Journal API can request `pl` while the admin SPA UI stays `en`. ## 4. Translator locale resolution (D-07) Do not invent a simpler order. Port `LocaleMiddleware` plus URL-prefix registration. ### Request order (CMS / public HTML) `LocaleMiddleware::handle` [VERIFIED: `classes/LocaleMiddleware.php:19-45`]: 1. **URL prefix** — `Translator::loadLocaleFromRequest()` reads `Request::segment(1)` and accepts it only if `Locale::isValid` [VERIFIED: `classes/Translator.php:129-138`]. 2. Else **authenticated user's `preferred_locale`** if valid [VERIFIED: `classes/LocaleMiddleware.php:54-83`]. 3. Else **session** `loadLocaleFromSession()` [VERIFIED: `classes/Translator.php:204-213`]. 4. Else **Accept-Language**, only if browser detection is enabled **and** the manual-selection cookie is absent [VERIFIED: `classes/LocaleMiddleware.php:33-36, 96-100, 214-228`]. 5. Else **default locale** `setLocale($translator->getDefaultLocale())` [VERIFIED: `classes/LocaleMiddleware.php:38-41`]. D-07 says do not *reduce* to Accept-Language. The PHP tree **does** use Accept-Language as step 4. Port the full chain; do not drop URL/session; do not make Accept-Language first. ### Cookie and session keys | Key | What it stores | |-----|----------------| | `golem15.translate.locale` | Session locale (`const SESSION_LOCALE = 'golem15.translate.locale';`) [VERIFIED: `classes/Translator.php:23`] | | `golem15.translate.configured` | Cache/session "plugin is configured" (`SESSION_CONFIGURED`) [VERIFIED: `classes/Translator.php:25`] | | `locale_manually_set` | Cookie **flag only** (`'manualSelectionCookie' => 'locale_manually_set'`) [VERIFIED: `config/config.php:82`]. Value `'1'`, expiry 525600 minutes [VERIFIED: `config/config.php:85` and `routes.php:31-35`]. It does **not** contain a locale code. | URL prefix visit queues that cookie so browser detection will not override [VERIFIED: `routes.php:29-35`]. `setLocale($locale, $remember = true)` calls `Locale::isValid` then `App::setLocale` and optionally session [VERIFIED: `classes/Translator.php:58-72`]. Invalid codes return false; never persist them. ### API locale (Journal) `ApiLocaleMiddleware` is **not** URL/session: (1) user `preferred_locale`, (2) Accept-Language, (3) default; `setLocale($locale, false)` — no session [VERIFIED: `classes/ApiLocaleMiddleware.php:15-59`]. Journal `/_journal/api/v1` in Phase 15 should use this chain (or Translator current locale already set on ctx). This phase still ships Translator so both chains can call it. ### URL prefix mechanics `routes.php` registers a `{locale}/…` CMS catch-all unless `golem15.translate::disableLocalePrefixRoutes` [VERIFIED: `routes.php:11-13, 40-44`]. `getPathInLocale` prefixes the path; `prefixDefaultLocale` defaults true [VERIFIED: `config/config.php:26` and `classes/Translator.php:160-193`]. Go proof host has no CMS router yet (Phase 16). Still implement: - Parse first path segment; if it is an **enabled** locale code, strip it and set Translator locale, set manual-selection cookie. - Config keys under plugin namespace: `forceDefaultLocale`, `prefixDefaultLocale` (default true), `disableLocalePrefixRoutes` (default false), `browserDetection.enabled` (default true), `browserDetection.manualSelectionCookie` (`locale_manually_set`), `browserDetection.manualSelectionExpiry` (525600) [VERIFIED: `config/config.php:15-86`]. ### Where it hooks (Go seam) PHP attaches `LocaleMiddleware` to `CmsController` [VERIFIED: `Plugin.php:269-271`] and URL prefix in `routes.php`. Go surf already wraps every route with house `locale` that does **only** `towel.WithLocale(r.Context(), r.Header.Get("Accept-Language"))` [VERIFIED: `modules/surf/router.go:439` and `707-710`], then `LocaleFromPrincipal` may overlay `PreferredLocale` [VERIFIED: `modules/surf/locale_from_principal.go:10-18`]. That **is** the reduction D-07 forbids if left as the only resolver on the proof host. **Prescription:** Publish a `translate.Resolver` (or `Translator`) on backpack from plugin `Boot`. Change surf house `locale` in `summercms.go` to: if a Resolver is published, call it (URL prefix → user preferred → session → cookie-gated Accept-Language → default) and write `towel.WithLocale`; else keep today's Accept-Language behavior so `fonoteka.go` (no translate plugin) does not change. Session storage: signed cookie or existing session mechanism; key `golem15.translate.locale`. Validate with enabled-locale list on every request (never trust cookie/session blindly). `isValid` = code in `Locale::listEnabled()` [VERIFIED: `models/Locale.php:228-232`]. ## 5. PHP `ml*` widgets and cabana `mltext` / `mlmarkdown` ### Widgets that exist (lean ships two YAML types) `registerFormWidgets` [VERIFIED: `Plugin.php:145-157`]: `mlblocks`, `mlmarkdowneditor`, `mlmediafinder`, `mlnestedform`, `mlrepeater`, `mlricheditor`, `mltext`, `mltextarea`, `mlurl`. EventRegistry auto-replaces translatable fields [VERIFIED: `classes/EventRegistry.php:147-157`]: `text→mltext`, `textarea→mltextarea`, `markdown→mlmarkdowneditor`, plus repeater/richeditor/etc. Go has no runtime widget swap. Journal YAML will **declare** `type: mltext` / `type: mlmarkdown` on translatable fields. PHP Journal content is `type: Golem15\Journal\FormWidgets\JournalMarkdown` [VERIFIED: `journal/models/post/fields.yaml:35-37`] wrapped by `MLJournalMarkdown` whose alias is `'mlmarkdowneditor'` [VERIFIED: `journal/formwidgets/MLJournalMarkdown.php:22`]. Locked YAML names: `mltext`, `mlmarkdown` (D-06). Map PHP `mlmarkdowneditor` → Go `mlmarkdown`. Do not register `mltextarea` unless a fixture needs it; `mltext` covers title/slug; `mlmarkdown` covers content. A title field that was `text` becomes `mltext`. ### Locale switcher chrome (reproduce) `MLControl` [VERIFIED: `traits/MLControl.php`]: - Hidden inputs per locale: `name="getName('RLTranslate['.$code.']') ?>"` with `data-locale-value` [VERIFIED: `traits/mlcontrol/partials/_locale_values.htm:7-13`]. - Dropdown `data-switch-locale` [VERIFIED: `_locale_selector.htm:20`]. - Optional copy-from-locale [VERIFIED: `_locale_copy.htm:15`]. - Save: `post('RLTranslate')` then `setAttributeTranslated($key, $value, $locale)` for each; return value is the **default locale** entry [VERIFIED: `traits/MLControl.php:186-217`]. - `getLocaleValue` uses `setTranslatableUseFallback(false)` so empty translations stay empty in hidden fields [VERIFIED: `traits/MLControl.php:158-159`]. - Ctrl/Cmd-click switches **all** ML controls on the form [VERIFIED: `assets/js/multilingual.js:66-68`]. - `data-default-locale` on the wrapper [VERIFIED: `formwidgets/mltext/partials/_mltext.htm:9`]. SPA prescription: one locale switcher per ML field (and form-wide Ctrl-equivalent: switching one ML field switches all). Visible editor shows the active locale; save body includes every locale. ### Cabana landmines (must change in `summercms.go`) `formFieldTypes` currently `"text"`, `"textarea"`, `"number"`, `"checkbox"`, `"switch"`, `"dropdown"`, `"relation"`, `"relation-manager"`, `"widget"`, `"partial"`, `"fileupload"`, `"datepicker"`, `"password"`, `"permissioneditor"` [VERIFIED: `modules/cabana/form_schema.go:24-29`]. Unknown type error: `"unsupported type %s"` [VERIFIED: `modules/cabana/form_schema.go:477-478`]. Unknown YAML key: `"unknown field %s"` [VERIFIED: `modules/cabana/form_schema.go:465-466`]. `ProjectWritableFields` **drops nested objects** (`if !ok || nestedValue(val) { continue }`) [VERIFIED: `modules/cabana/crud.go:154-171`]. `scalarFormField` is only `text/textarea/number/checkbox/switch/dropdown/datepicker` [VERIFIED: `modules/cabana/crud.go:1203-1208`]. `BindWritableFields` skips non-scalar types [VERIFIED: `modules/cabana/crud.go:201`]. **Prescription:** Add `mltext` and `mlmarkdown` (and `markdown`) to `formFieldTypes`. Treat ML save values as `map[string]string` (locale→text) **or** default-locale scalar plus a sibling `translations` object. Do not drop those maps. After projecting, call `SetTranslated` for non-default locales; fill the host column with the default-locale value only (PHP `getLocaleSaveValue` return [VERIFIED: `traits/MLControl.php:216`]). SPA `admin/src/components/form/registry.ts` registerers today: `text`, `textarea`, `number`, `dropdown`, `switch`, `checkbox`, `relation`, `widget`, `partial`, `fileupload`, `datepicker`, `password`, `permissioneditor` [VERIFIED: `admin/src/components/form/registry.ts:49-63`]. Add `markdown`, `mltext`, `mlmarkdown`. Same change: cabana README, `docs/backend/forms.md` field-types table (currently lists those types and says markdown editor is **not** provided [VERIFIED: `docs/backend/forms.md:86-101`]), OpenAPI, `openapi-typescript`, committed `modules/boardwalk/dist/`. ### Share markdown with Phase 15 D-11 Land the shared `markdown` primitive **in this phase**. `mlmarkdown` = markdown editor + ML chrome. Phase 15 Journal content field is `type: mlmarkdown` (translate required) or `type: markdown` if someone boots Journal without translate — Journal 15 D-09/D-10 requires translate, so YAML can be `mlmarkdown` only. Goldmark `v1.8.6` already in framework `go.mod` [VERIFIED: `go.mod:30`]. Preview HTML: reuse Phase 4 mail rules (no `html.WithUnsafe`; reject leftover script/iframe). Admin preview can be client-side for the SPA and server-side on the preview screen later; minimum this phase is edit+save of markdown source per locale, not a second WYSIWYG. Do not port `mlblocks`, `mlrepeater`, `mlricheditor`, `mlmediafinder`, `mlnestedform`, `mlurl` (out of lean / Journal YAML). ## 6. Go analog: user plugin mount, cabana registry, phrasebook ### Mount pattern (copy) | Piece | User plugin fact | Translate | |-------|------------------|-----------| | Module path | `module git.golem15.com/golem15/sm-user-plugin` [VERIFIED: `../fonoteka.go/plugins/golem15/user/go.mod:1`] | `git.golem15.com/golem15/sm-translate-plugin` | | Package | `package user` [VERIFIED: `plugin.go:1`] | `package translate` | | Plugin ID | `return "golem15.user"` [VERIFIED: `plugin.go:58`] | `golem15.translate` | | `init` | `party.Register(&Plugin{})` [VERIFIED: `plugin.go:236-238`] | same | | `summer.yaml` | `- id: golem15.user` / `module: git.golem15.com/golem15/sm-user-plugin` [VERIFIED: `../fonoteka.go/summer.yaml:4-5`] | add `golem15.translate` | | Submodule | `path = plugins/golem15/user` [VERIFIED: `../fonoteka.go/.gitmodules:1-3`] | `plugins/golem15/translate` | | `go.work` | `./plugins/golem15/user` [VERIFIED: `../fonoteka.go/go.work:10`] | `./plugins/golem15/translate` | | Host `go.mod` replace | `replace git.golem15.com/golem15/sm-user-plugin => ./plugins/golem15/user` [VERIFIED: `sm-bm-app/go.mod:16`] | same shape | | Plugin `go.mod` replace | `replace git.golem15.com/golem15/summercms => ../../../../summercms.go` [VERIFIED: `sm-user-plugin/go.mod:120`] | identical relative path when mounted at `plugins/golem15/translate` | | Admin | `HasAdminControllers`, `AdminFS`, `HasPermissions`, `HasNavigation` [VERIFIED: `admin.go:33-37`] | Locales controller | | Capabilities | `HasMigrations`, `HasModels`, `HasLang`, `HasConfig` | same; plus Translator publish in `Boot` | Sibling host precedent: `sm-bm-app` (`summer.yaml` + `go.work` + `.gitmodules` + `replace … => ../summercms.go` [VERIFIED: `sm-bm-app/go.mod:7`]). Proof host should clone `sm-grzybyfunkcjonalne-app` remote and copy this layout, not `fonoteka.go`. Decision note: `.planning/notes/core-plugins-own-repos.md` — module path equals repo path; README never names a consuming app [VERIFIED: `.planning/notes/core-plugins-own-repos.md:12-15, 36`]. ### Cabana field registry pattern New types go in `formFieldTypes`, SPA `registry.ts`, docs table, OpenAPI — same as `permissioneditor` (`field_permission.go`) and `datepicker` / `fileupload`. Fail-loud: unknown keys/types stop `cabana.Activate` [VERIFIED: `docs/backend/admin-controllers.md:161`]. ### Phrasebook vs model translations (do not conflate) phrasebook: `"phrasebook owns every translatable string in a SummerCMS application"` — keys `namespace::group.dot.path`, YAML under `lang//` [VERIFIED: `modules/phrasebook/README.md:3-9`]. That is plugin UI (Locales labels). Model translations: `winter_translate_attributes` JSON per record. `I18N-01` is phrasebook. This plugin is the missing model layer. Translator request locale should set `towel.WithLocale` so phrasebook and content usually share a code, but storage APIs stay separate. ## 7. Docs / OpenAPI / TS / dist (same change) When adding `markdown`, `mltext`, `mlmarkdown`: 1. `modules/cabana/README.md` Features list + API identifiers that exist in the package. 2. `docs/backend/forms.md` field-types table (replace the sentence that the markdown editor is not provided [VERIFIED: `docs/backend/forms.md:101`]). 3. `docs/backend/admin-controllers.md` only if controller activation rules change. 4. swag annotations / `admin/openapi/admin.json` / openapi-typescript → `admin/src` types. 5. `admin/src/components/form/registry.ts` + Vue controls + tests. 6. Rebuild committed `modules/boardwalk/dist/`. 7. `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check`. 8. Neutral names only (`acme`, `blog`). ML-specific YAML keys (if any, e.g. none beyond `type`) must be added to `formFieldKeys` or boot fails [VERIFIED: `modules/cabana/form_schema.go:36-47`]. Prefer **no extra keys**: ML types reuse `label`, `comment`, `span`, `size`, `required`, `tab`, `context`. ## 8. Proof-host layout **Local dirs do not exist this session:** - `/media/nvme/dev/golem15/summercms.io/summercms/sm-translate-plugin/` — **missing** - `/media/nvme/dev/golem15/summercms.io/summercms/sm-grzybyfunkcjonalne-app/` — **missing** - `git@git.golem15.com:golem15/sm-translate-plugin.git` — user creates (D-15) - `git@git.golem15.com:golem15/sm-grzybyfunkcjonalne-app.git` — already created (CONTEXT remotes) Plan Wave 0: (1) user creates Gitea remote; (2) `git clone` host; (3) init plugin repo; (4) submodule add at `plugins/golem15/user` and `plugins/golem15/translate`. Boot without Journal: `summer.yaml` lists only `golem15.user` and `golem15.translate`. `Requires()` on translate: empty (or `golem15.user` only if locale-from-user needs it). Locale from user is optional (`Auth::check` in PHP); Translator works without user. ### Fixture model for en+pl proof PHP test fixture `Country` [VERIFIED: `tests/fixtures/models/Country.php:10-27`]: table `translate_test_countries`, `$translatable = [['name', 'index' => true], 'states']`. Go: test-only model `Article` (neutral) in the plugin test package **or** a tiny `pact.HasModels` fixture compiled only in tests (Phase 5 DATA-11: production binary must not load test plugins). Proof host D-17 needs a model the **booted app** can save through admin ML fields. Prescription: - Plugin testdata model is not enough for "Locales admin works" on the host. - Add a **proof-only** model in the **host app** package (not the shared plugin): e.g. `acme.demo.posts` with `Translatable() []string{ "title", "body" }`, YAML `mltext`/`mlmarkdown`, gated behind app plugin `golem15.grzyby` stub **or** document UAT against plugin integration tests that boot `party.Activate([]{user, translate, fixture})` like 12.1 `newAdminEnv`. Prefer: plugin integration test (httptest + testcontainers) that boots user+translate+in-process fixture plugin, plus host smoke `migrate`/`serve` with empty extra models. D-17 "fixture model" is satisfied by the test fixture if the host has no third plugin yet. Planner should state that explicitly so executors do not block on a host demo model. Proof steps: seed `en`+`pl`; create Locale rows visible in admin; `SetTranslated` title `en`/`pl`; `WithLocale` readback; missing `pl` falls back to `en`. ## 9. Security landmines Run the security-review agent (CONTEXT discretion). | Threat | PHP / Go fact | Mitigation | |--------|-----------------|------------| | Forged locale in URL/session/cookie | `setLocale` requires `Locale::isValid` [VERIFIED: `Translator.php:60-62`]; cookie is a flag not a locale [VERIFIED: `config.php:82`] | Re-validate enabled codes every request; ignore unknown first segments | | Accept-Language injection | PHP takes first two letters and checks enabled list [VERIFIED: `LocaleMiddleware.php:126-132`] | Same allow-list; do not pass raw header into `towel.WithLocale` (today surf does pass the raw header [VERIFIED: `surf/router.go:709`] — **fix when Resolver is present**) | | `manage_locales` bypass | controller `$requiredPermissions` [VERIFIED: `Locales.php:21`] | cabana `AdminPermissioned` on Locales; 403 otherwise | | Delete/unset default locale | `beforeDelete` / `beforeUpdate` [VERIFIED: `Locale.php:77-92`] | Port as validation 422 | | Mass-assign `is_default` / `sort_order` | fillable is only code, name, is_enabled [VERIFIED: `Locale.php:37-41`] | cabana Fillable / BindWritableFields; `is_default` via hook | | Attribute mass-assign | `$fillable` locale, model_type, model_id, attribute_data [VERIFIED: `Attribute.php:20-24`]; `$guarded = ['*']` [VERIFIED: `Attribute.php:30`] | No public Attribute controller; writes only through Translatable helpers | | Admin ML write as confused deputy | PHP widgets call `setAttributeTranslated` inside the form save of the **host** controller | ML save runs inside cabana CRUD after the host controller's permissions and `FormExtendQuery`; never a public unauthenticated translate endpoint | | Session locale forging | session value still passes `isValid` on `setLocale` | same | | XSS in translated admin fields | SPA text inputs; markdown preview must not `html.WithUnsafe` | Goldmark safe + CSP already on admin | | Messages import path traversal | `PathTraversalTest` [VERIFIED: `tests/security/PathTraversalTest.php`] | **Out of scope** (import deferred) — do not port ImportCommand | | Message `$guarded = []` | `AccessControlTest` TRANSLATE-001 [VERIFIED: `tests/security/AccessControlTest.php:39-66`] | Create messages table with a Go model that uses an explicit fillable if the struct exists; or omit Message model entirely and only DDL the table | Do not log Accept-Language payloads as a security event that echoes header text to clients. ## 10. Test landmines (lean subset) PHP `tests/` that still apply: | PHP test | Lean Go equivalent | |----------|-------------------| | `testGetTranslationValue` fallback to default [VERIFIED: `TranslatableModelTest.php:59-69`] | missing `pl` returns `en` column | | `testGetTranslationValueUseFallbackFalse` [VERIFIED: `:74-83`] | optional; D-11 is fallback-on. Skip `noFallback` unless Journal needs it | | `testSetTranslationValue` default vs fr isolation [VERIFIED: `:100-121`] | save `en`+`pl`; default column unchanged when setting `pl` | | `testTranslateWhere` needs locale + index [VERIFIED: `:146-162`] | `WithLocale` + indexed slug | | `testTranslateOrderBy` [VERIFIED: `:164-191`] | skip unless Journal lists by translated title this phase | | morphMap eager load [VERIFIED: `:193-218`] | skip (no morph map) | | `testAddTranslatableAttributes` [VERIFIED: `:220-234`] | skip (not in D-12 API) | | TRANSLATE-002 Attribute fillable [VERIFIED: `MassAssignmentTest.php:19-56`] | if Attribute model exists | | TRANSLATE-001 / 003 / 004 Messages | skip (deferred) | | CMS page/url tests | skip | Add Go tests the PHP suite lacks: Translator order (URL wins over session; invalid prefix ignored; cookie flag skips Accept-Language; unlisted code rejected); `manage_locales` 403; cabana boot fails on `type: mlunknown`; ML save nested map not dropped; house locale without plugin still Accept-Language (fonoteka). ## 11. Suggested plan split (recommendation, not PLAN.md) Four plans. Present this count at the plan-count checkpoint. Unit tests last. | Plan | One-line scope | |------|----------------| | **14.2.1-01** | Plugin repo + squashed `golem15_translate_*` schema (all four tables) + Locale model + en/pl seed + Translator + surf Resolver seam. Manual: Gitea remote + local checkouts. | | **14.2.1-02** | Translatable API (`Translatable`, `WithLocale`, get/set, fallback, indexes) + Locales admin (`manage_locales`, YAML, phrasebook) + fixture save/read. | | **14.2.1-03** | Cabana `markdown` + `mltext` + `mlmarkdown` (SPA switcher, nested save, docs/OpenAPI/TS/`dist/`) + proof host `sm-grzybyfunkcjonalne-app` boots user+translate. | | **14.2.1-04** | Unit/integration tests last: Translator order, Translatable fallback, admin authz, ML save, migrations up/down. | Do not put coverage gates on 01–03; smoke the host boot in 03. ## Architecture Patterns ### System Architecture Diagram ``` Public/Admin HTTP → surf recover / body limit → house locale: backpack Translator.Resolver? (URL prefix | user preferred | session | cookie-gated Accept-Language | default) else Accept-Language (today) → towel.WithLocale(ctx, code) → admin guard / public handler Admin Locales CRUD → cabana.CRUDService (permission golem15.translate.manage_locales) → golem15_translate_locales Admin Journal-like form (fixture / Phase 15) → mltext / mlmarkdown controls → default locale → host table columns → other locales → golem15_translate_attributes.attribute_data JSON → indexed fields → golem15_translate_indexes Journal query (Phase 15) → Translator.Locale(ctx) or WithLocale(..., "pl") → transWhere via indexes, fallback to host column ``` ### Recommended plugin structure ``` sm-translate-plugin/ ├── go.mod # git.golem15.com/golem15/sm-translate-plugin ├── plugin.go # ID golem15.translate, party.Register ├── README.md ├── admin.go # HasAdminControllers, AdminFS, nav, permissions ├── config/config.yaml # prefixDefaultLocale, browserDetection, … ├── lang/en/lang.yaml ├── lang/pl/lang.yaml ├── models/locale.go ├── models/attribute.go # optional; helpers may use table SQL ├── classes/translator.go ├── classes/translatable.go ├── controllers/locales.go ├── controllers/locales/config_list.yaml ├── controllers/locales/config_form.yaml ├── models/locale/fields.yaml ├── models/locale/columns.yaml └── updates/ # gormigrate squash ``` Framework touches: `modules/surf/router.go` locale seam; `modules/cabana/form_schema.go` + CRUD fill; `admin/src/components/form/*`; `docs/backend/forms.md`; `modules/boardwalk/dist/`. ### Pattern 1: Default locale on the row, others in attributes **What:** Host columns are the default locale. Other locales are one JSON blob per row/locale. **When:** Every translatable model (Journal Post/Category). **Do not:** Add `title_pl` columns or a JSON column on `golem15_journal_posts`. ### Pattern 2: Optional Translator on backpack **What:** Plugin Boot publishes Resolver; surf locale uses it if present. **When:** Any host that mounts `golem15.translate`. **Do not:** Change fonoteka's resolution when the plugin is absent. ### Anti-Patterns to Avoid - **JSON-column "simpler" storage:** violates D-10 and PHP attribute-row semantics. - **Copying PHP `winter_translate_*` names into Go DDL:** D-10 is locked to `golem15_translate_*`; Winter import needs a later mapping. - **Translator singleton holding the request locale:** violates KERN-07. - **Accept-Language as the only resolver:** violates D-07; also stop stuffing the raw header into context when Resolver runs. - **Plugin-owned `type: widget` ML controls:** field types belong in cabana. - **Conflating phrasebook keys with attribute JSON.** - **Shipping Messages admin / locale picker** (deferred). - **Editing the PHP plugin.** ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Admin list/form | Custom Vue Locales app | cabana YAML + CRUDService | 12.1 pattern; permissions, CSRF, schema | | UI strings | Copy PHP `Lang::get` into Translator | phrasebook `HasLang` | I18N-01 already ships | | Markdown parse | New library | goldmark v1.8.6 | Already in go.mod | | Request locale bag | `var currentLocale` | `towel.WithLocale` | KERN-07 | | Plugin mount | Ad-hoc GOPATH | submodule + go.work replace | core-plugins-own-repos.md | | Postgres schema | `AutoMigrate` | gormigrate squash | DATA-02 | **Key insight:** The expensive parts are the attribute-table shape and the locale resolver order. Reusing cabana/phrasebook/surf is mandatory; inventing a parallel admin or i18n stack is not. ## Common Pitfalls ### Pitfall 1: Wrong table prefix **What goes wrong:** Creating `winter_translate_locales` in Go because PHP models use that name. **Why:** Execute-phase D-10 is locked to `golem15_translate_*`; PHP `winter_translate_*` stays a mapping concern. **How to avoid:** Use `golem15_translate_*` in every Go migration and query. **Warning signs:** Go source containing `winter_translate_` or `rainlab_translate_`. ### Pitfall 2: Nested ML save dropped **What goes wrong:** SPA posts `{title: {en, pl}}`; cabana drops maps; only default locale saves. **Why:** `ProjectWritableFields` skips `nestedValue`. **How to avoid:** Special-case `mltext`/`mlmarkdown` before the nested drop. **Warning signs:** Fixture `pl` read returns `en` after a two-locale save. ### Pitfall 3: Default locale duplicated into attributes **What goes wrong:** Writing `en` into `winter_translate_attributes` as well as `posts.title`. **Why:** PHP `isTranslatable` is false in default context; `storeTranslatableBasicData` is for other locales. **How to avoid:** Only persist attribute rows for non-default locales. ### Pitfall 4: Surf Accept-Language leftover **What goes wrong:** Proof host still keys locale off the raw header. **Why:** House `locale()` is hardcoded. **How to avoid:** Resolver lookup in surf; tests for plugin-present vs plugin-absent. ### Pitfall 5: Phrasebook `en`/`pl` files vs Locale rows **What goes wrong:** Seeding Locale rows from phrasebook presence, or vice versa. **Why:** D-08 is seed rows, not the 19 PHP `unsupported_lang` files. **How to avoid:** Two Locale rows; two phrasebook YAML trees for plugin UI only. ### Pitfall 6: Host/plugin directories missing **What goes wrong:** Executor assumes checkouts exist. **Why:** Both paths missing this session. **How to avoid:** Plan 01 starts with clone/init and the manual Gitea remote. ## Code Examples ### Locale model table (PHP source of truth) DATA_n4w8c1vz_START ``` public $table = 'winter_translate_locales'; ``` DATA_n4w8c1vz_END [VERIFIED: `models/Locale.php:24`] ### Minimum Go translatable surface (planner skeleton; identifiers below are prescribed) ```go // Source: this RESEARCH (maps PHP $translatable / translateContext / getAttributeTranslated) type Translatable interface { Translatable() []string MorphName() string } func WithLocale(ctx context.Context, db *gorm.DB, locale string) *gorm.DB func Translated(ctx context.Context, db *gorm.DB, m Translatable, field, locale string) (any, error) func SetTranslated(ctx context.Context, db *gorm.DB, m Translatable, field, locale string, value any) error ``` `en` and `pl` must appear as Locale `code` values [VERIFIED: `updates/v1.3.1/seed_all_tables.php:18` and `updates/v2.4.0/seed_additional_locales.php:15`]. ### House locale seam (current, to wrap) DATA_r3t6y0ab_START ``` func locale(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r.WithContext(towel.WithLocale(r.Context(), r.Header.Get("Accept-Language")))) }) } ``` DATA_r3t6y0ab_END [VERIFIED: `modules/surf/router.go:707-710`] ## State of the Art | Old Approach (PHP) | Current Approach (Go) | When | Impact | |--------------------|----------------------|------|--------| | RainLab then Winter table names | Squash to `golem15_translate_*` | 2.0.0 rename in PHP; Go never emits rainlab or winter table names | Winter import needs a later mapping | | Eloquent `$implement` behavior | Go interface + GORM callbacks | this port | Explicit `Translatable()` | | Winter formwidget swap at runtime | Explicit YAML `mltext`/`mlmarkdown` | compiled admin | Journal YAML names ML types | | Translator singleton | `towel.WithLocale` + backpack service | KERN-07 | No request globals | | CMS URL prefix routes | Optional first-segment strip + cookie flag | Phase 16 still needs prefix | Implement resolver now | **Deprecated/outdated:** PHP `noFallbackLocale()` (deprecated 2.1.5) [VERIFIED: `TranslatableBehavior.php:169-173`] — do not export it. PHP `setTranslateAttribute` already removed in 2.0.1 notes. ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | Proof D-17 may be satisfied by an in-process fixture plugin rather than a host demo model | §8 | Planner may need a host stub model after all | | A2 | Cabana has no ReorderController; skipping drag-reorder is acceptable | §2 | Operators cannot reorder locales in admin until a later phase | | A3 | Session locale can live in a signed cookie named `golem15.translate.locale` if the host has no PHP-style session store | §4 | May need to align with whatever session cabana/admin already uses | | A4 | `de` must not be seeded even though PHP 2.4.0 inserts it | §2 | Matches D-08 / deferred remaining locales; confirm if a live Winter DB import expects `de` | A1–A3 are execution details, not stack choices. A4 follows locked D-08. ## Open Questions (RESOLVED) 1. **Proof fixture location — RESOLVED** - What we know: D-17 wants a fixture model; host checkout is empty/missing. - Resolution: use a test-only fixture plugin/model in the translate plugin integration harness, plus a real `sm-grzybyfunkcjonalne-app` host boot with user+translate and no third production plugin. Plans 02–04 implement this split. 2. **Admin session for Translator — RESOLVED** - What we know: PHP uses Laravel `Session::put(SESSION_LOCALE)`. - Resolution: use a signed remembered-locale cookie named `golem15.translate.locale`, with `locale_manually_set` remaining a separate flag-only cookie; validate the remembered code against enabled locales on every request. Plan 01 implements this Go session analog. 3. **Phase 15 ROADMAP depends_on — RESOLVED** - Resolution: keep the ROADMAP dependency edit deferred and out of Phase 14.2.1. The plans document Phase 15 as the consumer without mutating its roadmap metadata or blocking this phase. ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Go | plugin + framework | ✓ | go1.27.0-X:nodwarf5 | — | | Git | repos/submodules | ✓ | 2.55.0 | — | | PostgreSQL | migrations, tests | ✓ | `pg_isready` accepting on 5432 | — | | Docker | testcontainers last plan | ✓ | 29.7.2 | `-short` skip slower tests | | `sm-translate-plugin` dir | D-16 | ✗ | — | Plan 01 creates it | | `sm-grzybyfunkcjonalne-app` dir | D-14 | ✗ | — | Plan 01 clones remote | | Gitea `sm-translate-plugin.git` | D-15 | ✗ (does not exist yet) | — | **Manual user step** | **Missing dependencies with no fallback:** Gitea remote creation (human). Local dirs are created by the plan once the remote exists. **Missing dependencies with fallback:** none besides testcontainers `-short`. Graphify: disabled (`gsd_run graphify status` → not enabled). No graph context. Nyquist: `workflow.nyquist_validation` is `true` in `.planning/config.json`. ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Go `testing` + testcontainers-go v0.44.0 (Postgres) | | Config file | none — `go test ./...` | | Quick run command | `go test ./... -short -count=1` (plugin + `./modules/cabana` + `./modules/surf`) | | Full suite command | `go test ./... -count=1` in `sm-translate-plugin`, `summercms.go`, and host `go test ./...` | ### Phase Requirements → Test Map No REQUIREMENTS.md IDs were mapped (TBD). Map locked decisions: | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|--------------| | D-05 | Locales admin requires `golem15.translate.manage_locales` | integration | `go test ./... -run TestLocalesAdminForbidden -count=1` | ❌ Wave 0 | | D-06 | `type: mltext` and `type: mlmarkdown` compile; unknown type fails boot | unit | `go test ./modules/cabana -run TestMLFieldTypes -count=1` | ❌ Wave 0 | | D-07 | URL prefix wins; invalid code ignored; session/cookie flag; not header-only | unit | `go test ./classes -run TestTranslatorResolve -count=1` | ❌ Wave 0 | | D-08 | Seed inserts `en` (default, enabled) and `pl` (enabled, not default); not `de` | integration | `go test ./updates -run TestSeedEnPl -count=1` | ❌ Wave 0 | | D-09/D-12 | `Translatable()` + get/set + `WithLocale` | unit | `go test ./classes -run TestTranslatableGetSet -count=1` | ❌ Wave 0 | | D-10 | Tables `golem15_translate_locales\|attributes\|indexes\|messages` exist after migrate | integration | `go test ./updates -run TestTranslateTables -count=1` | ❌ Wave 0 | | D-11 | Missing `pl` falls back to default locale column | unit | `go test ./classes -run TestFallbackDefaultLocale -count=1` | ❌ Wave 0 | | D-17 | Host/plugin Activate with user+translate | smoke | `go test ./... -run TestBootUserTranslate -count=1` | ❌ Wave 0 | | T-SEC-01 | Unlisted locale never stored | unit | `go test ./classes -run TestInvalidLocaleRejected -count=1` | ❌ Wave 0 | | T-SEC-02 | Nested ML body not dropped | unit | `go test ./modules/cabana -run TestMLNestedSave -count=1` | ❌ Wave 0 | ### Sampling Rate - **Per task commit:** `go test ./... -short -count=1` in the repo that changed - **Per wave merge:** full `go test ./... -count=1` in that repo + `go vet ./...` - **Phase gate:** all three repos green before `$gsd-verify-work` ### Wave 0 Gaps - [ ] `sm-translate-plugin` module and test files listed above - [ ] `modules/cabana` ML compile/save tests - [ ] `modules/surf` Resolver-present vs absent locale tests - [ ] Plugin Postgres harness (copy user plugin TestMain / testcontainers) - [ ] No new test framework install ## Security Domain `security_enforcement` absent in `.planning/config.json` → enabled. ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | no (no new login) | existing cabana/backend + user plugin | | V3 Session Management | yes | signed locale cookie + `locale_manually_set`; `isValid` on load | | V4 Access Control | yes | `golem15.translate.manage_locales`; host controller perms for ML writes | | V5 Input Validation | yes | locale code allow-list; cabana fillable; goldmark safe preview | | V6 Cryptography | no | do not hand-roll tokens; reuse existing cookie signing if any | ### Known Threat Patterns | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | Locale code injection via URL/header/cookie | Tampering | Enabled-locale allow-list; cookie is flag-only | | Privilege on Locales admin | Elevation | `RequiredPermissions` + cabana 403 | | Nested ML JSON smuggling extra columns | Tampering | Project only translatable field names; Attribute writes via helpers | | Stored XSS in translated markdown | Information disclosure / Tampering | Goldmark without unsafe HTML | | Mass assignment on Locale/Attribute | Tampering | fillable allow-lists (PHP TRANSLATE-002) | | Translator process-wide locale leak across requests | Information disclosure | `context.Context` only (KERN-07) | ## Sources ### Primary (HIGH confidence) - Frozen PHP plugin `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate` at `725d547ec839f02b5fdc0f0a6faaed601a414d50` — migrations, models, Translator, Locales, ML widgets, tests - Journal PHP `/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php` and `Category.php` — `$translatable` lists - `summercms.go` `modules/cabana`, `modules/surf`, `modules/towel`, `modules/phrasebook`, `modules/pact`, `docs/backend/forms.md`, `docs/backend/admin-controllers.md`, `admin/src/components/form/registry.ts` - `../fonoteka.go/plugins/golem15/user/` mount analog; `sm-bm-app` host analog - `.planning/notes/core-plugins-own-repos.md`, `15-CONTEXT.md` D-09–D-11, D-18–D-21 ### Secondary (MEDIUM confidence) - None required for stack versions; go.mod read directly ### Tertiary (LOW confidence) - A1–A3 in Assumptions Log ## Metadata **Confidence breakdown:** - Standard stack: HIGH — no new packages; versions from `go.mod` - Architecture: HIGH — PHP storage and resolver read in full; Go seams read in full - Pitfalls: HIGH — cabana nested drop and surf Accept-Language verified in source **Research date:** 2026-10-06 **Valid until:** 2026-11-05 (PHP pin frozen; re-research if SHA changes) **Graphify:** disabled — no graph queries **Documentation lookup:** in-repo PHP + Go; Context7 not applicable