feat(32-02): record events and content descriptors (tracer)
- pact.RecordSaved/RecordDeleted/RecordChange, fired as pointers after commit - pact.ContentDescriptor and request/result types; MenuItemType.Content - AdminFormSave.Changes carries the owner's changes to extension Save - cabana.EmitRecordSaved/EmitRecordDeleted; admin save snapshots columns and translated values and fires RecordSaved through lagoon.AfterCommit - signpost validates Content and gains ContentTypes, ContentTypeForModel, Describe
This commit is contained in:
@@ -61,6 +61,7 @@ Use cases:
|
||||
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
|
||||
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
|
||||
- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. That URL is kept only when it is an absolute `http`/`https` URL with a host or a same-origin path (one leading `/`); anything else, `javascript:` and `data:` included, is dropped and logged without the URL. A section whose `Status` fails is listed with an empty status and `error: true`, and an action whose `Applies` fails is left out and marks its section `error: true` as well; both are logged and the other sections are answered as usual. A status is translated only when it is a phrase key. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot.
|
||||
- Record events: every admin save fires `pact.RecordSaved` on the application's event bus after the commit, with the controller ID, the record's morph type, its id, the request locale, `Created` and the changed attributes with old and new values (column changes, then translated values per locale, read before the save writes them). A rolled-back save fires nothing and a listener's error is logged, never returned. The same changes reach a form extension's `Save` in `pact.AdminFormSave.Changes`. Plugin code that writes records directly fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`.
|
||||
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A revoked token's event carries its `TokenID`, so only the sessions opened with that token end; the administrator's other sessions stay open. Only the My account revoke fires it: `admin:token:revoke` runs in its own process, where no MCP endpoint listens, so that token's sessions end when the token is next verified (the session's next request or its stream's periodic bearer re-check). A listener's error is logged and never fails the save.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
|
||||
@@ -313,6 +314,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.BackendUser.MorphName` / `cabana.BackendUser.AttachRelations` | `attach.Owner` and `attach.HasRelations` of the backend user: one public attachOne `avatar`. |
|
||||
| `cabana.Allows` | Checks a principal against required permission codes. |
|
||||
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
|
||||
| `cabana.EmitRecordSaved` | Fires a `pact.RecordSaved` on the application's bus after the given lagoon transaction commits (at once with a nil transaction), for plugin code that writes records outside the admin forms; a listener's error is logged. |
|
||||
| `cabana.EmitRecordDeleted` | The same for `pact.RecordDeleted`. |
|
||||
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
||||
| `cabana.FieldWrite` | One value a save wrote for a plugin field type: the content `Locale` (translatable types), `Default` (written to the host column) and the decoded `Value`. |
|
||||
| `cabana.FieldWritesFromContext` | The values a create or update save writes for the form's plugin field types, per field and locale, from the context of a Form hook; untouched values are not listed. A copy; false outside a save. |
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
@@ -32,6 +33,9 @@ type CRUDService struct {
|
||||
// writer persists non-default locale values for mltext/mltextarea/mlmarkdown
|
||||
// fields. Nil when the translate plugin is not mounted.
|
||||
writer TranslationWriter
|
||||
// app receives the record events of the service's writes
|
||||
// (pact.RecordSaved, pact.RecordDeleted). Nil fires none.
|
||||
app *backpack.App
|
||||
}
|
||||
|
||||
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
|
||||
@@ -825,6 +829,9 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// D-92: the record's values before the save, for the changes its
|
||||
// RecordSaved event and the extensions' Save report.
|
||||
var before *recordSnapshot
|
||||
if update {
|
||||
pk, err := coercePK(target, id)
|
||||
if err != nil {
|
||||
@@ -833,6 +840,9 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if before, err = takeRecordSnapshot(ctx, tx, s.writer, target, translations); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
projected := projectOperation(cc, in.Body, op)
|
||||
decoded, err := decodePluginFields(ctx, pluginDecodeInput{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: pluginValues, Projected: projected, Translations: translations, Update: update})
|
||||
@@ -942,13 +952,19 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes, err := recordChanges(ctx, tx, s.writer, target, before, translations)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// D-91: form extensions save their own values in this transaction,
|
||||
// after the owner row is written; an error rolls everything back.
|
||||
extNotices, err := saveFormExtensions(ctx, extensionSave{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: extensions, Visible: visible, Created: !update})
|
||||
extNotices, err := saveFormExtensions(ctx, extensionSave{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: extensions, Visible: visible, Created: !update, Changes: changes})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notices = append(notices, extNotices...)
|
||||
// D-92: subscribers hear about the save once it commits.
|
||||
emitRecordSaved(ctx, s.app, tx, cc, target, !update, changes)
|
||||
result, err = projectFullRecord(ctx, tx, cc, target)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1250,6 +1250,7 @@ type extensionSave struct {
|
||||
Values extensionValues
|
||||
Visible map[*compiledFormExtension]bool
|
||||
Created bool
|
||||
Changes []pact.RecordChange
|
||||
}
|
||||
|
||||
// saveFormExtensions runs every visible extension's Save inside the
|
||||
@@ -1318,6 +1319,7 @@ func saveFormExtensions(ctx context.Context, in extensionSave) ([]pluginNotice,
|
||||
ID: recordIDText(in.Target),
|
||||
Created: in.Created,
|
||||
Values: values,
|
||||
Changes: append([]pact.RecordChange(nil), in.Changes...),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, extensionFailure(cc, ext, "save", err)
|
||||
|
||||
@@ -1127,7 +1127,7 @@ func (s *service) crud() (CRUDService, error) {
|
||||
if err != nil {
|
||||
return CRUDService{}, err
|
||||
}
|
||||
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator()}
|
||||
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator(), app: s.app}
|
||||
if s.app != nil {
|
||||
if w, ok := s.app.Lookup[TranslationWriter](); ok {
|
||||
svc.writer = w
|
||||
|
||||
270
modules/cabana/record_events.go
Normal file
270
modules/cabana/record_events.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// EmitRecordSaved fires ev as a *pact.RecordSaved through the application's
|
||||
// event bus once tx commits, for plugin code that writes records outside the
|
||||
// admin forms (its own API handlers, console imports, record actions). tx is
|
||||
// the write's lagoon.Transaction handle: the event is buffered until that
|
||||
// transaction commits and dropped when it rolls back. With a nil tx, or
|
||||
// outside a transaction, it fires at once, so call it after the write has
|
||||
// committed. A missing application or bus is a no-op and a listener's error
|
||||
// is logged: the write that caused the event never fails because of it.
|
||||
func EmitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordSaved) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ev.Changes = append([]pact.RecordChange(nil), ev.Changes...)
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
event := ev
|
||||
if err := app.Events.Fire(ctx, &event); err != nil {
|
||||
moduleLog.WarnContext(ctx, "cabana: record saved listener failed", "controller", ev.Controller, "model", ev.Model, "id", ev.ID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// EmitRecordDeleted fires ev as a *pact.RecordDeleted after tx commits, with
|
||||
// the rules of EmitRecordSaved.
|
||||
func EmitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, ev pact.RecordDeleted) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
event := ev
|
||||
if err := app.Events.Fire(ctx, &event); err != nil {
|
||||
moduleLog.WarnContext(ctx, "cabana: record deleted listener failed", "controller", ev.Controller, "model", ev.Model, "id", ev.ID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// emitRecordSaved fires the admin save of model through controller cc after
|
||||
// tx commits.
|
||||
func emitRecordSaved(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any, created bool, changes []pact.RecordChange) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
locale, _ := towel.Locale(ctx)
|
||||
EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
|
||||
Controller: controllerID(cc),
|
||||
Model: recordMorphType(tx, model),
|
||||
ID: recordIDText(model),
|
||||
Locale: locale,
|
||||
Created: created,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
|
||||
// emitRecordDeleted fires the admin delete of model through controller cc
|
||||
// after tx commits.
|
||||
func emitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *CompiledController, model any) {
|
||||
if app == nil || app.Events == nil {
|
||||
return
|
||||
}
|
||||
EmitRecordDeleted(ctx, app, tx, pact.RecordDeleted{
|
||||
Controller: controllerID(cc),
|
||||
Model: recordMorphType(tx, model),
|
||||
ID: recordIDText(model),
|
||||
})
|
||||
}
|
||||
|
||||
// recordMorphType is lagoon.MorphType, logged and "" when it fails.
|
||||
func recordMorphType(db *gorm.DB, model any) string {
|
||||
morph, err := lagoon.MorphType(db, model)
|
||||
if err != nil {
|
||||
moduleLog.Warn("cabana: record event morph type", "error", err)
|
||||
return ""
|
||||
}
|
||||
return morph
|
||||
}
|
||||
|
||||
// recordSnapshot holds a record's column values and, for the translatable
|
||||
// fields a save submits, their stored non-default locale values, taken
|
||||
// before the save changes them, so the save can report what it changed.
|
||||
type recordSnapshot struct {
|
||||
columns []string
|
||||
values map[string]any
|
||||
translations map[string]map[string]any // field -> locale -> old value (nil when missing)
|
||||
}
|
||||
|
||||
// snapshotColumns lists the columns a change is reported for: every column
|
||||
// except the primary key and the timestamps GORM maintains itself.
|
||||
func snapshotColumns(db *gorm.DB, model any) ([]*schema.Field, error) {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []*schema.Field{}
|
||||
for _, name := range stmt.Schema.DBNames {
|
||||
field := stmt.Schema.FieldsByDBName[name]
|
||||
if field == nil || field.PrimaryKey || field.AutoCreateTime != 0 || field.AutoUpdateTime != 0 || !field.Readable {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// takeRecordSnapshot reads model's column values and, through writer, the
|
||||
// stored values of the submitted translatable fields in their non-default
|
||||
// locales. A model that GORM cannot parse snapshots nothing.
|
||||
func takeRecordSnapshot(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, translations []mlValue) (*recordSnapshot, error) {
|
||||
snap := &recordSnapshot{values: map[string]any{}, translations: map[string]map[string]any{}}
|
||||
fields, err := snapshotColumns(tx, model)
|
||||
if err != nil {
|
||||
moduleLog.Warn("cabana: record event snapshot", "error", err)
|
||||
return snap, nil
|
||||
}
|
||||
rv := reflect.ValueOf(model)
|
||||
for _, field := range fields {
|
||||
value, _ := field.ValueOf(ctx, rv)
|
||||
snap.columns = append(snap.columns, field.DBName)
|
||||
snap.values[field.DBName] = changeValue(value)
|
||||
}
|
||||
if writer == nil || len(translations) == 0 {
|
||||
return snap, nil
|
||||
}
|
||||
def, err := writer.DefaultLocale(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range translations {
|
||||
for locale := range item.values {
|
||||
if locale == def {
|
||||
continue
|
||||
}
|
||||
text, ok, err := writer.TranslatedExact(ctx, tx, model, item.field, locale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snap.translations[item.field] == nil {
|
||||
snap.translations[item.field] = map[string]any{}
|
||||
}
|
||||
if ok {
|
||||
snap.translations[item.field][locale] = text
|
||||
} else {
|
||||
snap.translations[item.field][locale] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// recordChanges compares model after the save with the snapshot taken
|
||||
// before it: one change per column whose value differs, then one per
|
||||
// submitted translatable field and non-default locale whose text differs.
|
||||
// On create (snap nil) it lists every column the save wrote a value to and
|
||||
// every submitted non-empty translation, each with a nil Old.
|
||||
func recordChanges(ctx context.Context, tx *gorm.DB, writer TranslationWriter, model any, snap *recordSnapshot, translations []mlValue) ([]pact.RecordChange, error) {
|
||||
changes := []pact.RecordChange{}
|
||||
fields, err := snapshotColumns(tx, model)
|
||||
if err != nil {
|
||||
moduleLog.Warn("cabana: record event changes", "error", err)
|
||||
return changes, nil
|
||||
}
|
||||
rv := reflect.ValueOf(model)
|
||||
for _, field := range fields {
|
||||
value, zero := field.ValueOf(ctx, rv)
|
||||
next := changeValue(value)
|
||||
if snap == nil {
|
||||
if zero || next == nil {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, pact.RecordChange{Attribute: field.DBName, New: next})
|
||||
continue
|
||||
}
|
||||
prev, known := snap.values[field.DBName]
|
||||
if known && sameChangeValue(prev, next) {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, pact.RecordChange{Attribute: field.DBName, Old: prev, New: next})
|
||||
}
|
||||
if writer == nil || len(translations) == 0 {
|
||||
return changes, nil
|
||||
}
|
||||
def, err := writer.DefaultLocale(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range translations {
|
||||
for _, locale := range sortedLocales(item.values) {
|
||||
if locale == def {
|
||||
continue
|
||||
}
|
||||
next := item.values[locale]
|
||||
var prev any
|
||||
if snap != nil {
|
||||
prev = snap.translations[item.field][locale]
|
||||
}
|
||||
old, _ := prev.(string)
|
||||
if old == next {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, pact.RecordChange{Attribute: item.field, Locale: locale, Old: prev, New: next})
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// changeValue is a column value as a change reports it: pointers are
|
||||
// dereferenced (a nil pointer is nil) and slices and maps are copied, so a
|
||||
// later write to the model does not reach the reported value.
|
||||
func changeValue(value any) any {
|
||||
rv := reflect.ValueOf(value)
|
||||
for rv.IsValid() && rv.Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
rv = rv.Elem()
|
||||
}
|
||||
if !rv.IsValid() {
|
||||
return nil
|
||||
}
|
||||
switch rv.Kind() {
|
||||
case reflect.Slice:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len())
|
||||
reflect.Copy(out, rv)
|
||||
return out.Interface()
|
||||
case reflect.Map:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
out := reflect.MakeMapWithSize(rv.Type(), rv.Len())
|
||||
iter := rv.MapRange()
|
||||
for iter.Next() {
|
||||
out.SetMapIndex(iter.Key(), iter.Value())
|
||||
}
|
||||
return out.Interface()
|
||||
}
|
||||
return rv.Interface()
|
||||
}
|
||||
|
||||
// sameChangeValue compares two change values; times compare as instants.
|
||||
func sameChangeValue(a, b any) bool {
|
||||
ta, okA := a.(time.Time)
|
||||
tb, okB := b.(time.Time)
|
||||
if okA && okB {
|
||||
return ta.Equal(tb)
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
@@ -35,7 +35,8 @@ Use cases:
|
||||
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
|
||||
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), values of unstored list columns filled once per page (`pact.ListExtendRecords`), the record title of the form and preview screens (`pact.FormRecordTitle`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
|
||||
- Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree.
|
||||
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot.
|
||||
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot. A record type may set `Content` to a `pact.ContentDescriptor`, which describes its records for consumers that work on every content type at once (sitemaps, search indexers, metadata editors): the admin controller that edits them, their morph type, `Describe` (a `pact.ContentRecord` with per-locale `pact.ContentLocale` URL, title, description, image and word count), and the optional `Records` (paged through `pact.ContentList` and `pact.ContentPage`) and `Match` (a public path to a `pact.ContentMatchResult`).
|
||||
- Record events: `pact.RecordSaved` and `pact.RecordDeleted` are fired as pointers on the application's event bus after a record write commits, with the controller, the morph type, the id, the content locale and, for a save, the `pact.RecordChange` list of changed attributes with old and new values per locale. The admin framework fires them for every admin write and `cabana.EmitRecordSaved` lets plugin code that writes directly fire the same events.
|
||||
- Singleton admin controllers: `pact.AdminSingleton` (`SingletonRecord`, first or create) makes a controller edit exactly one record, opened directly instead of from a list.
|
||||
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
|
||||
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
|
||||
@@ -149,7 +150,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.HasAdminActionExtensions` | Implemented by a plugin (not a controller) that adds actions or form fields to admin controllers, its own or another plugin's. An unknown controller ID, a target without a preview block (record actions) or a form (form fields), or a duplicate name stops the boot. |
|
||||
| `pact.AdminFormExtension` | Fields and tabs a plugin places in another controller's form, loaded and saved by the plugin itself: a fields document in the `fields.yaml` format (`Fields`, a path in the plugin's `AdminFS`, or `FieldsYAML`, the bytes built at boot) whose fields take `before:`/`after:` anchors and `zone:` and whose `tabs:` list places new tabs; `Hide` (owner fields to leave out), `Permissions` and `Visible` (who sees the fields), `Dir`, `PartialData` and `Actions` (its partial and widget fields), `Load` (values on show) and `Save` (values in the record's save transaction). The owner's model is never written. |
|
||||
| `pact.AdminFormLoad` | What `Load` receives: the read or save transaction `DB`, the target `Controller` ID, the loaded `Record` and its `ID` as text. |
|
||||
| `pact.AdminFormSave` | What `Save` receives: the save transaction `DB`, `Controller`, the written `Record`, its `ID`, `Created` (true on create) and `Values`, the submitted values of the extension's fields (a translatable field as a `map[string]string` of locale to text). |
|
||||
| `pact.AdminFormSave` | What `Save` receives: the save transaction `DB`, `Controller`, the written `Record`, its `ID`, `Created` (true on create), `Values`, the submitted values of the extension's fields (a translatable field as a `map[string]string` of locale to text), and `Changes`, what the owner's save changed (as `pact.RecordSaved` will report it). |
|
||||
| `pact.AdminFormSaveResult` | What `Save` returns: `Notices`, save notices per field name (`pact.FormFieldNotice`), shown like a field type's notices. |
|
||||
| `pact.AdminFormErrors` | A `Save` refusal: field name to messages (phrase keys or text). The save answers 422 with them on the fields and rolls back. |
|
||||
| `pact.AdminFormZoneTabs` | The `zone:` value of the form's tab zone, the default and the only zone rendered so far; `pact.AdminFormZoneOutside` and `pact.AdminFormZoneSecondary` are reserved and stop the boot ("is not supported yet"). |
|
||||
@@ -202,11 +203,22 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.DependentOptionsProvider` | Optional controller hook serving the choices of a dropdown with `dependsOn` from the submitted values of its siblings; asked by the options route and on every save. |
|
||||
| `pact.DependentOptionSet` | `DependentOptions`' answer: `Applies` (false when the sibling's value takes no choice; the field then stores null) and `Options`. |
|
||||
| `pact.HasMenuItemTypes` | Implemented by plugins that register menu item types; collected once at boot by signpost. |
|
||||
| `pact.MenuItemType` | One menu item type: `Key`, `Label`, `DefaultPattern` (required, starts with `/`), `PatternFrom` (a list type's record type), `Placeholders`, `Nesting`, `DynamicItems`, the optional `References` func and the required `Resolve` func. |
|
||||
| `pact.MenuItemType` | One menu item type: `Key`, `Label`, `DefaultPattern` (required, starts with `/`), `PatternFrom` (a list type's record type), `Placeholders`, `Nesting`, `DynamicItems`, the optional `References` func, the required `Resolve` func and the optional `Content` descriptor. |
|
||||
| `pact.MenuItemReference` | One record a menu item can point at: value, label and nested records. |
|
||||
| `pact.MenuItemResolve` | What `Resolve` receives: type, reference, the layered URL pattern, whether to expand nested items and the unprefixed locale. |
|
||||
| `pact.ResolvedMenuItem` | A resolved item: title, URL (empty for a pure list type), modification time, ordered alternates and nested items. |
|
||||
| `pact.MenuItemAlternate` | The URL and title of a resolved item in one locale. |
|
||||
| `pact.ContentDescriptor` | The records behind a menu item type: `Controller` (the admin controller ID that edits them), `Model` (their morph type, required), `Describe` (required), and the optional `Records` and `Match`. The funcs read through `cabana.TxFromContext` when the context carries a write transaction. |
|
||||
| `pact.ContentList` | What `Records` receives: the layered pattern, `After` (the last ID of the previous page), `Limit` and the unprefixed locale. |
|
||||
| `pact.ContentPage` | One page of `Records`, and `Next`, the `After` of the following page ("" on the last). |
|
||||
| `pact.ContentDescribe` | What `Describe` receives: the record `ID`, the layered pattern and the unprefixed locale. |
|
||||
| `pact.ContentRecord` | A described record: `ID`, `Published`, `LastMod` and its `Locales`. |
|
||||
| `pact.ContentLocale` | A record in one locale: `URL` ("" without a public page), `Title`, `Description`, `Image` and `Words`. |
|
||||
| `pact.ContentMatch` | What `Match` receives: a public `Path`, the layered pattern and the unprefixed locale. |
|
||||
| `pact.ContentMatchResult` | `Match`'s answer: the record `ID`, the `Locale` the path addresses and `Exact` (false when a segment does not match the record). |
|
||||
| `pact.RecordSaved` | Event fired as a pointer after a record create or update commits: `Controller` ("" outside the admin forms), `Model` (morph type), `ID`, `Locale`, `Created` and `Changes`. |
|
||||
| `pact.RecordDeleted` | Event fired as a pointer after a record delete commits, one per record: `Controller`, `Model`, `ID`. |
|
||||
| `pact.RecordChange` | One changed attribute: `Attribute`, `Locale` ("" for the record's own row, the locale of a translated value), `Old` (nil on create) and `New`. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -546,7 +546,10 @@ type AdminFormLoad struct {
|
||||
// the JSON body (numbers as json.Number), a translatable field's a
|
||||
// map[string]string of locale to text, a plugin field type's the value its
|
||||
// Decode returned. Save runs on every create and update of a record whose
|
||||
// administrator sees the extension, with or without values.
|
||||
// administrator sees the extension, with or without values. Changes lists
|
||||
// what the owner's save changed on the record, as RecordSaved.Changes will
|
||||
// (a translated slug once per changed locale), so Save sees a slug change
|
||||
// inside the save transaction.
|
||||
type AdminFormSave struct {
|
||||
DB *gorm.DB
|
||||
Controller string
|
||||
@@ -554,6 +557,7 @@ type AdminFormSave struct {
|
||||
ID string
|
||||
Created bool
|
||||
Values map[string]any
|
||||
Changes []RecordChange
|
||||
}
|
||||
|
||||
// AdminFormSaveResult is Save's answer. Notices are non-blocking messages per
|
||||
@@ -944,7 +948,9 @@ type DependentOptionsProvider interface {
|
||||
// item generates its children from records. References, when set, returns the
|
||||
// records an item of the type can point at as a tree; nil means the type takes
|
||||
// no reference. Resolve is required and turns one item into its URL, title,
|
||||
// alternates and children.
|
||||
// alternates and children. Content, when set, describes the records behind a
|
||||
// record type (ContentDescriptor) for consumers such as sitemaps; nil means
|
||||
// the type takes no part in them.
|
||||
type MenuItemType struct {
|
||||
Key string
|
||||
Label string
|
||||
@@ -955,6 +961,7 @@ type MenuItemType struct {
|
||||
DynamicItems bool
|
||||
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"`
|
||||
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
|
||||
Content *ContentDescriptor `json:"-"`
|
||||
}
|
||||
|
||||
// MenuItemReference is one record a menu item can point at: the stored
|
||||
|
||||
100
modules/pact/content.go
Normal file
100
modules/pact/content.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package pact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContentDescriptor describes the records behind a menu item type, for
|
||||
// consumers that work on every content type at once: sitemaps, search
|
||||
// indexers, audit tools, metadata editors. A type sets it in
|
||||
// MenuItemType.Content; a type without one takes no part and behaves as
|
||||
// before.
|
||||
//
|
||||
// Controller is the ID of the admin controller whose form edits the
|
||||
// records, "" when none does. Model is the records' morph type, the value
|
||||
// RecordSaved and RecordDeleted carry (lagoon.MorphType), so an event maps to
|
||||
// its content type with one lookup; it is required. Describe is required and
|
||||
// describes one record by id. Records pages through the records and Match
|
||||
// maps a public path to a record; both are optional.
|
||||
//
|
||||
// The functions read through cabana.TxFromContext(ctx) when ctx carries a
|
||||
// write transaction, so code running inside a save (a form extension's Save,
|
||||
// for example) sees the values the save has just written.
|
||||
type ContentDescriptor struct {
|
||||
Controller string
|
||||
Model string
|
||||
Records func(ctx context.Context, in ContentList) (ContentPage, error) `json:"-"`
|
||||
Describe func(ctx context.Context, in ContentDescribe) (*ContentRecord, error) `json:"-"`
|
||||
Match func(ctx context.Context, in ContentMatch) (*ContentMatchResult, error) `json:"-"`
|
||||
}
|
||||
|
||||
// ContentList is what ContentDescriptor.Records receives. Pattern is the URL
|
||||
// pattern already layered by the registry (the host's override, else the
|
||||
// type's default). After is the ID of the last record of the previous page,
|
||||
// "" for the first page; Limit is the page size the caller wants (the type
|
||||
// may cap it). UnprefixedLocale names the locale whose URLs carry no locale
|
||||
// prefix; "" leaves the choice to the type.
|
||||
type ContentList struct {
|
||||
Pattern string
|
||||
After string
|
||||
Limit int
|
||||
UnprefixedLocale string
|
||||
}
|
||||
|
||||
// ContentPage is one page of records in a stable order. Next is the After of
|
||||
// the following page, "" on the last page.
|
||||
type ContentPage struct {
|
||||
Records []ContentRecord
|
||||
Next string
|
||||
}
|
||||
|
||||
// ContentDescribe is what ContentDescriptor.Describe receives: the record's
|
||||
// ID, the layered URL pattern and the unprefixed locale, as in ContentList.
|
||||
type ContentDescribe struct {
|
||||
ID string
|
||||
Pattern string
|
||||
UnprefixedLocale string
|
||||
}
|
||||
|
||||
// ContentRecord describes one record. ID is its primary key as text,
|
||||
// Published whether visitors can see it, LastMod its last change (nil when
|
||||
// unknown). Locales lists the record per locale in the site's locale order.
|
||||
type ContentRecord struct {
|
||||
ID string
|
||||
Published bool
|
||||
LastMod *time.Time
|
||||
Locales []ContentLocale
|
||||
}
|
||||
|
||||
// ContentLocale is a record in one locale. URL is its public path, "" when
|
||||
// the locale or the host has no public page for it. Title, Description and
|
||||
// Image (a URL) are the record's own defaults for page metadata; Words is the
|
||||
// body's word count, nil when the type has no body.
|
||||
type ContentLocale struct {
|
||||
Locale string
|
||||
URL string
|
||||
Title string
|
||||
Description string
|
||||
Image string
|
||||
Words *int
|
||||
}
|
||||
|
||||
// ContentMatch is what ContentDescriptor.Match receives: a public path (no
|
||||
// query, no host), the layered URL pattern and the unprefixed locale.
|
||||
type ContentMatch struct {
|
||||
Path string
|
||||
Pattern string
|
||||
UnprefixedLocale string
|
||||
}
|
||||
|
||||
// ContentMatchResult names the record a path belongs to and the locale the
|
||||
// path addresses. Exact is false when the path names the record but a
|
||||
// segment does not match it (another category, a stale locale prefix, a
|
||||
// slug of another locale): the caller may then redirect to the record's URL.
|
||||
// Match answers nil when the path names no record visitors can see.
|
||||
type ContentMatchResult struct {
|
||||
ID string
|
||||
Locale string
|
||||
Exact bool
|
||||
}
|
||||
54
modules/pact/record_events.go
Normal file
54
modules/pact/record_events.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package pact
|
||||
|
||||
// RecordChange is one changed attribute of a saved record. Attribute is the
|
||||
// column or form field name. Locale is "" for a value stored on the record's
|
||||
// own row, and the locale code for a translated value stored elsewhere (a
|
||||
// translatable field's non-default locales); a translated slug therefore
|
||||
// appears once per changed locale. Old is nil on create and for a
|
||||
// translation that did not exist before; New is the value after the save.
|
||||
// Values are as the model holds them (string, number, bool, *time.Time and
|
||||
// so on), not formatted.
|
||||
type RecordChange struct {
|
||||
Attribute string
|
||||
Locale string
|
||||
Old any
|
||||
New any
|
||||
}
|
||||
|
||||
// RecordSaved is fired through the application's event bus (app.Events)
|
||||
// after a record is created or updated, once the write has committed: a
|
||||
// rolled-back save fires nothing. It is fired and listened to as a pointer:
|
||||
//
|
||||
// app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error { ... })
|
||||
//
|
||||
// The admin framework fires it for every admin write; plugin code that
|
||||
// writes records directly fires it through cabana.EmitRecordSaved. A
|
||||
// listener's error is logged and never fails the write that caused it.
|
||||
//
|
||||
// Controller is the ID of the admin controller that saved the record, ""
|
||||
// for a write outside the admin forms. Model is the record's morph type
|
||||
// (lagoon.MorphType: the model's MorphName, else its table name), the same
|
||||
// value a content type declares in ContentDescriptor.Model. ID is the
|
||||
// primary key as text. Locale is the request's content locale, "" when it
|
||||
// has none. Created is true on create. Changes lists the attributes the save
|
||||
// changed with their old and new values; on create it lists every written
|
||||
// column with a nil Old. A save that changed nothing still fires, with an
|
||||
// empty Changes, so subscribers may re-check what they derive from the
|
||||
// record.
|
||||
type RecordSaved struct {
|
||||
Controller string
|
||||
Model string
|
||||
ID string
|
||||
Locale string
|
||||
Created bool
|
||||
Changes []RecordChange
|
||||
}
|
||||
|
||||
// RecordDeleted is fired like RecordSaved after a record is deleted and the
|
||||
// delete has committed, once per record (a bulk delete fires one event per
|
||||
// row). Controller, Model and ID follow RecordSaved.
|
||||
type RecordDeleted struct {
|
||||
Controller string
|
||||
Model string
|
||||
ID string
|
||||
}
|
||||
@@ -25,6 +25,7 @@ Use cases:
|
||||
- Boot-time collection in plugin order, then declaration order. Every error is prefixed `signpost:` and fails the boot: an empty key, a key two plugins register (the message names the key and both plugin IDs), a nil `Resolve`, a `DefaultPattern` that is empty or does not start with `/`, a `PatternFrom` that names an unknown type, the type itself or a type that sets `PatternFrom` too, and a host override for an unknown type or one that does not start with `/`.
|
||||
- URL patterns in layers (`signpost.Registry.Pattern`): the item's own override, else the host override of the type, else, for a list type that sets `PatternFrom`, the host override of the record type it lists, else the type's `DefaultPattern`. A host that changes the post URL once therefore also changes the children of every list of posts, unless the list type itself is overridden. An item override is taken only when it is a path (`signpost.ValidItemPattern`): it starts with a single `/` and has no surrounding whitespace or control characters. Any other override, such as a `javascript:` URL or a protocol-relative `//host`, is ignored and the next layer applies.
|
||||
- `signpost.Registry.Resolve` hands the layered pattern to the type's `Resolve` in `pact.MenuItemResolve.Pattern`. The result, `pact.ResolvedMenuItem`, carries the title, URL, modification time, nested items, and the alternates per locale as an ordered `[]pact.MenuItemAlternate`, never a map.
|
||||
- Content descriptors: a record type that sets `pact.MenuItemType.Content` describes its records to consumers that work on every content type at once. `signpost.Registry.ContentTypes` lists those types, `signpost.Registry.ContentTypeForModel` maps the morph type a record event carries (`pact.RecordSaved.Model`) to its type, and `signpost.Registry.Describe` describes one record with the layered pattern. A descriptor without `Model` or `Describe` fails the boot.
|
||||
- `signpost.ReferenceOptions` flattens a type's reference tree depth first into dropdown options with a `Depth`, and answers `Applies` false for a type that takes no reference or that no plugin registers.
|
||||
|
||||
## Usage
|
||||
@@ -85,6 +86,55 @@ func (c entriesController) DependentOptions(ctx context.Context, field string, v
|
||||
}
|
||||
```
|
||||
|
||||
### Content descriptors
|
||||
|
||||
A record type adds a `pact.ContentDescriptor`: the admin controller that edits the records, their morph type, and a `Describe` that answers each locale's URL (built from the pattern it receives, as `Resolve` builds alternates), title, description, image and word count:
|
||||
|
||||
```go
|
||||
{
|
||||
Key: "acme-post",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
Resolve: resolvePost,
|
||||
Content: &pact.ContentDescriptor{
|
||||
Controller: "acme.blog.posts",
|
||||
Model: "acme_blog_posts",
|
||||
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
post, err := loadPost(ctx, in.ID)
|
||||
if err != nil || post == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pact.ContentRecord{
|
||||
ID: in.ID,
|
||||
Published: post.Published,
|
||||
Locales: []pact.ContentLocale{{
|
||||
Locale: "en",
|
||||
URL: strings.ReplaceAll(in.Pattern, ":slug", post.Slug),
|
||||
Title: post.Title,
|
||||
}},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
A consumer maps a record event to its type and reads the record's new URL:
|
||||
|
||||
```go
|
||||
app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error {
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
typ, ok := reg.ContentTypeForModel(ev.Model)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rec, err := reg.Describe(ctx, typ.Key, ev.ID, "")
|
||||
if err != nil || rec == nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("%s now lives at %s", ev.ID, rec.Locales[0].URL)
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
@@ -96,6 +146,9 @@ func (c entriesController) DependentOptions(ctx context.Context, field string, v
|
||||
| `signpost.Registry.Pattern` | The layered URL pattern of an item: item override, host override, the record type's host override for a list type, default. |
|
||||
| `signpost.ValidItemPattern` | Whether a pattern may serve as an item override: a path starting with a single `/`, without surrounding whitespace or control characters. Plugins use it to refuse such a pattern on save. |
|
||||
| `signpost.Registry.Resolve` | Resolves one item through its type with the layered pattern; an unknown type is an error. |
|
||||
| `signpost.Registry.ContentTypes` | The types that carry a content descriptor, in registration order. |
|
||||
| `signpost.Registry.ContentTypeForModel` | The content type whose descriptor declares a morph type, as record events carry it. |
|
||||
| `signpost.Registry.Describe` | Describes one record of a content type through its descriptor with the layered pattern; an unknown type or one without a descriptor is an error, an unknown record answers nil. |
|
||||
| `signpost.ReferenceOptions` | The dependent dropdown options of a type's references, flattened with their depth; `Applies` false for a type without references. |
|
||||
| `signpost.ConfigKey` | `menu_items.patterns`, the host configuration key of the pattern overrides. |
|
||||
|
||||
|
||||
85
modules/signpost/content.go
Normal file
85
modules/signpost/content.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package signpost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// checkContent validates a type's content descriptor: Model and Describe are
|
||||
// required when Content is set.
|
||||
func checkContent(owner string, t pact.MenuItemType) error {
|
||||
c := t.Content
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(c.Model) == "" {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s has a content descriptor without Model", t.Key, owner)
|
||||
}
|
||||
if c.Describe == nil {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s has a content descriptor without Describe", t.Key, owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContentTypes returns the types that carry a content descriptor
|
||||
// (pact.MenuItemType.Content), in registration order. The slice is a copy.
|
||||
func (r *Registry) ContentTypes() []pact.MenuItemType {
|
||||
out := []pact.MenuItemType{}
|
||||
if r == nil {
|
||||
return out
|
||||
}
|
||||
for _, t := range r.types {
|
||||
if t.Content != nil {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ContentTypeForModel returns the content type whose descriptor declares
|
||||
// model, the morph type record events carry (pact.RecordSaved.Model).
|
||||
func (r *Registry) ContentTypeForModel(model string) (pact.MenuItemType, bool) {
|
||||
if r == nil || model == "" {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
for _, t := range r.types {
|
||||
if t.Content != nil && t.Content.Model == model {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
|
||||
// Describe describes one record of the content type key through its
|
||||
// descriptor, handing it the layered pattern (Pattern(key, "")). An unknown
|
||||
// key or a type without a content descriptor is an error; a record the type
|
||||
// does not know answers nil.
|
||||
func (r *Registry) Describe(ctx context.Context, key, id, unprefixedLocale string) (*pact.ContentRecord, error) {
|
||||
t, err := r.contentType(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return t.Content.Describe(ctx, pact.ContentDescribe{
|
||||
ID: id,
|
||||
Pattern: r.Pattern(key, ""),
|
||||
UnprefixedLocale: unprefixedLocale,
|
||||
})
|
||||
}
|
||||
|
||||
// contentType is the type key with its content descriptor.
|
||||
func (r *Registry) contentType(key string) (pact.MenuItemType, error) {
|
||||
t, ok := r.Type(key)
|
||||
if !ok {
|
||||
return pact.MenuItemType{}, fmt.Errorf("signpost: unknown menu item type %q", key)
|
||||
}
|
||||
if t.Content == nil {
|
||||
return pact.MenuItemType{}, fmt.Errorf("signpost: menu item type %q has no content descriptor", key)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -78,6 +78,9 @@ func (r *Registry) add(owner string, t pact.MenuItemType) error {
|
||||
if !strings.HasPrefix(t.DefaultPattern, "/") {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s needs a DefaultPattern starting with \"/\", got %q", t.Key, owner, t.DefaultPattern)
|
||||
}
|
||||
if err := checkContent(owner, t); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Placeholders = append([]string(nil), t.Placeholders...)
|
||||
r.owners[t.Key] = owner
|
||||
r.byKey[t.Key] = len(r.types)
|
||||
|
||||
Reference in New Issue
Block a user