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

+6 -2
View File
@@ -170,8 +170,12 @@ func main() {
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService) opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
opdsHandler.SetSettings(registry) opdsHandler.SetSettings(registry)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager) // One composed-engine service shared by search AND the collection
dashboardService := services.NewDashboardService(queries) // 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) dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries)
+1
View File
@@ -503,6 +503,7 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
"auto_assign_rules", "view_settings", "books", "book_count", "auto_assign_rules", "view_settings", "books", "book_count",
"is_smart", "created_at", "up_next", "is_smart", "created_at", "up_next",
"rule_match_mode", // item-2.4 widening (docs/contract_pins.md) "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) { t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
+119
View File
@@ -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 ---- // ---- download worker: state machine, idempotency, file layout ----
func TestPodcastDownloadWorker(t *testing.T) { func TestPodcastDownloadWorker(t *testing.T) {
+5 -2
View File
@@ -596,9 +596,12 @@ func setupTestServer(t *testing.T) *TestServerSetup {
worker := services.NewWorker(3, connManager) worker := services.NewWorker(3, connManager)
services.WorkerInstance = worker services.WorkerInstance = worker
jobsHandler := handlers.NewJobsHandler(queries, 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) filtersHandler := handlers.NewFiltersHandler(queries)
dashboardService := services.NewDashboardService(queries) dashboardService := services.NewDashboardService(queries, collectionSearchService)
dashboardHandler := handlers.NewDashboardHandler(queries) dashboardHandler := handlers.NewDashboardHandler(queries)
statisticsService := services.NewStatisticsService(queries) statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService) statsHandler := handlers.NewStatsHandler(statisticsService)
+10 -1
View File
@@ -1347,6 +1347,9 @@ CREATE TABLE IF NOT EXISTS collections (
-- AND (the daily briefing needs feed∈{…} ∧ unplayed ∧ <7d). -- AND (the daily briefing needs feed∈{…} ∧ unplayed ∧ <7d).
-- Applied only when rules exist; static collections ignore it. -- Applied only when rules exist; static collections ignore it.
rule_match_mode TEXT NOT NULL DEFAULT 'any', 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, view_settings JSONB,
show_on_dashboard BOOLEAN DEFAULT false, show_on_dashboard BOOLEAN DEFAULT false,
query_type TEXT DEFAULT 'filter', query_type TEXT DEFAULT 'filter',
@@ -1367,12 +1370,18 @@ ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- DEFAULT covers existing rows. -- DEFAULT covers existing rows.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS rule_match_mode TEXT NOT NULL DEFAULT 'any'; 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); CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Index for dashboard queries -- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority) CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
WHERE show_on_dashboard = true; WHERE show_on_dashboard = true;
-- Create user_dashboard_preferences table -- Create user_dashboard_preferences table
CREATE TABLE IF NOT EXISTS user_dashboard_preferences ( CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+8
View File
@@ -481,3 +481,11 @@ Two shapes widen, both ADDITIVE — nothing removed, nothing renamed:
episode-collections extras. Adapters that predate episode awareness episode-collections extras. Adapters that predate episode awareness
emit their zero values (`false`/`""`), matching the emit their zero values (`false`/`""`), matching the
viewer_percentage precedent (nil-representable, key always present). 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.
+1
View File
@@ -36,6 +36,7 @@ type Collections struct {
Icon pgtype.Text `db:"icon" json:"icon"` Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"` 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"` ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
QueryType pgtype.Text `db:"query_type" json:"query_type"` 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 const CreateCollection = `-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
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 CreateCollectionParams struct { type CreateCollectionParams struct {
@@ -685,6 +685,7 @@ type CreateCollectionParams struct {
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings []byte `db:"view_settings" json:"view_settings"` ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
} }
// COLLECTIONS QUERIES // COLLECTIONS QUERIES
@@ -699,6 +700,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
arg.AutoAssignRules, arg.AutoAssignRules,
arg.ViewSettings, arg.ViewSettings,
arg.ShowOnDashboard, arg.ShowOnDashboard,
arg.FilterTree,
) )
var i Collections var i Collections
err := row.Scan( err := row.Scan(
@@ -710,6 +712,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -2253,7 +2256,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
const CreateSystemCollection = `-- name: CreateSystemCollection :one 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) 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) 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 { type CreateSystemCollectionParams struct {
@@ -2288,6 +2291,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -4385,7 +4389,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
} }
const GetCollection = `-- name: GetCollection :one 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 // Get collection
@@ -4401,6 +4405,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -4413,7 +4418,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
} }
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one 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 WHERE user_id = $1
AND view_settings->>'imported_from_series' = $2::text AND view_settings->>'imported_from_series' = $2::text
AND deleted_at IS NULL AND deleted_at IS NULL
@@ -4440,6 +4445,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -4452,7 +4458,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
} }
const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one 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 WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
LIMIT 1 LIMIT 1
` `
@@ -4474,6 +4480,7 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -4966,7 +4973,7 @@ func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNe
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
SELECT 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 COUNT(ci.id) as book_count
FROM collections c FROM collections c
LEFT JOIN collection_items ci ON c.id = ci.collection_id 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"` Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"` 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"` ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
QueryType pgtype.Text `db:"query_type" json:"query_type"` 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.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -5019,7 +5028,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
} }
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many 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 // Get collections by user
@@ -5041,6 +5050,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -5060,7 +5070,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
} }
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many 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 FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1 WHERE ci.media_item_id = $1
@@ -5086,6 +5096,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -10790,7 +10801,7 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one 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 WHERE user_id = $1
AND query_type = $2 AND query_type = $2
AND is_system_collection = true AND is_system_collection = true
@@ -10818,6 +10829,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -10830,7 +10842,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
} }
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many 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 WHERE user_id = $1
AND is_system_collection = true AND is_system_collection = true
AND show_on_dashboard = true AND show_on_dashboard = true
@@ -10857,6 +10869,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -11562,7 +11575,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
} }
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many 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 WHERE c.user_id = $1
AND c.is_system_collection = false AND c.is_system_collection = false
AND c.deleted_at IS NULL AND c.deleted_at IS NULL
@@ -11591,6 +11604,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -16470,7 +16484,7 @@ SET name = $2,
show_on_dashboard = true, show_on_dashboard = true,
deleted_at = NULL deleted_at = NULL
WHERE id = $1 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 { type RestoreSystemCollectionRowParams struct {
@@ -16503,6 +16517,7 @@ func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSys
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -18217,9 +18232,10 @@ SET
icon = $5, icon = $5,
auto_assign_rules = $6, auto_assign_rules = $6,
view_settings = $7, view_settings = $7,
rule_match_mode = $8 rule_match_mode = $8,
filter_tree = $9
WHERE id = $1 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 { type UpdateCollectionParams struct {
@@ -18231,6 +18247,7 @@ type UpdateCollectionParams struct {
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings []byte `db:"view_settings" json:"view_settings"` ViewSettings []byte `db:"view_settings" json:"view_settings"`
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"` RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
} }
// Update collection // Update collection
@@ -18244,6 +18261,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
arg.AutoAssignRules, arg.AutoAssignRules,
arg.ViewSettings, arg.ViewSettings,
arg.RuleMatchMode, arg.RuleMatchMode,
arg.FilterTree,
) )
var i Collections var i Collections
err := row.Scan( err := row.Scan(
@@ -18255,6 +18273,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -18283,7 +18302,7 @@ func (q *Queries) UpdateCollectionMatchMode(ctx context.Context, arg UpdateColle
} }
const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one 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 { type UpdateCollectionRulesJSONParams struct {
@@ -18305,6 +18324,7 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode, &i.RuleMatchMode,
&i.FilterTree,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
+4 -3
View File
@@ -3489,8 +3489,8 @@ DELETE FROM device_file_aliases WHERE id = $1;
-- Create collection -- Create collection
-- name: CreateCollection :one -- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *; RETURNING *;
-- A2c §2.4: provenance + playlist flags on an existing collection without -- A2c §2.4: provenance + playlist flags on an existing collection without
@@ -3535,7 +3535,8 @@ SET
icon = $5, icon = $5,
auto_assign_rules = $6, auto_assign_rules = $6,
view_settings = $7, view_settings = $7,
rule_match_mode = $8 rule_match_mode = $8,
filter_tree = $9
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
+80 -12
View File
@@ -26,10 +26,10 @@ type CollectionHandler struct {
connManager *wsync.ConnectionManager 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{ return &CollectionHandler{
db: db, db: db,
collectionService: services.NewCollectionService(db), collectionService: services.NewCollectionService(db, search...),
libraryService: libraryService, libraryService: libraryService,
connManager: connManager, connManager: connManager,
} }
@@ -43,6 +43,47 @@ type CreateCollectionRequest struct {
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based) 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"` AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` 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 { type UpdateCollectionRequest struct {
@@ -50,6 +91,11 @@ type UpdateCollectionRequest struct {
// Smart-collection match semantics (item 2.4): "any" (OR) | "all" // Smart-collection match semantics (item 2.4): "any" (OR) | "all"
// (AND). Empty/invalid keeps the stored value. // (AND). Empty/invalid keeps the stored value.
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"` 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"` Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"` Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"` 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()}) 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( collection, err := h.collectionService.CreateCollection(
c.Request().Context(), c.Request().Context(),
userUUID, userUUID,
@@ -150,6 +200,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
req.Icon, req.Icon,
req.AutoAssignRules, req.AutoAssignRules,
req.ViewSettings, req.ViewSettings,
treeJSON,
) )
if err != nil { if err != nil {
if errors.Is(err, services.ErrCollectionNameTaken) { if errors.Is(err, services.ErrCollectionNameTaken) {
@@ -200,7 +251,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
response := make([]CollectionSummary, 0, len(collections)) response := make([]CollectionSummary, 0, len(collections))
for _, col := range collections { for _, col := range collections {
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules) isSmart := services.IsCollectionSmart(col)
if !includeAuto && isSmart { if !includeAuto && isSmart {
// Legacy default for API clients (the Android app pre-dates // Legacy default for API clients (the Android app pre-dates
// smart collections): rule-driven collections stay opt-in so // smart collections): rule-driven collections stay opt-in so
@@ -216,10 +267,9 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
} }
} }
if isSmart { if isSmart {
rules := h.collectionService.ParseRules(col.AutoAssignRules)
visibleIDs, visErr := h.userVisibleLibraryIDs(c) visibleIDs, visErr := h.userVisibleLibraryIDs(c)
if visErr == nil { 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, LibraryID: libUUID,
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: col.RuleMatchMode, MatchMode: col.RuleMatchMode,
@@ -291,15 +341,14 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
libUUID = pgtype.UUID{Bytes: parsed, Valid: true} libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
} }
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules) isSmart := services.IsCollectionSmart(collection)
libTypes := LibraryTypeMap(c.Request().Context(), h.db) libTypes := LibraryTypeMap(c.Request().Context(), h.db)
if isSmart { if isSmart {
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
visibleIDs, err := h.userVisibleLibraryIDs(c) visibleIDs, err := h.userVisibleLibraryIDs(c)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 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, LibraryID: libUUID,
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: collection.RuleMatchMode, MatchMode: collection.RuleMatchMode,
@@ -419,6 +468,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules), "auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": viewSettings, "view_settings": viewSettings,
"rule_match_mode": collection.RuleMatchMode, "rule_match_mode": collection.RuleMatchMode,
"filter_tree": treeEnvelope(collection.FilterTree),
"books": bookList, "books": bookList,
"book_count": len(bookList), "book_count": len(bookList),
"is_smart": isSmart, "is_smart": isSmart,
@@ -458,6 +508,10 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.ViewSettings["show_up_next"] = *v 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( collection, err := h.collectionService.UpdateCollection(
c.Request().Context(), c.Request().Context(),
collectionID, collectionID,
@@ -468,6 +522,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.AutoAssignRules, req.AutoAssignRules,
req.ViewSettings, req.ViewSettings,
req.RuleMatchMode, req.RuleMatchMode,
treeJSON,
) )
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) 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 { if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "collection not found") 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 echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
} }
return nil return nil
@@ -916,7 +971,7 @@ func (h *CollectionHandler) AddRule(c *echo.Context) error {
return c.JSON(http.StatusCreated, map[string]interface{}{ return c.JSON(http.StatusCreated, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(), "collection_id": uuid.UUID(updated.ID.Bytes).String(),
"rule": rule, "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{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(), "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{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(), "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"` 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 { type BookMatch struct {
MediaItemID string `json:"media_item_id"` MediaItemID string `json:"media_item_id"`
Title string `json:"title"` Title string `json:"title"`
+3 -3
View File
@@ -562,11 +562,11 @@ func registerFrontendRoutes(cfg *Config) {
colData := make([]templates.CollectionData, len(collections)) colData := make([]templates.CollectionData, len(collections))
colService := cfg.CollectionHandler.CollectionService() colService := cfg.CollectionHandler.CollectionService()
for i, col := range collections { for i, col := range collections {
isSmart := colService.IsSmartCollection(col.AutoAssignRules) isSmart := services.IsCollectionSmart(col)
bookCount := 0 bookCount := 0
if isSmart { if isSmart {
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{ col, services.SmartEvalOptions{
LibraryID: libScope, LibraryID: libScope,
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: col.RuleMatchMode, 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. // 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) { 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() colService := cfg.CollectionHandler.CollectionService()
isSmart = colService.IsSmartCollection(collection.AutoAssignRules) isSmart = services.IsCollectionSmart(collection)
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries) libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart { 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 // visible libraries, live items only, rules applied under the
// collection's match mode (item 2.4: any|all), with the // collection's match mode (item 2.4: any|all), with the
// viewer's progress feeding played/unplayed rules. // viewer's progress feeding played/unplayed rules.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{ collection, services.SmartEvalOptions{
LibraryID: libScope, LibraryID: libScope,
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: collection.RuleMatchMode, 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 // order, never written back) — evaluate the rules for THIS viewer
// and chain the downloaded episode matches in native feed order // and chain the downloaded episode matches in native feed order
// (R3: every listening order routes through OrderEpisodes). // (R3: every listening order routes through OrderEpisodes).
if len(coll.AutoAssignRules) > 0 { if services.IsCollectionSmart(coll) {
return buildSmartCollectionSequenceContext(c, cfg, item, userID, coll) return buildSmartCollectionSequenceContext(c, cfg, item, userID, coll)
} }
// Downloaded episodes in collection order (SQL orders; RLS scopes // 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 { if err != nil {
visibleIDs = nil visibleIDs = nil
} }
matches, err := colService.EvaluateSmartCollection(ctx, colService.ParseRules(coll.AutoAssignRules), services.SmartEvalOptions{ matches, err := colService.EvaluateSmartMembership(ctx, coll, services.SmartEvalOptions{
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: coll.RuleMatchMode, MatchMode: coll.RuleMatchMode,
ViewerUserID: userID, ViewerUserID: userID,
+161 -8
View File
@@ -78,17 +78,28 @@ type RuleEvaluation struct {
// CollectionService handles collection management // CollectionService handles collection management
type CollectionService struct { type CollectionService struct {
db *database.Queries 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 // NewCollectionService creates a new collection service. search is
func NewCollectionService(db *database.Queries) *CollectionService { // optional (variadic for constructor compatibility) — required for
return &CollectionService{ // TREE-driven smart collections (item 3.2), ignored otherwise.
db: db, 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 // CreateCollection creates a new collection. filterTree (item 3.2)
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) { // 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) // Convert rules to JSONB ([]byte)
var rulesJSON []byte var rulesJSON []byte
if len(autoAssignRules) > 0 { 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 != ""}, Icon: pgtype.Text{String: icon, Valid: icon != ""},
AutoAssignRules: rulesJSON, AutoAssignRules: rulesJSON,
ViewSettings: settingsJSON, ViewSettings: settingsJSON,
FilterTree: filterTree,
}) })
if err != nil { if err != nil {
@@ -155,14 +167,18 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
// UpdateCollection updates a collection // UpdateCollection updates a collection
// UpdateCollection rewrites the collection row. ruleMatchMode empty or // UpdateCollection rewrites the collection row. ruleMatchMode empty or
// invalid = keep the stored value (the edit form only sends the toggles // invalid = keep the stored value (the edit form only sends the toggles
// it renders — plan item 2.4). // it renders — plan item 2.4). filterTree semantics: nil = leave the
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) { // 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 // A2c §3.3: the edit form carries only the toggles it renders — merge
// them OVER the stored view_settings so provenance keys // them OVER the stored view_settings so provenance keys
// (imported_from_series) and anything else on the row survive. // (imported_from_series) and anything else on the row survive.
existingMode := RuleMatchAny existingMode := RuleMatchAny
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil { if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
existingMode = existing.RuleMatchMode existingMode = existing.RuleMatchMode
if filterTree == nil {
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
}
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 { if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
var stored map[string]any var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil { if json.Unmarshal(existing.ViewSettings, &stored) == nil {
@@ -207,6 +223,7 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
AutoAssignRules: rulesJSON, AutoAssignRules: rulesJSON,
ViewSettings: settingsJSON, ViewSettings: settingsJSON,
RuleMatchMode: ruleMatchMode, RuleMatchMode: ruleMatchMode,
FilterTree: filterTree,
}) })
if err != nil { if err != nil {
@@ -461,6 +478,142 @@ type SmartEvalOptions struct {
ViewerUserID pgtype.UUID 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: // EvaluateSmartCollection resolves a smart collection's membership LIVE:
// every visible live book matched by at least one enabled rule is in. // every visible live book matched by at least one enabled rule is in.
// No stored links are consulted — "truly dynamic," always current. // No stored links are consulted — "truly dynamic," always current.
@@ -2,9 +2,12 @@ package services
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"encoding/json"
"testing" "testing"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestEvaluateRules_Equality(t *testing.T) { 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) 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 collectionService *CollectionService
} }
func NewDashboardService(db *database.Queries) *DashboardService { func NewDashboardService(db *database.Queries, search ...*SearchService) *DashboardService {
return &DashboardService{ return &DashboardService{
db: db, db: db,
collectionService: NewCollectionService(db), collectionService: NewCollectionService(db, search...),
} }
} }
+37
View File
@@ -9,6 +9,7 @@ import (
"bookhoard/internal/database" "bookhoard/internal/database"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "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, // ComposedCount — the live match count for the builder (rules + scope,
// no pagination): a true COUNT(*), not the page length. // no pagination): a true COUNT(*), not the page length.
func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) { func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) {