Files
summercms/.planning/phases/14.2.1-translate-plugin/14.2.1-RESEARCH.md
2026-10-06 10:57:17 +02:00

829 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>
## 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.
</user_constraints>
## 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 `winter_translate_*` (not `golem15_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`. CONTEXT D-10's `golem15_translate_*` placeholder is wrong against the frozen source. Use the verified `winter_translate_*` names so a Winter import and Journal cutover see the same tables.
## 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 |
|------------|-----------|--------------|
| `winter_translate_*` tables | JSON columns on host models | D-10 forbids it |
| `winter_translate_*` | Invented `golem15_translate_*` | Frozen models use `winter_translate_*` |
| 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_winter_translate_locales`
- `202610060002_create_winter_translate_attributes`
- `202610060003_create_winter_translate_indexes`
- `202610060004_create_winter_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="<?= $field->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/<locale>/` [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 `winter_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)
→ winter_translate_locales
Admin Journal-like form (fixture / Phase 15)
→ mltext / mlmarkdown controls
→ default locale → host table columns
→ other locales → winter_translate_attributes.attribute_data JSON
→ indexed fields → winter_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 Winter import.
- **Renaming tables to `golem15_translate_*`:** frozen PHP uses `winter_translate_*`.
- **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 `golem15_translate_locales` because CONTEXT guessed that name.
**Why:** D-10 asked the researcher to confirm; models say `winter_translate_*`.
**How to avoid:** Use the verified names in every migration and query.
**Warning signs:** `Schema::hasTable('winter_translate_locales')` in Translator.
### 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 `winter_translate_*` | 2.0.0 rename | Do not emit rainlab tables |
| 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
1. **Proof fixture location**
- What we know: D-17 wants a fixture model; host checkout is empty/missing.
- What's unclear: host app plugin vs test-only fixture.
- Recommendation: plugin integration test + host boot without Journal; no third production plugin.
2. **Admin session for Translator**
- What we know: PHP uses Laravel `Session::put(SESSION_LOCALE)`.
- What's unclear: SummerCMS public requests may not have a session store yet (admin uses JWT cookie).
- Recommendation: signed cookie `golem15.translate.locale` plus `locale_manually_set`; document as the Go analog of session+cookie.
3. **Phase 15 ROADMAP depends_on**
- Deferred idea: `$gsd-phase --edit 15` not done in discuss.
- Recommendation: planner notes it; do not block 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 `winter_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