package cabana import ( "context" "errors" "fmt" "reflect" "sort" "strings" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/parser" "gorm.io/gorm" ) // defaultTabKey is the name a form extension uses for the tab of the fields // without a tab: key (D-91 P-04). const defaultTabKey = "default" // compiledFormExtension is one plugin's contribution to one controller's // form (pact.AdminActionExtension.Form, D-91) after the boot checks. type compiledFormExtension struct { pluginID string def pact.AdminFormExtension // fields are the names of the fields the extension adds, in compiled // order; own holds them by name. fields []string own map[string]FormField } // formContribution is one extension waiting to be compiled onto a target. type formContribution struct { plugin party.Plugin def pact.AdminFormExtension } // extensionDoc is a parsed form extension fields document: the new tabs and // the fields with their placement keys, in document order. type extensionDoc struct { tabs []extensionTab fields []extensionField } // extensionTab is one entry of a fields document's tabs: list. type extensionTab struct { name, before, after, zone string } // extensionField is one compiled field of a fields document with its // before:, after: and zone: keys. type extensionField struct { field FormField before, after, zone string } // placementKeys are the keys a form extension's field takes next to the // fields.yaml keys; they are taken out before the field is compiled. var placementKeys = map[string]bool{"before": true, "after": true, "zone": true} // compileFormExtensions merges the form fields and tabs plugins add through // pact.AdminActionExtension.Form (D-91) into the compiled forms of reg, in // plugin slice (activation) order. It must run after every controller is // compiled. An extension's fields document is compiled like a fields.yaml // (every type, option and plugin field type check applies), then each field // is placed at its anchor. Every failure stops boot and names the plugin, the // controller and the offending name. func compileFormExtensions(reg *Registry, plugins []party.Plugin) error { if reg == nil { return fmt.Errorf("cabana: registry is nil") } targets := map[string][]formContribution{} var order []string for _, plugin := range plugins { if plugin == nil { continue } src, ok := plugin.(pact.HasAdminActionExtensions) if !ok || src == nil { continue } for _, ext := range src.AdminActionExtensions() { if ext.Form == nil { continue } cc, ok := reg.byID[ext.Controller] if !ok || cc == nil { return fmt.Errorf("cabana: plugin %s extends the form of unknown admin controller %q", plugin.ID(), ext.Controller) } if cc.Form == nil { return fmt.Errorf("cabana: plugin %s extends the form of admin controller %s, which has no form (config_form.yaml)", plugin.ID(), ext.Controller) } if _, seen := targets[ext.Controller]; !seen { order = append(order, ext.Controller) } targets[ext.Controller] = append(targets[ext.Controller], formContribution{plugin: plugin, def: *ext.Form}) } } for _, id := range order { if err := applyFormExtensions(reg, reg.byID[id], targets[id]); err != nil { return err } } return nil } // extErr is a form extension boot error naming the plugin and the controller. func extErr(pluginID, controller, format string, args ...any) error { return fmt.Errorf("cabana: plugin %s: controller %q: "+format, append([]any{pluginID, controller}, args...)...) } // applyFormExtensions compiles the contributions of one target controller. func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []formContribution) error { controller := cc.Controller.ID() docs := make([]extensionDoc, len(contributions)) layout := false for i, c := range contributions { doc, err := loadExtensionDoc(reg, c, controller) if err != nil { return err } docs[i] = doc if len(doc.fields) > 0 || len(doc.tabs) > 0 || len(c.def.Hide) > 0 { layout = true } } form := newFormLayout(cc.Form.Fields) for i, c := range contributions { ext := &compiledFormExtension{pluginID: c.plugin.ID(), def: c.def, own: map[string]FormField{}} if err := form.apply(ext, docs[i], controller); err != nil { return err } cc.formExtensions = append(cc.formExtensions, ext) if cc.extensionFields == nil { cc.extensionFields = map[string]*compiledFormExtension{} } for _, name := range ext.fields { cc.extensionFields[name] = ext } } if layout { // A new slice: the owner's compiled list may be shared. cc.Form.Fields = form.flatten() } return nil } // loadExtensionDoc reads and parses one extension's fields document. func loadExtensionDoc(reg *Registry, c formContribution, controller string) (extensionDoc, error) { pluginID := c.plugin.ID() if strings.TrimSpace(c.def.Fields) != "" && len(c.def.FieldsYAML) > 0 { return extensionDoc{}, extErr(pluginID, controller, "form extension sets both Fields and FieldsYAML") } raw := c.def.FieldsYAML source := "FieldsYAML" if file := strings.TrimSpace(c.def.Fields); file != "" { assets, ok := c.plugin.(pact.AdminAssets) if !ok || assets == nil || assets.AdminFS() == nil { return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: the plugin has no AdminFS", file) } name, err := assetPath(pluginID, file) if err != nil { return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err) } if raw, err = readAsset(assets.AdminFS(), name); err != nil { return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err) } source = name } if len(raw) == 0 { return extensionDoc{}, nil } doc, err := parseExtensionDoc(raw, reg.fieldTypes) if err != nil { return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", source, err) } return doc, nil } // parseExtensionDoc compiles a form extension fields document: a mapping // with fields: (a fields.yaml mapping whose entries may add before:, after: // and zone:) and tabs: (a list of name/before/after/zone mappings). func parseExtensionDoc(raw []byte, types *fieldTypeRegistry) (extensionDoc, error) { file, err := parser.ParseBytes(raw, 0) if err != nil { return extensionDoc{}, normalizeYAMLError(err) } var entries []*ast.MappingValueNode for i, doc := range file.Docs { if doc == nil { continue } if i > 0 { return extensionDoc{}, fmt.Errorf("want one YAML document") } switch body := unwrapNode(doc.Body).(type) { case nil, *ast.NullNode: case *ast.MappingNode: entries = body.Values case *ast.MappingValueNode: entries = []*ast.MappingValueNode{body} default: return extensionDoc{}, fmt.Errorf("the document must be a mapping with fields: and tabs:") } } var out extensionDoc seen := map[string]bool{} for _, entry := range entries { key, err := nodeString(unwrapNode(entry.Key)) if err != nil { return extensionDoc{}, err } if seen[key] { return extensionDoc{}, fmt.Errorf("duplicate key %s", key) } seen[key] = true switch key { case "fields": if out.fields, err = decodeExtensionFields(entry.Value, types); err != nil { return extensionDoc{}, err } case "tabs": if out.tabs, err = decodeExtensionTabs(entry.Value); err != nil { return extensionDoc{}, err } default: return extensionDoc{}, fmt.Errorf("unknown key %s (want fields or tabs)", key) } } return out, nil } // decodeExtensionFields takes the placement keys out of every field entry // and compiles the rest through decodeFieldMapping. func decodeExtensionFields(node ast.Node, types *fieldTypeRegistry) ([]extensionField, error) { node = unwrapNode(node) placement := map[string]map[string]string{} if mapping, ok := node.(*ast.MappingNode); ok { for _, entry := range mapping.Values { name, err := nodeString(unwrapNode(entry.Key)) if err != nil { continue // decodeFieldMapping reports the name } body, ok := unwrapNode(entry.Value).(*ast.MappingNode) if !ok { continue } keys := map[string]string{} kept := make([]*ast.MappingValueNode, 0, len(body.Values)) for _, item := range body.Values { key, err := nodeString(unwrapNode(item.Key)) if err != nil || !placementKeys[key] { kept = append(kept, item) continue } value, err := nodeString(unwrapNode(item.Value)) if err != nil { return nil, fmt.Errorf("field %s: %s: %w", name, key, err) } keys[key] = strings.TrimSpace(value) } body.Values = kept placement[name] = keys } } compiled, err := decodeFieldMapping(node, nil, types) if err != nil { return nil, err } out := make([]extensionField, 0, len(compiled)) for _, field := range compiled { keys := placement[field.Name] out = append(out, extensionField{field: field, before: keys["before"], after: keys["after"], zone: keys["zone"]}) } return out, nil } // decodeExtensionTabs decodes the tabs: list. func decodeExtensionTabs(node ast.Node) ([]extensionTab, error) { node = unwrapNode(node) if _, ok := node.(*ast.NullNode); ok || node == nil { return nil, nil } list, ok := node.(*ast.SequenceNode) if !ok { return nil, fmt.Errorf("tabs must be a list of mappings with name, before, after and zone") } out := make([]extensionTab, 0, len(list.Values)) for i, item := range list.Values { mapping, ok := unwrapNode(item).(*ast.MappingNode) if !ok { return nil, fmt.Errorf("tabs[%d] must be a mapping with name, before, after and zone", i) } var tab extensionTab for _, entry := range mapping.Values { key, err := nodeString(unwrapNode(entry.Key)) if err != nil { return nil, fmt.Errorf("tabs[%d]: %w", i, err) } value, err := nodeString(unwrapNode(entry.Value)) if err != nil { return nil, fmt.Errorf("tabs[%d]: %s: %w", i, key, err) } value = strings.TrimSpace(value) switch key { case "name": tab.name = value case "before": tab.before = value case "after": tab.after = value case "zone": tab.zone = value default: return nil, fmt.Errorf("tabs[%d]: unknown key %s (want name, before, after or zone)", i, key) } } if tab.name == "" { return nil, fmt.Errorf("tabs[%d]: name is required", i) } out = append(out, tab) } return out, nil } // tabGroup is one tab of a form being laid out: its raw tab key ("" for the // default tab) and its fields in order. type tabGroup struct { key string fields []FormField } // formLayout is a form being laid out by its extensions: the fields grouped // by tab in first-appearance order, the default tab first (P-02), plus who // added each field. type formLayout struct { groups []*tabGroup // fieldPlugin maps every field name to the plugin that added it; owner // fields map to "". fieldPlugin map[string]string } func newFormLayout(fields []FormField) *formLayout { layout := &formLayout{fieldPlugin: map[string]string{}} index := map[string]*tabGroup{} for _, field := range fields { group, ok := index[field.Tab] if !ok { group = &tabGroup{key: field.Tab} index[field.Tab] = group layout.groups = append(layout.groups, group) } group.fields = append(group.fields, field) layout.fieldPlugin[field.Name] = "" } // The SPA always renders the default tab first. for i, group := range layout.groups { if group.key == "" && i > 0 { copy(layout.groups[1:i+1], layout.groups[:i]) layout.groups[0] = group break } } return layout } // flatten is the laid-out field list. func (l *formLayout) flatten() []FormField { out := []FormField{} for _, group := range l.groups { out = append(out, group.fields...) } return out } // group returns the tab group with key, nil when the form has none. func (l *formLayout) group(key string) *tabGroup { for _, group := range l.groups { if group.key == key { return group } } return nil } // locate returns the group holding field name and its index there. func (l *formLayout) locate(name string) (*tabGroup, int) { for _, group := range l.groups { for i, field := range group.fields { if field.Name == name { return group, i } } } return nil, -1 } // apply places one extension's fields. func (l *formLayout) apply(ext *compiledFormExtension, doc extensionDoc, controller string) error { pluginID := ext.pluginID if len(doc.tabs) > 0 { return extErr(pluginID, controller, "tab %s: new tabs are not supported yet", doc.tabs[0].name) } for _, item := range doc.fields { field := item.field if owner, taken := l.fieldPlugin[field.Name]; taken { by := "the owner" if owner != "" { by = "plugin " + owner } return extErr(pluginID, controller, "field %s: duplicate field name (%s already adds it)", field.Name, by) } anchor, before := item.after, false if anchor == "" && item.before != "" { anchor, before = item.before, true } var ( group *tabGroup index int ) if anchor != "" { group, index = l.locate(anchor) if group == nil { return extErr(pluginID, controller, "field %s: unknown anchor %q", field.Name, anchor) } if field.Tab == "" { field.Tab = group.key } else if field.Tab != group.key { return extErr(pluginID, controller, "field %s: anchor %q is in another tab than %q", field.Name, anchor, field.Tab) } if !before { index++ } } else { group = l.group(field.Tab) if group == nil { if field.Tab != "" { return extErr(pluginID, controller, "field %s: unknown tab %q", field.Name, field.Tab) } group = &tabGroup{} l.groups = append([]*tabGroup{group}, l.groups...) } index = len(group.fields) } group.fields = append(group.fields[:index], append([]FormField{field}, group.fields[index:]...)...) l.fieldPlugin[field.Name] = pluginID ext.fields = append(ext.fields, field.Name) ext.own[field.Name] = field } return nil } // extensionOf returns the form extension that added field name, nil for an // owner field. func (cc *CompiledController) extensionOf(name string) *compiledFormExtension { if cc == nil { return nil } return cc.extensionFields[name] } // extensionValues are the submitted values of one save, per extension. type extensionValues map[*compiledFormExtension]map[string]any // liftExtensionValues takes the values of every extension field out of a // save body before anything else reads it, so they never reach Fill, the // owner's bindings or translation rows (D-91, T-32-01). Only fields whose // context allows op are kept for their extension's Save. func liftExtensionValues(cc *CompiledController, body map[string]any, op string) extensionValues { out := extensionValues{} if cc == nil || len(cc.extensionFields) == 0 || body == nil { return out } for name, ext := range cc.extensionFields { value, present := body[name] if !present { continue } delete(body, name) if !contextAllows(cc, name, op) { continue } if out[ext] == nil { out[ext] = map[string]any{} } out[ext][name] = value } return out } // saveFormExtensions runs every extension's Save inside the record's save // transaction, after the owner row is written. created is true on create. func saveFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, values extensionValues, created bool) ([]pluginNotice, error) { if cc == nil { return nil, nil } var notices []pluginNotice for _, ext := range cc.formExtensions { if ext.def.Save == nil { continue } own := values[ext] if own == nil { own = map[string]any{} } result, err := ext.def.Save(ctx, pact.AdminFormSave{ DB: tx, Controller: controllerID(cc), Record: target, ID: recordIDText(target), Created: created, Values: own, }) if err != nil { return nil, extensionFailure(cc, ext, "save", err) } notices = append(notices, extensionNotices(result)...) } return notices, nil } // extensionNotices are a Save result's notices in field name order. func extensionNotices(result pact.AdminFormSaveResult) []pluginNotice { var out []pluginNotice names := make([]string, 0, len(result.Notices)) for name := range result.Notices { names = append(names, name) } sort.Strings(names) for _, name := range names { for _, notice := range result.Notices[name] { out = append(out, pluginNotice{field: name, notice: notice}) } } return out } // extensionFailure classifies an extension hook error: AdminFormErrors is // validation_failed with its messages on the fields, a cabana refusal passes // through, anything else is logged and becomes the opaque lifecycle error. func extensionFailure(cc *CompiledController, ext *compiledFormExtension, stage string, err error) error { var invalid pact.AdminFormErrors if errors.As(err, &invalid) && len(invalid) > 0 { details := make(map[string]any, len(invalid)) for name, messages := range invalid { details[name] = append([]string(nil), messages...) } return &ValidationError{Details: details} } var refused *ValidationError var forbidden *ForbiddenError if errors.As(err, &refused) || errors.As(err, &forbidden) { return err } moduleLog.Error("cabana: form extension "+stage+" failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err) return &lifecycleError{ControllerID: controllerID(cc)} } // hydrateFormExtensions adds every extension's Load values to a show or save // response. Only keys that are the extension's own fields whose context // allows op are kept. func hydrateFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, data map[string]any, op string) error { if cc == nil || data == nil { return nil } for _, ext := range cc.formExtensions { if ext.def.Load == nil { continue } loaded, err := ext.def.Load(ctx, pact.AdminFormLoad{DB: tx, Controller: controllerID(cc), Record: target, ID: recordIDText(target)}) if err != nil { moduleLog.Error("cabana: form extension load failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err) return &lifecycleError{ControllerID: controllerID(cc)} } for _, name := range ext.fields { value, ok := loaded[name] if !ok || !contextAllows(cc, name, op) { continue } data[name] = value } } return nil } // recordIDText is the record's primary key as text, "" when it has none. func recordIDText(model any) string { v := reflect.ValueOf(model) for v.Kind() == reflect.Pointer { if v.IsNil() { return "" } v = v.Elem() } if !v.IsValid() { return "" } field := fieldByColumn(v, primaryColumn(model)) if !field.IsValid() || !field.CanInterface() { return "" } value := derefValue(field.Interface()) if value == nil { return "" } return fmt.Sprint(value) }