package cabana import ( "context" "fmt" "io/fs" "path" "reflect" "strings" "time" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/phrasebook" "git.golem15.com/golem15/summercms/towel" "github.com/goccy/go-yaml" "gorm.io/gorm" ) const listSearchTerm = "search" var listColumnTypes = map[string]struct{}{ "text": {}, "datetime": {}, "switch": {}, } 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"` } type listSortDocument struct { Column string `yaml:"column"` Direction string `yaml:"direction"` } type listToolbar struct { Buttons string `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, err := compileToolbarButtons(doc.Toolbar) 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 } 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 } } return &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, Columns: columns, Filters: filters, RowActions: rowActions, BulkActions: bulk, }, 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 := true 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 } func compileToolbarButtons(toolbar *listToolbar) ([]string, error) { if toolbar == nil || strings.TrimSpace(toolbar.Buttons) == "" { return []string{}, nil } switch toolbar.Buttons { case "list_toolbar": return []string{"create"}, nil default: return nil, fmt.Errorf("unsupported action %s", toolbar.Buttons) } } 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 i := 0; i < t.NumField(); i++ { field := t.Field(i) if field.PkgPath != "" { continue } if isListRelation(field.Type) { rels[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 } 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...) 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) 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 }