41 KiB
Phase 15: Journal plugin - Pattern Map
Mapped: 2026-10-06 Files analyzed: 54 new or modified files (plugin + host + tests); 0 framework field-type files Analogs found: 52 / 54
Path roots:
FW/=/media/nvme/dev/golem15/summercms.io/summercms/summercms.go(working directory; relative paths below are from here unless prefixed).TR/=../sm-translate-plugin/— closest compiled-plugin analog (sibling checkout,Requiresempty,Translatable, host already mounts it).USR/=../fonoteka.go/plugins/golem15/user/— thesm-user-pluginsubmodule. Closest analog forHasRoutes,Buckets, CLI commands, YAML with filters/relations/fileupload,ListExtendQuery, CORS test./media/nvme/dev/golem15/fonoteka.go/plugins/golem15/user/does not exist; this path does.APP/=../sm-grzybyfunkcjonalne-app/— proof host from 14.2.1 (user + translate only today).PHP/=/media/nvme/dev/golem15/fonoteka/plugins/golem15/journalat SHA02110eb1c0c3861370b0b9b47b209a0702ac5d88(verifiedgit rev-parse HEADthis session). Read-only contract. Do not edit (D-07).JR/=../sm-journal-plugin/— does not exist yet (D-23). Plan 01 clonesgit@git.golem15.com:golem15/sm-journal-plugin.gitthere.
All analog paths below are git-tracked (git ls-files in summercms.go, inside sm-translate-plugin, inside the user submodule, inside sm-grzybyfunkcjonalne-app, and inside the PHP journal plugin). No gitignored mirror is named. modules/boardwalk/dist, admin/openapi/admin.json and admin/src/api/schema.d.ts are generated outputs: this phase does not regenerate them (D-11 already shipped).
D-11 no-op: type: markdown / mltext / mlmarkdown already live in FW/modules/cabana/form_schema.go lines 24-29 and docs/backend/forms.md 324-346. Do not add a second YAML type. Do not change cabana.RenderMarkdown. Journal post content is mlmarkdown. Plugin FormatHTML owns footnotes/tables/attributes + the same rejectUnsafe gate.
Replace paths (locked RESEARCH pitfall 9): sibling plugin go.mod uses replace git.golem15.com/golem15/summercms => ../summercms.go (TR/go.mod line 122). Host uses replace …sm-journal-plugin => ./plugins/golem15/journal. Do not copy USR/go.mod's ../../../../summercms.go into the sibling checkout.
Tables (locked): squash to golem15_journal_*. Do not emit rainlab_journal_*. Settings is a dedicated singleton table ID=1, not PHP system_settings.
Suggested plan split from RESEARCH (present at the plan-count checkpoint; unit tests last): 15-01 clone plugin + squashed schema + models + Translatable + host submodule/summer.yaml/go.work/http.yaml CORS + boot_test 3 plugins; 15-02 admin controllers, adapted YAML, settings, permissions, nav SVG, FormatHTML, import/export CLI + toolbar; 15-03 /_journal/api/v1, buckets, optional editor on GET, backend Bearer writes, media upload, Typesense gate; 15-04 unit/integration tests last.
File Classification
New plugin (JR/ = sm-journal-plugin)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
JR/go.mod |
config | — | TR/go.mod (sibling replace ../summercms.go) |
exact |
JR/plugin.go |
provider | request-response | TR/plugin.go + USR/plugin.go (HasRoutes/Buckets/HasCommands) |
exact |
JR/README.md |
docs | — | TR/README.md (neutral names; never name the host) |
exact |
JR/admin.go |
provider | — | TR/admin.go + USR/admin.go (file-by-file embed) |
exact |
JR/admin_permissions.go |
config | — | USR/admin_permissions.go + PHP/Plugin.php 55-90 |
exact |
JR/admin_navigation.go |
config | — | USR/admin_navigation.go + PHP/Plugin.php 96-134; icon alias FW/admin/src/app/icons.ts 273 |
exact |
JR/lang/en/lang.yaml, JR/lang/pl/lang.yaml |
config | transform | TR/lang/{en,pl}/lang.yaml + PHP/lang/en/lang.php keys plugin.* / journal.* / post.* |
exact |
JR/assets/images/journal-icon.svg |
config | file-I/O | PHP/assets/images/journal-icon.svg (embed bytes; D-08) |
exact |
JR/models/registry.go |
utility | — | TR/models/registry.go |
exact |
JR/models/post.go |
model | CRUD | USR/models/user.go (Fillable/MorphName/AttachRelations/FilterScope) + PHP/models/Post.php (translatable, canEdit, formatHtml, Searchable) |
exact |
JR/models/category.go |
model | CRUD | USR/models/user_group.go + PHP/models/Category.php fillable 45-51 |
exact |
JR/models/tag.go |
model | CRUD | USR/models/user_group.go + PHP/models/Tag.php fillable 27-31 |
exact |
JR/models/settings.go |
model | CRUD | FW/modules/cabana/example_controller_test.go BlogSettings 58-72 |
exact |
JR/updates/registry.go |
utility | — | TR/updates/registry.go |
exact |
JR/updates/202610060001_create_golem15_journal_posts.go |
migration | CRUD | TR/updates/202610060001_create_golem15_translate_locales.go |
exact |
JR/updates/202610060002_create_golem15_journal_categories.go |
migration | CRUD | same CREATE pattern | exact |
JR/updates/202610060003_create_golem15_journal_tags.go |
migration | CRUD | same CREATE pattern | exact |
JR/updates/202610060004_create_golem15_journal_posts_categories.go |
migration | CRUD | same CREATE pattern (pivot) | exact |
JR/updates/202610060005_create_golem15_journal_posts_tags.go |
migration | CRUD | same CREATE pattern (pivot) | exact |
JR/updates/202610060006_create_golem15_journal_settings.go |
migration | CRUD | same CREATE pattern (singleton ID=1) | exact |
JR/updates/202610060007_add_author_slug_to_backend_users.go |
migration | CRUD | USR/updates/202609220005_extend_users.go (ALTER); use IF NOT EXISTS |
role-match |
JR/classes/format_html.go |
service | transform | FW/modules/cabana/field_markdown.go rejectUnsafe + PHP/models/Post.php formatHtml 199-225 |
partial |
JR/controllers/admin_registry.go |
config | — | TR/controllers/admin_registry.go + USR/controllers/admin_registry.go |
exact |
JR/controllers/posts.go |
controller | CRUD | USR/controllers/users_admin_controller.go (ListExtendQuery, FormBeforeCreate, permissions) |
exact |
JR/controllers/categories.go |
controller | CRUD | TR/controllers/locales.go |
exact |
JR/controllers/tags.go |
controller | CRUD | TR/controllers/locales.go |
exact |
JR/controllers/posts/{config_list,config_form,config_filter}.yaml |
config | file-I/O | USR/controllers/users/*.yaml + PHP/controllers/posts/* (adapt, do not byte-copy) |
exact |
JR/controllers/categories/{config_list,config_form}.yaml |
config | file-I/O | TR/controllers/locales/*.yaml + USR/controllers/usergroups/*.yaml |
exact |
JR/controllers/tags/{config_list,config_form}.yaml |
config | file-I/O | same | exact |
JR/models/post/{fields,columns}.yaml |
config | file-I/O | USR/models/user/fields.yaml (relation/fileupload) + FW/docs/backend/forms.md 332-346 (mltext/mlmarkdown) |
exact |
JR/models/category/{fields,columns}.yaml |
config | file-I/O | TR/models/locale/fields.yaml + PHP/models/category/* |
exact |
JR/models/tag/{fields,columns}.yaml |
config | file-I/O | same | exact |
JR/models/settings/fields.yaml |
config | file-I/O | PHP/models/settings/fields.yaml minus trigger/placeholder |
exact |
JR/routes.go |
route | request-response | USR/routes.go + PHP/routes.php |
exact |
JR/controllers/api/posts.go |
controller | request-response | USR/controllers/api_controller.go (flat {error} JSON) + PHP/controllers/api/PostApiController.php |
exact |
JR/controllers/api/media.go |
controller | file-I/O | PHP/controllers/api/MediaApiController.php + USR avatar upload (blob, mime, size) |
exact |
JR/console/export_posts.go, JR/console/import_posts.go |
utility | batch | USR/console/require_password_change.go + PHP/console/ExportPosts.php |
exact |
JR/search.go (Gate on Post, or methods on post.go) |
service | CRUD | FW/modules/beachcomber/searchable.go + example_test.go installGate 123-133 |
exact |
Proof host (APP/ = sm-grzybyfunkcjonalne-app)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
APP/summer.yaml |
config | — | same file today (add journal) | exact |
APP/go.work |
config | — | same file (add ./plugins/golem15/journal) |
exact |
APP/go.mod |
config | — | same file replace block 85-87 | exact |
APP/.gitmodules |
config | — | same file (add journal submodule) | exact |
APP/config/http.yaml |
config | request-response | same file CORS paths; add _journal/api/* |
exact |
APP/boot_test.go |
test | request-response | same file TestBootUserTranslate (become 3 plugins) |
exact |
APP/plugins.gen.go, APP/main.go |
route | — | same files (summer build; do not hand-author after first boot) |
exact |
Tests (plan 04 last)
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
JR/admin_harness_test.go + Posts/Categories/Tags admin tests |
test | request-response | TR/admin_harness_test.go newAdminEnv 172-182 |
exact |
JR/updates/postgres_test.go |
test | CRUD | TR/updates/postgres_test.go TestMain + testcontainers |
exact |
JR/controllers/api/*_test.go JOURNAL-005/006, 401 shape, per_page 9/30 |
test | request-response | PHP tests/security/ + USR/register_test.go TestRegisterCORSPath 263-271 |
exact |
JR/models/*_test.go Fillable / Translatable / MorphName |
test | CRUD | USR/models/admin_models_test.go FilterScopes |
exact |
APP/boot_test.go (3 plugins + CORS + controller IDs) |
test | — | same file (modify) | exact |
Framework (FW/) — do not create
| File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| cabana markdown / mlmarkdown | — | — | already shipped Phase 14.2.1 | n/a (no-op) |
Pattern Assignments
JR/go.mod (config) — plan 01
Analog: TR/go.mod lines 1-14, 122
module git.golem15.com/golem15/sm-translate-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-journal-plugin, Go 1.27.0, require GORM + gormigrate + summercms, identical sibling replace ../summercms.go. Do not add goldmark unless FormatHTML lives in the plugin and imports it (then pin github.com/yuin/goldmark v1.8.6, already FW/go.mod line 30). Do not require sm-user-plugin (redactor_id is a naked integer; RESEARCH pitfall 7 / anti-pattern). TR/go.mod currently requires user for admin harness only — Journal must not copy that unless the last-plan harness truly needs it; host tests may join users.
Decision note: .planning/notes/core-plugins-own-repos.md — module path equals repo path; package journal; plugin ID golem15.journal. README never names a consuming app.
JR/plugin.go (provider) — plan 01 / 03
Analog (compiled init): TR/plugin.go lines 19-68
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
)
func (p *Plugin) ID() string { return "golem15.translate" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
func (p *Plugin) Models() []any { return models.All() }
func init() { party.Register(&Plugin{}) }
Journal: ID() "golem15.journal", Requires() []string{"golem15.translate"}. PHP $require also names Apparatus — Apparatus is dissolved; do not require an apparatus plugin (PHP/Plugin.php 15-18 vs RESEARCH §2).
Analog (routes / buckets / commands): USR/plugin.go lines 28-40, 145-166, 203-204 and USR/routes.go 9-31
_ pact.HasRoutes = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(p.app.Config)
return map[string]surf.Bucket{
"user-api": {
Max: 120,
Decay: time.Minute,
Key: func(r *http.Request) string {
if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil {
return "u:" + sub
}
return surf.ClientIP(r, trusted)
},
},
}
}
Copy two buckets named journal-public-api and journal-api, Max 120, Decay time.Minute (PHP/routes.php 7-14; FW/modules/surf/limiter.go 17-33). Public key is IP only. Write-bucket key is u: + backend principal ID when bouncer.User(r.Context()) is a backend principal, else IP. 429 body stays surf's {"message":"Too Many Attempts."} (limiter.go 17) — do not invent a Journal 429 shape.
Do not copy user JWT/bouncer/mail from USR/plugin.go Boot. Do not register "backend" middleware onto public GET (pitfall 4). Admin capability assertions live in admin.go. Also _ pact.HasSettings.
PHP contract (do not port): registerComponents, Winter.Pages menu types, dashboard report widget, Scout service-provider registration as a required dep. Port Scout only as beachcomber.Searchable behind a Gate that defaults off.
JR/admin.go + permissions + navigation — plan 02
Analog: TR/admin.go 12-34, USR/admin.go 12-38, USR/admin_permissions.go 14-21, USR/admin_navigation.go 11-43
//go:embed controllers/locales/config_list.yaml controllers/locales/config_form.yaml models/locale/fields.yaml models/locale/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 })
}
Embed YAML file-by-file (controllers/ also holds .go). Include settings models/settings/fields.yaml and the SVG if AdminFS is the right tree; otherwise //go:embed assets/images/journal-icon.svg on the plugin for nav. Do not embed the whole controllers/ directory.
Permissions — copy codes exactly from PHP/Plugin.php 55-90:
golem15.journal.manage_settingsgolem15.journal.access_postsgolem15.journal.access_categoriesgolem15.journal.access_other_postsgolem15.journal.access_import_exportgolem15.journal.access_publishgolem15.journal.access_tags
Tab/label phrase keys from PHP/lang/en/lang.php 8-24. Roles: []string{"developer"} like user. cabana.Allows is Winter hasAnyAccess (OR) (FW/modules/cabana/contracts.go 143-161).
Navigation analog: main item Code: "journal", Permissions: []string{"golem15.journal.*"}, Order: 300, Controller: "golem15.journal.posts". PHP icon-pencil already maps to lucide pencil (FW/admin/src/app/icons.ts 273) — use "pencil" or "icon-pencil"; do not invent a pack. Side menu: new_post (create URL → controller create), posts, categories, tags with the PHP permission lists. Embed PHP/assets/images/journal-icon.svg (D-08). Skip dashboard widget.
Settings analog: FW/modules/cabana/example_controller_test.go 120-132 and docs/backend/settings.md 7-29
func (p *BlogPlugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "blog",
Label: "acme.blog::lang.settings.label",
Icon: "icon-pencil",
Permissions: []string{"acme.blog.access_settings"},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &BlogSettings{} },
}}
}
Journal: Code: "journal", permissions golem15.journal.manage_settings, Form: "models/settings/fields.yaml", NewModel: func() any { return &models.Settings{} }. Dedicated table golem15_journal_settings ID=1 — not system_settings (PHP/models/Settings.php 13 vs cabana docs).
JR/models/post.go (model, CRUD) — plan 01/02
Analog (Go struct + Fillable + MorphName + Attach): USR/models/user.go 57-71, 75-80
func (User) TableName() string { return "users" }
func (User) MorphName() string { return `Golem15\User\Models\User` }
func (User) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: AvatarField, Public: true}}
}
func (User) Fillable() []string { return []string{ "name", "surname", "email", ... } }
Contract: PHP/models/Post.php 37, 48-65, 102-128, 144-197
TableName() "golem15_journal_posts"MorphName() \Golem15\Journal\Models\Post`— neverjournal.Postorreflect.TypeOf` (pitfall 10)Translatable():title,content,content_html,excerpt,metadataTranslatableIndexes():slug- Admin Fillable: form columns only. Never fill
redactor_id,content_html,user_idfrom the public API body.user_idstamped from backend principal on create (PHPbeforeSave 145-150). - Rules:
titlerequired;slugrequired + regex + unique;contentrequired; published+published_at together (PHPafterValidate181-187) asFormBeforeCreate/FormBeforeUpdatereturning&cabana.ValidationError - Relations:
userbelongsTocabana.BackendUser;categories/tagsbelongsToMany viagolem15_journal_posts_categories/_tags;featured_images/content_imagesattachMany (system_files, morph PHP class string) canEdit: owner orgolem15.journal.access_other_posts(194-197)FilterScopes:FilterPublished,FilterCategories(and daterange usescolumn: created_at— noconditionskey)
FilterScope analog: USR/models/user.go 99-117
func (User) FilterScopes() []string { return []string{FilterByGroup} }
func (User) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if name != FilterByGroup {
return db.Where("1 = 0")
}
...
}
PHP config_filter.yaml conditions: boot-fails in cabana (FW/modules/cabana/filter_schema.go 17-20). Re-express:
publishedswitch →FilterPublishedapplyingpublished <> true/published = truepublished_datedaterange → YAMLtype: daterangecolumn: created_at(legal keys only)categoryscope: FilterCategories— include child categories as PHP does
Searchable analog: FW/modules/beachcomber/searchable.go 12-22 and example_test.go 31-34, 123-133
func (Post) SearchableAs() string { return "acme_blog_posts" }
func (p *Post) ShouldBeSearchable() bool { return p.Published }
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
var enabled bool
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
return err == nil && enabled
}))
Journal: SearchableAs() "golem15_journal_posts". Gate reads golem15_journal_settings.search_use_typesense for ID=1; read errors count as off. ShouldBeSearchable false when unpublished or gate off. Fresh install never contacts Typesense.
JR/models/category.go / tag.go (model, CRUD) — plan 01
Analog: USR/models/user_group.go 24-46 + PHP fillable
Category (PHP/models/Category.php 21, 27-40, 45-51): TableName "golem15_journal_categories", Fillable only name, slug, code, description, parent_id. Not nest_left/nest_right/nest_depth (JOURNAL-002). Translatable name, description; indexes slug. MorphName() \Golem15\Journal\Models\Category`. Keep nest_* columns in DDL; no NestedTree reorder UI; parent_idis arelation`.
Tag (PHP/models/Tag.php 14, 19-31): TableName "golem15_journal_tags", Fillable only name, slug, description. Not translatable. Rules name required; slug required|between:3,64|unique.
JR/models/settings.go (model, CRUD) — plan 02
Analog: FW/modules/cabana/example_controller_test.go 58-72 + FW/modules/cabana/settings.go 19-26, 46-51 (Fillable + Rules required at boot)
PHP $rules (PHP/models/Settings.php 17-31): show_all_posts, use_rich_editor, search_use_typesense, weights, RSS fields. Defaults: show_all_posts true, use_rich_editor false, search_use_typesense false, weights 5/3/1. use_rich_editor stored and ignored at compile time (always mlmarkdown). Settings YAML: drop every trigger block; always show weight fields; drop placeholder.
JR/updates/* (migration, CRUD) — plan 01
Analog (CREATE + Register): TR/updates/202610060001_create_golem15_translate_locales.go 8-38 and TR/updates/registry.go 7-15
ID: "202610060001_create_golem15_translate_locales",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{`CREATE TABLE golem15_translate_locales (... )`, `CREATE INDEX ...`} {
if err := tx.Exec(stmt).Error; err != nil { return err }
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS golem15_translate_locales`).Error
},
Analog (ALTER another plugin's table): USR/updates/202609220005_extend_users.go 10-31 — Journal's author_slug uses ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE. Do not put this in lagoon's framework migration. BackendUser.TableName() is "backend_users" (FW/modules/cabana/contracts.go 56).
Squash IDs (planner may adjust date prefix, not table names):
| ID | Table / change |
|---|---|
202610060001_create_golem15_journal_posts |
posts columns from RESEARCH §1 (incl. content_html, metadata JSONB, sources JSONB, is_pinned, redactor_id, unique slug) |
202610060002_create_golem15_journal_categories |
+ nest_* + unique slug |
202610060003_create_golem15_journal_tags |
unique slug |
202610060004_create_golem15_journal_posts_categories |
pivot |
202610060005_create_golem15_journal_posts_tags |
pivot |
202610060006_create_golem15_journal_settings |
typed singleton columns |
202610060007_add_author_slug_to_backend_users |
ALTER |
Do not emit rainlab_journal_*. Seed optional Uncategorized only if PHP seeder at this pin still inserts it (assumption A4). AutoMigrate is never the schema source.
JR/classes/format_html.go (service, transform) — plan 02
No plugin analog. Combine:
- Contract:
PHP/models/Post.php199-225 — footnotes + attributes + tables, then Html::clean unlessbackend.allow_unsafe_markdown. Ignoreuse_rich_editor(deferred WYSIWYG). - Reject gate analog:
FW/modules/cabana/field_markdown.go11-46 — goldmark withouthtml.WithUnsafe; reject script/iframe/event handlers/dangerous URLs.
markdownEngine = goldmark.New()
func RenderMarkdown(src string) (string, error) {
...
if err := rejectUnsafeMarkdownHTML(html); err != nil { return "", err }
return html, nil
}
Do not change cabana.RenderMarkdown globally (pitfall 15; mail-aligned). Put journal.FormatHTML in the plugin: same github.com/yuin/goldmark module with footnote/table/attribute extensions (assumption A1 — if a separate module is required, stop). Stored content_html and public API use FormatHTML. SPA preview may use cabana.RenderMarkdown. Call FormatHTML from admin save hooks and API writes, not only API.
Admin controllers (controller, CRUD) — plan 02
Analog: USR/controllers/users_admin_controller.go 40-96, 243-258 + TR/controllers/locales.go 14-33 + USR/controllers/admin_registry.go 12-36
func (usersAdminController) ID() string { return "golem15.user.users" }
func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` }
func (usersAdminController) ConfigDir() string { return "controllers/users" }
func (usersAdminController) RequiredPermissions() []string {
return []string{PermissionAccessUsers}
}
func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Unscoped()
}
| PHP | Go ID | Perm | ModelName |
|---|---|---|---|
| Posts.php | golem15.journal.posts |
access_posts |
Golem15\Journal\Models\Post |
| Categories.php | golem15.journal.categories |
access_categories |
Golem15\Journal\Models\Category |
| Tags.php | golem15.journal.tags |
access_tags |
Golem15\Journal\Models\Tag |
PHP Posts $requiredPermissions is OR of access_other_posts and access_posts (PHP/controllers/Posts.php 21). Cabana Allows is OR — RequiredPermissions: []string{"golem15.journal.access_posts"} for the screen; additionally restrict query without access_other_posts.
ListExtendQuery / FormExtendQuery analog: PHP/controllers/Posts.php 60-72 — without access_other_posts, where user_id = principal.ID. Principal from bouncer.User(ctx) (FW/modules/bouncer/context.go 32-38).
FormBeforeCreate analog: stamp user_id from backend principal if empty (PHP/models/Post.php 145-150). Pattern of hook shape: USR/controllers/users_admin_controller.go 243-258 (stamp fields, return ValidationError/ForbiddenError). Also regenerate content_html via FormatHTML on create/update.
Publish without permission: return &cabana.ForbiddenError{Message: ...} (FW/modules/cabana/crud.go 71-84) on publish writes without golem15.journal.access_publish. Hiding fields is UX; refuse is mandatory (PHP/models/Post.php filterFields 164-178).
Import/export toolbar: no cabana ImportExport behavior. pact.HasAdminActions (FW/modules/pact/capabilities.go 216-228, 266-269) with Permissions: []string{"golem15.journal.access_import_export"}. CLI names journal:export-posts / journal:import-posts (PHP/Plugin.php 169-170). Command analog: USR/console/require_password_change.go 16-25 (bonfire.Command{Name, Description, Args, Run}). Port PHP PostImport/PostExport column sets; do not invent a generic CSV framework.
Error types: FW/modules/cabana/crud.go 65-84 ValidationError (422) / ForbiddenError (403).
Admin YAML (config, file-I/O) — plan 02
Go analog (list/form): USR/controllers/users/config_list.yaml 1-16, config_form.yaml 1-17, config_filter.yaml 1-14 (legal keys only: label, type, column, modelClass, nameFrom, scope). TR/controllers/locales/config_list.yaml recordUrl / defaultSort.
Go analog (fields): USR/models/user/fields.yaml 38-57 (type: relation, type: fileupload mode: image) and FW/docs/backend/forms.md 332-346:
fields:
title:
type: mltext
body:
type: mlmarkdown
size: huge
PHP contract to rewrite, not copy: PHP/models/post/fields.yaml uses boot-fail keys (placeholder, cssClass, stretch, commentAbove, trigger, type: taglist, type: repeater, widget class, tabs.stretch). Cabana allow-list: FW/modules/cabana/form_schema.go 24-47.
| PHP field | Go YAML |
|---|---|
title |
type: mltext |
slug |
type: mltext + preset field title type slug |
content |
type: mlmarkdown — not JournalMarkdown widget |
excerpt |
type: mltext |
categories |
type: relation nameFrom: name |
tags |
type: relation nameFrom: name — not taglist; create tags on Tags admin |
published |
type: switch or checkbox |
is_pinned |
type: checkbox |
user |
type: relation nameFrom: login emptyOption current user |
published_at |
type: datepicker mode: datetime. Drop trigger. |
featured_images |
type: fileupload mode: image imageWidth/imageHeight 200 |
sources repeater |
Omit from admin form. Keep JSONB; write API still accepts sources. |
metadata[preview_page] |
Omit (Phase 16). Keep metadata JSONB. |
toolbar partial |
Omit |
List: type: date is legal (FW/modules/cabana/list_schema.go 22-24). Unpublished row class → pact.RowStateDisabled if wired (USR ListRowStates 98-125); else skip. config_list.yaml recordUrl: golem15/journal/posts/update/:id like users/locales. PHP recordsPerPage: 25 (PHP/controllers/posts/config_list.yaml 21).
Filters: no conditions:. Analog USR/controllers/users/config_filter.yaml:
scopes:
groups:
type: (omit — modelClass + scope)
modelClass: Golem15\User\Models\UserGroup
nameFrom: name
scope: filterByGroup
created_date:
type: daterange
column: created_at
activated:
type: switch
column: is_activated
JR/routes.go + API controllers (controller, request-response) — plan 03
Analog (group + throttle + Where): USR/routes.go 9-31 and FW/modules/surf/example_test.go 75-88
func (p *Plugin) Routes(r pact.Router) error {
r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) {
g.Get("/fetch", controllers.Fetch(p.app))
g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth")
g.Where("id", "[0-9]+")
})
return nil
}
Contract: PHP/routes.php 16-67. Two groups, same prefix /_journal/api/v1:
- Public:
throttle:journal-public-apionly. GETposts,posts/{slug}withWhere("slug",[a-z0-9][a-z0-9-/]*),categories,tags,rss. - Writes: required backend principal +
throttle:journal-api. POST/PUT/DELETE posts, featured-images, media/upload.Where("id",[0-9]+).
Do not attach cabana "backend" middleware (writes WriteError 401 unauthenticated / "Unauthenticated" — FW/modules/cabana/http.go 200-201, contracts.go 215-231). PHP shape is flat {"error":"..."}.
Flat JSON analog: USR/controllers/api_tokens.go 96 (writeJSON(w, 404, map[string]any{"error": "Token not found"})) and USR/controllers/api_controller.go 1025-1028 (wire.WriteJSON). Not cabana.WriteError.
PHP error strings (PHP/controllers/api/PostApiController.php 237, 263, 268; MediaApi 26-41; show 204-216):
- 401
{"error":"Authentication required"} - 403
{"error":"Insufficient permissions"}/{"error":"You do not have permission to publish posts"} - 404
{"error":"Post not found"}— nodatakey on drafts (JOURNAL-005) - 422
{"error":"Validation failed","errors":{...}}
Write auth: verify cabana backend JWT (HS256, audience backend, blacklist backend_jwt_blacklist). Analog: FW/modules/bouncer/jwt.go 90-104 NewBackendJWTGuard + FW/modules/cabana/http.go 137-153 (cabana registers "backend"). Journal write handlers Lookup *bouncer.Registry, Authenticate the "backend" guard in-handler (or a plugin middleware that writes PHP JSON, not cabana's writeUnauthenticated). D-15: backend Bearer only — not user.api_token, not g15_.
Optional editor on public GET: PHP/controllers/api/PostApiController.php isEditor 685-688. Do not attach "backend" (missing token would 401). If Authorization: Bearer is present, verify with the same backend JWT and bouncer.WithUser; otherwise anonymous. Analog pieces: bouncer.Registry.Middleware 67-101 (on failure without UnauthorizedWriter, next runs unauthenticated — but cabana's guard does implement UnauthorizedWriter, so attaching it 401s). Therefore: call Guard.Authenticate only when the header is present; on failure treat as anonymous for GET (do not write 401 on public GET). bouncer.User(ctx) (context.go 32-38) afterwards. cabana.Allows(pr, []string{"golem15.journal.access_posts"}).
Draft visibility (JOURNAL-005): unpublished or published_at in the future. Show 404 no data unless owner or access_other_posts (PHP 207-217, canViewDraft 696-708). Index: anonymous published=true; editor default includes drafts unless ?published=true. per_page default 9 max 30 (PHP 34) — not API.md 15/50.
Serialize: do not apply UNPUBLISHED_TITLE_PREFIX on API JSON. Include previous_post, next_post, related_posts on show. Translations object on write via translate plugin helpers (classes.SetTranslated), not extra columns. Field-by-field assign on store (PHP 276-287) — never lagoon.Fill the whole body onto Post.
RSS: ship GET rss as XML (PHP/routes.php 33-35). No Go analog; stdlib encoding/xml.
JR/controllers/api/media.go (controller, file-I/O) — plan 03
Contract: PHP/controllers/api/MediaApiController.php 21-62. Require backend user + access_posts; folder regex ^[A-Za-z0-9_\-\/]*$; force under journal/; strip leading journal; reject ... Image mimes jpg/jpeg/png/gif/webp max 10240 KB. 201 {data:{url,path}}. Use gocloud blob + host upload_bytes but still enforce 10MB in the handler. Analog blob/size: USR avatar (avatarMaxBytes in api_controller.go 37) — copy the permission/path rules from PHP, not the avatar field.
JR/console/* (utility, batch) — plan 02
Analog: USR/console/require_password_change.go 16-25 + USR/plugin.go 203-204 Commands() []bonfire.Command. Names from PHP/Plugin.php 169-170 and PHP/console/ExportPosts.php 11-15 (journal:export-posts, --path, --dry-run). Register via pact.HasCommands (FW/modules/pact/capabilities.go 15-18).
Phrasebook + README — plan 01/02
Lang analog: TR/lang/en/lang.yaml — plugin: + screen keys. Port PHP/lang/en/lang.php and lang/pl/lang.php only (D-06). Do not load the other 19 PHP locales.
README analog: TR/README.md 1-15, 30-73 — H1 plugin name, one-sentence summary, import line, Overview/Features/Usage. Neutral the application, example blog / acme. Never name grzybyfunkcjonalne or Płytarium (pitfall 13). Document Phase 16 successor for Winter components (journalPost, …) in a short out-of-scope note. MorphName example for Journal must be the PHP class string, not the translate README's Acme\Blog\Models\Post except as a generic illustration in framework docs.
Proof host (APP/) — plan 01
Analog: current APP/summer.yaml 1-7, APP/go.work 5-9, APP/go.mod 1-11 and 85-87, APP/.gitmodules 1-8, APP/config/http.yaml 1-13, APP/boot_test.go 17-90, APP/plugins.gen.go 1-14
Today:
plugins:
- id: golem15.user
module: git.golem15.com/golem15/sm-user-plugin
- id: golem15.translate
module: git.golem15.com/golem15/sm-translate-plugin
Add:
- id: golem15.journal
module: git.golem15.com/golem15/sm-journal-plugin
go.work use ./plugins/golem15/journal. go.mod require + replace git.golem15.com/golem15/sm-journal-plugin => ./plugins/golem15/journal. .gitmodules path plugins/golem15/journal url git@git.golem15.com:golem15/sm-journal-plugin.git. CORS add _journal/api/*; keep supports_credentials: false.
boot_test.go: len(plugins) != 2 → 3; allow golem15.journal; assert controller IDs golem15.journal.posts|categories|tags and that /_journal/api/v1 routes exist; assertGitlink for journal. Today's test forbids golem15.journal (line 40-42) — change first in 15-01 (pitfall 8).
plugins.gen.go / main.go: regenerate with summer build; stamp // Code generated by summer build. DO NOT EDIT. Host README may name itself; plugin README must not.
Tests — plan 04
Admin boot analog: TR/admin_harness_test.go 172-182 newAdminEnv — party.Activate, lagoon.Migrate, mint backend admin with a permission set. Posts 403 without access_posts.
Postgres analog: TR/updates/postgres_test.go 25-36 TestMain + testcontainers, -short skip.
CORS analog: USR/register_test.go 263-271 TestRegisterCORSPath — read host config/http.yaml, require _journal/api/* (and keep _user/api/*).
PHPUnit map: RESEARCH §8. JOURNAL-005 draft 404; JOURNAL-006 media 403 + path prefix; JOURNAL-001/002 fillable; FormatHTML rejectUnsafe substitutes JOURNAL-003/004 templates (Phase 16). Redactor_id column exists, not Fillable. Also: limiter names, anonymous list hides drafts, write without Bearer 401 PHP shape, publish without access_publish 403, YAML compile of adapted fields, mlmarkdown save through TranslationWriter, Typesense gate off (zero HTTP).
Shared Patterns
Plugin mount (submodule + go.work + replace)
Source: TR/go.mod line 122, APP/go.mod 85-87, APP/go.work 5-9, APP/summer.yaml, .planning/notes/core-plugins-own-repos.md
Apply to: JR/ repo creation and APP/ journal mount
Module git.golem15.com/golem15/sm-journal-plugin, package journal, ID golem15.journal, party.Register in init, Requires translate. Sibling replace ../summercms.go. Host replace ./plugins/golem15/journal. Submodule plugins/golem15/journal.
Admin CRUD + permissions
Source: USR/admin.go, users_admin_controller.go, pact.HasAdminControllers / AdminPermissioned (FW/modules/pact/capabilities.go 184-197)
Apply to: Posts, Categories, Tags
YAML + CRUDService. RequiredPermissions. Fillable allow-list. Lifecycle hooks return ValidationError (422) or ForbiddenError (403). Fail-loud YAML at cabana.Activate.
Translatable + MorphName
Source: TR/classes/translatable.go 16-24; USR/models/user.go 59-60; TR/README.md 70-72
Apply to: Post, Category
Implement Translatable(), TranslatableIndexes(), MorphName() with PHP class strings. Default locale on host columns; other locales via translate plugin. Do not invent title_pl columns.
Public plugin HTTP vs cabana admin envelope
Source: USR/routes.go + USR/controllers/api_tokens.go flat {error}; FW/modules/cabana/contracts.go 215-231 (do not use on /_journal/api/v1)
Apply to: all Journal API handlers
Cabana SPA admin uses {error:{code,message,details}}. Journal API uses PHP {error: string}. Two envelopes, one binary.
Rate limits
Source: USR/plugin.go Buckets 145-166; FW/modules/surf/limiter.go 17-33
Apply to: journal-public-api / journal-api
Named surf.BucketProvider. TrustedProxies + ClientIP. 429 framework shape.
Backend principal
Source: FW/modules/bouncer/context.go 24-38; jwt.go 90-104; cabana/http.go 137-153; cabana/contracts.go Allows 143-161
Apply to: write API (required), public GET (optional), admin ListExtendQuery
Writes: authenticate backend JWT, 401 PHP string if missing. Public GET: optional Authenticate when Bearer present; never 401 anonymous. Admin: bouncer.User + cabana.Allows.
Search gate
Source: FW/modules/beachcomber/searchable.go Gate 146-163; example_test.go installGate
Apply to: Post Searchable
Off by default. Errors count as off. Unpublished not indexed.
Settings singleton
Source: FW/docs/backend/settings.md; example_controller_test.go BlogSettings / Settings()
Apply to: golem15_journal_settings ID=1
pact.HasSettings. Fillable + Rules. No system_settings.
Test harness
Source: TR/admin_harness_test.go newAdminEnv; TR/updates/postgres_test.go
Apply to: plugin admin + migration + API tests last
party.Activate + lagoon.Migrate + surf.Assemble. testcontainers behind -short.
Markdown
Source: FW/modules/cabana/field_markdown.go; FW/go.mod goldmark v1.8.6
Apply to: admin field type (existing mlmarkdown) vs stored HTML (plugin FormatHTML)
One library. Two pipelines. Do not merge them.
No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
JR/classes/format_html.go (goldmark footnote/table/attribute + rejectUnsafe, not cabana.RenderMarkdown) |
service | transform | No plugin currently enables goldmark extensions. Copy PHP formatHtml contract and cabana's rejectUnsafe helper; do not edit field_markdown.go. |
JR RSS XML handler |
controller | request-response | No compiled plugin serves RSS. Use stdlib encoding/xml and PHP PostApiController::rss as the contract. |
Planner should use RESEARCH.md §4–§5 for those two.
Do not copy / anti-patterns
- Byte-copy PHP YAML —
taglist,repeater,trigger,placeholder,conditionsfail boot. - Attaching
"backend"middleware to public GET — anonymous 401 (pitfall 4). - Cabana
WriteErrorenvelope on/_journal/api/v1— PHP{error}string (pitfall 3). user.api_tokenorg15_— D-15.- Replaying
rainlab_journal_*table names. - Changing
cabana.RenderMarkdownto enable footnotes globally (pitfall 15). - Adding a second markdown YAML type — D-11 shipped.
- Importing
sm-user-pluginonly to typeredactor. replace … => ../../../../summercms.goin the sibling checkout — use../summercms.go(pitfall 9).- MorphName
journal.Post— PHP class strings (pitfall 10). - Typesense on by default (pitfall 11).
- API.md per_page 15/50 — controller 9/30 (pitfall 12).
- Naming the host in the plugin README (pitfall 13).
- Editing
wn-journal-plugin/ PHP tree (D-07). - Adding Journal to the tide 154-route harness (D-16).
- Porting Translate, Pages menu types, dashboard widgets, or remaining 19 locales.
- Hand-authoring
plugins.gen.goafter firstsummer build. - AutoMigrate as schema.
Metadata
Analog search scope: TR/ (sm-translate-plugin), USR/ (sm-user-plugin), APP/ (sm-grzybyfunkcjonalne-app), FW/modules/{cabana,pact,surf,bouncer,beachcomber,lagoon}, FW/docs/backend, FW/admin/src/app/icons.ts, PHP/ journal plugin at 02110eb1c0c3861370b0b9b47b209a0702ac5d88
Files scanned: ~70 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