refactor(collections): retire the legacy rules surface — tree-only storage
The pre-tree rules machinery is deleted: no row ever carried a rules array or a non-default match mode (verified in the dev DB), the web never read the envelope keys, and the Android app consumes none of it. Dead code gets no documentation. Removed: - PUT /api/collections/:id/match-mode — endpoint, route, and the UpdateCollectionMatchMode query. The tree's root mode owns any|all. - auto_assign_rules / rule_match_mode from every envelope (create/list/detail/update), from the create/update request shapes, and from the schema: both columns DROPPED (schema.sql carries idempotent DROP COLUMN IF EXISTS heals for existing DBs; sqlc regenerated). - The rules CRUD service layer (AddRule/SetRuleEnabled/RemoveRule + validateRule + UpdateCollectionRulesJSON), ParseRules / EnabledRules / IsSmartCollection, RulesToFilterTree (+ the SSR preload branch), the legacy branch of EvaluateSmartMembership, and SmartEvalOptions.MatchMode. IsCollectionSmart = tree presence; create-path isSmart = collection_type=smart OR a filter_tree. Kept (live consumers): POST /api/collections/preview still evaluates rules payloads in memory (the custom-section builder's preview), and include_auto stays on the list (the Android app sends it). The custom-section builder's SAVE was already broken — its stringified auto_assign_rules payload failed request binding (probed live: 400 invalid request). Create now rejects that payload loudly with a 400 naming the retirement, instead of silently creating a hollow static section; custom-section-builder.md carries the flag. The builder's tree migration remains the flagged follow-up. Test-side (asymmetry rule — deleted behavior, deleted tests): TestPodcastSmartCollections rewritten onto trees (mode lives in the tree root), match-mode subtests and tree_wins_over_legacy_rules deleted, TestRulesToFilterTree deleted, contract-pin envelope keys retracted per the contract_pins 2026-10-31 retirement entry. Mirrors hand-synced: openapi.yaml, bruno create/update, docs/api/*.md, docs/schema.go, api.d.ts. Full container suite green (155s).
This commit is contained in:
1 parent
ab98015190
commit
a86bdac87e
27 files changed
+239
-1093
No files matched your search
@@ -32,42 +32,6 @@ type Rule struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// Known rule fields and operators — the rules builder UI and the
|
||||
// persisted JSONB documents must agree with this vocabulary.
|
||||
// Episode fields (plan item 2.4): feed/downloaded/published_within ride
|
||||
// the media row; played reads the VIEWER's progress (the candidate
|
||||
// query carries it — SmartEvalOptions) so "unplayed" is per-user.
|
||||
var ruleFields = map[string]bool{
|
||||
"genre": true, "series": true, "author": true, "language": true,
|
||||
"publisher": true, "copyright_year": true, "tags": true,
|
||||
// Episode collections (plan item 2.4):
|
||||
"title": true, "description": true,
|
||||
"feed": true, "played": true, "downloaded": true, "published_within": true,
|
||||
}
|
||||
|
||||
var ruleOperators = map[string]bool{
|
||||
"equals": true, "not_equals": true, "contains": true, "not_contains": true,
|
||||
"starts_with": true, "ends_with": true, "greater_than": true, "less_than": true,
|
||||
"in": true, // multi-value (comma-separated) — the feed multi-select
|
||||
}
|
||||
|
||||
// Smart-collection match semantics (plan item 2.4): 'any' = OR (the
|
||||
// legacy behavior), 'all' = AND (the daily briefing).
|
||||
const (
|
||||
RuleMatchAny = "any"
|
||||
RuleMatchAll = "all"
|
||||
)
|
||||
|
||||
func ValidRuleMatchMode(m string) bool {
|
||||
return m == RuleMatchAny || m == RuleMatchAll
|
||||
}
|
||||
|
||||
// RuleValidationError reports a rule payload the evaluator could never
|
||||
// honor (unknown field/operator, empty value).
|
||||
type RuleValidationError struct{ Msg string }
|
||||
|
||||
func (e *RuleValidationError) Error() string { return e.Msg }
|
||||
|
||||
// RuleEvaluation represents the result of evaluating a rule
|
||||
type RuleEvaluation struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
@@ -96,20 +60,10 @@ func NewCollectionService(db *database.Queries, search ...*SearchService) *Colle
|
||||
return svc
|
||||
}
|
||||
|
||||
// CreateCollection creates a new collection. filterTree (item 3.2)
|
||||
// replaces autoAssignRules as the smart engine's source when present —
|
||||
// both may coexist on the row (tree wins at evaluation).
|
||||
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
|
||||
// Convert rules to JSONB ([]byte)
|
||||
var rulesJSON []byte
|
||||
if len(autoAssignRules) > 0 {
|
||||
var err error
|
||||
rulesJSON, err = json.Marshal(autoAssignRules)
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCollection creates a new collection. filterTree (item 3.2), when
|
||||
// present, makes it smart — the qf engine is the only membership source
|
||||
// (the legacy rules-array storage retired 2026-10-31).
|
||||
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
|
||||
// Convert view settings to JSONB ([]byte)
|
||||
var settingsJSON []byte
|
||||
if len(viewSettings) > 0 {
|
||||
@@ -121,14 +75,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
|
||||
}
|
||||
|
||||
collection, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
AutoAssignRules: rulesJSON,
|
||||
ViewSettings: settingsJSON,
|
||||
FilterTree: filterTree,
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
ViewSettings: settingsJSON,
|
||||
FilterTree: filterTree,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -164,18 +117,13 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
// UpdateCollection updates a collection
|
||||
// UpdateCollection rewrites the collection row. ruleMatchMode empty or
|
||||
// invalid = keep the stored value (the edit form only sends the toggles
|
||||
// it renders — plan item 2.4). filterTree semantics: nil = leave the
|
||||
// stored tree untouched.
|
||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string, filterTree []byte) (database.Collections, error) {
|
||||
// UpdateCollection rewrites the collection row. filterTree semantics:
|
||||
// nil = leave the stored tree untouched (forms don't send trees).
|
||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
|
||||
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
||||
// them OVER the stored view_settings so provenance keys
|
||||
// (imported_from_series) and anything else on the row survive.
|
||||
existingMode := RuleMatchAny
|
||||
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
|
||||
existingMode = existing.RuleMatchMode
|
||||
if filterTree == nil {
|
||||
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
|
||||
}
|
||||
@@ -201,20 +149,6 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ValidRuleMatchMode(ruleMatchMode) {
|
||||
ruleMatchMode = existingMode
|
||||
}
|
||||
|
||||
// Convert rules to JSONB ([]byte)
|
||||
var rulesJSON []byte
|
||||
if len(autoAssignRules) > 0 {
|
||||
var err error
|
||||
rulesJSON, err = json.Marshal(autoAssignRules)
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert view settings to JSONB ([]byte)
|
||||
var settingsJSON []byte
|
||||
if len(viewSettings) > 0 {
|
||||
@@ -226,15 +160,13 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
}
|
||||
|
||||
collection, err := s.db.UpdateCollection(ctx, database.UpdateCollectionParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
AutoAssignRules: rulesJSON,
|
||||
ViewSettings: settingsJSON,
|
||||
RuleMatchMode: ruleMatchMode,
|
||||
FilterTree: filterTree,
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
ViewSettings: settingsJSON,
|
||||
FilterTree: filterTree,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -316,162 +248,7 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
// ParseRules decodes a collection's auto_assign_rules JSONB into typed
|
||||
// rules. "null", empty, and malformed docs mean "no rules".
|
||||
func (s *CollectionService) ParseRules(raw []byte) []Rule {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var rules []Rule
|
||||
if err := json.Unmarshal(raw, &rules); err != nil {
|
||||
return nil
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// IsSmartCollection derives the collection type: a non-empty ruleset
|
||||
// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC
|
||||
// (stored collection_items links).
|
||||
func (s *CollectionService) IsSmartCollection(raw []byte) bool {
|
||||
return len(s.ParseRules(raw)) > 0
|
||||
}
|
||||
|
||||
// EnabledRules filters a ruleset down to the rules that participate in
|
||||
// live evaluation (absent enabled = enabled for backward compatibility).
|
||||
func (s *CollectionService) EnabledRules(rules []Rule) []Rule {
|
||||
enabled := make([]Rule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule.Enabled == nil || *rule.Enabled {
|
||||
enabled = append(enabled, rule)
|
||||
}
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
// validateRule checks a rule payload the evaluator will have to honor.
|
||||
func (s *CollectionService) validateRule(rule Rule) error {
|
||||
if !ruleFields[rule.Field] {
|
||||
return &RuleValidationError{Msg: "unknown rule field: " + rule.Field}
|
||||
}
|
||||
if !ruleOperators[rule.Operator] {
|
||||
return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator}
|
||||
}
|
||||
if strings.TrimSpace(rule.Value) == "" {
|
||||
return &RuleValidationError{Msg: "rule value is required"}
|
||||
}
|
||||
if rule.Priority < 0 || rule.Priority > 10 {
|
||||
return &RuleValidationError{Msg: "priority must be between 0 and 10"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRule appends a rule to the collection's ruleset, generating an id
|
||||
// when the caller did not supply one. Adding the first rule CONVERTS a
|
||||
// static collection to smart (type is derived from rules presence).
|
||||
func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) {
|
||||
if err := s.validateRule(rule); err != nil {
|
||||
return database.Collections{}, Rule{}, err
|
||||
}
|
||||
|
||||
collection, err := s.GetCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
return database.Collections{}, Rule{}, err
|
||||
}
|
||||
|
||||
rules := s.ParseRules(collection.AutoAssignRules)
|
||||
if rule.ID == "" {
|
||||
rule.ID = uuid.NewString()
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
|
||||
rulesJSON, marshalErr := json.Marshal(rules)
|
||||
if marshalErr != nil {
|
||||
return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
return updated, rule, nil
|
||||
}
|
||||
|
||||
// SetRuleEnabled flips a single rule's enabled flag in place.
|
||||
func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) {
|
||||
collection, err := s.GetCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
return database.Collections{}, err
|
||||
}
|
||||
rules := s.ParseRules(collection.AutoAssignRules)
|
||||
found := false
|
||||
for i := range rules {
|
||||
if rules[i].ID == ruleID {
|
||||
rules[i].Enabled = &enabled
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
||||
}
|
||||
|
||||
rulesJSON, marshalErr := json.Marshal(rules)
|
||||
if marshalErr != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// RemoveRule deletes a single rule from the ruleset. Removing the LAST
|
||||
// rule converts the collection back to static (type is derived).
|
||||
func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) {
|
||||
collection, err := s.GetCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
return database.Collections{}, err
|
||||
}
|
||||
rules := s.ParseRules(collection.AutoAssignRules)
|
||||
kept := make([]Rule, 0, len(rules))
|
||||
found := false
|
||||
for _, rule := range rules {
|
||||
if rule.ID == ruleID {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, rule)
|
||||
}
|
||||
if !found {
|
||||
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
||||
}
|
||||
|
||||
rulesJSON, marshalErr := json.Marshal(kept)
|
||||
if marshalErr != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
||||
}
|
||||
|
||||
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
AutoAssignRules: rulesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// SmartEvalOptions scopes a smart collection's live evaluation.
|
||||
} // EnabledRules filters a ruleset down to the rules that participate in// validateRule checks a rule payload the evaluator will have to honor.// AddRule appends a rule to the collection's ruleset, generating an id// SetRuleEnabled flips a single rule's enabled flag in place.// RemoveRule deletes a single rule from the ruleset. Removing the LAST// SmartEvalOptions scopes a smart collection's live evaluation.
|
||||
type SmartEvalOptions struct {
|
||||
// LibraryID, when valid, restricts matching to one library (the
|
||||
// detail page's library switcher).
|
||||
@@ -479,10 +256,6 @@ type SmartEvalOptions struct {
|
||||
// VisibleIDs is the requesting user's visible-library set (family
|
||||
// lens). Empty means no visible-library filter.
|
||||
VisibleIDs []pgtype.UUID
|
||||
// MatchMode is the collection's rule_match_mode: "any" (OR —
|
||||
// legacy default) or "all" (AND — the daily briefing). Empty
|
||||
// resolves to "any".
|
||||
MatchMode string
|
||||
// ViewerUserID scopes the candidate query's progress join — the
|
||||
// played/unplayed rule is per-user. Zero UUID = no viewer (played
|
||||
// rules then see "unplayed" for everything, honestly).
|
||||
@@ -490,126 +263,17 @@ type SmartEvalOptions struct {
|
||||
}
|
||||
|
||||
// IsCollectionSmart is the ONE smart detection: a collection is smart
|
||||
// when it carries the unified filter tree (item 3.2) or a legacy rules
|
||||
// array (item 2's shape). Every surface routes through this — rules
|
||||
// presence alone misses tree collections.
|
||||
// when it carries the unified filter tree (item 3.2). Every surface
|
||||
// routes through this. (The legacy rules-array branch retired
|
||||
// 2026-10-31 — the tree is the only smart storage.)
|
||||
func IsCollectionSmart(coll database.Collections) bool {
|
||||
if len(coll.FilterTree) > 0 {
|
||||
return true
|
||||
}
|
||||
var rules []Rule
|
||||
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(rules) > 0
|
||||
return len(coll.FilterTree) > 0
|
||||
}
|
||||
|
||||
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
||||
// tree-bearing collections evaluate through the unified filter engine;
|
||||
// legacy rule arrays keep per-item evaluation until rewritten. One
|
||||
// call site surface, two storage epochs.
|
||||
// tree-bearing collections evaluate through the unified filter engine.
|
||||
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||
if len(coll.FilterTree) > 0 {
|
||||
return s.evaluateTreeMembership(ctx, coll, opts)
|
||||
}
|
||||
return s.EvaluateSmartCollection(ctx, s.ParseRules(coll.AutoAssignRules), opts)
|
||||
}
|
||||
|
||||
// RulesToFilterTree converts a legacy rules array + its match mode
|
||||
// into a qf tree so a collection upgrades to the unified engine at its
|
||||
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
|
||||
// upgrade at write. Episode rules map mechanically (feed→in-list,
|
||||
// played/downloaded→booleans, published_within→published_date
|
||||
// within_days); legacy operators without an engine equivalent
|
||||
// (starts_with/ends_with) fall back to contains — documented, and the
|
||||
// operator labels stay in the rules docs.
|
||||
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
|
||||
children := make([]FilterChild, 0, len(rules))
|
||||
scope := ""
|
||||
for _, r := range rules {
|
||||
child, err := ruleToFilterChild(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if episodeOnlyFields[r.Field] {
|
||||
scope = ScopeEpisodes
|
||||
}
|
||||
children = append(children, child)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
return nil, fmt.Errorf("no rules to convert")
|
||||
}
|
||||
mode := RuleMatchAny
|
||||
if matchMode == RuleMatchAll {
|
||||
mode = RuleMatchAll
|
||||
}
|
||||
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
|
||||
}
|
||||
|
||||
func ruleToFilterChild(r Rule) (FilterChild, error) {
|
||||
child := FilterChild{Field: r.Field}
|
||||
switch r.Field {
|
||||
case "feed":
|
||||
// The rules format stores comma-separated feed IDs in one value.
|
||||
child.Op = OpIn
|
||||
child.Values = splitRuleValues(r.Value)
|
||||
return child, nil
|
||||
case "downloaded":
|
||||
child.Op = isTrueFalse(r.Value == "true")
|
||||
return child, nil
|
||||
case "played":
|
||||
child.Op = isTrueFalse(r.Value == "true")
|
||||
return child, nil
|
||||
case "published_within":
|
||||
child.Field = "published_date"
|
||||
child.Op = OpWithinDays
|
||||
child.Values = []string{r.Value}
|
||||
return child, nil
|
||||
}
|
||||
// Registry fields: legacy operators → engine operators.
|
||||
switch r.Operator {
|
||||
case "equals":
|
||||
child.Op = OpIs
|
||||
case "not_equals":
|
||||
child.Op = OpIsNot
|
||||
case "contains":
|
||||
child.Op = OpContains
|
||||
case "not_contains":
|
||||
child.Op = OpNotContains
|
||||
case "greater_than":
|
||||
child.Op = OpAtLeast
|
||||
case "less_than":
|
||||
child.Op = OpAtMost
|
||||
case "in":
|
||||
child.Op = OpIn
|
||||
child.Values = splitRuleValues(r.Value)
|
||||
case "starts_with", "ends_with":
|
||||
// No engine equivalent — contains is the honest nearest.
|
||||
child.Op = OpContains
|
||||
default:
|
||||
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
|
||||
}
|
||||
child.Values = []string{r.Value}
|
||||
return child, nil
|
||||
}
|
||||
|
||||
func isTrueFalse(b bool) string {
|
||||
if b {
|
||||
return OpIsTrue
|
||||
}
|
||||
return OpIsFalse
|
||||
}
|
||||
|
||||
func splitRuleValues(v string) []string {
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return s.evaluateTreeMembership(ctx, coll, opts)
|
||||
}
|
||||
|
||||
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||
@@ -621,87 +285,12 @@ func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll dat
|
||||
return nil, errors.New("tree collections require the search service (constructor wiring)")
|
||||
}
|
||||
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
|
||||
}
|
||||
|
||||
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
|
||||
// every visible live book matched by at least one enabled rule is in.
|
||||
// No stored links are consulted — "truly dynamic," always current.
|
||||
func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||
enabled := s.EnabledRules(rules)
|
||||
if len(enabled) == 0 {
|
||||
return []database.MediaItems{}, nil
|
||||
}
|
||||
|
||||
var visibleArg []pgtype.UUID
|
||||
if len(opts.VisibleIDs) > 0 {
|
||||
visibleArg = opts.VisibleIDs
|
||||
}
|
||||
items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{
|
||||
LibraryID: opts.LibraryID,
|
||||
VisibleLibraryIds: visibleArg,
|
||||
ViewerUserID: opts.ViewerUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load candidate books: %w", err)
|
||||
}
|
||||
|
||||
mode := opts.MatchMode
|
||||
if !ValidRuleMatchMode(mode) {
|
||||
mode = RuleMatchAny
|
||||
}
|
||||
matches := make([]database.MediaItems, 0, len(items))
|
||||
for _, row := range items {
|
||||
if s.mediaItemMatches(mode, row.MediaItems, row.ViewerPercentage, enabled) {
|
||||
matches = append(matches, row.MediaItems)
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// mediaItemMatches evaluates the enabled rules under the collection's
|
||||
// match mode: "any" (OR — legacy) or "all" (AND — the briefing).
|
||||
// viewerPercentage is the requesting user's progress for this item
|
||||
// (0 when none) — the played/unplayed episode rule reads it.
|
||||
func (s *CollectionService) mediaItemMatches(mode string, item database.MediaItems, viewerPercentage float64, rules []Rule) bool {
|
||||
if len(rules) == 0 {
|
||||
return false
|
||||
}
|
||||
if mode == RuleMatchAll {
|
||||
for _, rule := range rules {
|
||||
if !s.mediaItemMatchesRule(item, viewerPercentage, rule) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if s.mediaItemMatchesRule(item, viewerPercentage, rule) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mediaItemMatchesAnyRule is the legacy OR path, kept for callers that
|
||||
// predate match modes.
|
||||
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool {
|
||||
return s.mediaItemMatches(RuleMatchAny, item, 0, rules)
|
||||
}
|
||||
|
||||
// episodeFinished is THE canonical finished definition (same predicate
|
||||
} // mediaItemMatchesAnyRule is the legacy OR path, kept for callers that// episodeFinished is THE canonical finished definition (same predicate
|
||||
// as hide_finished): viewer progress percentage >= 0.999.
|
||||
func episodeFinished(viewerPercentage float64) bool {
|
||||
return viewerPercentage >= 0.999
|
||||
}
|
||||
|
||||
// mediaItemMatchesRule evaluates one rule against one item.
|
||||
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, viewerPercentage float64, rule Rule) bool {
|
||||
if rule.Enabled != nil && !*rule.Enabled {
|
||||
return false
|
||||
}
|
||||
return s.FieldMatches(item, viewerPercentage, rule.Field, rule.Operator, rule.Value)
|
||||
}
|
||||
|
||||
// FieldMatches is THE single rule evaluator (the rules-builder preview
|
||||
// delegates here too — one vocabulary, one semantics). Episode/user-state
|
||||
// fields (feed/played/downloaded/published_within) evaluate directly;
|
||||
|
||||
@@ -2,12 +2,9 @@ package services
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEvaluateRules_Equality(t *testing.T) {
|
||||
@@ -280,44 +277,7 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesToFilterTree(t *testing.T) {
|
||||
t.Run("episode_rules_map_mechanically", func(t *testing.T) {
|
||||
rules := []Rule{
|
||||
{ID: "1", Field: "feed", Operator: "in", Value: "feed-a, feed-b"},
|
||||
{ID: "2", Field: "played", Operator: "equals", Value: "false"},
|
||||
{ID: "3", Field: "downloaded", Operator: "equals", Value: "true"},
|
||||
{ID: "4", Field: "published_within", Operator: "greater_than", Value: "7"},
|
||||
}
|
||||
tree, err := RulesToFilterTree(rules, RuleMatchAll)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, RuleMatchAll, tree.Mode, "the collection's match mode becomes the tree mode")
|
||||
assert.Equal(t, ScopeEpisodes, tree.Scope, "episode rules force the episodes scope")
|
||||
require.Len(t, tree.Children, 4)
|
||||
assert.Equal(t, FilterChild{Field: "feed", Op: OpIn, Values: []string{"feed-a", "feed-b"}}, tree.Children[0])
|
||||
assert.Equal(t, FilterChild{Field: "played", Op: OpIsFalse}, tree.Children[1])
|
||||
assert.Equal(t, FilterChild{Field: "downloaded", Op: OpIsTrue}, tree.Children[2])
|
||||
assert.Equal(t, FilterChild{Field: "published_date", Op: OpWithinDays, Values: []string{"7"}}, tree.Children[3])
|
||||
// The converted tree parses under the strict engine.
|
||||
b, _ := json.Marshal(tree)
|
||||
_, err = ParseFilterTree(string(b))
|
||||
require.NoError(t, err, "converted tree must satisfy the engine grammar")
|
||||
})
|
||||
|
||||
t.Run("book_rules_map_and_stay_books_scope", func(t *testing.T) {
|
||||
rules := []Rule{
|
||||
{Field: "genre", Operator: "equals", Value: "fantasy"},
|
||||
{Field: "author", Operator: "contains", Value: "Voss"},
|
||||
{Field: "copyright_year", Operator: "greater_than", Value: "1990"},
|
||||
{Field: "tags", Operator: "starts_with", Value: "dyst"},
|
||||
}
|
||||
tree, err := RulesToFilterTree(rules, RuleMatchAny)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", tree.Scope, "no episode rules → the books scope (legacy default)")
|
||||
assert.Equal(t, RuleMatchAny, tree.Mode)
|
||||
assert.Equal(t, FilterChild{Field: "genre", Op: OpIs, Values: []string{"fantasy"}}, tree.Children[0])
|
||||
assert.Equal(t, FilterChild{Field: "copyright_year", Op: OpAtLeast, Values: []string{"1990"}}, tree.Children[2])
|
||||
// Documented fallback: rules ops without an engine equivalent
|
||||
// (starts_with/ends_with) become contains.
|
||||
assert.Equal(t, OpContains, tree.Children[3].Op)
|
||||
})
|
||||
}
|
||||
// TestRulesToFilterTree retired 2026-10-31 with the function it tested:
|
||||
// rules-array storage is gone, the filter tree is the only smart
|
||||
// membership. (Tests of deleted behavior are deleted — the asymmetry
|
||||
// rule.)
|
||||
@@ -292,56 +292,15 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
}
|
||||
|
||||
// The query filters excluded rows in SQL and embeds the media row,
|
||||
// so the items are MediaItems as-is.
|
||||
var manualNonExcluded []database.MediaItems
|
||||
// so the items are MediaItems as-is. (The legacy auto-assign branch
|
||||
// retired 2026-10-31 with the rules-array storage: smart user
|
||||
// collections evaluate on their detail page; sections render their
|
||||
// stored membership.)
|
||||
items := make([]database.MediaItems, 0, len(manualItems))
|
||||
for _, row := range manualItems {
|
||||
manualNonExcluded = append(manualNonExcluded, row.MediaItems)
|
||||
items = append(items, row.MediaItems)
|
||||
}
|
||||
|
||||
var autoItems []database.MediaItems
|
||||
if len(coll.AutoAssignRules) > 0 {
|
||||
var rules []Rule
|
||||
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
|
||||
allLibraryItems, err := s.db.GetLibraryItems(ctx, libraryID)
|
||||
if err == nil {
|
||||
for _, item := range allLibraryItems {
|
||||
alreadyInCollection := false
|
||||
for _, manualItem := range manualNonExcluded {
|
||||
manualUUID := uuid.UUID(manualItem.ID.Bytes)
|
||||
itemUUID := uuid.UUID(item.ID.Bytes)
|
||||
if manualUUID == itemUUID {
|
||||
alreadyInCollection = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if alreadyInCollection {
|
||||
continue
|
||||
}
|
||||
|
||||
// Legacy dashboard auto-assign path: no viewer progress
|
||||
// loaded here (the dashboard's own hide_finished handles
|
||||
// queue semantics) — played rules evaluate unplayed, honestly.
|
||||
evaluations := s.collectionService.EvaluateRules(item, 0, rules)
|
||||
for _, eval := range evaluations {
|
||||
if eval.Matches {
|
||||
autoItems = append(autoItems, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var finalItems []database.MediaItems
|
||||
finalItems = append(finalItems, manualNonExcluded...)
|
||||
finalItems = append(finalItems, autoItems...)
|
||||
|
||||
if len(finalItems) > limit {
|
||||
finalItems = finalItems[:limit]
|
||||
}
|
||||
|
||||
return finalItems, nil
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
||||
|
||||
@@ -433,7 +433,6 @@ func (s *MediaScanner) upsertPlaylistCollection(ctx context.Context, entry Serie
|
||||
UserID: owner,
|
||||
Name: entry.Name,
|
||||
Description: pgtype.Text{String: description, Valid: true},
|
||||
AutoAssignRules: nil, // jsonb NULL — an empty []byte encodes as bytea and 22P02s
|
||||
ViewSettings: viewSettings,
|
||||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user