feat(podcasts): smart collections ride the unified filter engine — filter_tree column (item 3.2)
collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
This commit is contained in:
1 parent
56871b65ed
commit
6b2125a45a
17 files changed
+524
-55
No files matched your search
@@ -36,6 +36,7 @@ type Collections struct {
|
||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
||||
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
||||
|
||||
@@ -671,9 +671,9 @@ func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg Create
|
||||
|
||||
const CreateCollection = `-- name: CreateCollection :one
|
||||
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, filter_tree)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type CreateCollectionParams struct {
|
||||
@@ -685,6 +685,7 @@ type CreateCollectionParams struct {
|
||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||
}
|
||||
|
||||
// COLLECTIONS QUERIES
|
||||
@@ -699,6 +700,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
|
||||
arg.AutoAssignRules,
|
||||
arg.ViewSettings,
|
||||
arg.ShowOnDashboard,
|
||||
arg.FilterTree,
|
||||
)
|
||||
var i Collections
|
||||
err := row.Scan(
|
||||
@@ -710,6 +712,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -2253,7 +2256,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
|
||||
const CreateSystemCollection = `-- name: CreateSystemCollection :one
|
||||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type CreateSystemCollectionParams struct {
|
||||
@@ -2288,6 +2291,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -4385,7 +4389,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
|
||||
}
|
||||
|
||||
const GetCollection = `-- name: GetCollection :one
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
|
||||
`
|
||||
|
||||
// Get collection
|
||||
@@ -4401,6 +4405,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -4413,7 +4418,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
||||
}
|
||||
|
||||
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1
|
||||
AND view_settings->>'imported_from_series' = $2::text
|
||||
AND deleted_at IS NULL
|
||||
@@ -4440,6 +4445,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -4452,7 +4458,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
|
||||
}
|
||||
|
||||
const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`
|
||||
@@ -4474,6 +4480,7 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -4966,7 +4973,7 @@ func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNe
|
||||
|
||||
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
|
||||
SELECT
|
||||
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
|
||||
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
|
||||
COUNT(ci.id) as book_count
|
||||
FROM collections c
|
||||
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
||||
@@ -4983,6 +4990,7 @@ type GetCollectionWithBookCountRow struct {
|
||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
||||
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
||||
@@ -5006,6 +5014,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -5019,7 +5028,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
|
||||
}
|
||||
|
||||
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
// Get collections by user
|
||||
@@ -5041,6 +5050,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -5060,7 +5070,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
|
||||
}
|
||||
|
||||
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
|
||||
FROM collections c
|
||||
JOIN collection_items ci ON c.id = ci.collection_id
|
||||
WHERE ci.media_item_id = $1
|
||||
@@ -5086,6 +5096,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -10790,7 +10801,7 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
|
||||
|
||||
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
|
||||
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1
|
||||
AND query_type = $2
|
||||
AND is_system_collection = true
|
||||
@@ -10818,6 +10829,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -10830,7 +10842,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
|
||||
}
|
||||
|
||||
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1
|
||||
AND is_system_collection = true
|
||||
AND show_on_dashboard = true
|
||||
@@ -10857,6 +10869,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -11562,7 +11575,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
||||
}
|
||||
|
||||
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
@@ -11591,6 +11604,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -16470,7 +16484,7 @@ SET name = $2,
|
||||
show_on_dashboard = true,
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type RestoreSystemCollectionRowParams struct {
|
||||
@@ -16503,6 +16517,7 @@ func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSys
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -18217,9 +18232,10 @@ SET
|
||||
icon = $5,
|
||||
auto_assign_rules = $6,
|
||||
view_settings = $7,
|
||||
rule_match_mode = $8
|
||||
rule_match_mode = $8,
|
||||
filter_tree = $9
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type UpdateCollectionParams struct {
|
||||
@@ -18231,6 +18247,7 @@ type UpdateCollectionParams struct {
|
||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
||||
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||
}
|
||||
|
||||
// Update collection
|
||||
@@ -18244,6 +18261,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
||||
arg.AutoAssignRules,
|
||||
arg.ViewSettings,
|
||||
arg.RuleMatchMode,
|
||||
arg.FilterTree,
|
||||
)
|
||||
var i Collections
|
||||
err := row.Scan(
|
||||
@@ -18255,6 +18273,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -18283,7 +18302,7 @@ func (q *Queries) UpdateCollectionMatchMode(ctx context.Context, arg UpdateColle
|
||||
}
|
||||
|
||||
const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one
|
||||
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type UpdateCollectionRulesJSONParams struct {
|
||||
@@ -18305,6 +18324,7 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.FilterTree,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
|
||||
@@ -3489,8 +3489,8 @@ DELETE FROM device_file_aliases WHERE id = $1;
|
||||
|
||||
-- Create collection
|
||||
-- name: CreateCollection :one
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, filter_tree)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *;
|
||||
|
||||
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
||||
@@ -3535,7 +3535,8 @@ SET
|
||||
icon = $5,
|
||||
auto_assign_rules = $6,
|
||||
view_settings = $7,
|
||||
rule_match_mode = $8
|
||||
rule_match_mode = $8,
|
||||
filter_tree = $9
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ type CollectionHandler struct {
|
||||
connManager *wsync.ConnectionManager
|
||||
}
|
||||
|
||||
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
|
||||
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager, search ...*services.SearchService) *CollectionHandler {
|
||||
return &CollectionHandler{
|
||||
db: db,
|
||||
collectionService: services.NewCollectionService(db),
|
||||
collectionService: services.NewCollectionService(db, search...),
|
||||
libraryService: libraryService,
|
||||
connManager: connManager,
|
||||
}
|
||||
@@ -43,6 +43,47 @@ type CreateCollectionRequest struct {
|
||||
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
|
||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||
// The unified filter tree (item 3.2): when present the collection's
|
||||
// smart membership runs through the qf engine. INVALID trees are a
|
||||
// 400 (ParseFilterTree at the handler); a tree supersedes
|
||||
// auto_assign_rules. Persisted as-is (any). RawMessage for
|
||||
// round-trip safety.
|
||||
FilterTree any `form:"filter_tree" json:"filter_tree"`
|
||||
}
|
||||
|
||||
// treeFilterJSON returns the request's filter tree as JSON storage
|
||||
// bytes (nil when none): object payloads marshal; strings (a legacy
|
||||
// double-encoded client, the custom-section builder's known bug class)
|
||||
// are written verbatim after object-attempt.
|
||||
func treeFilterJSON(payload any) ([]byte, error) {
|
||||
switch v := payload.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case map[string]interface{}:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
case string:
|
||||
return []byte(v), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("filter_tree must be an object")
|
||||
}
|
||||
}
|
||||
|
||||
// parseRequestFilterTree validates the request's tree against the
|
||||
// engine grammar (ParseFilterTree) so an invalid tree is a 400, never
|
||||
// a stored 500 at render time.
|
||||
func parseRequestFilterTree(payload any) ([]byte, error) {
|
||||
b, err := treeFilterJSON(payload)
|
||||
if err != nil || b == nil {
|
||||
return b, err
|
||||
}
|
||||
if _, err := services.ParseFilterTree(string(b)); err != nil {
|
||||
return nil, fmt.Errorf("invalid filter_tree: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
type UpdateCollectionRequest struct {
|
||||
@@ -50,6 +91,11 @@ type UpdateCollectionRequest struct {
|
||||
// Smart-collection match semantics (item 2.4): "any" (OR) | "all"
|
||||
// (AND). Empty/invalid keeps the stored value.
|
||||
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"`
|
||||
// The unified filter tree (item 3.2): when present the collection's
|
||||
// smart membership runs through the qf engine. INVALID trees are a
|
||||
// 400 (ParseFilterTree at the handler); a tree supersedes
|
||||
// auto_assign_rules.
|
||||
FilterTree any `form:"filter_tree" json:"filter_tree"`
|
||||
Description string `form:"description" json:"description"`
|
||||
Color string `form:"color" json:"color"`
|
||||
Icon string `form:"icon" json:"icon"`
|
||||
@@ -141,6 +187,10 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
treeJSON, treeErr := parseRequestFilterTree(req.FilterTree)
|
||||
if treeErr != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
||||
}
|
||||
collection, err := h.collectionService.CreateCollection(
|
||||
c.Request().Context(),
|
||||
userUUID,
|
||||
@@ -150,6 +200,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
req.Icon,
|
||||
req.AutoAssignRules,
|
||||
req.ViewSettings,
|
||||
treeJSON,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrCollectionNameTaken) {
|
||||
@@ -200,7 +251,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
|
||||
response := make([]CollectionSummary, 0, len(collections))
|
||||
for _, col := range collections {
|
||||
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
|
||||
isSmart := services.IsCollectionSmart(col)
|
||||
if !includeAuto && isSmart {
|
||||
// Legacy default for API clients (the Android app pre-dates
|
||||
// smart collections): rule-driven collections stay opt-in so
|
||||
@@ -216,10 +267,9 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
if isSmart {
|
||||
rules := h.collectionService.ParseRules(col.AutoAssignRules)
|
||||
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||
if visErr == nil {
|
||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
||||
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
|
||||
LibraryID: libUUID,
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: col.RuleMatchMode,
|
||||
@@ -291,15 +341,14 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules)
|
||||
isSmart := services.IsCollectionSmart(collection)
|
||||
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
|
||||
if isSmart {
|
||||
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
|
||||
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
||||
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{
|
||||
LibraryID: libUUID,
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: collection.RuleMatchMode,
|
||||
@@ -419,6 +468,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
||||
"view_settings": viewSettings,
|
||||
"rule_match_mode": collection.RuleMatchMode,
|
||||
"filter_tree": treeEnvelope(collection.FilterTree),
|
||||
"books": bookList,
|
||||
"book_count": len(bookList),
|
||||
"is_smart": isSmart,
|
||||
@@ -458,6 +508,10 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
||||
req.ViewSettings["show_up_next"] = *v
|
||||
}
|
||||
|
||||
treeJSON, treeErr := parseRequestFilterTree(req.FilterTree)
|
||||
if treeErr != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
||||
}
|
||||
collection, err := h.collectionService.UpdateCollection(
|
||||
c.Request().Context(),
|
||||
collectionID,
|
||||
@@ -468,6 +522,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
||||
req.AutoAssignRules,
|
||||
req.ViewSettings,
|
||||
req.RuleMatchMode,
|
||||
treeJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -860,7 +915,7 @@ func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uu
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
||||
}
|
||||
if h.collectionService.IsSmartCollection(collection.AutoAssignRules) {
|
||||
if services.IsCollectionSmart(collection) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
|
||||
}
|
||||
return nil
|
||||
@@ -916,7 +971,7 @@ func (h *CollectionHandler) AddRule(c *echo.Context) error {
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||
"rule": rule,
|
||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||
"is_smart": services.IsCollectionSmart(updated),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -952,7 +1007,7 @@ func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||
"is_smart": services.IsCollectionSmart(updated),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -977,7 +1032,7 @@ func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
||||
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
||||
"is_smart": services.IsCollectionSmart(updated),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -988,6 +1043,19 @@ type TestRulesRequest struct {
|
||||
MatchMode string `json:"match_mode"`
|
||||
}
|
||||
|
||||
// treeEnvelope renders the stored filter tree for the envelope: nil
|
||||
// (legacy/static collections) serializes as null, trees as the object.
|
||||
func treeEnvelope(t []byte) any {
|
||||
if len(t) == 0 {
|
||||
return nil
|
||||
}
|
||||
var v interface{}
|
||||
if json.Unmarshal(t, &v) == nil {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type BookMatch struct {
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
Title string `json:"title"`
|
||||
|
||||
@@ -562,11 +562,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
colData := make([]templates.CollectionData, len(collections))
|
||||
colService := cfg.CollectionHandler.CollectionService()
|
||||
for i, col := range collections {
|
||||
isSmart := colService.IsSmartCollection(col.AutoAssignRules)
|
||||
isSmart := services.IsCollectionSmart(col)
|
||||
bookCount := 0
|
||||
if isSmart {
|
||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
|
||||
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
|
||||
col, services.SmartEvalOptions{
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: col.RuleMatchMode,
|
||||
|
||||
@@ -289,7 +289,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
||||
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
||||
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
||||
colService := cfg.CollectionHandler.CollectionService()
|
||||
isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
|
||||
isSmart = services.IsCollectionSmart(collection)
|
||||
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
|
||||
|
||||
if isSmart {
|
||||
@@ -297,8 +297,8 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
|
||||
// visible libraries, live items only, rules applied under the
|
||||
// collection's match mode (item 2.4: any|all), with the
|
||||
// viewer's progress feeding played/unplayed rules.
|
||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
||||
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
|
||||
collection, services.SmartEvalOptions{
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: collection.RuleMatchMode,
|
||||
|
||||
@@ -286,7 +286,7 @@ func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database
|
||||
// order, never written back) — evaluate the rules for THIS viewer
|
||||
// and chain the downloaded episode matches in native feed order
|
||||
// (R3: every listening order routes through OrderEpisodes).
|
||||
if len(coll.AutoAssignRules) > 0 {
|
||||
if services.IsCollectionSmart(coll) {
|
||||
return buildSmartCollectionSequenceContext(c, cfg, item, userID, coll)
|
||||
}
|
||||
// Downloaded episodes in collection order (SQL orders; RLS scopes
|
||||
@@ -339,7 +339,7 @@ func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *dat
|
||||
if err != nil {
|
||||
visibleIDs = nil
|
||||
}
|
||||
matches, err := colService.EvaluateSmartCollection(ctx, colService.ParseRules(coll.AutoAssignRules), services.SmartEvalOptions{
|
||||
matches, err := colService.EvaluateSmartMembership(ctx, coll, services.SmartEvalOptions{
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: coll.RuleMatchMode,
|
||||
ViewerUserID: userID,
|
||||
|
||||
@@ -78,17 +78,28 @@ type RuleEvaluation struct {
|
||||
// CollectionService handles collection management
|
||||
type CollectionService struct {
|
||||
db *database.Queries
|
||||
// search powers the unified-engine path (item 3.2): tree-driven
|
||||
// smart collections evaluate via ComposedLiveMedia. Optional —
|
||||
// nil disables tree evaluation (legacy-only services, which keeps
|
||||
// every existing constructor call site valid).
|
||||
search *SearchService
|
||||
}
|
||||
|
||||
// NewCollectionService creates a new collection service
|
||||
func NewCollectionService(db *database.Queries) *CollectionService {
|
||||
return &CollectionService{
|
||||
db: db,
|
||||
// NewCollectionService creates a new collection service. search is
|
||||
// optional (variadic for constructor compatibility) — required for
|
||||
// TREE-driven smart collections (item 3.2), ignored otherwise.
|
||||
func NewCollectionService(db *database.Queries, search ...*SearchService) *CollectionService {
|
||||
svc := &CollectionService{db: db}
|
||||
if len(search) > 0 {
|
||||
svc.search = search[0]
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// CreateCollection creates a new collection
|
||||
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
|
||||
// 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 {
|
||||
@@ -117,6 +128,7 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
AutoAssignRules: rulesJSON,
|
||||
ViewSettings: settingsJSON,
|
||||
FilterTree: filterTree,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -155,14 +167,18 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
|
||||
// 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).
|
||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string) (database.Collections, error) {
|
||||
// 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) {
|
||||
// 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)
|
||||
}
|
||||
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
|
||||
var stored map[string]any
|
||||
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
|
||||
@@ -207,6 +223,7 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
AutoAssignRules: rulesJSON,
|
||||
ViewSettings: settingsJSON,
|
||||
RuleMatchMode: ruleMatchMode,
|
||||
FilterTree: filterTree,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -461,6 +478,142 @@ type SmartEvalOptions struct {
|
||||
ViewerUserID pgtype.UUID
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||
tree, err := ParseFilterTree(string(coll.FilterTree))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collection %s has an invalid filter tree: %v", coll.ID, err)
|
||||
}
|
||||
if s.search == nil {
|
||||
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.
|
||||
|
||||
@@ -2,9 +2,12 @@ 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) {
|
||||
@@ -276,3 +279,45 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
|
||||
t.Errorf("expected higher priority rule to be first, got %s", evaluations[0].RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -17,10 +17,10 @@ type DashboardService struct {
|
||||
collectionService *CollectionService
|
||||
}
|
||||
|
||||
func NewDashboardService(db *database.Queries) *DashboardService {
|
||||
func NewDashboardService(db *database.Queries, search ...*SearchService) *DashboardService {
|
||||
return &DashboardService{
|
||||
db: db,
|
||||
collectionService: NewCollectionService(db),
|
||||
collectionService: NewCollectionService(db, search...),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
@@ -873,6 +874,42 @@ func composedQueryBlock(qBind, exactBind string) string {
|
||||
))`
|
||||
}
|
||||
|
||||
// ComposedLiveMedia — full media rows matching the tree: the TREE path
|
||||
// of the smart-collection evaluator (item 3.2). Same filters as
|
||||
// ComposedSearch (scope head, family lens via the visibility join,
|
||||
// tree clause, optional visible-library belt) but SELECT mi.* so
|
||||
// collection surfaces get the whole media row (episode-card stamping,
|
||||
// feed position for the replay chain). Replaces the candidate query's
|
||||
// Sqlc embed for tree collections; ordering matches the legacy
|
||||
// candidate query (title ASC).
|
||||
func (s *SearchService) ComposedLiveMedia(ctx context.Context, userID, libraryID pgtype.UUID, visibleIDs []pgtype.UUID, tree *FilterTree) ([]database.MediaItems, error) {
|
||||
b := newFilterBuilder(userID)
|
||||
head := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b)
|
||||
visibleClause := ""
|
||||
if len(visibleIDs) > 0 {
|
||||
visibleClause = " AND (l.id = ANY(" + b.bind(visibleIDs) + "::uuid[])"
|
||||
if libraryID.Valid {
|
||||
// Redundant by construction; keep SQL valid either way.
|
||||
visibleClause += " OR l.id = " + b.bind(libraryID)
|
||||
}
|
||||
visibleClause += ")"
|
||||
}
|
||||
sqlStr := "SELECT mi.* " + filterFrom + b.userIDBind +
|
||||
b.joinBlock() + `
|
||||
WHERE ` + head + visibleClause + `
|
||||
ORDER BY mi.title ASC`
|
||||
|
||||
rows, err := s.raw.Query(ctx, sqlStr, b.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
// Name-based decoding:mi.* column order and the struct order stay
|
||||
// decoupled (position-based scanning would silently break the next
|
||||
// schema append).
|
||||
return pgx.CollectRows(rows, pgx.RowToStructByName[database.MediaItems])
|
||||
}
|
||||
|
||||
// ComposedCount — the live match count for the builder (rules + scope,
|
||||
// no pagination): a true COUNT(*), not the page length.
|
||||
func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) {
|
||||
|
||||
Reference in new issue
Block a user