From d3a93073c9ea3e937eca92c3d9768e77a0437fcc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 18:53:52 +0200 Subject: [PATCH] feat(09-04): compile switch, date-range, and scope filters - Filters keep typed values and only registered scope names - Raw conditions and arbitrary methods fail activation - Request localization copies labels and leaves identifiers unchanged --- cabana/filter_schema.go | 304 ++++++++++++++++++++++++++++++++++++++++ cabana/http.go | 23 ++- cabana/list_schema.go | 88 +++++++++++- cabana/schema_types.go | 3 + pact/capabilities.go | 5 +- 5 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 cabana/filter_schema.go diff --git a/cabana/filter_schema.go b/cabana/filter_schema.go new file mode 100644 index 0000000..7ef1aea --- /dev/null +++ b/cabana/filter_schema.go @@ -0,0 +1,304 @@ +package cabana + +import ( + "fmt" + "io/fs" + "path" + "reflect" + "strings" + "time" + + "git.golem15.com/golem15/summercms/pact" + "github.com/goccy/go-yaml/ast" +) + +var filterScopeKeys = map[string]struct{}{ + "label": {}, "type": {}, "column": {}, "options": {}, + "modelClass": {}, "nameFrom": {}, "scope": {}, +} + +type filterDocument struct { + Scopes scopeMap `yaml:"scopes"` +} + +type scopeMap struct { + items []ListFilter +} + +func compileFilters(pluginID string, ctl pact.AdminController, fsys fs.FS, dir, ref string) ([]ListFilter, error) { + file, err := filterAssetPath(pluginID, dir, ref) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), "config_filter.yaml", err) + } + raw, err := readAsset(fsys, file) + if err != nil { + return nil, bootErr(pluginID, ctl.ID(), file, err) + } + var doc filterDocument + if err := decodeStrict(raw, &doc); err != nil { + return nil, bootErr(pluginID, ctl.ID(), file, err) + } + filters := doc.Scopes.items + if filters == nil { + filters = []ListFilter{} + } + provider := filterProvider(ctl) + for _, filter := range filters { + if err := validateFilter(ctl, provider, filter); err != nil { + return nil, bootErr(pluginID, ctl.ID(), file, err) + } + } + return filters, nil +} + +func filterAssetPath(pluginID, dir, ref string) (string, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return "", fmt.Errorf("filter file is empty") + } + if strings.Contains(ref, "..") { + return "", fmt.Errorf("filter path escapes the plugin") + } + if strings.HasPrefix(ref, "~/") || strings.Contains(ref, "/") { + return assetPath(pluginID, ref) + } + return path.Join(dir, ref), nil +} + +func (m *scopeMap) UnmarshalYAML(node ast.Node) error { + node = unwrapNode(node) + if _, ok := node.(*ast.NullNode); ok || node == nil { + m.items = []ListFilter{} + return nil + } + mapping, ok := node.(*ast.MappingNode) + if !ok { + return fmt.Errorf("scopes must be a mapping") + } + items := make([]ListFilter, 0, len(mapping.Values)) + seen := map[string]struct{}{} + for _, entry := range mapping.Values { + name, err := nodeString(unwrapNode(entry.Key)) + if err != nil || !identifier(name) { + return fmt.Errorf("filter name %q is not an identifier", nodeText(entry.Key)) + } + if _, dup := seen[name]; dup { + return fmt.Errorf("duplicate filter %s", name) + } + seen[name] = struct{}{} + filter, err := compileScopeNode(name, unwrapNode(entry.Value)) + if err != nil { + return fmt.Errorf("filter %s: %w", name, err) + } + items = append(items, filter) + } + m.items = items + return nil +} + +func compileScopeNode(name string, node ast.Node) (ListFilter, error) { + mapping, ok := node.(*ast.MappingNode) + if !ok { + return ListFilter{}, fmt.Errorf("must be a mapping") + } + values := map[string]ast.Node{} + for _, entry := range mapping.Values { + key, err := nodeString(unwrapNode(entry.Key)) + if err != nil { + return ListFilter{}, err + } + if key == "conditions" { + return ListFilter{}, fmt.Errorf("conditions is not supported; use a column or a registered model scope") + } + if _, known := filterScopeKeys[key]; !known { + return ListFilter{}, fmt.Errorf("unknown field %s", key) + } + if _, dup := values[key]; dup { + return ListFilter{}, fmt.Errorf("duplicate field %s", key) + } + values[key] = unwrapNode(entry.Value) + } + filter := ListFilter{Name: name} + var err error + if node, ok := values["label"]; ok { + filter.Label, err = nodeString(node) + if err != nil { + return ListFilter{}, fmt.Errorf("label: %w", err) + } + } + if node, ok := values["type"]; ok { + filter.Type, err = nodeString(node) + if err != nil { + return ListFilter{}, fmt.Errorf("type: %w", err) + } + } + if node, ok := values["column"]; ok { + filter.Column, err = nodeString(node) + if err != nil || !identifier(filter.Column) { + return ListFilter{}, fmt.Errorf("column %q is not an identifier", filter.Column) + } + } + if node, ok := values["modelClass"]; ok { + filter.ModelClass, err = nodeString(node) + if err != nil || strings.TrimSpace(filter.ModelClass) == "" { + return ListFilter{}, fmt.Errorf("modelClass is empty") + } + } + if node, ok := values["nameFrom"]; ok { + filter.NameFrom, err = nodeString(node) + if err != nil || !identifier(filter.NameFrom) { + return ListFilter{}, fmt.Errorf("nameFrom %q is not an identifier", filter.NameFrom) + } + } + if node, ok := values["scope"]; ok { + filter.Scope, err = nodeString(node) + if err != nil || !identifier(filter.Scope) { + return ListFilter{}, fmt.Errorf("scope %q is not an identifier", filter.Scope) + } + } + if node, ok := values["options"]; ok { + filter.Options, err = compileFilterOptions(node) + if err != nil { + return ListFilter{}, fmt.Errorf("options: %w", err) + } + } + if filter.Type == "" && filter.Scope != "" { + filter.Type = "scope" + } + switch filter.Type { + case "switch": + if filter.Column == "" { + return ListFilter{}, fmt.Errorf("column is required") + } + if len(filter.Options) == 0 { + on := jsonScalar{raw: []byte("true")} + off := jsonScalar{raw: []byte("false")} + filter.TrueValue = &on + filter.FalseValue = &off + } + case "daterange": + if filter.Column == "" { + return ListFilter{}, fmt.Errorf("column is required") + } + case "scope": + if filter.Scope == "" || filter.ModelClass == "" || filter.NameFrom == "" { + return ListFilter{}, fmt.Errorf("scope, modelClass, and nameFrom are required") + } + default: + if filter.Type == "" { + return ListFilter{}, fmt.Errorf("type is required") + } + return ListFilter{}, fmt.Errorf("unsupported filter type %s", filter.Type) + } + return filter, nil +} + +func compileFilterOptions(node ast.Node) ([]FormOption, error) { + mapping, ok := node.(*ast.MappingNode) + if !ok { + return nil, fmt.Errorf("must be a map") + } + options := make([]FormOption, 0, len(mapping.Values)) + for _, entry := range mapping.Values { + value, err := nodeScalar(unwrapNode(entry.Key)) + if err != nil { + return nil, fmt.Errorf("value: %w", err) + } + label, err := nodeString(unwrapNode(entry.Value)) + if err != nil { + return nil, fmt.Errorf("label: %w", err) + } + options = append(options, FormOption{Value: value, Label: label}) + } + return options, nil +} + +func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter ListFilter) error { + switch filter.Type { + case "switch", "daterange": + if _, _, check := listModelContract(ctl); check { + ft, ok := modelColumnType(ctl, filter.Column) + if !ok { + return fmt.Errorf("column %s is not on the model", filter.Column) + } + if filter.Type == "switch" && !isBoolType(ft) { + return fmt.Errorf("column %s is not a boolean", filter.Column) + } + if filter.Type == "daterange" && !isTimeType(ft) { + return fmt.Errorf("column %s is not a date", filter.Column) + } + } + case "scope": + if provider == nil { + return fmt.Errorf("scope %s requires FilterScope", filter.Scope) + } + for _, name := range provider.FilterScopes() { + if name == filter.Scope { + return nil + } + } + return fmt.Errorf("scope %s is not registered", filter.Scope) + } + return nil +} + +func filterProvider(ctl pact.AdminController) pact.FilterScope { + src, ok := ctl.(pact.AdminRecordSource) + if !ok || src == nil { + return nil + } + rec := src.NewRecord() + provider, ok := rec.(pact.FilterScope) + if !ok || provider == nil { + return nil + } + return provider +} + +func modelColumnType(ctl pact.AdminController, column string) (reflect.Type, bool) { + src, ok := ctl.(pact.AdminRecordSource) + if !ok || src == nil || src.NewRecord() == nil { + return nil, false + } + t := reflect.TypeOf(src.NewRecord()) + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil, false + } + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" || isListRelation(field.Type) { + continue + } + name := gormColumn(field) + if name == "" { + name = field.Name + } + if name == column { + return field.Type, true + } + } + return nil, false +} + +func isTimeType(t reflect.Type) bool { + if t == nil { + return false + } + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t == reflect.TypeOf(time.Time{}) +} + +func isBoolType(t reflect.Type) bool { + if t == nil { + return false + } + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t.Kind() == reflect.Bool +} diff --git a/cabana/http.go b/cabana/http.go index 402454d..370424a 100644 --- a/cabana/http.go +++ b/cabana/http.go @@ -14,6 +14,7 @@ import ( "git.golem15.com/golem15/summercms/bouncer" "git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/party" + "git.golem15.com/golem15/summercms/phrasebook" "gorm.io/gorm" ) @@ -140,10 +141,30 @@ func (s *service) listSchema(w http.ResponseWriter, r *http.Request) { if schema == nil { schema = &ListSchema{} } - WriteData(w, http.StatusOK, schema, map[string]any{}) + view, err := schema.Localize(r.Context(), s.translator()) + if err != nil { + WriteError(w, http.StatusInternalServerError, "error", msgServerError) + return + } + meta := map[string]any{} + if view.Meta != nil { + meta["locale"] = view.Meta.Locale + } + WriteData(w, http.StatusOK, view, meta) }) } +func (s *service) translator() *phrasebook.Translator { + if s == nil || s.app == nil { + return nil + } + tr, ok := s.app.Lookup[*phrasebook.Translator]() + if !ok { + return nil + } + return tr +} + func (s *service) list(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { db, err := s.db() diff --git a/cabana/list_schema.go b/cabana/list_schema.go index d69bd58..19d0c7d 100644 --- a/cabana/list_schema.go +++ b/cabana/list_schema.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -35,6 +36,7 @@ type listDocument struct { ShowSearch bool `yaml:"showSearch"` DefaultSort *listSortDocument `yaml:"defaultSort"` Toolbar *listToolbar `yaml:"toolbar"` + Filter string `yaml:"filter"` } type listSortDocument struct { @@ -145,6 +147,13 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc 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, @@ -161,7 +170,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc DefaultSort: sort, ToolbarButtons: buttons, Columns: columns, - Filters: []ListFilter{}, + Filters: filters, RowActions: rowActions, BulkActions: bulk, }, nil @@ -354,8 +363,8 @@ func isListRelation(t reflect.Type) bool { return true } -// Localize copies the cached list schema for one request. -// The RED seam returns source keys; request-time translation replaces this body. +// 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") @@ -363,7 +372,78 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (* if ctx == nil { ctx = context.Background() } + locale := schemaLocale(ctx, tr) + ctx = towel.WithLocale(ctx, locale) out := *s - _ = tr + 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 +} diff --git a/cabana/schema_types.go b/cabana/schema_types.go index 99da5f8..47a964d 100644 --- a/cabana/schema_types.go +++ b/cabana/schema_types.go @@ -29,6 +29,8 @@ type ListFilter struct { Scope string `json:"scope,omitempty"` ModelClass string `json:"modelClass,omitempty"` NameFrom string `json:"nameFrom,omitempty"` + TrueValue *jsonScalar `json:"trueValue,omitempty"` + FalseValue *jsonScalar `json:"falseValue,omitempty"` Options []FormOption `json:"options,omitempty"` } @@ -66,6 +68,7 @@ type ListSchema struct { Filters []ListFilter `json:"filters"` RowActions []RowAction `json:"rowActions"` BulkActions []BulkAction `json:"bulkActions"` + Meta *FormMeta `json:"meta,omitempty"` } // MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null. diff --git a/pact/capabilities.go b/pact/capabilities.go index dde0ed9..2bebf21 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -217,8 +217,11 @@ type RelationBeforeLink interface { RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error } -// FilterScope is a model method referenced by a config_filter scope name. +// FilterScope is the model capability a config_filter scope name may call. +// FilterScopes is the exact, case-sensitive set of names; request text never +// selects a method outside that set. type FilterScope interface { + FilterScopes() []string FilterScope(name string, db *gorm.DB, value any) *gorm.DB }