diff --git a/cmd/server/main.go b/cmd/server/main.go index 2a15fc98..abd96908 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -170,8 +170,12 @@ func main() { opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) opdsHandler.SetSettings(registry) - collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) - dashboardService := services.NewDashboardService(queries) + // One composed-engine service shared by search AND the collection + // subsystem (item 3.2): tree-driven smart collections evaluate + // through the unified engine. + collectionSearchService := services.NewSearchService(queries, scopedDB) + collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService) + dashboardService := services.NewDashboardService(queries, collectionSearchService) dashboardHandler := handlers.NewDashboardHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries) diff --git a/cmd/server/tests/contract_pins_test.go b/cmd/server/tests/contract_pins_test.go index 245ab6c8..889fdad3 100644 --- a/cmd/server/tests/contract_pins_test.go +++ b/cmd/server/tests/contract_pins_test.go @@ -503,6 +503,7 @@ func TestContractPin_CollectionsJSON(t *testing.T) { "auto_assign_rules", "view_settings", "books", "book_count", "is_smart", "created_at", "up_next", "rule_match_mode", // item-2.4 widening (docs/contract_pins.md) + "filter_tree", // item-3.2 widening (docs/contract_pins.md) } t.Run("user collection with library_id (badged branch)", func(t *testing.T) { diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 7a2c0b8d..bed726cf 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -952,6 +952,125 @@ func TestPodcastSmartCollections(t *testing.T) { }) } +// TestEpisodeCollectionsFilterTree pins item 3.2: smart collections +// WITH A FILTER TREE evaluate through the unified engine; the briefing +// rules ride the qf DSL; the reader chain follows the tree's live +// membership; the tree wins when legacy rules coexist; edits without a +// tree keep the stored one. +func TestEpisodeCollectionsFilterTree(t *testing.T) { + setup := setupTestServer(t) + regUUID := getRegularUserID(t, setup.DB) + regToken := setup.RegularToken + + lib, _ := createPodcastsLibrary(t, setup, "test TreeColl Podcasts") + marker := fmt.Sprintf("treecoll%d", time.Now().UnixNano()%100000) + + feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ + LibraryID: lib.ID, + FeedUrl: "https://feed.invalid/" + marker + "-a.xml", + Title: "TreeColl Feed A " + marker, + }) + require.NoError(t, err) + aNew := upsertTestEpisode(t, setup, feedA, marker+"-new", "TreeColl New", 1) + aNew2 := upsertTestEpisode(t, setup, feedA, marker+"-new2", "TreeColl New Two", 5) + aOld := upsertTestEpisode(t, setup, feedA, marker+"-old", "TreeColl Old", 9) + adminScopeExec(t, setup, adminCtxValue(t, setup), ` + UPDATE media_items SET download_state = 'downloaded', file_size = 1000, + date_published = CURRENT_DATE WHERE id IN ($1, $2)`, aNew.ID, aNew2.ID) + adminScopeExec(t, setup, adminCtxValue(t, setup), ` + UPDATE media_items SET download_state = 'downloaded', file_size = 1000, + date_published = CURRENT_DATE - 30 WHERE id = $1`, aOld.ID) + subscribeRaw(t, setup, regUUID, feedA.ID) + + feedAID := uuid.UUID(feedA.ID.Bytes).String() + // Sequence config rides a data-json attribute — templ escapes quotes. + esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) } + + // The Daily Fiction briefing AS A TREE (item 2.5's shape, unified DSL). + briefing, _ := json.Marshal(map[string]interface{}{ + "scope": "episodes", "mode": "all", + "children": []map[string]interface{}{ + {"field": "feed", "op": "in", "values": []string{feedAID}}, + {"field": "played", "op": "is_false"}, + {"field": "published_date", "op": "within_days", "values": []string{"7"}}, + }, + }) + + code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", + `{"name":"test TreeColl `+marker+`","filter_tree":`+string(briefing)+`}`) + require.Equal(t, http.StatusCreated, code, "%s", string(body)) + var coll struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(body, &coll)) + collID := coll.ID + t.Cleanup(func() { + podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+collID, "") + }) + + t.Run("tree_evaluates_through_the_engine", func(t *testing.T) { + code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "") + require.Equal(t, http.StatusOK, code) + var out struct { + IsSmart bool `json:"is_smart"` + Tree interface{} `json:"filter_tree"` + } + require.NoError(t, json.Unmarshal(body, &out)) + assert.True(t, out.IsSmart, "a tree collection IS smart without any rules array") + assert.NotNil(t, out.Tree, "the tree round-trips in the envelope") + h := string(body) + assert.Contains(t, h, "TreeColl New") + assert.Contains(t, h, "TreeColl New Two") + assert.NotContains(t, h, "TreeColl Old", "published 30d ago fails within_days") + }) + + t.Run("chain_follows_the_tree", func(t *testing.T) { + code, body := podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "") + require.Equal(t, http.StatusOK, code) + html := string(body) + assert.Contains(t, html, esc(`"source":"collection"`)) + assert.Contains(t, html, esc(`"episode_index":1`)) + assert.Contains(t, html, esc(`"index_total":2`), "the briefing tree = the 2 fresh downloaded episodes") + assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`), + "chain hops stay on the tree's live membership") + }) + + t.Run("invalid_tree_is_400", func(t *testing.T) { + code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", + `{"name":"Bad Tree `+marker+`","filter_tree":{"scope":"manga","mode":"all","children":[]}}`) + assert.Equal(t, http.StatusBadRequest, code, + "an invalid tree must be rejected at write: %s", string(body)) + }) + + t.Run("tree_wins_over_legacy_rules", func(t *testing.T) { + // Update carries BOTH: a rules array that would match everything + // with the marker, and the strict briefing tree. The tree drives. + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID, + `{"name":"test TreeColl `+marker+`","rule_match_mode":"any","auto_assign_rules":[{"field":"title","operator":"contains","value":"TreeColl"}],"filter_tree":`+string(briefing)+`}`) + require.Equal(t, http.StatusCreated, code, "%s", string(body)) + code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "") + require.Equal(t, http.StatusOK, code) + h := string(body) + assert.Contains(t, h, "TreeColl New") + assert.NotContains(t, h, "TreeColl Old", + "the tree's AND governs — legacy rules alone would have matched Old") + }) + + t.Run("edit_form_without_tree_keeps_it", func(t *testing.T) { + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID, + `{"name":"test TreeColl `+marker+`","description":"kept the tree"}`) + require.Equal(t, http.StatusCreated, code, "%s", string(body)) + code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "") + require.Equal(t, http.StatusOK, code) + var out struct { + Tree interface{} `json:"filter_tree"` + } + require.NoError(t, json.Unmarshal(body, &out)) + assert.NotNil(t, out.Tree, "an edit that sends no tree keeps the stored one") + }) +} + // ---- download worker: state machine, idempotency, file layout ---- func TestPodcastDownloadWorker(t *testing.T) { diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 8f3a741a..aa3df61b 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -596,9 +596,12 @@ func setupTestServer(t *testing.T) *TestServerSetup { worker := services.NewWorker(3, connManager) services.WorkerInstance = worker jobsHandler := handlers.NewJobsHandler(queries, worker) - collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) + // One composed-engine service for search AND the collection + // subsystem (item 3.2 — mirrors main.go). + collectionSearchService := services.NewSearchService(queries, scopedDB) + collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService) filtersHandler := handlers.NewFiltersHandler(queries) - dashboardService := services.NewDashboardService(queries) + dashboardService := services.NewDashboardService(queries, collectionSearchService) dashboardHandler := handlers.NewDashboardHandler(queries) statisticsService := services.NewStatisticsService(queries) statsHandler := handlers.NewStatsHandler(statisticsService) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 1acf5334..77e174a1 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -1347,6 +1347,9 @@ CREATE TABLE IF NOT EXISTS collections ( -- AND (the daily briefing needs feed∈{…} ∧ unplayed ∧ <7d). -- Applied only when rules exist; static collections ignore it. rule_match_mode TEXT NOT NULL DEFAULT 'any', + -- The unified filter tree (plan item 3.2): when present the qf + -- engine drives membership; see FilterTree. NULL = legacy rules. + filter_tree JSONB, view_settings JSONB, show_on_dashboard BOOLEAN DEFAULT false, query_type TEXT DEFAULT 'filter', @@ -1367,12 +1370,18 @@ ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -- DEFAULT covers existing rows. ALTER TABLE collections ADD COLUMN IF NOT EXISTS rule_match_mode TEXT NOT NULL DEFAULT 'any'; +-- Smart collections in the UNIFIED filter engine (plan item 3.2): the +-- qf tree (same DSL as saved filters, See FilterTree). NULL = no tree +-- (the legacy rules array governs until the collection is rewritten). +-- Legacy rule_match_mode stays authoritative for rules-array +-- collections; a tree's root mode owns its semantics. +ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB; + CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); -- Index for dashboard queries CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority) WHERE show_on_dashboard = true; - -- Create user_dashboard_preferences table CREATE TABLE IF NOT EXISTS user_dashboard_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 1484f766..e20a3e70 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -481,3 +481,11 @@ Two shapes widen, both ADDITIVE — nothing removed, nothing renamed: episode-collections extras. Adapters that predate episode awareness emit their zero values (`false`/`""`), matching the viewer_percentage precedent (nil-representable, key always present). + +## 2026-10-30 — collections envelope gains filter_tree (additive widening, item 3.2) + +`GET /api/collections/:id` adds `filter_tree` to the envelope: the +unified filter tree when the smart collection carries one (item 3.2 — +tree-driven membership), `null` for legacy/static collections. The +tree is the same qf DSL as saved filters (`{scope?, mode, children}`). +Nothing removed, nothing renamed. diff --git a/internal/database/models.go b/internal/database/models.go index d429b670..d3eb690e 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -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"` diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index e6f02e03..8ff27e96 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index a0b163b8..687275d5 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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 *; diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 87095634..408cfcd1 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -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"` diff --git a/internal/router/frontend.go b/internal/router/frontend.go index be7199cc..dcaec798 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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, diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 0fe2fa1f..f0a8de05 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -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, diff --git a/internal/router/reader.go b/internal/router/reader.go index 51c05cf3..b2d1524e 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -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, diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index b6eeadaf..5e0bdd73 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -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. diff --git a/internal/services/collection_service_test.go b/internal/services/collection_service_test.go index 22cff8b1..d03842d1 100644 --- a/internal/services/collection_service_test.go +++ b/internal/services/collection_service_test.go @@ -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) + }) +} diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 9c0dc4b7..d499f63f 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -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...), } } diff --git a/internal/services/filter_query.go b/internal/services/filter_query.go index fa7229d2..232a9737 100644 --- a/internal/services/filter_query.go +++ b/internal/services/filter_query.go @@ -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) {