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:
John O'Keefe committed 2026-10-30 19:02:49 -04:00
1 parent 56871b65ed
commit 6b2125a45a
17 files changed
+524 -55

No files matched your search

+1
View File
@@ -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"`
+37 -17
View File
@@ -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,
+4 -3
View File
@@ -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 *;
+80 -12
View File
@@ -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"`
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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,
+161 -8
View File
@@ -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)
})
}
+2 -2
View File
@@ -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...),
}
}
+37
View File
@@ -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) {