Files
summercms/.planning/phases/15-journal-plugin/15-PATTERNS.md
2026-10-06 18:02:17 +02:00

41 KiB
Raw Blame History

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, Requires empty, Translatable, host already mounts it).
  • USR/ = ../fonoteka.go/plugins/golem15/user/ — the sm-user-plugin submodule. Closest analog for HasRoutes, 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/journal at SHA 02110eb1c0c3861370b0b9b47b209a0702ac5d88 (verified git rev-parse HEAD this session). Read-only contract. Do not edit (D-07).
  • JR/ = ../sm-journal-plugin/ — does not exist yet (D-23). Plan 01 clones git@git.golem15.com:golem15/sm-journal-plugin.git there.

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_settings
  • golem15.journal.access_posts
  • golem15.journal.access_categories
  • golem15.journal.access_other_posts
  • golem15.journal.access_import_export
  • golem15.journal.access_publish
  • golem15.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, metadata
  • TranslatableIndexes(): slug
  • Admin Fillable: form columns only. Never fill redactor_id, content_html, user_id from the public API body. user_id stamped from backend principal on create (PHP beforeSave 145-150).
  • Rules: title required; slug required + regex + unique; content required; published+published_at together (PHP afterValidate 181-187) as FormBeforeCreate/FormBeforeUpdate returning &cabana.ValidationError
  • Relations: user belongsTo cabana.BackendUser; categories/tags belongsToMany via golem15_journal_posts_categories / _tags; featured_images/content_images attachMany (system_files, morph PHP class string)
  • canEdit: owner or golem15.journal.access_other_posts (194-197)
  • FilterScopes: FilterPublished, FilterCategories (and daterange uses column: created_at — no conditions key)

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:

  • published switch → FilterPublished applying published <> true / published = true
  • published_date daterange → YAML type: daterange column: created_at (legal keys only)
  • category scope: 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:

  1. Contract: PHP/models/Post.php 199-225 — footnotes + attributes + tables, then Html::clean unless backend.allow_unsafe_markdown. Ignore use_rich_editor (deferred WYSIWYG).
  2. Reject gate analog: FW/modules/cabana/field_markdown.go 11-46 — goldmark without html.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:

  1. Public: throttle:journal-public-api only. GET posts, posts/{slug} with Where("slug", [a-z0-9][a-z0-9-/]*), categories, tags, rss.
  2. 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"} — no data key 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, conditions fail boot.
  • Attaching "backend" middleware to public GET — anonymous 401 (pitfall 4).
  • Cabana WriteError envelope on /_journal/api/v1 — PHP {error} string (pitfall 3).
  • user.api_token or g15_ — D-15.
  • Replaying rainlab_journal_* table names.
  • Changing cabana.RenderMarkdown to enable footnotes globally (pitfall 15).
  • Adding a second markdown YAML type — D-11 shipped.
  • Importing sm-user-plugin only to type redactor.
  • replace … => ../../../../summercms.go in 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.go after first summer 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