Files
summercms/.planning/phases/14.2.1-translate-plugin/14.2.1-PATTERNS.md
Jakub Zych bd0a27f59f docs(14.2.1): lock Go tables to golem15_translate_*
Execute-phase Task 2 chose golem15-prefix over the researched winter_translate_* names so the plugin ships vendor tables; PHP winter names stay a later import mapping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 11:36:28 +02:00

630 lines
39 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 - Pattern Map
**Mapped:** 2026-10-06
**Files analyzed:** 42 new or modified files (plugin + framework + host + tests)
**Analogs found:** 40 / 42
Path roots:
- `FW/` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go` (working directory; relative paths below are from here unless prefixed).
- `USR/` = `../fonoteka.go/plugins/golem15/user/` — the `sm-user-plugin` submodule. This is the plugin-mount analog RESEARCH named. `/media/nvme/dev/golem15/fonoteka.go/plugins/golem15/user/` does **not** exist.
- `BM/` = `../sm-bm-app/` — proof-host analog (`summer.yaml` + `go.work` + `.gitmodules` + `replace`). Copy this layout, not `fonoteka.go`.
- `PHP/` = `/media/nvme/dev/golem15/fonoteka/plugins/golem15/translate` at SHA `725d547ec839f02b5fdc0f0a6faaed601a414d50` (verified `git rev-parse HEAD` this session; 2026-08-26). Read-only contract. Do not edit.
- `TR/` = `../sm-translate-plugin/` — **does not exist yet** (D-16). Plan 01 creates it.
- `APP/` = `../sm-grzybyfunkcjonalne-app/` — **does not exist yet** (D-14). Plan 01 clones the empty remote.
All analog paths below are git-tracked (`git ls-files` in `summercms.go`, inside the user submodule, inside `sm-bm-app`, and inside the PHP plugin). No gitignored mirror is named. `modules/boardwalk/dist`, `admin/openapi/admin.json` and `admin/src/api/schema.d.ts` are generated outputs: regenerate them, never hand-edit.
**Table names (locked D-10 execute-phase 2026-10-06):** Go tables are `golem15_translate_locales`, `golem15_translate_attributes`, `golem15_translate_indexes`, `golem15_translate_messages`. PHP at SHA 725d547 still uses `winter_translate_*`; copy column/index shape from PHP, not the Winter table names. Do not emit `winter_translate_*` or `rainlab_translate_*` in Go DDL.
Suggested plan split from RESEARCH (present at the plan-count checkpoint; unit tests last): **14.2.1-01** plugin repo + squashed schema + Locale + Translator + surf Resolver seam; **14.2.1-02** Translatable API + Locales admin + fixture; **14.2.1-03** cabana `markdown`/`mltext`/`mlmarkdown` + SPA + proof host boot; **14.2.1-04** unit/integration tests last.
## File Classification
### New plugin (`TR/` = `sm-translate-plugin`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `TR/go.mod` | config | — | `USR/go.mod` | exact |
| `TR/plugin.go` | provider | request-response | `USR/plugin.go` | exact |
| `TR/README.md` | docs | — | `USR/README.md` | exact |
| `TR/admin.go` | provider | — | `USR/admin.go` | exact |
| `TR/admin_permissions.go` | config | — | `USR/admin_permissions.go` | exact |
| `TR/admin_navigation.go` | config | — | `USR/admin_navigation.go` | exact |
| `TR/config/config.yaml` | config | — | `USR/config/config.yaml` + `PHP/config/config.php` | exact |
| `TR/lang/en/lang.yaml`, `TR/lang/pl/lang.yaml` | config | transform | `USR/lang/{en,pl}/lang.yaml` + `PHP/lang/en/lang.php` keys `plugin.*` / `locale.*` | exact |
| `TR/models/registry.go` | utility | — | `USR/models/registry.go` | exact |
| `TR/models/locale.go` | model | CRUD | `USR/models/user_group.go` (Fillable/Rules/TableName) + `PHP/models/Locale.php` (guards) | exact |
| `TR/models/attribute.go` | model | CRUD | `USR/models/api_token.go` shape + `PHP/models/Attribute.php` fillable | exact |
| `TR/updates/registry.go` | utility | — | `USR/updates/registry.go` | exact |
| `TR/updates/202610060001_create_golem15_translate_locales.go` | migration | CRUD | `USR/updates/00_base.go` + `USR/updates/202610020001_create_user_groups.go` (CREATE + seed style) | exact |
| `TR/updates/202610060002_create_golem15_translate_attributes.go` | migration | CRUD | `USR/updates/00_base.go` | exact |
| `TR/updates/202610060003_create_golem15_translate_indexes.go` | migration | CRUD | `USR/updates/00_base.go` | exact |
| `TR/updates/202610060004_create_golem15_translate_messages.go` | migration | CRUD | `USR/updates/00_base.go` (DDL only; no Message admin) | exact |
| `TR/updates/202610060005_seed_en_pl_locales.go` | migration | CRUD | `USR/updates/202610020001_create_user_groups.go` INSERT + `PHP/updates/v1.3.1/seed_all_tables.php` and `v2.4.0/seed_additional_locales.php` | exact |
| `TR/classes/translator.go` | service | request-response | `PHP/classes/Translator.php` + `LocaleMiddleware.php` + `ApiLocaleMiddleware.php` (contract) and `USR/plugin.go` Boot `app.Publish` | role-match |
| `TR/classes/translatable.go` | service | CRUD | `PHP/behaviors/TranslatableModel.php` + `classes/TranslatableBehavior.php` (no Go translatable analog) | partial |
| `TR/controllers/admin_registry.go` | config | — | `USR/controllers/admin_registry.go` | exact |
| `TR/controllers/locales.go` | controller | CRUD | `USR/controllers/usergroups_admin_controller.go` | exact |
| `TR/controllers/locales/config_list.yaml` | config | file-I/O | `USR/controllers/usergroups/config_list.yaml` + `PHP/controllers/locales/config_list.yaml` | exact |
| `TR/controllers/locales/config_form.yaml` | config | file-I/O | `USR/controllers/usergroups/config_form.yaml` + `PHP/controllers/locales/config_form.yaml` | exact |
| `TR/models/locale/fields.yaml` | config | file-I/O | `USR/models/usergroup/fields.yaml` + `PHP/models/locale/fields.yaml` | exact |
| `TR/models/locale/columns.yaml` | config | file-I/O | `USR/models/usergroup/columns.yaml` + `PHP/models/locale/columns.yaml` | exact |
### Framework (`FW/`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `modules/surf/router.go` (`locale()` seam) | middleware | request-response | same file `locale` 707-710 and `wrap` 439; `backpack.Lookup` from `USR/plugin.go` Boot | exact |
| `modules/surf/locale_resolver.go` (new interface, if extracted) | middleware | request-response | `modules/surf/locale_from_principal.go` + `modules/pact/capabilities.go` `HasHouseMiddleware` (do **not** use house-MW to replace `locale()`) | role-match |
| `modules/cabana/form_schema.go` | config compiler | transform | same file `formFieldTypes` 24-29, `compileFieldNode` 454-478 | exact |
| `modules/cabana/crud.go` | service | CRUD | same file `ProjectWritableFields` 154-176, `scalarFormField` 1203-1209, `save` lifts 575-590 | exact |
| `modules/cabana/field_ml.go` (new: `markdown` / `mltext` / `mlmarkdown`) | service | transform + CRUD | `modules/cabana/field_permission.go` (`liftPermissionValues` nested object) + `field_date.go` (`compileDatepickerKeys`) | role-match |
| `modules/cabana/README.md` | docs | — | same file Features list | exact |
| `docs/backend/forms.md` | docs | — | same file Field types table 86-101 | exact |
| `docs/backend/admin-controllers.md` | docs | — | same file Compilation at boot 159-161 (only if activation rules change) | exact |
| `admin/src/components/form/registry.ts` | config (registry) | transform | same file 49-63 | exact |
| `admin/src/components/form/fields/MarkdownField.vue` | component | request-response | `admin/src/components/form/fields/TextareaField.vue` | exact |
| `admin/src/components/form/fields/MLTextField.vue` | component | request-response | `TextField.vue` (editor) + `PHP/formwidgets/mltext/partials/_mltext.htm` (chrome) | role-match |
| `admin/src/components/form/fields/MLMarkdownField.vue` | component | request-response | MarkdownField + ML chrome (compose; do not duplicate markdown) | role-match |
| `admin/src/components/form/formState.ts` | utility | transform | same file `editablePayload` 50-69 (permissioneditor nested object already sent) | exact |
| `admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist/` | generated | — | regenerate; never analog-copy | — |
### Proof host (`APP/` = `sm-grzybyfunkcjonalne-app`)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `APP/go.mod` | config | — | `BM/go.mod` | exact |
| `APP/go.work` | config | — | `BM/go.work` | exact |
| `APP/summer.yaml` | config | — | `BM/summer.yaml` | exact |
| `APP/.gitmodules` | config | — | `BM/.gitmodules` | exact |
| `APP/main.go`, `APP/plugins.gen.go` | route | — | `BM/main.go`, `BM/plugins.gen.go` (`summer build` emits these; do not hand-author after first boot) | exact |
### Tests (plan 04 last)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `TR/admin_harness_test.go` + Locales admin tests | test | request-response | `USR/admin_harness_test.go` `newAdminEnv` | exact |
| `TR/updates/postgres_test.go` | test | CRUD | `USR/updates/postgres_test.go` TestMain + testcontainers | exact |
| `TR/classes/*_test.go` Translator + Translatable | test | request-response / CRUD | PHP `tests/unit/behaviors/TranslatableModelTest.php` landmines (lean subset) | partial |
| `modules/cabana/*_test.go` ML compile/save | test | transform | `modules/cabana/form_schema_test.go` `TestFormSchemaRejects` | exact |
| `modules/surf/*_test.go` Resolver present vs absent | test | request-response | `modules/surf/locale_from_principal_test.go` | exact |
| `admin/tests/form/registry.test.ts` + ML field tests | test | — | `admin/tests/form/registry.test.ts`, `admin/tests/form/DatepickerField.test.ts` | exact |
## Pattern Assignments
### `TR/go.mod` (config) — plan 01
**Analog:** `USR/go.mod` lines 1-14, 120
```
module git.golem15.com/golem15/sm-user-plugin
go 1.27.0
require (
git.golem15.com/golem15/summercms v0.0.0
github.com/go-gormigrate/gormigrate/v2 v2.1.7
...
gorm.io/gorm v1.31.2
)
replace git.golem15.com/golem15/summercms => ../../../../summercms.go
```
Copy: module `git.golem15.com/golem15/sm-translate-plugin`, Go 1.27.0, require GORM + gormigrate + summercms, **identical** `replace … => ../../../../summercms.go` when mounted at `plugins/golem15/translate`. Do not add goldmark here unless the plugin itself parses markdown; goldmark stays a framework dep.
Decision note: `.planning/notes/core-plugins-own-repos.md` lines 12-15 — module path equals repo path; package `translate`; plugin ID `golem15.translate`. README never names a consuming app.
---
### `TR/plugin.go` (provider) — plan 01
**Analog:** `USR/plugin.go` lines 28-64, 178-180, 207-209, 236-238
```go
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
)
//go:embed config
var configFS embed.FS
//go:embed lang
var langFS embed.FS
func (p *Plugin) ID() string { return "golem15.user" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
func (p *Plugin) Models() []any { return models.All() }
func init() { party.Register(&Plugin{}) }
```
Copy: `ID() "golem15.translate"`, `Requires()` empty (Translator works without user; locale-from-user is optional). **Do not** copy JWT/bouncer/mail/commands/middleware from user. **Do** `app.Publish` a Resolver in `Boot` (see Translator assignment). Admin capability assertions live in `admin.go`, not here.
PHP contract (do **not** port): `PHP/Plugin.php` `registerComponents`, `manage_messages`, `registerFormWidgets`, console commands, CMS extend — all deferred. Port only `manage_locales` permission (lines 71-75) and Locales as admin CRUD, not `HasSettings` (PHP `registerSettings` points at a list controller; cabana `HasSettings` is a singleton — RESEARCH §2).
---
### `TR/admin.go` + permissions + navigation (provider / config) — plan 02
**Analog:** `USR/admin.go` lines 12-38, `USR/admin_permissions.go` 14-21, `USR/admin_navigation.go` 11-19
```go
//go:embed controllers/usergroups/config_list.yaml controllers/usergroups/config_form.yaml models/usergroup/fields.yaml models/usergroup/columns.yaml
var adminFS embed.FS
func (p *Plugin) AdminFS() fs.FS { return adminFS }
func (p *Plugin) AdminControllers() []pact.AdminController {
return controllers.AdminControllers(func() *backpack.App { return p.app })
}
var (
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
)
```
Permissions analog (`USR/admin_permissions.go`):
```go
{
Code: "golem15.users.access_users",
Tab: userPermissionTab,
Label: "golem15.user::lang.plugin.access_users",
Roles: []string{"developer"},
},
```
Translate: `Code: "golem15.translate.manage_locales"`, tab `golem15.translate::lang.plugin.tab`, label `golem15.translate::lang.plugin.manage_locales`, `Roles: []string{"developer"}`. **Do not** register `golem15.translate.manage_messages` this phase.
Navigation analog: main item `Code: "translate"`, `Icon` lucide (`languages` or similar; PHP was `icon-language`), `Permissions: []string{"golem15.translate.manage_locales"}`, `Controller: "golem15.translate.locales"`, `Order` ~550 (PHP settings order 550). Locales is CRUD list+form, not a settings singleton.
---
### `TR/models/locale.go` (model, CRUD) — plan 01/02
**Analog (Go struct + Fillable + Rules):** `USR/models/user_group.go` lines 9-50
```go
type UserGroup struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Code *string `gorm:"column:code"`
// ...
}
func (UserGroup) TableName() string { return "user_groups" }
func (UserGroup) Fillable() []string { return []string{"name", "code", "description"} }
func (UserGroup) Rules() map[string]string {
return map[string]string{"name": "required|between:3,64", "code": "required|unique:user_groups"}
}
func init() { Register(UserGroup{}) }
```
**Contract (PHP):** `PHP/models/Locale.php` lines 24-43, 77-109
```php
public $table = 'winter_translate_locales';
public $rules = ['code' => 'required', 'name' => 'required'];
public $fillable = ['code', 'name', 'is_enabled'];
public $timestamps = false;
// beforeDelete: cannot delete default
// beforeUpdate: cannot unset default; makeDefault() writes is_default
// makeDefault: cannot make a disabled locale default
```
Copy: `TableName() "golem15_translate_locales"`, no `CreatedAt`/`UpdatedAt`, Fillable **only** `code`, `name`, `is_enabled`. `is_default` and `sort_order` are **not** fillable (PHP). Rules: `code` required, `name` required. Port delete/unset/disabled-default as `FormBeforeDelete` / `FormBeforeUpdate` returning `&cabana.ValidationError{Details: ...}` (422) — analog `USR/controllers/usergroups_admin_controller.go` 162-175.
`isValid` = code in enabled list (`PHP/models/Locale.php` 228-232). Default locale: `is_default` true row; seed `en` as default.
---
### `TR/models/attribute.go` (model, CRUD) — plan 02
**Contract:** `PHP/models/Attribute.php` lines 15-34 — table `winter_translate_attributes`, fillable `locale`, `model_type`, `model_id`, `attribute_data`, `$guarded = ['*']`.
**Go analog:** `USR/models/user_group.go` TableName + Register. No public Attribute controller. Writes only through Translatable helpers (RESEARCH T-SEC). Optional: omit a Message model entirely and only DDL `golem15_translate_messages` (RESEARCH §9). If Attribute exists, Fillable matches PHP; never expose an admin controller. Go `TableName()` is `golem15_translate_attributes` even though PHP's `$table` is `winter_translate_attributes`.
---
### `TR/updates/*` (migration, CRUD) — plan 01
**Analog (CREATE + Register):** `USR/updates/00_base.go` lines 1-33
```go
var migrations = []*gormigrate.Migration{
{
ID: "202609170001_create_users",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
...
)`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS users`).Error
},
},
}
func init() { Register(migrations...) }
```
**Analog (indexes + idempotent seed):** `USR/updates/202610020001_create_user_groups.go` lines 17-45 — `CREATE INDEX …`, `INSERT INTO … VALUES`.
**Contract columns (squash to final names; do not emit `rainlab_translate_*`):**
| Table | Columns (from PHP create + later updates) |
|-------|-------------------------------------------|
| `winter_translate_locales` | `id SERIAL PK`, `code TEXT` indexed, `name TEXT` indexed nullable, `is_default BOOLEAN DEFAULT FALSE`, `is_enabled BOOLEAN DEFAULT FALSE`, `sort_order INTEGER DEFAULT 0`. No timestamps. |
| `winter_translate_attributes` | `id`, `locale` indexed, `model_id` indexed nullable, `model_type` indexed nullable, `attribute_data TEXT` nullable |
| `winter_translate_indexes` | `id`, `locale` indexed, `model_id` indexed nullable, `model_type` indexed nullable, `item` indexed nullable, `value TEXT` nullable |
| `winter_translate_messages` | `id`, `code` indexed nullable, `message_data TEXT` nullable, `found BOOLEAN DEFAULT TRUE`, `code_pre_2_1_0 TEXT` indexed nullable — create empty; no Messages admin |
Suggested IDs (planner may adjust date prefix, not table names): `202610060001_create_golem15_translate_locales` … `202610060005_seed_en_pl_locales`.
**Seed contract:** `PHP/updates/v1.3.1/seed_all_tables.php` 16-22 (`en` / English / default / enabled) and `PHP/updates/v2.4.0/seed_additional_locales.php` 13-20 (`pl` / Polski / not default / enabled / `sort_order` 2). Set `en.sort_order = 1`. **Do not seed `de`.** Idempotent: insert by `code` if missing (`$exists` check lines 33-36).
---
### `TR/classes/translator.go` (service, request-response) — plan 01
**Contract order (do not reduce):** `PHP/classes/LocaleMiddleware.php` 24-41
1. URL prefix (`Translator::loadLocaleFromRequest` — first path segment, only if `Locale::isValid`) — `PHP/classes/Translator.php` 129-138
2. Else authenticated user's `preferred_locale` if valid — middleware 54-83
3. Else session `golem15.translate.locale` — Translator 204-213
4. Else Accept-Language **only if** `browserDetection.enabled` **and** cookie `locale_manually_set` is absent — middleware 33-36, 96-100, 214-228. Parse q-values; take first two letters; allow-list against enabled codes (126-132). **Do not** pass the raw header into `towel.WithLocale`.
5. Else default locale.
API chain (ship Translator so Phase 15 can call it): `PHP/classes/ApiLocaleMiddleware.php` 40-59 — user preferred → Accept-Language → default; `setLocale($locale, false)` no session.
Keys (`PHP/classes/Translator.php` 23-25, `config/config.php` 77-86):
- session/cookie locale: `golem15.translate.locale`
- configured flag: `golem15.translate.configured`
- manual-selection cookie **flag only**: `locale_manually_set` = `'1'`, expiry 525600 minutes. Does **not** contain a locale code. URL-prefix visit queues it (`PHP/routes.php` 29-35).
`setLocale` requires `Locale::isValid`; invalid codes return false and are never persisted (`Translator.php` 58-72).
**Go seam analog:** `USR/plugin.go` Boot Publish (lines 77-95) + `FW/modules/backpack/app.go` 59-73
```go
func (a *App) Publish[T any](value T) error { return a.Services.Publish(value) }
func (a *App) Lookup[T any]() (T, bool) { ... }
```
User plugin publishes `*bouncer.Registry` / `bouncer.BlacklistStore`. Translate Boot publishes a `surf.LocaleResolver` (or equivalent interface **in the framework**, because `surf` must not import the plugin). Methods take `ctx` and `*http.Request`; they must not store the active locale on a process-wide struct (KERN-07). Write the resolved code with `towel.WithLocale`.
**Do not** implement PHP Singleton `Translator::instance()`. **Do not** use `pact.HasHouseMiddleware` to replace house `locale()`: house-MW is a named middleware table (`FW/modules/pact/capabilities.go` 47-59); `locale()` is hardcoded in `wrap` after named MW (`router.go` 439).
Config keys under plugin namespace (`PHP/config/config.php`): `forceDefaultLocale`, `prefixDefaultLocale` (default true), `disableLocalePrefixRoutes` (default false), `browserDetection.enabled` (default true), `browserDetection.manualSelectionCookie`, `browserDetection.manualSelectionExpiry`. Port into `TR/config/config.yaml` via `HasConfig` like `USR/config/config.yaml`.
---
### `modules/surf/router.go` locale seam (middleware) — plan 01
**Analog (current, to wrap):** `FW/modules/surf/router.go` 439 and 707-710
```go
h = locale(h)
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"))))
})
}
```
`wrap` currently has `*Router` but **no** `*backpack.App`. `BuildRouter(app, plugins)` has `app` (459-464). Prescription: store `app` on `Router` during `BuildRouter`, then `locale()` Lookup Resolver; if published, call it and write a **validated** code; else keep today's Accept-Language behavior so `fonoteka.go` (no translate plugin) does not change.
**Existing overlay (keep when no Resolver):** `FW/modules/surf/locale_from_principal.go` 10-18 — `PreferredLocale` on the principal. With Resolver, user preferred is step 2 of the PHP chain (after URL prefix), not a post-hoc overlay of the raw header.
**Context bag:** `FW/modules/towel/context.go` 55-62
```go
func WithLocale(ctx context.Context, locale string) context.Context {
return withValue(ctx, localeKey{}, locale)
}
func Locale(ctx context.Context) (string, bool) {
return stringValue(ctx, localeKey{})
}
```
Phrasebook UI locale and model content locale usually match after Translator runs; Translatable helpers still take an **explicit** locale argument (admin SPA UI can stay `en` while content is `pl`).
**Cookie attributes analog (not JWT):** `FW/modules/cabana/auth.go` 243-252 — `HttpOnly`, `Secure`, `SameSite`. Locale cookies are not admin session tokens; re-validate enabled codes every request. RESEARCH A3: signed cookie `golem15.translate.locale` is the Go stand-in for Laravel session; do not invent a second JWT library.
---
### `TR/classes/translatable.go` (service, CRUD) — plan 02
**No Go translatable analog.** Copy PHP storage, not Eloquent magic.
**Contract:** `PHP/classes/TranslatableBehavior.php` 141-216 and `PHP/behaviors/TranslatableModel.php` 89-108, 224-294, 345-348
- Default locale values live on the **host model's own columns**. `isTranslatable` is false when context equals default.
- Other locales: JSON object in `winter_translate_attributes.attribute_data`, one row per `(locale, model_id, model_type)`.
- Missing key + fallback on (default): return default-locale column.
- Indexed attributes (`['slug', 'index' => true]`) also write `winter_translate_indexes` (`item` = attribute, `value` = translated string).
- `model_type` = `getMorphClass()` → Go `MorphName() string`.
- `scopeTransWhere`: look up index table; if no rows, `where` on the host column (104-108).
- Do **not** port Redis `translation:%s:%s:%s` cache this phase.
**MorphName analog:** `USR/models/user.go` 59-60 and `FW/modules/lagoon/attach/example_test.go` 21
```go
func (User) MorphName() string { return `Golem15\User\Models\User` }
func (Post) MorphName() string { return `Acme\Blog\Models\Post` }
```
Fixture models may use a Go type string. Document that Phase 15 Journal import must pin PHP class strings (`Golem15\Journal\Models\Post`).
Minimum exported API (RESEARCH §3; identifiers prescribed there):
```go
type Translatable interface {
Translatable() []string
MorphName() string
}
func WithLocale(ctx context.Context, db *gorm.DB, locale string) *gorm.DB
func Translated(...) (any, error)
func SetTranslated(...) error
```
Plus `TranslatableIndexes() []string` (or options) for Journal slugs. Do not 1:1 every PHP method. Do not export deprecated `noFallbackLocale`.
---
### `TR/controllers/locales.go` (controller, CRUD) — plan 02
**Analog:** `USR/controllers/usergroups_admin_controller.go` 41-53, 123-131, 162-175 + `USR/controllers/admin_registry.go` 12-18, 42-50
```go
func (usergroupsAdminController) ID() string { return "golem15.user.usergroups" }
func (usergroupsAdminController) ModelName() string { return `Golem15\User\Models\UserGroup` }
func (usergroupsAdminController) ConfigDir() string { return "controllers/usergroups" }
func (usergroupsAdminController) RequiredPermissions() []string {
return []string{PermissionAccessGroups}
}
func (usergroupsAdminController) NewRecord() any { return &models.UserGroup{} }
```
Translate: `ID() "golem15.translate.locales"`, `ModelName() \`Golem15\Translate\Models\Locale\``, `ConfigDir() "controllers/locales"`, `RequiredPermissions() []string{"golem15.translate.manage_locales"}`, `NewRecord() &models.Locale{}`.
PHP `Locales.php` 21: `$requiredPermissions = ['golem15.translate.manage_locales']`. Implement `pact.AdminPermissioned`. 403 without it.
Hooks: `FormBeforeDelete` refuse default locale; `FormBeforeUpdate` refuse unsetting default / making disabled default — return `&cabana.ValidationError` (422) with phrase keys from `PHP/lang/en/lang.php` 27-29 (`unset_default`, `delete_default`, `disabled_default`). Analog Forbidden vs Validation: usergroups uses `ForbiddenError` for permission (403) and `ValidationError` for code format (422). Locale guards are validation, not 403.
PHP ReorderController has **no cabana analog**. Keep `sort_order`, seed `en=1` `pl=2`, list `defaultSort` `sort_order` asc. Do not invent drag-reorder.
**Error types:** `FW/modules/cabana/crud.go` 62-81 `ValidationError` / `ForbiddenError`.
---
### Admin YAML (config, file-I/O) — plan 02
**Go analog:** `USR/controllers/usergroups/config_list.yaml` (recordUrl, recordsPerPage 20, toolbar create, search) and `config_form.yaml` (form path, modelClass, redirects).
**PHP contract fields:** `PHP/models/locale/fields.yaml` — `name`, `code`, `is_enabled` checkbox, `is_default` checkbox. `PHP/models/locale/columns.yaml` — name/code searchable, is_default switch, is_enabled switch invisible, sort_order number invisible.
Cabana list types (`FW/modules/cabana/list_schema.go` 22-24): `"text"`, `"datetime"`, `"switch"`, `"date"`, `"time"`. Map PHP `type: number` on invisible `sort_order` → omit type (text) or drop from visible columns.
`config_list.yaml`: PHP `recordOnClick` is Winter AJAX; Go uses `recordUrl: golem15/translate/locales/update/:id` like usergroups. `title: golem15.translate::lang.locale.label_plural`. `defaultSort.column: sort_order`, `direction: asc`.
Embed YAML file-by-file in `admin.go` (`//go:embed controllers/locales/... models/locale/...`), same as user — do not embed the whole `controllers/` tree (it will hold `.go` files).
---
### Phrasebook lang YAML (config) — plan 02
**Analog:** `USR/lang/en/lang.yaml` `plugin:` block lines 4-10 and `USR/lang/pl/lang.yaml` same keys.
**Contract keys to port (UI only):** `PHP/lang/en/lang.php` 4-41 `plugin.name/description/tab/manage_locales` and `locale.*` (label, label_plural, title, create/update titles, name, code, is_default, is_enabled, help, delete/unset/disabled_default, sort_order, hint). Do **not** load PHP `unsupported_lang/` or `messages.*` this phase.
Do not conflate phrasebook files with Locale seed rows (D-08 is two Locale rows; two YAML trees for plugin UI).
---
### Cabana `markdown` / `mltext` / `mlmarkdown` — plan 03
**Registry analog:** `FW/modules/cabana/form_schema.go` 24-47 and 465-478
```go
formFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, ... "datepicker": {}, "password": {}, "permissioneditor": {},
}
// unknown key → "unknown field %s"
// unknown type → "unsupported type %s"
```
Add `"markdown"`, `"mltext"`, `"mlmarkdown"` to `formFieldTypes`. Prefer **no extra YAML keys** (reuse `label`, `comment`, `span`, `size`, `required`, `tab`, `context`). If a key is added, it must go in `formFieldKeys` or boot fails.
**Compile analog:** `FW/modules/cabana/field_date.go` 42-52 `compileDatepickerKeys` — refuse type-specific keys on other types; call from `compileFieldNode` next to `compilePermissionKeys` / `compileDatepickerKeys` (form_schema.go 551-558).
**Nested save analog:** `FW/modules/cabana/field_permission.go` 177-210 `liftPermissionValues` — read `body[name]` as `map[string]any` **before** scalar projection. Hook the lift from `CRUDService.save` (`crud.go` 575-590) the same way relations/virtual/permissions are lifted.
**The landmine:** `ProjectWritableFields` (`crud.go` 154-176) drops `nestedValue` (maps/slices, 1224-1230). `scalarFormField` (1203-1209) is only `text/textarea/number/checkbox/switch/dropdown/datepicker` — `mltext`/`mlmarkdown`/`markdown` are skipped by `BindWritableFields` (201) unless treated as scalar **or** lifted like permissioneditor.
**PHP save contract:** `PHP/traits/MLControl.php` 186-216 — POST all locales; `setAttributeTranslated` per locale; return value is the **default locale** entry only. `getLocaleValue` uses `setTranslatableUseFallback(false)` so empty translations stay empty in hidden fields (158-159). Host column = default locale; attribute rows = other locales only (do not duplicate default into `winter_translate_attributes`).
**SPA registry analog:** `FW/admin/src/components/form/registry.ts` 49-63 — add `markdown`, `mltext`, `mlmarkdown`. `isRegistered` must stay true so `formState.editablePayload` (50-69) includes them. permissioneditor already sends a nested object whenever shown (60-63) — ML fields should send `Record<locale, string>` the same way.
**Chrome contract:** `PHP/formwidgets/mltext/partials/_mltext.htm` — wrapper `data-default-locale`, locale selector, hidden inputs per locale, copy-from-locale optional. One switcher per ML field; switching one switches all (PHP Ctrl/Cmd-click). Visible editor shows the active locale.
**Markdown primitive analog:** `FW/modules/postcard/templates.go` 24-29 `goldmark.New()` already in framework `go.mod` v1.8.6. Land shared `markdown` **this phase** (`mlmarkdown` = markdown editor + ML chrome). Safe mode: no `html.WithUnsafe`; reject leftover script/iframe (postcard `rawUnsafeTag`). Minimum this phase is edit+save of markdown source per locale, not WYSIWYG.
**Docs analog:** `FW/docs/backend/forms.md` 86-101 — add the three types; **replace** the sentence that the markdown editor is not provided (line 101). Neutral names only (`acme`, `blog`). Same change: `modules/cabana/README.md` Features, OpenAPI, openapi-typescript, committed `boardwalk/dist/`. `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check`.
**Fail-loud test analog:** `FW/modules/cabana/form_schema_test.go` `TestFormSchemaRejects` 184-197 (`unknown key` / `unknown type`). Add `type: mlunknown` fails boot.
---
### SPA field components — plan 03
**Text analog:** `FW/admin/src/components/form/fields/TextField.vue` 1-26 — `FieldControlProps`, `update:modelValue`, `controlAttributes` / `controlClass`.
**Textarea analog for markdown source:** `FW/admin/src/components/form/fields/TextareaField.vue` 1-30 — size → rows.
**Value-field test analog:** `FW/admin/tests/form/DatepickerField.test.ts` mount + emit; `FW/admin/tests/form/registry.test.ts` `it.each` renderer map (31-42).
No existing multilingual control. Compose: inner TextField/MarkdownField + locale switcher chrome. `modelValue` is `Record<string, string>` (locale → text), not a scalar.
---
### Proof host (`APP/`) — plan 03 (clone in plan 01)
**Analog:** `BM/go.mod` 1-20, `BM/go.work` 5-10, `BM/summer.yaml` 1-9, `BM/.gitmodules` 1-3, `BM/main.go` 22-38, `BM/plugins.gen.go` 1-16
```
module git.golem15.com/jakub/sm-bm-app
replace git.golem15.com/golem15/summercms => ../summercms.go
replace git.golem15.com/golem15/sm-user-plugin => ./plugins/golem15/user
```
```yaml
plugins:
- id: golem15.user
module: git.golem15.com/golem15/sm-user-plugin
```
```
[submodule "plugins/golem15/user"]
path = plugins/golem15/user
url = git@git.golem15.com:golem15/sm-user-plugin.git
```
```go
plugins, err := party.Activate(app, PluginIDs)
```
Proof host: module `git.golem15.com/golem15/sm-grzybyfunkcjonalne-app` (or whatever the empty remote uses), plugins **only** `golem15.user` + `golem15.translate`, submodule paths `plugins/golem15/user` and `plugins/golem15/translate`, `go.work` use both, `replace` both to `./plugins/...`, framework replace `../summercms.go`. `main.go` / `plugins.gen.go` come from `summer build` after `summer.yaml` exists (`FW/examples/hello/plugins.gen.go` same stamp `// Code generated by summer build. DO NOT EDIT.`).
D-17 fixture: RESEARCH A1 — prefer plugin integration test (`party.Activate([]{user, translate, in-process fixture})`) plus host smoke `migrate`/`serve` without a third production plugin. Do not block on a host demo model.
Manual Wave 0: user creates `git@git.golem15.com:golem15/sm-translate-plugin.git` (does not exist). Host remote already exists.
---
### Tests — plan 04
**Admin boot analog:** `USR/admin_harness_test.go` 129-191 `newAdminEnv` — `party.Activate`, `lagoon.Migrate`, `surf.Assemble`, mint backend admin with a permission set. Locales 403 without `golem15.translate.manage_locales`.
**Postgres analog:** `USR/updates/postgres_test.go` 25-36 TestMain + testcontainers `postgres:16-alpine`, `-short` skip.
**Surf analog:** `FW/modules/surf/locale_from_principal_test.go` — table of Resolver-absent (raw Accept-Language still set, fonoteka) vs Resolver-present (URL wins; invalid prefix ignored; cookie flag skips Accept-Language; unlisted code rejected).
Lean PHP landmines (do not 1:1 the suite): fallback default; set `pl` does not change default column; `WithLocale` + indexed slug; skip morphMap, `addTranslatableAttributes`, Messages, CMS page/url.
## Shared Patterns
### Plugin mount (submodule + go.work + replace)
**Source:** `USR/go.mod`, `BM/go.mod` / `go.work` / `summer.yaml` / `.gitmodules`, `.planning/notes/core-plugins-own-repos.md`
**Apply to:** `TR/` repo creation and `APP/` first mount
Module `git.golem15.com/golem15/sm-translate-plugin`, package `translate`, ID `golem15.translate`, `party.Register` in `init`, `summer.yaml` lists the id+module, submodule `plugins/golem15/translate`, plugin `replace` framework `../../../../summercms.go`, host `replace` plugin `./plugins/golem15/translate`.
### Backpack Publish / Lookup (optional Translator)
**Source:** `FW/modules/backpack/app.go` 59-73; `USR/plugin.go` Boot 77-95
**Apply to:** Translator Resolver; surf `locale()` Lookup
Interface lives in **framework** (`surf` or a tiny contract next to `locale()`). Plugin Boot publishes. Surf looks up; if missing, today's Accept-Language. No process-wide locale (KERN-07).
### Request locale bag
**Source:** `FW/modules/towel/context.go` 55-62
**Apply to:** Translator and phrasebook
Always `towel.WithLocale(ctx, validatedCode)`. Never `var currentLocale`. Never stuff the raw `Accept-Language` header into context when Resolver is present.
### Admin CRUD + permissions
**Source:** `USR/admin.go`, `usergroups_admin_controller.go`, `pact.HasAdminControllers` / `AdminPermissioned` (`FW/modules/pact/capabilities.go` 182-195)
**Apply to:** Locales controller
YAML + `CRUDService`. `RequiredPermissions` `golem15.translate.manage_locales`. Fillable allow-list. Lifecycle hooks return `ValidationError` (422) or `ForbiddenError` (403). Fail-loud YAML at `cabana.Activate` (`docs/backend/admin-controllers.md` 161).
### Nested form values (ML save)
**Source:** `FW/modules/cabana/field_permission.go` `liftPermissionValues`; `crud.go` `save` lifts before `ProjectWritableFields`
**Apply to:** `mltext` / `mlmarkdown` locale maps
Lift `map[string]string` per ML field before nested drop. Default locale → host column; other locales → `SetTranslated`. Do not bind ML maps as extra model columns.
### Morph type strings
**Source:** `USR/models/user.go` `MorphName`; `FW/modules/lagoon/attach/example_test.go`
**Apply to:** `winter_translate_attributes.model_type` / indexes
Explicit `MorphName() string`. Do not use `reflect.TypeOf(x).String()`.
### Phrasebook vs model translations
**Source:** `USR/lang/{en,pl}/lang.yaml`; `FW/modules/phrasebook` (HasLang)
**Apply to:** Locales UI strings only
`I18N-01` is phrasebook. Attribute JSON is the model layer. Seed Locale **rows** independently of YAML files.
### Markdown
**Source:** `FW/modules/postcard/templates.go` goldmark pipeline
**Apply to:** cabana `markdown` / `mlmarkdown` preview if any
One library (`github.com/yuin/goldmark` v1.8.6). Safe HTML. No second markdown parser.
### Cookie flags
**Source:** `FW/modules/cabana/auth.go` `sessionCookie` 243-252
**Apply to:** `locale_manually_set` and locale cookie
HttpOnly + Secure + SameSite. Manual cookie is flag `"1"`, not a locale code. Re-validate locale cookies against enabled list every request.
### Test harness
**Source:** `USR/admin_harness_test.go` `newAdminEnv`; `USR/updates/postgres_test.go`
**Apply to:** plugin admin + migration tests last
`party.Activate` + `lagoon.Migrate` + `surf.Assemble`. testcontainers behind `-short`.
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `TR/classes/translatable.go` (attribute JSON + `WithLocale` index lookup) | service | CRUD | No Go model currently stores translations in `winter_translate_*`. Copy PHP `TranslatableModel` / `TranslatableBehavior` storage; use `MorphName` + GORM only as structural analogs. |
| `admin/.../MLTextField.vue` / `MLMarkdownField.vue` locale switcher | component | request-response | No multilingual SPA control exists. Compose TextField/TextareaField + PHP `_mltext.htm` chrome; nested save follows permissioneditor. |
Planner should use RESEARCH.md §3–§5 for those two, not invent JSON columns or `type: widget` ML controls.
## Do not copy / anti-patterns
- **`golem15_translate_*` table names** — frozen PHP uses `winter_translate_*`.
- **JSON column on host models** — D-10.
- **Translator singleton / package-level request locale** — KERN-07.
- **Accept-Language as the only resolver** — D-07; also stop stuffing the raw header into context when Resolver runs.
- **Plugin-owned `type: widget` ML controls** — field types belong in cabana (`formFieldTypes`).
- **`HasSettings` for Locales** — it is CRUD; use `HasAdminControllers` + `HasNavigation`.
- **`HasHouseMiddleware` as the locale seam** — house-MW is named; `locale()` is hardcoded in `wrap`.
- **Messages admin, locale picker, AI/theme commands, message import** — deferred.
- **Seeding `de`** — D-08 is `en`+`pl` only.
- **Editing `wn-translate-plugin` / PHP tree**.
- **AutoMigrate as schema** — gormigrate squash only.
- **Hand-editing `boardwalk/dist` or OpenAPI JSON**.
- **Naming a consuming app in the plugin README**.
## Metadata
**Analog search scope:** `USR/` (sm-user-plugin), `BM/` (sm-bm-app), `FW/modules/{cabana,surf,towel,backpack,pact,postcard,lagoon}`, `FW/admin/src/components/form`, `FW/docs/backend`, `PHP/` translate plugin at `725d547ec839f02b5fdc0f0a6faaed601a414d50`
**Files scanned:** ~90 analog files read or grepped; 3–5 strong matches per new file; PHP pin SHA verified
**Pattern extraction date:** 2026-10-06
**Tracked-source gate:** every analog path printed by `git ls-files` in its own repository