package cabana import ( "context" "fmt" "io/fs" "path" "reflect" "strings" "time" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" "github.com/goccy/go-yaml" "github.com/goccy/go-yaml/ast" "gorm.io/gorm" ) const listSearchTerm = "search" var listColumnTypes = map[string]struct{}{ "text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {}, } type listDocument struct { List string `yaml:"list"` ModelClass string `yaml:"modelClass"` Title string `yaml:"title"` RecordURL string `yaml:"recordUrl"` NoRecordsMessage string `yaml:"noRecordsMessage"` RecordsPerPage int `yaml:"recordsPerPage"` PerPageOptions []int `yaml:"perPageOptions"` ShowCheckboxes bool `yaml:"showCheckboxes"` ShowSetup bool `yaml:"showSetup"` ShowSorting *bool `yaml:"showSorting"` ShowSearch bool `yaml:"showSearch"` DefaultSort *listSortDocument `yaml:"defaultSort"` Toolbar *listToolbar `yaml:"toolbar"` Filter string `yaml:"filter"` Messages *listMessageKeys `yaml:"messages"` HeaderPartial string `yaml:"headerPartial"` } type listSortDocument struct { Column string `yaml:"column"` Direction string `yaml:"direction"` } type listToolbar struct { Buttons toolbarButtons `yaml:"buttons"` Search *struct { Prompt string `yaml:"prompt"` Mode string `yaml:"mode"` } `yaml:"search"` } type columnsDocument struct { Columns yaml.MapSlice `yaml:"columns"` } type columnDocument struct { Label string `yaml:"label"` Searchable bool `yaml:"searchable"` Sortable *bool `yaml:"sortable"` Type string `yaml:"type"` Relation string `yaml:"relation"` Select string `yaml:"select"` } // CompileListSchema compiles config_list.yaml and the columns.yaml it names. func CompileListSchema(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) { return compileList(pluginID, ctl, fsys) } // CompileList compiles one controller's config_list.yaml and columns.yaml. func CompileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) { return compileList(pluginID, ctl, fsys) } func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) { if ctl == nil { return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID) } dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/") if dir == "." || strings.HasPrefix(dir, "..") { return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin")) } cfgPath := path.Join(dir, "config_list.yaml") raw, err := readAsset(fsys, cfgPath) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } var doc listDocument if err := decodeStrict(raw, &doc); err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } if doc.ModelClass != ctl.ModelName() { return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName())) } if strings.TrimSpace(doc.List) == "" { return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("list file is empty")) } colPath, err := assetPath(pluginID, doc.List) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } columns, err := compileColumns(pluginID, ctl, fsys, colPath) if err != nil { return nil, err } per := doc.RecordsPerPage if per < 1 { per = 20 } options, err := compilePageOptions(per, doc.PerPageOptions) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } showSearch := doc.ShowSearch prompt := "" if doc.Toolbar != nil && doc.Toolbar.Search != nil { showSearch = true prompt = doc.Toolbar.Search.Prompt if mode := doc.Toolbar.Search.Mode; mode != "" && mode != "all" && mode != "any" && mode != "exact" { return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode)) } } buttons, toolbarActions, err := compileToolbarButtons(ctl, doc.Toolbar, doc.ShowCheckboxes) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } sort, err := compileDefaultSort(doc.DefaultSort, columns) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) } showSorting := true if doc.ShowSorting != nil { showSorting = *doc.ShowSorting } if doc.HeaderPartial != "" && !identifier(doc.HeaderPartial) { return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("headerPartial %q: %s", doc.HeaderPartial, partialPathHint)) } rowActions := []RowAction{} if doc.RecordURL != "" { rowActions = append(rowActions, RowAction{ Name: "update", Label: "backend::lang.form.update", URL: doc.RecordURL, }) } bulk := []BulkAction{} if doc.ShowCheckboxes { bulk = append(bulk, BulkAction{Name: "delete", Label: "backend::lang.list.delete_selected"}) } filters := []ListFilter{} if strings.TrimSpace(doc.Filter) != "" { filters, err = compileFilters(pluginID, ctl, fsys, dir, doc.Filter) if err != nil { return nil, err } } schema := &ListSchema{ Title: doc.Title, ModelClass: doc.ModelClass, RecordURL: doc.RecordURL, NoRecordsMessage: doc.NoRecordsMessage, RecordsPerPage: per, PerPageOptions: options, ShowSearch: showSearch, ShowSetup: doc.ShowSetup, ShowCheckboxes: doc.ShowCheckboxes, ShowSorting: showSorting, SearchTerm: listSearchTerm, SearchPrompt: prompt, DefaultSort: sort, ToolbarButtons: buttons, ToolbarActions: toolbarActions, HeaderPartial: doc.HeaderPartial, Columns: columns, Filters: filters, RowActions: rowActions, BulkActions: bulk, } if doc.Messages != nil { schema.messageKeys = *doc.Messages } keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, schema.listMessageKeySet()) schema.Messages = &keys return schema, nil } func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) { colRaw, err := readAsset(fsys, colPath) if err != nil { return nil, bootErr(pluginID, ctl.ID(), colPath, err) } var cols columnsDocument if err := decodeStrict(colRaw, &cols); err != nil { return nil, bootErr(pluginID, ctl.ID(), colPath, err) } modelCols, modelRels, checkModel := listModelContract(ctl) compiled := make([]ListColumn, 0, len(cols.Columns)) seen := map[string]struct{}{} for _, item := range cols.Columns { key, ok := item.Key.(string) if !ok || !identifier(key) { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column key %v is not an identifier", item.Key)) } if _, dup := seen[key]; dup { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("duplicate column %s", key)) } seen[key] = struct{}{} encoded, err := yaml.Marshal(item.Value) if err != nil { return nil, bootErr(pluginID, ctl.ID(), colPath, err) } var spec columnDocument if err := decodeStrict(encoded, &spec); err != nil { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err)) } if spec.Type != "" { if _, known := listColumnTypes[spec.Type]; !known { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("unsupported column type %s", spec.Type)) } } if spec.Relation != "" || spec.Select != "" { if !identifier(spec.Relation) { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("relation %q is not an identifier", spec.Relation)) } if !identifier(spec.Select) { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("select %q is not an identifier", spec.Select)) } if checkModel { if _, known := modelRels[spec.Relation]; !known { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("relation %s is not on the model", spec.Relation)) } } } else if checkModel { if _, known := modelCols[key]; !known { return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s is not on the model", key)) } } sortable := spec.Relation == "" if spec.Sortable != nil { sortable = *spec.Sortable } compiled = append(compiled, ListColumn{ Key: key, Label: spec.Label, Searchable: spec.Searchable, Sortable: sortable, Type: spec.Type, Relation: spec.Relation, Select: spec.Select, }) } return compiled, nil } func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) { if len(declared) == 0 { return []int{recordsPerPage}, nil } options := make([]int, len(declared)) seen := map[int]struct{}{} found := false for i, n := range declared { if n < 1 { return nil, fmt.Errorf("perPageOptions must be positive") } if _, dup := seen[n]; dup { return nil, fmt.Errorf("duplicate perPageOptions %d", n) } seen[n] = struct{}{} options[i] = n if n == recordsPerPage { found = true } } if !found { return nil, fmt.Errorf("recordsPerPage %d is not in perPageOptions", recordsPerPage) } return options, nil } // toolbarButtons is the declarative toolbar.buttons list (D-14, D-12): // action names in display order. The built-in create and delete sit next to // names the controller registers through pact.HasAdminActions; decode has no // controller, so membership is resolved in compileToolbarButtons. A scalar // (Winter's partial name) is rejected with a pointer at the list syntax. type toolbarButtons struct { items []string } func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error { node = unwrapNode(node) switch n := node.(type) { case nil, *ast.NullNode: b.items = nil return nil case *ast.SequenceNode: values := sequenceValues(n) items := make([]string, 0, len(values)) seen := map[string]struct{}{} for _, item := range values { action, err := nodeString(unwrapNode(item)) if err != nil { return fmt.Errorf("toolbar.buttons entries must be action names") } if _, dup := seen[action]; dup { return fmt.Errorf("toolbar.buttons: duplicate action %s", action) } seen[action] = struct{}{} items = append(items, action) } b.items = items return nil default: return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete or registered actions); the Winter partial %q is not supported", nodeText(node)) } } // compileToolbarButtons resolves every toolbar.buttons name against the // built-in create and delete and the controller's registered actions: the one // action namespace widgets use too. It returns the names in declared order and // the registered entries with their (source) labels. func compileToolbarButtons(ctl pact.AdminController, toolbar *listToolbar, showCheckboxes bool) ([]string, []ToolbarAction, error) { if toolbar == nil || len(toolbar.Buttons.items) == 0 { return []string{}, []ToolbarAction{}, nil } registered := map[string]pact.AdminAction{} if src, ok := ctl.(pact.HasAdminActions); ok && src != nil { for _, action := range src.AdminActions() { registered[action.Name] = action } } out := append([]string(nil), toolbar.Buttons.items...) custom := []ToolbarAction{} for _, name := range out { if builtinToolbarActions[name] { if name == "delete" && !showCheckboxes { return nil, nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true") } continue } action, ok := registered[name] if !ok { return nil, nil, fmt.Errorf("toolbar.buttons: unsupported action %s (want create, delete or an action the controller registers)", name) } if strings.TrimSpace(action.Label) == "" { return nil, nil, fmt.Errorf("toolbar.buttons: action %s needs a label", name) } custom = append(custom, ToolbarAction{Name: name, Label: action.Label}) } return out, custom, nil } func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) { if doc == nil { return nil, nil } var match *ListColumn for i := range columns { if columns[i].Key == doc.Column { match = &columns[i] break } } if match == nil { return nil, fmt.Errorf("default sort column %s is not a list column", doc.Column) } if !match.Sortable { return nil, fmt.Errorf("default sort column %s is not sortable", doc.Column) } if doc.Direction != "asc" && doc.Direction != "desc" { return nil, fmt.Errorf("default sort direction %s must be asc or desc", doc.Direction) } return &ListSort{Column: doc.Column, Direction: doc.Direction}, nil } func listModelContract(ctl pact.AdminController) (map[string]struct{}, map[string]struct{}, bool) { src, ok := ctl.(pact.AdminRecordSource) if !ok || src == nil { return nil, nil, false } model := src.NewRecord() if model == nil { return nil, nil, false } t := reflect.TypeOf(model) for t != nil && t.Kind() == reflect.Pointer { t = t.Elem() } if t == nil || t.Kind() != reflect.Struct { return nil, nil, false } cols := map[string]struct{}{} rels := map[string]struct{}{} for _, mf := range modelFields(t) { field := mf.Field if isListRelation(field.Type) { rels[field.Name] = struct{}{} // Winter YAML spells relations in lowercase (relation: genre) // while the Go field is exported (Genre). rels[strings.ToLower(field.Name)] = struct{}{} continue } name := gormColumn(field) if name == "" { name = field.Name } cols[name] = struct{}{} } return cols, rels, true } func isListRelation(t reflect.Type) bool { for t.Kind() == reflect.Pointer { t = t.Elem() } if t.Kind() == reflect.Slice { t = t.Elem() for t.Kind() == reflect.Pointer { t = t.Elem() } } if t.Kind() != reflect.Struct { return false } if t == reflect.TypeOf(time.Time{}) { return false } if t == reflect.TypeOf(gorm.DeletedAt{}) { return false } // A struct stored in one column (it scans or values itself, such as // lagoon.Date and lagoon.TimeOfDay) is a scalar, not a relation. if reflect.PointerTo(t).Implements(scannerType) || t.Implements(valuerType) || reflect.PointerTo(t).Implements(valuerType) { return false } return true } // Localize copies the cached list schema and resolves display strings for one request. // Column, filter, and scope identifiers and option values are not translated. func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*ListSchema, error) { if s == nil { return nil, fmt.Errorf("cabana: nil list schema") } if ctx == nil { ctx = context.Background() } locale := schemaLocale(ctx, tr) ctx = towel.WithLocale(ctx, locale) out := *s out.Title = translateKey(ctx, tr, s.Title) out.NoRecordsMessage = translateKey(ctx, tr, s.NoRecordsMessage) out.SearchPrompt = translateKey(ctx, tr, s.SearchPrompt) out.PerPageOptions = append([]int(nil), s.PerPageOptions...) out.ToolbarButtons = append([]string(nil), s.ToolbarButtons...) out.ToolbarActions = make([]ToolbarAction, len(s.ToolbarActions)) for i, action := range s.ToolbarActions { action.Label = translateKey(ctx, tr, action.Label) out.ToolbarActions[i] = action } out.Assets = ControllerAssets{ Scripts: append([]string{}, s.Assets.Scripts...), Styles: append([]string{}, s.Assets.Styles...), } if s.DefaultSort != nil { sort := *s.DefaultSort out.DefaultSort = &sort } out.Columns = localizeListColumns(ctx, tr, s.Columns) out.Filters = localizeListFilters(ctx, tr, s.Filters) out.RowActions = localizeRowActions(ctx, tr, s.RowActions) out.BulkActions = localizeBulkActions(ctx, tr, s.BulkActions) messages := localizeMessages[listMessageKeys, ListMessages](ctx, tr, s.listMessageKeySet()) out.Messages = &messages out.Meta = &FormMeta{Locale: locale} return &out, nil } func localizeListColumns(ctx context.Context, tr *phrasebook.Translator, cols []ListColumn) []ListColumn { out := make([]ListColumn, len(cols)) for i, col := range cols { col.Label = translateKey(ctx, tr, col.Label) out[i] = col } if out == nil { out = []ListColumn{} } return out } func localizeListFilters(ctx context.Context, tr *phrasebook.Translator, filters []ListFilter) []ListFilter { out := make([]ListFilter, len(filters)) for i, filter := range filters { filter.Label = translateKey(ctx, tr, filter.Label) if len(filter.Options) > 0 { opts := make([]FormOption, len(filter.Options)) for j, opt := range filter.Options { opt.Label = translateKey(ctx, tr, opt.Label) opts[j] = opt } filter.Options = opts } out[i] = filter } if out == nil { out = []ListFilter{} } return out } func localizeRowActions(ctx context.Context, tr *phrasebook.Translator, actions []RowAction) []RowAction { out := make([]RowAction, len(actions)) for i, action := range actions { action.Label = translateKey(ctx, tr, action.Label) out[i] = action } if out == nil { out = []RowAction{} } return out } func localizeBulkActions(ctx context.Context, tr *phrasebook.Translator, actions []BulkAction) []BulkAction { out := make([]BulkAction, len(actions)) for i, action := range actions { action.Label = translateKey(ctx, tr, action.Label) out[i] = action } if out == nil { out = []BulkAction{} } return out }