diff --git a/bruno/collections/create_collection.yml b/bruno/collections/create_collection.yml index dd7221b8..62a74e94 100644 --- a/bruno/collections/create_collection.yml +++ b/bruno/collections/create_collection.yml @@ -16,7 +16,6 @@ http: "color": "#00FF00", "icon": "πŸ“–", "collection_type": "static", - "auto_assign_rules": [], "view_settings": { "kobo": { "view_mode": "grid" } } } settings: diff --git a/bruno/collections/update_collection.yml b/bruno/collections/update_collection.yml index 1947f634..0b762094 100644 --- a/bruno/collections/update_collection.yml +++ b/bruno/collections/update_collection.yml @@ -15,7 +15,6 @@ http: "description": "Updated description", "color": "#33CCFF", "icon": "πŸ“š", - "auto_assign_rules": [], "view_settings": { "hide_finished": true }, "hide_finished": ["true"], "show_up_next": ["true"] diff --git a/cmd/server/tests/contract_pins_test.go b/cmd/server/tests/contract_pins_test.go index 889fdad3..58cf355e 100644 --- a/cmd/server/tests/contract_pins_test.go +++ b/cmd/server/tests/contract_pins_test.go @@ -500,10 +500,9 @@ func TestContractPin_CollectionsJSON(t *testing.T) { // payload; docs/contract_pins.md changelog). envelopeKeys := []string{ "id", "user_id", "name", "description", "color", "icon", - "auto_assign_rules", "view_settings", "books", "book_count", + "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) + "filter_tree", // item-3.2 widening (docs/contract_pins.md) } t.Run("user collection with library_id (badged branch)", func(t *testing.T) { @@ -566,11 +565,14 @@ func TestContractPin_CollectionsJSON(t *testing.T) { assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys) }) - t.Run("smart collection (rules branch)", func(t *testing.T) { + t.Run("smart collection (tree branch)", func(t *testing.T) { resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{ "name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()), - "auto_assign_rules": []map[string]interface{}{ - {"id": "rule-1", "field": "genre", "operator": "equals", "value": "Science Fiction", "priority": 5}, + "filter_tree": map[string]interface{}{ + "scope": "books", "mode": "any", + "children": []map[string]interface{}{ + {"field": "genre", "op": "is", "values": []string{"Science Fiction"}}, + }, }, }, setup.Token) defer resp.Body.Close() diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 40ebee36..3bb94772 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -772,7 +772,6 @@ func TestPodcastSmartCollections(t *testing.T) { setup := setupTestServer(t) regUUID := getRegularUserID(t, setup.DB) regToken := setup.RegularToken - adminToken := setup.Token lib, _ := createPodcastsLibrary(t, setup, "test SmartColl Podcasts") marker := fmt.Sprintf("smartcoll%d", time.Now().UnixNano()%100000) @@ -816,13 +815,18 @@ func TestPodcastSmartCollections(t *testing.T) { esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) } // Smart collection with the briefing's AND shape, created through - // the real API. + // the real API. The tree carries the mode ('all' at the root) β€” the + // match-mode endpoint retired 2026-10-31 with the rules storage. + 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 Daily Fiction `+marker+`","collection_type":"smart","auto_assign_rules":[ - {"field":"feed","operator":"in","value":"`+feedAID+`"}, - {"field":"played","operator":"equals","value":"false"}, - {"field":"published_within","operator":"greater_than","value":"7"} - ]}`) + `{"name":"test Daily Fiction `+marker+`","collection_type":"smart","filter_tree":`+string(briefing)+`}`) require.Equal(t, http.StatusCreated, code, "%s", string(body)) var coll struct { ID string `json:"id"` @@ -830,17 +834,15 @@ func TestPodcastSmartCollections(t *testing.T) { require.NoError(t, json.Unmarshal(body, &coll)) collID := coll.ID - // ALL mode (AND): the daily briefing β€” feed ∈ {A} ∧ unplayed ∧ <7d. - code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`) - require.Equal(t, http.StatusOK, code, "%s", string(body)) - t.Run("all_mode_is_the_briefing", func(t *testing.T) { code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "") require.Equal(t, http.StatusOK, code) var out struct { - Books []struct{ Title string `json:"title"` } `json:"books"` - IsSmart bool `json:"is_smart"` - MatchAll string `json:"rule_match_mode"` + Books []struct { + Title string `json:"title"` + } `json:"books"` + IsSmart bool `json:"is_smart"` + Tree map[string]interface{} `json:"filter_tree"` } require.NoError(t, json.Unmarshal(body, &out)) require.True(t, out.IsSmart) @@ -848,34 +850,11 @@ func TestPodcastSmartCollections(t *testing.T) { for _, b := range out.Books { titles[b.Title] = true } - assert.Equal(t, "all", out.MatchAll, "the match mode round-trips") + assert.Equal(t, "all", out.Tree["mode"], "the tree's root mode IS the match mode now") assert.True(t, titles["SmartColl A New"], "feed-A + unplayed + <7d matches") assert.Len(t, out.Books, 1, "exactly the briefing set β€” aOld fails <7d, aPlayed fails unplayed, bEp is RLS-dropped") }) - t.Run("any_mode_widens_to_or", func(t *testing.T) { - code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"any"}`) - require.Equal(t, http.StatusOK, code) - code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "") - require.Equal(t, http.StatusOK, code) - assert.Contains(t, string(body), "SmartColl A Old", "OR: aOld matches unplayed even though it's 30d old") - assert.Contains(t, string(body), "SmartColl A New") - assert.Contains(t, string(body), "SmartColl A Played", "OR: aPlayed matches via the feed rule alone") - assert.NotContains(t, string(body), "SmartColl B Ep", "unsubscribed feed's episode never reaches evaluation (RLS)") - }) - - t.Run("match_mode_is_owner_only", func(t *testing.T) { - code, body := podcastAuthed(t, setup, adminToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`) - assert.Equal(t, http.StatusForbidden, code, "only the owner flips the mode: %s", string(body)) - }) - - t.Run("match_mode_endpoint_still_serves_legacy", func(t *testing.T) { - // Item 3.4 retired the rules PAGE, not the legacy channel: the - // match-mode endpoint keeps serving rules-array collections. - code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"any"}`) - assert.Equal(t, http.StatusOK, code, "%s", string(body)) - }) - // ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ---- // One more briefing match so the chain has a next hop. @@ -885,11 +864,10 @@ func TestPodcastSmartCollections(t *testing.T) { date_published = CURRENT_DATE WHERE id = $1`, aNew2.ID) t.Run("acceptance_briefing_plays_back_to_back", func(t *testing.T) { - // Back to ALL: today's unplayed feed-A episodes = aNew + aNew2, - // chained in native feed order. Playing the FIRST match (the - // header's Play-as-Sequence target) must chain into the second. - code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`) - require.Equal(t, http.StatusOK, code) + // ALL mode is the stored tree root: today's unplayed feed-A + // episodes = aNew + aNew2, chained in native feed order. Playing + // the FIRST match (the header's Play-as-Sequence target) must + // chain into the second. // The detail page (smart branch, All Libraries view) renders // episode cards with the play affordance + the header button. @@ -933,10 +911,9 @@ 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. +// evaluate through the unified engine; the briefing rules ride the qf +// DSL; the reader chain follows the tree's live membership; edits +// without a tree keep the stored one. func TestEpisodeCollectionsFilterTree(t *testing.T) { setup := setupTestServer(t) regUUID := getRegularUserID(t, setup.DB) @@ -1023,20 +1000,6 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) { "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"}`) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 947de34c..1f82be99 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -1341,14 +1341,8 @@ CREATE TABLE IF NOT EXISTS collections ( description TEXT, color VARCHAR(7), icon VARCHAR(50), - auto_assign_rules JSONB, - -- Smart-collection match semantics (plan item 2.4): 'any' = OR - -- (the original behavior β€” every legacy row's default), 'all' = - -- 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. + -- engine drives membership; see FilterTree. NULL = static. filter_tree JSONB, view_settings JSONB, show_on_dashboard BOOLEAN DEFAULT false, @@ -1365,16 +1359,16 @@ CREATE TABLE IF NOT EXISTS collections ( -- the row id (and everything referencing it) never churn. ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; --- Smart-collection match mode (plan item 2.4): 'any' (OR, legacy --- default) | 'all' (AND β€” the daily briefing). Idempotent; NOT NULL --- DEFAULT covers existing rows. -ALTER TABLE collections ADD COLUMN IF NOT EXISTS rule_match_mode TEXT NOT NULL DEFAULT 'any'; +-- Legacy rules-array columns RETIRED (2026-10-31): the unified filter +-- tree is the only smart-membership storage. No production row ever +-- carried a rules array or a non-default match mode (verified), so the +-- columns drop clean; the DROPs are the idempotent heal for databases +-- created before this change. +ALTER TABLE collections DROP COLUMN IF EXISTS auto_assign_rules; +ALTER TABLE collections DROP COLUMN IF EXISTS rule_match_mode; -- 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. +-- qf tree (same DSL as saved filters, See FilterTree). NULL = static. ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB; CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); diff --git a/docs/contract_pins.md b/docs/contract_pins.md index d929c1ad..011d87b9 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -545,3 +545,35 @@ Test-side: TestCollectionsRLS pins the cross-user matrix end-to-end; fixture writes to non-system collections run as the row's OWNER (scopeExecAs/ownerSeedCtx) because the admin write-escape covers system collections only. + +## 2026-10-31 β€” legacy collection rules surface RETIRED (tree-only storage) + +The pre-tree rules machinery is gone β€” no production row ever carried a +rules array or a non-default match mode (verified in the dev DB), the +web never reads the envelope keys, and the Android app consumes none of +it. Deletions: + +- `PUT /api/collections/:id/match-mode` β€” endpoint retired (it had no + doc page). The tree's root `mode` owns any|all semantics now. +- Envelope: `auto_assign_rules` and `rule_match_mode` are REMOVED from + the create/list/detail/update payloads. This entry retracts the + 2026-10-30 pins that listed them. `collection_type` and `is_smart` + stay. +- Request: `auto_assign_rules`/`rule_match_mode` are no longer accepted + on create/update. A create carrying `auto_assign_rules` answers 400 + naming the retirement β€” the custom-section builder's legacy save + (already failing to bind, verified live) fails loudly instead of + silently creating a hollow static section. +- Storage: the `collections.auto_assign_rules` and + `collections.rule_match_mode` columns are DROPPED (idempotent + `DROP COLUMN IF EXISTS` in schema.sql). +- Service: the rules CRUD layer (AddRule/SetRuleEnabled/RemoveRule + + validateRule + the UpdateCollectionRulesJSON query), + ParseRules/EnabledRules/IsSmartCollection, RulesToFilterTree (+ the + SSR preload branch), and the legacy branch of EvaluateSmartMembership + are deleted. IsCollectionSmart = tree presence. + +KEPT (live consumers): `POST /api/collections/preview` still accepts +`rules` and evaluates them in memory (the custom-section builder's +preview button); `include_auto` on the list stays (the Android app +sends it). The builder's SAVE remains the flagged Β§12 tree migration. diff --git a/docs/developer/api/collections/create_collection.md b/docs/developer/api/collections/create_collection.md index 934ec709..a5ea0e08 100644 --- a/docs/developer/api/collections/create_collection.md +++ b/docs/developer/api/collections/create_collection.md @@ -23,7 +23,6 @@ Creating a collection whose name already exists for the user returns **409**. "color": "#00FF00", "icon": "πŸ“–", "collection_type": "static", - "auto_assign_rules": [], "view_settings": { "kobo": { "view_mode": "grid" } } } ``` @@ -39,7 +38,6 @@ Creating a collection whose name already exists for the user returns **409**. "color": "#00FF00", "icon": "πŸ“–", "collection_type": "static", - "auto_assign_rules": null, "view_settings": { "kobo": { "view_mode": "grid" } }, "book_count": 0, "created_at": "2026-10-24 13:57:01.81234 +0000 UTC" @@ -57,20 +55,9 @@ RFC3339. Treat it as display text; do not parse it as an ISO timestamp. | description | string | no | | | color | string | no | hex color, e.g. `"#FF5733"` | | icon | string | no | emoji | -| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by non-empty `auto_assign_rules` | -| auto_assign_rules | array | no | rule objects, see below | +| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by a `filter_tree` | | view_settings | object | no | per-device display preferences | - -### Auto-assign rule object - -| Field | Type | Required | Notes | -| ----- | ---- | -------- | ----- | -| id | string | no | rule id | -| field | string | yes | one of `genre`, `series`, `author`, `language`, `publisher`, `copyright_year`, `tags` | -| operator | string | yes | one of `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `greater_than`, `less_than` | -| value | string | yes | value to compare against | -| priority | number | no | 1-10; higher values take precedence | -| enabled | boolean | no | absent = enabled (rules written before this field existed keep matching) | +| filter_tree | object | no | the unified filter tree (qf DSL); presence makes the collection smart | ## Errors diff --git a/docs/developer/api/collections/get_collection.md b/docs/developer/api/collections/get_collection.md index e5b02ab0..ba070197 100644 --- a/docs/developer/api/collections/get_collection.md +++ b/docs/developer/api/collections/get_collection.md @@ -27,7 +27,6 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000 "description": "Books I want to read soon", "color": "#00FF00", "icon": "πŸ“–", - "auto_assign_rules": null, "view_settings": { "kobo": { "view_mode": "grid" } }, "books": [], "book_count": 0, @@ -57,8 +56,8 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000 | description | string | | | color | string | | | icon | string | | -| auto_assign_rules | array \| null | | | view_settings | object | | +| filter_tree | object \| null | the unified filter tree (qf DSL); null = static | | books | array | book cards in this collection | | book_count | number | length of `books` | | is_smart | boolean | | diff --git a/docs/developer/api/collections/list_collections.md b/docs/developer/api/collections/list_collections.md index 73a50e35..316f2bc5 100644 --- a/docs/developer/api/collections/list_collections.md +++ b/docs/developer/api/collections/list_collections.md @@ -29,7 +29,6 @@ GET /api/collections?include_auto=true&sort_by=name "description": "Books I want to read soon", "color": "#00FF00", "icon": "πŸ“–", - "auto_assign_rules": null, "created_at": "2026-10-24 13:57:01.81234 +0000 UTC", "book_count": 12, "is_smart": false @@ -65,7 +64,6 @@ GET /api/collections?include_auto=true&sort_by=name | description | string | | | color | string | | | icon | string | | -| auto_assign_rules | array \| null | raw rules JSON; non-null implies smart | | created_at | string | Go `time.String()` format | | book_count | number | live count; respects `library_id` for smart collections | | is_smart | boolean | | diff --git a/docs/developer/api/collections/update_collection.md b/docs/developer/api/collections/update_collection.md index c65e65d4..04e939eb 100644 --- a/docs/developer/api/collections/update_collection.md +++ b/docs/developer/api/collections/update_collection.md @@ -24,7 +24,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly. "description": "Updated description", "color": "#33CCFF", "icon": "πŸ“š", - "auto_assign_rules": [], "view_settings": { "hide_finished": true }, "hide_finished": ["true"], "show_up_next": ["true"] @@ -41,7 +40,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly. "description": "Updated description", "color": "#33CCFF", "icon": "πŸ“š", - "auto_assign_rules": null, "view_settings": { "hide_finished": true, "show_up_next": true }, "created_at": "2026-10-24 13:57:01.81234 +0000 UTC" } @@ -57,8 +55,8 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly. | description | string | no | | | color | string | no | | | icon | string | no | | -| auto_assign_rules | array | no | replaces the whole rule set (rule object shape: see Create Collection) | | view_settings | object | no | merged into stored settings | +| filter_tree | object | no | replaces the stored filter tree (omitting it keeps the stored one) | | hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings | | show_up_next | array | no | same shape as `hide_finished` | diff --git a/docs/developer/api/custom-section-builder.md b/docs/developer/api/custom-section-builder.md index 0dfb6ea8..b71bea36 100644 --- a/docs/developer/api/custom-section-builder.md +++ b/docs/developer/api/custom-section-builder.md @@ -68,6 +68,11 @@ Creates a new custom collection with filter rules and/or manual book selection. **Request Body:** +**Status (2026-10-31):** the rules-array storage this payload targets +was retired β€” collections carry filter trees now. The server rejects +this save with a 400 naming the retired fields; the builder needs its +flagged tree migration (Β§12) before saving works again. + ```json { "name": "My Custom Section", diff --git a/docs/developer/openapi.yaml b/docs/developer/openapi.yaml index b5884e94..afc4d38b 100644 --- a/docs/developer/openapi.yaml +++ b/docs/developer/openapi.yaml @@ -229,7 +229,6 @@ paths: "color": "#00FF00", "icon": "πŸ“–", "collection_type": "static", - "auto_assign_rules": [], "view_settings": { "kobo": { "view_mode": "grid" } } } responses: @@ -308,7 +307,6 @@ paths: "description": "Updated description", "color": "#33CCFF", "icon": "πŸ“š", - "auto_assign_rules": [], "view_settings": { "hide_finished": true }, "hide_finished": ["true"], "show_up_next": ["true"] diff --git a/internal/database/models.go b/internal/database/models.go index d3eb690e..f5119da3 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -34,8 +34,6 @@ type Collections struct { Description pgtype.Text `db:"description" json:"description"` Color pgtype.Text `db:"color" json:"color"` 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"` diff --git a/internal/database/querier.go b/internal/database/querier.go index 3327cc44..36771d67 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -599,14 +599,6 @@ type Querier interface { GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) - // Smart ("dynamic") collections: rules are evaluated LIVE at render, so - // membership comes from the candidate book set instead of collection_items. - // The set is scoped to the requesting user's visible libraries (family - // lens enforced at the source) and live items only β€” the same filters the - // static collection paths apply. The viewer's progress rides along (plan - // item 2.4): the played/unplayed episode rule evaluates per-user, exactly - // like the hide_finished predicate (percentage >= 0.999 = finished). - GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]GetVisibleLiveMediaForRulesRow, error) HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error) // Soft-dismiss one row (ownership is checked by the caller against the // device's user_id before calling this). @@ -828,12 +820,6 @@ type Querier interface { UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error // Update collection UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) - // Smart-collection match semantics (plan item 2.4): flip any|all on its - // own channel β€” the rules page toggle writes exactly this column. - UpdateCollectionMatchMode(ctx context.Context, arg UpdateCollectionMatchModeParams) error - // Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write - // of the JSONB without touching metadata stale clients did not send). - UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) // A2c Β§2.4: provenance + playlist flags on an existing collection without // touching the rest of the row. // (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 8ff27e96..a469d3c8 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, 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 +INSERT INTO collections (user_id, name, description, color, icon, view_settings, show_on_dashboard, filter_tree) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at ` type CreateCollectionParams struct { @@ -682,7 +682,6 @@ type CreateCollectionParams struct { Description pgtype.Text `db:"description" json:"description"` Color pgtype.Text `db:"color" json:"color"` Icon pgtype.Text `db:"icon" json:"icon"` - 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"` @@ -697,7 +696,6 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara arg.Description, arg.Color, arg.Icon, - arg.AutoAssignRules, arg.ViewSettings, arg.ShowOnDashboard, arg.FilterTree, @@ -710,8 +708,6 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -2254,9 +2250,9 @@ 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, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at +INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true) +RETURNING id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at ` type CreateSystemCollectionParams struct { @@ -2289,8 +2285,6 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -4389,7 +4383,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, filter_tree, 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, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1 ` // Get collection @@ -4403,8 +4397,6 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -4418,7 +4410,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, filter_tree, 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, 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 @@ -4443,8 +4435,6 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -4458,7 +4448,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, filter_tree, 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, 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 ` @@ -4478,8 +4468,6 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -4973,7 +4961,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.filter_tree, 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.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 @@ -4988,8 +4976,6 @@ type GetCollectionWithBookCountRow struct { Description pgtype.Text `db:"description" json:"description"` Color pgtype.Text `db:"color" json:"color"` 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"` @@ -5012,8 +4998,6 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -5028,7 +5012,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, 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 +SELECT id, user_id, name, description, color, icon, 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 @@ -5048,8 +5032,6 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID) &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -5070,7 +5052,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.filter_tree, 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.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 @@ -5094,8 +5076,6 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype. &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -10801,7 +10781,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, filter_tree, 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, 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 @@ -10827,8 +10807,6 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -10842,7 +10820,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, filter_tree, 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, 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 @@ -10867,8 +10845,6 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -11575,7 +11551,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.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 +SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, 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 @@ -11602,8 +11578,6 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -12364,146 +12338,6 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp return items, nil } -const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, COALESCE(rp.percentage, 0) AS viewer_percentage FROM media_items mi -LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid -WHERE ($2::uuid IS NULL OR mi.library_id = $2::uuid) - AND ($3::uuid[] IS NULL OR mi.library_id = ANY($3::uuid[])) - AND mi.archived_at IS NULL - AND mi.missing_scan_count = 0 -ORDER BY mi.title ASC -` - -type GetVisibleLiveMediaForRulesParams struct { - ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"` - LibraryID pgtype.UUID `db:"library_id" json:"library_id"` - VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` -} - -type GetVisibleLiveMediaForRulesRow struct { - MediaItems MediaItems `db:"media_items" json:"media_items"` - ViewerPercentage float64 `db:"viewer_percentage" json:"viewer_percentage"` -} - -// Smart ("dynamic") collections: rules are evaluated LIVE at render, so -// membership comes from the candidate book set instead of collection_items. -// The set is scoped to the requesting user's visible libraries (family -// lens enforced at the source) and live items only β€” the same filters the -// static collection paths apply. The viewer's progress rides along (plan -// item 2.4): the played/unplayed episode rule evaluates per-user, exactly -// like the hide_finished predicate (percentage >= 0.999 = finished). -func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]GetVisibleLiveMediaForRulesRow, error) { - rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.ViewerUserID, arg.LibraryID, arg.VisibleLibraryIds) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetVisibleLiveMediaForRulesRow{} - for rows.Next() { - var i GetVisibleLiveMediaForRulesRow - if err := rows.Scan( - &i.MediaItems.ID, - &i.MediaItems.LibraryID, - &i.MediaItems.Title, - &i.MediaItems.Subtitle, - &i.MediaItems.Author, - &i.MediaItems.Isbn, - &i.MediaItems.Description, - &i.MediaItems.FilePath, - &i.MediaItems.FileSize, - &i.MediaItems.MimeType, - &i.MediaItems.CoverImagePath, - &i.MediaItems.Series, - &i.MediaItems.SeriesNumber, - &i.MediaItems.Tags, - &i.MediaItems.Narrators, - &i.MediaItems.Authors, - &i.MediaItems.AuthorsSearch, - &i.MediaItems.Publishers, - &i.MediaItems.PublishersSearch, - &i.MediaItems.Abridged, - &i.MediaItems.Explicit, - &i.MediaItems.Asin, - &i.MediaItems.DatePublished, - &i.MediaItems.Publisher, - &i.MediaItems.Contributors, - &i.MediaItems.Language, - &i.MediaItems.Edition, - &i.MediaItems.PageCount, - &i.MediaItems.Genre, - &i.MediaItems.CopyrightYear, - &i.MediaItems.GoodreadsID, - &i.MediaItems.OpenlibraryID, - &i.MediaItems.GoogleBooksID, - &i.MediaItems.AddedByAdminID, - &i.MediaItems.CreatedAt, - &i.MediaItems.ImportedAt, - &i.MediaItems.UpdatedAt, - &i.MediaItems.FormatGroup, - &i.MediaItems.FormatMimetype, - &i.MediaItems.IsReflowable, - &i.MediaItems.HasFixedLayout, - &i.MediaItems.TotalCharacters, - &i.MediaItems.ChapterCount, - &i.MediaItems.EntitlementID, - &i.MediaItems.RevisionNumber, - &i.MediaItems.KoboContentID, - &i.MediaItems.KoboMetadata, - &i.MediaItems.MangaType, - &i.MediaItems.ReadingDirection, - &i.MediaItems.SeriesCount, - &i.MediaItems.Volume, - &i.MediaItems.Imprint, - &i.MediaItems.AgeRating, - &i.MediaItems.WebUrl, - &i.MediaItems.StoryArc, - &i.MediaItems.IsBlackAndWhite, - &i.MediaItems.MetadataNotes, - &i.MediaItems.CommunityRating, - &i.MediaItems.AlternateInfo, - &i.MediaItems.ScanInformation, - &i.MediaItems.Summary, - &i.MediaItems.MetadataOverrides, - &i.MediaItems.MissingScanCount, - &i.MediaItems.ArchivedAt, - &i.MediaItems.ChapterMetadata, - &i.MediaItems.LibraryTypeName, - &i.MediaItems.AudioMetadata, - &i.MediaItems.TagsSearch, - &i.MediaItems.ContributorsSearch, - &i.MediaItems.NarratorsSearch, - &i.MediaItems.FileSha256, - &i.MediaItems.OpfIdentifier, - &i.MediaItems.OpfUuid, - &i.MediaItems.HashConfidence, - &i.MediaItems.Source, - &i.MediaItems.SourceFeedID, - &i.MediaItems.EpisodeGuid, - &i.MediaItems.FeedPosition, - &i.MediaItems.SeasonNumber, - &i.MediaItems.EpisodeNumber, - &i.MediaItems.EnclosureUrl, - &i.MediaItems.DeclaredDurationSeconds, - &i.MediaItems.TranscriptUrl, - &i.MediaItems.ChaptersUrl, - &i.MediaItems.Hosts, - &i.MediaItems.Guests, - &i.MediaItems.DownloadState, - &i.MediaItems.DownloadError, - &i.MediaItems.DownloadAttempts, - &i.MediaItems.DownloadQueuePos, - &i.ViewerPercentage, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one SELECT EXISTS( SELECT 1 FROM sync_conflicts @@ -16484,7 +16318,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, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at +RETURNING id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at ` type RestoreSystemCollectionRowParams struct { @@ -16515,8 +16349,6 @@ func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSys &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, @@ -18230,24 +18062,20 @@ SET description = $3, color = $4, icon = $5, - auto_assign_rules = $6, - view_settings = $7, - rule_match_mode = $8, - filter_tree = $9 + view_settings = $6, + filter_tree = $7 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 +RETURNING id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at ` type UpdateCollectionParams struct { - ID pgtype.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - Description pgtype.Text `db:"description" json:"description"` - Color pgtype.Text `db:"color" json:"color"` - Icon pgtype.Text `db:"icon" json:"icon"` - 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"` + ID pgtype.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Description pgtype.Text `db:"description" json:"description"` + Color pgtype.Text `db:"color" json:"color"` + Icon pgtype.Text `db:"icon" json:"icon"` + ViewSettings []byte `db:"view_settings" json:"view_settings"` + FilterTree []byte `db:"filter_tree" json:"filter_tree"` } // Update collection @@ -18258,9 +18086,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara arg.Description, arg.Color, arg.Icon, - arg.AutoAssignRules, arg.ViewSettings, - arg.RuleMatchMode, arg.FilterTree, ) var i Collections @@ -18271,59 +18097,6 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara &i.Description, &i.Color, &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, - &i.FilterTree, - &i.ViewSettings, - &i.ShowOnDashboard, - &i.QueryType, - &i.Priority, - &i.IsSystemCollection, - &i.CreatedAt, - &i.DeletedAt, - ) - return i, err -} - -const UpdateCollectionMatchMode = `-- name: UpdateCollectionMatchMode :exec -UPDATE collections SET rule_match_mode = $2 WHERE id = $1 -` - -type UpdateCollectionMatchModeParams struct { - ID pgtype.UUID `db:"id" json:"id"` - RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"` -} - -// Smart-collection match semantics (plan item 2.4): flip any|all on its -// own channel β€” the rules page toggle writes exactly this column. -func (q *Queries) UpdateCollectionMatchMode(ctx context.Context, arg UpdateCollectionMatchModeParams) error { - _, err := q.db.Exec(ctx, UpdateCollectionMatchMode, arg.ID, arg.RuleMatchMode) - return err -} - -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, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at -` - -type UpdateCollectionRulesJSONParams struct { - ID pgtype.UUID `db:"id" json:"id"` - AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` -} - -// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write -// of the JSONB without touching metadata stale clients did not send). -func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) { - row := q.db.QueryRow(ctx, UpdateCollectionRulesJSON, arg.ID, arg.AutoAssignRules) - var i Collections - err := row.Scan( - &i.ID, - &i.UserID, - &i.Name, - &i.Description, - &i.Color, - &i.Icon, - &i.AutoAssignRules, - &i.RuleMatchMode, &i.FilterTree, &i.ViewSettings, &i.ShowOnDashboard, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 687275d5..f409c009 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, filter_tree) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +INSERT INTO collections (user_id, name, description, color, icon, view_settings, show_on_dashboard, filter_tree) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; -- A2c Β§2.4: provenance + playlist flags on an existing collection without @@ -3502,11 +3502,6 @@ RETURNING *; -- dropping the phantom column write.) UPDATE collections SET view_settings = $2 WHERE id = $1; --- Smart-collection match semantics (plan item 2.4): flip any|all on its --- own channel β€” the rules page toggle writes exactly this column. --- name: UpdateCollectionMatchMode :exec -UPDATE collections SET rule_match_mode = $2 WHERE id = $1; - -- Get collection -- name: GetCollection :one SELECT * FROM collections WHERE id = $1; @@ -3533,18 +3528,11 @@ SET description = $3, color = $4, icon = $5, - auto_assign_rules = $6, - view_settings = $7, - rule_match_mode = $8, - filter_tree = $9 + view_settings = $6, + filter_tree = $7 WHERE id = $1 RETURNING *; --- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write --- of the JSONB without touching metadata stale clients did not send). --- name: UpdateCollectionRulesJSON :one -UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *; - -- Delete collection -- name: DeleteCollection :exec DELETE FROM collections WHERE id = $1; @@ -4034,8 +4022,8 @@ RETURNING *; DELETE FROM collection_items WHERE collection_id = $1; -- 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) +INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true) RETURNING *; -- name: ResetSystemCollectionMetadata :exec @@ -4151,22 +4139,6 @@ LIMIT sqlc.narg('limit'); -- name: CountCollectionItems :one SELECT COUNT(*) FROM collection_items WHERE collection_id = $1; --- Smart ("dynamic") collections: rules are evaluated LIVE at render, so --- membership comes from the candidate book set instead of collection_items. --- The set is scoped to the requesting user's visible libraries (family --- lens enforced at the source) and live items only β€” the same filters the --- static collection paths apply. The viewer's progress rides along (plan --- item 2.4): the played/unplayed episode rule evaluates per-user, exactly --- like the hide_finished predicate (percentage >= 0.999 = finished). --- name: GetVisibleLiveMediaForRules :many -SELECT sqlc.embed(mi), COALESCE(rp.percentage, 0) AS viewer_percentage FROM media_items mi -LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid -WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) - AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) - AND mi.archived_at IS NULL - AND mi.missing_scan_count = 0 -ORDER BY mi.title ASC; - -- Live item count for a STATIC collection (rule-free cards on the -- collections page); same live filter as GetCollectionItems, plus the -- future-hybrid excluded exclusion and an optional library scope. diff --git a/internal/docs/schema.go b/internal/docs/schema.go index 16104694..37e7d2fd 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -49,7 +49,7 @@ var Schemas = map[string]Schema{ Response: json.RawMessage(`{ "id": "", "user_id": "", "name": "", "description": "", "color": "", "icon": "", "collection_type": "", - "auto_assign_rules": null, "view_settings": {}, + "view_settings": {}, "book_count": 0, "created_at": "" }`), }, @@ -62,7 +62,7 @@ var Schemas = map[string]Schema{ "GET /api/collections/:id": { Response: json.RawMessage(`{ "id": "", "user_id": "", "name": "", "description": "", - "color": "", "icon": "", "auto_assign_rules": null, + "color": "", "icon": "", "view_settings": {}, "books": [], "book_count": 0, "is_smart": false, "up_next": [], "created_at": "" }`), @@ -71,7 +71,7 @@ var Schemas = map[string]Schema{ Request: handlers.UpdateCollectionRequest{}, Response: json.RawMessage(`{ "id": "", "user_id": "", "name": "", "description": "", - "color": "", "icon": "", "auto_assign_rules": null, + "color": "", "icon": "", "view_settings": {}, "created_at": "" }`), }, @@ -609,15 +609,6 @@ var Schemas = map[string]Schema{ "id": "", "chain_order": "", "view_settings": {} }`), }, - "PUT /api/collections/:id/rules/:ruleId": { - // Handler is SetRuleEnabled: toggle only, request {enabled required}, - // response {collection_id, is_smart}. - Request: json.RawMessage(`{"enabled": false}`), - Response: json.RawMessage(`{ - "collection_id": "", "is_smart": false - }`), - }, - // --- Beam batch 7: scanner --- "POST /api/scanner/scan": { Request: handlers.ScanLibraryRequest{}, diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 3009cfc6..9380fb55 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -35,12 +35,17 @@ func NewCollectionHandler(db *database.Queries, libraryService *services.Library } type CreateCollectionRequest struct { - Name string `form:"name" json:"name" validate:"required"` - Description string `form:"description" json:"description"` - Color string `form:"color" json:"color"` - Icon string `form:"icon" json:"icon"` - 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"` + Name string `form:"name" json:"name" validate:"required"` + Description string `form:"description" json:"description"` + Color string `form:"color" json:"color"` + Icon string `form:"icon" json:"icon"` + CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" + // Retired custom-section-builder payload (2026-10-31): the rules + // ARRAY storage is gone β€” the filter tree is the only smart + // membership. The field exists ONLY so the builder's legacy save + // fails LOUDLY (400) instead of silently creating a hollow static + // section. Remove it at the builder's tree migration. + AutoAssignRules json.RawMessage `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 @@ -105,22 +110,17 @@ func resolveTreePayload(jsonTree any, formTree string) any { type UpdateCollectionRequest struct { Name string `form:"name" json:"name" validate:"required"` - // 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. JSON bodies bind FilterTree; form bodies bind + // 400 (ParseFilterTree at the handler). JSON bodies bind FilterTree; form bodies bind // FilterTreeForm (echo's binder cannot set an any-typed field from // a form string β€” see CreateCollectionRequest). - FilterTree any `json:"filter_tree"` - FilterTreeForm string `form:"filter_tree"` - Description string `form:"description" json:"description"` - Color string `form:"color" json:"color"` - Icon string `form:"icon" json:"icon"` - AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"` - ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` + FilterTree any `json:"filter_tree"` + FilterTreeForm string `form:"filter_tree"` + Description string `form:"description" json:"description"` + Color string `form:"color" json:"color"` + Icon string `form:"icon" json:"icon"` + ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` // A2c Β§3.3: playlist toggles β€” merged into view_settings server-side // (nil = untouched). []string because the form posts a hidden "false" // sibling next to the checkbox; the LAST value wins. JSON clients may @@ -158,15 +158,14 @@ type DeviceMappingSummary struct { // CollectionSummary is one entry in GET /api/collections. Package-level so // the docs schema registry (internal/docs) can pin the response shape. type CollectionSummary struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Color string `json:"color"` - Icon string `json:"icon"` - AutoAssignRules json.RawMessage `json:"auto_assign_rules"` - CreatedAt string `json:"created_at"` - BookCount int `json:"book_count"` - IsSmart bool `json:"is_smart"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + Icon string `json:"icon"` + CreatedAt string `json:"created_at"` + BookCount int `json:"book_count"` + IsSmart bool `json:"is_smart"` } // BookCollectionSummary is one entry in GET /api/collections/:id/books β€” @@ -207,6 +206,14 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } + // The custom-section builder still posts the RETIRED rules payload + // (its save was already failing to bind β€” verified 2026-10-31). + // Reject loudly rather than silently creating a hollow section. + if len(req.AutoAssignRules) > 0 { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "auto_assign_rules was retired β€” collections carry filter trees now (the custom-section builder needs its flagged tree migration)", + }) + } treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm)) if treeErr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()}) @@ -218,7 +225,6 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { req.Description, req.Color, req.Icon, - req.AutoAssignRules, req.ViewSettings, treeJSON, ) @@ -230,19 +236,18 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { } bookCount := int32(0) - isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0 + isSmart := req.CollectionType == "smart" || treeJSON != nil response := map[string]interface{}{ - "id": uuid.UUID(collection.ID.Bytes).String(), - "user_id": uuid.UUID(collection.UserID.Bytes).String(), - "name": collection.Name, - "description": utils.PgTextString(collection.Description), - "color": utils.PgTextString(collection.Color), - "icon": utils.PgTextString(collection.Icon), - "collection_type": map[bool]string{true: "smart", false: "static"}[isSmart], - "auto_assign_rules": collection.AutoAssignRules, - "view_settings": collection.ViewSettings, - "book_count": bookCount, - "created_at": collection.CreatedAt.Time.String(), + "id": uuid.UUID(collection.ID.Bytes).String(), + "user_id": uuid.UUID(collection.UserID.Bytes).String(), + "name": collection.Name, + "description": utils.PgTextString(collection.Description), + "color": utils.PgTextString(collection.Color), + "icon": utils.PgTextString(collection.Icon), + "collection_type": map[bool]string{true: "smart", false: "static"}[isSmart], + "view_settings": collection.ViewSettings, + "book_count": bookCount, + "created_at": collection.CreatedAt.Time.String(), } // Add redirect header for HTMX requests @@ -292,7 +297,6 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{ LibraryID: libUUID, VisibleIDs: visibleIDs, - MatchMode: col.RuleMatchMode, ViewerUserID: MustGetAuthenticatedUser(c).ID, }) if evalErr == nil { @@ -310,15 +314,14 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { } response = append(response, CollectionSummary{ - ID: col.ID.Bytes, - Name: col.Name, - Description: utils.PgTextString(col.Description), - Color: utils.PgTextString(col.Color), - Icon: utils.PgTextString(col.Icon), - AutoAssignRules: col.AutoAssignRules, - CreatedAt: col.CreatedAt.Time.String(), - BookCount: bookCount, - IsSmart: isSmart, + ID: col.ID.Bytes, + Name: col.Name, + Description: utils.PgTextString(col.Description), + Color: utils.PgTextString(col.Color), + Icon: utils.PgTextString(col.Icon), + CreatedAt: col.CreatedAt.Time.String(), + BookCount: bookCount, + IsSmart: isSmart, }) } @@ -371,7 +374,6 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{ LibraryID: libUUID, VisibleIDs: visibleIDs, - MatchMode: collection.RuleMatchMode, ViewerUserID: MustGetAuthenticatedUser(c).ID, }) if evalErr != nil { @@ -479,21 +481,19 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { } return c.JSON(http.StatusOK, map[string]interface{}{ - "id": uuid.UUID(collection.ID.Bytes).String(), - "user_id": uuid.UUID(collection.UserID.Bytes).String(), - "name": collection.Name, - "description": utils.PgTextString(collection.Description), - "color": utils.PgTextString(collection.Color), - "icon": utils.PgTextString(collection.Icon), - "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, - "up_next": upNext, - "created_at": collection.CreatedAt.Time.String(), + "id": uuid.UUID(collection.ID.Bytes).String(), + "user_id": uuid.UUID(collection.UserID.Bytes).String(), + "name": collection.Name, + "description": utils.PgTextString(collection.Description), + "color": utils.PgTextString(collection.Color), + "icon": utils.PgTextString(collection.Icon), + "view_settings": viewSettings, + "filter_tree": treeEnvelope(collection.FilterTree), + "books": bookList, + "book_count": len(bookList), + "is_smart": isSmart, + "up_next": upNext, + "created_at": collection.CreatedAt.Time.String(), }) } @@ -539,9 +539,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error { req.Description, req.Color, req.Icon, - req.AutoAssignRules, req.ViewSettings, - req.RuleMatchMode, treeJSON, ) if err != nil { @@ -555,15 +553,14 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error { } response := map[string]interface{}{ - "id": uuid.UUID(collection.ID.Bytes).String(), - "user_id": uuid.UUID(collection.UserID.Bytes).String(), - "name": collection.Name, - "description": utils.PgTextString(collection.Description), - "color": utils.PgTextString(collection.Color), - "icon": utils.PgTextString(collection.Icon), - "auto_assign_rules": json.RawMessage(collection.AutoAssignRules), - "view_settings": json.RawMessage(collection.ViewSettings), - "created_at": collection.CreatedAt.Time.String(), + "id": uuid.UUID(collection.ID.Bytes).String(), + "user_id": uuid.UUID(collection.UserID.Bytes).String(), + "name": collection.Name, + "description": utils.PgTextString(collection.Description), + "color": utils.PgTextString(collection.Color), + "icon": utils.PgTextString(collection.Icon), + "view_settings": json.RawMessage(collection.ViewSettings), + "created_at": collection.CreatedAt.Time.String(), } // Add redirect header for HTMX requests @@ -624,9 +621,9 @@ func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error { if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) } - // Same owner gate as the sibling config mutations (SetMatchMode, - // ReorderItems) β€” my endpoint writes the row, so it must not let a - // non-owner flip someone else's chain order. + // Same owner gate as the sibling config mutation (ReorderItems) β€” + // my endpoint writes the row, so it must not let a non-owner flip + // someone else's chain order. if u, ok := c.Get("user").(database.Users); ok { owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes)) if err != nil || !owns { @@ -1035,15 +1032,13 @@ func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uu return echo.NewHTTPError(http.StatusNotFound, "collection not found") } 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 tree-based β€” membership is managed by the collection's filter tree, not by hand") } return nil } -// AddRule handles POST /api/collections/:id/rules β€” appends one rule to -// the ruleset. The first rule converts a static collection to smart. // treeEnvelope renders the stored filter tree for the envelope: nil -// (legacy/static collections) serializes as null, trees as the object. +// (static collections) serializes as null, trees as the object. func treeEnvelope(t []byte) any { if len(t) == 0 { return nil @@ -1255,43 +1250,6 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error { // (A2c Β§3.2). Owner-only; the payload is the COMPLETE ordered id list β€” // ids omitted keep their relative order appended after (the service never // deletes membership). -// SetMatchMode handles PUT /api/collections/:id/match-mode (plan item -// 2.4): flips the smart ruleset's semantics any|all on its own channel -// β€” the rules page toggle writes exactly this column, nothing else. -func (h *CollectionHandler) SetMatchMode(c *echo.Context) error { - collectionID, err := uuid.Parse(c.Param("id")) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) - } - user, ok := c.Get("user").(database.Users) - if !ok { - return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) - } - - var req struct { - Mode string `json:"mode" form:"mode" validate:"required,oneof=any all"` - } - if err := c.Bind(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "mode must be any or all"}) - } - if err := c.Validate(&req); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) - } - - owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes)) - if err != nil || !owns { - return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"}) - } - - if err := h.db.UpdateCollectionMatchMode(c.Request().Context(), database.UpdateCollectionMatchModeParams{ - ID: pgtype.UUID{Bytes: collectionID, Valid: true}, - RuleMatchMode: req.Mode, - }); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusOK, map[string]string{"mode": req.Mode}) -} - func (h *CollectionHandler) ReorderItems(c *echo.Context) error { collectionID, err := uuid.Parse(c.Param("id")) if err != nil { diff --git a/internal/router/collections.go b/internal/router/collections.go index 04c4ef17..edeac441 100644 --- a/internal/router/collections.go +++ b/internal/router/collections.go @@ -17,7 +17,6 @@ func registerCollectionsRoutes(cfg *Config) { collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections) collections.POST("/:id/books", cfg.CollectionHandler.AddBooks) collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems) - collections.PUT("/:id/match-mode", cfg.CollectionHandler.SetMatchMode) // The chain's listening order lives ON the collection (plan Β§10 R2): // merges into view_settings; the episode chain producer honors it. collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index c78bf54b..17c1cb28 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -569,7 +569,6 @@ func registerFrontendRoutes(cfg *Config) { col, services.SmartEvalOptions{ LibraryID: libScope, VisibleIDs: visibleIDs, - MatchMode: col.RuleMatchMode, ViewerUserID: func() pgtype.UUID { uid, _ := uuid.Parse(user.ID) return uuidToPGType(uid) @@ -702,9 +701,9 @@ func registerFrontendRoutes(cfg *Config) { } // The editor's vocabulary + prefill (item 3.4): the unified - // builder in the collection's scope. A stored tree renders as-is; - // a legacy rules array preloads as its CONVERTED tree - // (RulesToFilterTree) β€” the upgrade lands at the first Apply. + // builder in the collection's scope. A stored tree renders + // as-is. (The legacy rules-array preload retired 2026-10-31 + // with the rules-array storage β€” the tree is the only form.) scope := "books" treeJSON := "{}" if len(collection.FilterTree) > 0 { @@ -714,13 +713,6 @@ func registerFrontendRoutes(cfg *Config) { treeJSON = string(b) } } - } else if rules := cfg.CollectionHandler.CollectionService().ParseRules(collection.AutoAssignRules); len(rules) > 0 { - if t, cerr := services.RulesToFilterTree(rules, collection.RuleMatchMode); cerr == nil { - scope = t.FilterScope() - if b, merr := json.Marshal(t); merr == nil { - treeJSON = string(b) - } - } } fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope)) if merr != nil { diff --git a/internal/router/helpers.go b/internal/router/helpers.go index e273ab5f..920304e7 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -288,7 +288,6 @@ func collectionDataFromRow(collection database.Collections, collectionID string, Icon: collection.Icon.String, IsSystem: collection.IsSystemCollection.Bool, IsSmart: isSmart, - MatchMode: collection.RuleMatchMode, ChainOrder: chainOrder, BookCount: bookCount, } @@ -312,7 +311,6 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collection, services.SmartEvalOptions{ LibraryID: libScope, VisibleIDs: visibleIDs, - MatchMode: collection.RuleMatchMode, ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if evalErr != nil { diff --git a/internal/router/reader.go b/internal/router/reader.go index d9d807d6..a8534969 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -341,7 +341,6 @@ func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *dat } matches, err := colService.EvaluateSmartMembership(ctx, coll, services.SmartEvalOptions{ VisibleIDs: visibleIDs, - MatchMode: coll.RuleMatchMode, ViewerUserID: userID, }) if err != nil { diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index ee45ac1c..8f462da1 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -32,42 +32,6 @@ type Rule struct { Enabled *bool `json:"enabled,omitempty"` } -// Known rule fields and operators β€” the rules builder UI and the -// persisted JSONB documents must agree with this vocabulary. -// Episode fields (plan item 2.4): feed/downloaded/published_within ride -// the media row; played reads the VIEWER's progress (the candidate -// query carries it β€” SmartEvalOptions) so "unplayed" is per-user. -var ruleFields = map[string]bool{ - "genre": true, "series": true, "author": true, "language": true, - "publisher": true, "copyright_year": true, "tags": true, - // Episode collections (plan item 2.4): - "title": true, "description": true, - "feed": true, "played": true, "downloaded": true, "published_within": true, -} - -var ruleOperators = map[string]bool{ - "equals": true, "not_equals": true, "contains": true, "not_contains": true, - "starts_with": true, "ends_with": true, "greater_than": true, "less_than": true, - "in": true, // multi-value (comma-separated) β€” the feed multi-select -} - -// Smart-collection match semantics (plan item 2.4): 'any' = OR (the -// legacy behavior), 'all' = AND (the daily briefing). -const ( - RuleMatchAny = "any" - RuleMatchAll = "all" -) - -func ValidRuleMatchMode(m string) bool { - return m == RuleMatchAny || m == RuleMatchAll -} - -// RuleValidationError reports a rule payload the evaluator could never -// honor (unknown field/operator, empty value). -type RuleValidationError struct{ Msg string } - -func (e *RuleValidationError) Error() string { return e.Msg } - // RuleEvaluation represents the result of evaluating a rule type RuleEvaluation struct { RuleID string `json:"rule_id"` @@ -96,20 +60,10 @@ func NewCollectionService(db *database.Queries, search ...*SearchService) *Colle return svc } -// CreateCollection creates a new collection. filterTree (item 3.2) -// replaces autoAssignRules as the smart engine's source when present β€” -// both may coexist on the row (tree wins at evaluation). -func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, filterTree []byte) (database.Collections, error) { - // Convert rules to JSONB ([]byte) - var rulesJSON []byte - if len(autoAssignRules) > 0 { - var err error - rulesJSON, err = json.Marshal(autoAssignRules) - if err != nil { - return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err) - } - } - +// CreateCollection creates a new collection. filterTree (item 3.2), when +// present, makes it smart β€” the qf engine is the only membership source +// (the legacy rules-array storage retired 2026-10-31). +func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) { // Convert view settings to JSONB ([]byte) var settingsJSON []byte if len(viewSettings) > 0 { @@ -121,14 +75,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU } collection, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - Name: name, - Description: pgtype.Text{String: description, Valid: description != ""}, - Color: pgtype.Text{String: color, Valid: color != ""}, - Icon: pgtype.Text{String: icon, Valid: icon != ""}, - AutoAssignRules: rulesJSON, - ViewSettings: settingsJSON, - FilterTree: filterTree, + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + Name: name, + Description: pgtype.Text{String: description, Valid: description != ""}, + Color: pgtype.Text{String: color, Valid: color != ""}, + Icon: pgtype.Text{String: icon, Valid: icon != ""}, + ViewSettings: settingsJSON, + FilterTree: filterTree, }) if err != nil { @@ -164,18 +117,13 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid. return collections, nil } -// UpdateCollection updates a collection -// UpdateCollection rewrites the collection row. ruleMatchMode empty or -// invalid = keep the stored value (the edit form only sends the toggles -// it renders β€” plan item 2.4). filterTree semantics: nil = leave the -// stored tree untouched. -func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string, filterTree []byte) (database.Collections, error) { +// UpdateCollection rewrites the collection row. filterTree semantics: +// nil = leave the stored tree untouched (forms don't send trees). +func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) { // A2c Β§3.3: the edit form carries only the toggles it renders β€” merge // them OVER the stored view_settings so provenance keys // (imported_from_series) and anything else on the row survive. - existingMode := RuleMatchAny if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil { - existingMode = existing.RuleMatchMode if filterTree == nil { filterTree = existing.FilterTree // nil = untouched (forms don't send trees) } @@ -201,20 +149,6 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u } } } - if !ValidRuleMatchMode(ruleMatchMode) { - ruleMatchMode = existingMode - } - - // Convert rules to JSONB ([]byte) - var rulesJSON []byte - if len(autoAssignRules) > 0 { - var err error - rulesJSON, err = json.Marshal(autoAssignRules) - if err != nil { - return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err) - } - } - // Convert view settings to JSONB ([]byte) var settingsJSON []byte if len(viewSettings) > 0 { @@ -226,15 +160,13 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u } collection, err := s.db.UpdateCollection(ctx, database.UpdateCollectionParams{ - ID: pgtype.UUID{Bytes: collectionID, Valid: true}, - Name: name, - Description: pgtype.Text{String: description, Valid: description != ""}, - Color: pgtype.Text{String: color, Valid: color != ""}, - Icon: pgtype.Text{String: icon, Valid: icon != ""}, - AutoAssignRules: rulesJSON, - ViewSettings: settingsJSON, - RuleMatchMode: ruleMatchMode, - FilterTree: filterTree, + ID: pgtype.UUID{Bytes: collectionID, Valid: true}, + Name: name, + Description: pgtype.Text{String: description, Valid: description != ""}, + Color: pgtype.Text{String: color, Valid: color != ""}, + Icon: pgtype.Text{String: icon, Valid: icon != ""}, + ViewSettings: settingsJSON, + FilterTree: filterTree, }) if err != nil { @@ -316,162 +248,7 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID } return collections, nil -} - -// ParseRules decodes a collection's auto_assign_rules JSONB into typed -// rules. "null", empty, and malformed docs mean "no rules". -func (s *CollectionService) ParseRules(raw []byte) []Rule { - if len(raw) == 0 { - return nil - } - var rules []Rule - if err := json.Unmarshal(raw, &rules); err != nil { - return nil - } - return rules -} - -// IsSmartCollection derives the collection type: a non-empty ruleset -// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC -// (stored collection_items links). -func (s *CollectionService) IsSmartCollection(raw []byte) bool { - return len(s.ParseRules(raw)) > 0 -} - -// EnabledRules filters a ruleset down to the rules that participate in -// live evaluation (absent enabled = enabled for backward compatibility). -func (s *CollectionService) EnabledRules(rules []Rule) []Rule { - enabled := make([]Rule, 0, len(rules)) - for _, rule := range rules { - if rule.Enabled == nil || *rule.Enabled { - enabled = append(enabled, rule) - } - } - return enabled -} - -// validateRule checks a rule payload the evaluator will have to honor. -func (s *CollectionService) validateRule(rule Rule) error { - if !ruleFields[rule.Field] { - return &RuleValidationError{Msg: "unknown rule field: " + rule.Field} - } - if !ruleOperators[rule.Operator] { - return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator} - } - if strings.TrimSpace(rule.Value) == "" { - return &RuleValidationError{Msg: "rule value is required"} - } - if rule.Priority < 0 || rule.Priority > 10 { - return &RuleValidationError{Msg: "priority must be between 0 and 10"} - } - return nil -} - -// AddRule appends a rule to the collection's ruleset, generating an id -// when the caller did not supply one. Adding the first rule CONVERTS a -// static collection to smart (type is derived from rules presence). -func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) { - if err := s.validateRule(rule); err != nil { - return database.Collections{}, Rule{}, err - } - - collection, err := s.GetCollection(ctx, collectionID) - if err != nil { - return database.Collections{}, Rule{}, err - } - - rules := s.ParseRules(collection.AutoAssignRules) - if rule.ID == "" { - rule.ID = uuid.NewString() - } - rules = append(rules, rule) - - rulesJSON, marshalErr := json.Marshal(rules) - if marshalErr != nil { - return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) - } - - updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ - ID: pgtype.UUID{Bytes: collectionID, Valid: true}, - AutoAssignRules: rulesJSON, - }) - if err != nil { - return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err) - } - return updated, rule, nil -} - -// SetRuleEnabled flips a single rule's enabled flag in place. -func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) { - collection, err := s.GetCollection(ctx, collectionID) - if err != nil { - return database.Collections{}, err - } - rules := s.ParseRules(collection.AutoAssignRules) - found := false - for i := range rules { - if rules[i].ID == ruleID { - rules[i].Enabled = &enabled - found = true - break - } - } - if !found { - return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID) - } - - rulesJSON, marshalErr := json.Marshal(rules) - if marshalErr != nil { - return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) - } - - updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ - ID: pgtype.UUID{Bytes: collectionID, Valid: true}, - AutoAssignRules: rulesJSON, - }) - if err != nil { - return database.Collections{}, fmt.Errorf("failed to save rules: %w", err) - } - return updated, nil -} - -// RemoveRule deletes a single rule from the ruleset. Removing the LAST -// rule converts the collection back to static (type is derived). -func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) { - collection, err := s.GetCollection(ctx, collectionID) - if err != nil { - return database.Collections{}, err - } - rules := s.ParseRules(collection.AutoAssignRules) - kept := make([]Rule, 0, len(rules)) - found := false - for _, rule := range rules { - if rule.ID == ruleID { - found = true - continue - } - kept = append(kept, rule) - } - if !found { - return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID) - } - - rulesJSON, marshalErr := json.Marshal(kept) - if marshalErr != nil { - return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) - } - - updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ - ID: pgtype.UUID{Bytes: collectionID, Valid: true}, - AutoAssignRules: rulesJSON, - }) - if err != nil { - return database.Collections{}, fmt.Errorf("failed to save rules: %w", err) - } - return updated, nil -} - -// SmartEvalOptions scopes a smart collection's live evaluation. +} // EnabledRules filters a ruleset down to the rules that participate in// validateRule checks a rule payload the evaluator will have to honor.// AddRule appends a rule to the collection's ruleset, generating an id// SetRuleEnabled flips a single rule's enabled flag in place.// RemoveRule deletes a single rule from the ruleset. Removing the LAST// SmartEvalOptions scopes a smart collection's live evaluation. type SmartEvalOptions struct { // LibraryID, when valid, restricts matching to one library (the // detail page's library switcher). @@ -479,10 +256,6 @@ type SmartEvalOptions struct { // VisibleIDs is the requesting user's visible-library set (family // lens). Empty means no visible-library filter. VisibleIDs []pgtype.UUID - // MatchMode is the collection's rule_match_mode: "any" (OR β€” - // legacy default) or "all" (AND β€” the daily briefing). Empty - // resolves to "any". - MatchMode string // ViewerUserID scopes the candidate query's progress join β€” the // played/unplayed rule is per-user. Zero UUID = no viewer (played // rules then see "unplayed" for everything, honestly). @@ -490,126 +263,17 @@ type SmartEvalOptions struct { } // IsCollectionSmart is the ONE smart detection: a collection is smart -// when it carries the unified filter tree (item 3.2) or a legacy rules -// array (item 2's shape). Every surface routes through this β€” rules -// presence alone misses tree collections. +// when it carries the unified filter tree (item 3.2). Every surface +// routes through this. (The legacy rules-array branch retired +// 2026-10-31 β€” the tree is the only smart storage.) func IsCollectionSmart(coll database.Collections) bool { - if len(coll.FilterTree) > 0 { - return true - } - var rules []Rule - if err := json.Unmarshal(coll.AutoAssignRules, &rules); err != nil { - return false - } - return len(rules) > 0 + return len(coll.FilterTree) > 0 } // EvaluateSmartMembership is THE single smart evaluator (item 3.2): -// tree-bearing collections evaluate through the unified filter engine; -// legacy rule arrays keep per-item evaluation until rewritten. One -// call site surface, two storage epochs. +// tree-bearing collections evaluate through the unified filter engine. func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) { - if len(coll.FilterTree) > 0 { - return s.evaluateTreeMembership(ctx, coll, opts) - } - return s.EvaluateSmartCollection(ctx, s.ParseRules(coll.AutoAssignRules), opts) -} - -// RulesToFilterTree converts a legacy rules array + its match mode -// into a qf tree so a collection upgrades to the unified engine at its -// first edit (item 3.2): zero-rewrite back-compat at read, one-way -// upgrade at write. Episode rules map mechanically (feedβ†’in-list, -// played/downloadedβ†’booleans, published_withinβ†’published_date -// within_days); legacy operators without an engine equivalent -// (starts_with/ends_with) fall back to contains β€” documented, and the -// operator labels stay in the rules docs. -func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) { - children := make([]FilterChild, 0, len(rules)) - scope := "" - for _, r := range rules { - child, err := ruleToFilterChild(r) - if err != nil { - return nil, err - } - if episodeOnlyFields[r.Field] { - scope = ScopeEpisodes - } - children = append(children, child) - } - if len(children) == 0 { - return nil, fmt.Errorf("no rules to convert") - } - mode := RuleMatchAny - if matchMode == RuleMatchAll { - mode = RuleMatchAll - } - return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil -} - -func ruleToFilterChild(r Rule) (FilterChild, error) { - child := FilterChild{Field: r.Field} - switch r.Field { - case "feed": - // The rules format stores comma-separated feed IDs in one value. - child.Op = OpIn - child.Values = splitRuleValues(r.Value) - return child, nil - case "downloaded": - child.Op = isTrueFalse(r.Value == "true") - return child, nil - case "played": - child.Op = isTrueFalse(r.Value == "true") - return child, nil - case "published_within": - child.Field = "published_date" - child.Op = OpWithinDays - child.Values = []string{r.Value} - return child, nil - } - // Registry fields: legacy operators β†’ engine operators. - switch r.Operator { - case "equals": - child.Op = OpIs - case "not_equals": - child.Op = OpIsNot - case "contains": - child.Op = OpContains - case "not_contains": - child.Op = OpNotContains - case "greater_than": - child.Op = OpAtLeast - case "less_than": - child.Op = OpAtMost - case "in": - child.Op = OpIn - child.Values = splitRuleValues(r.Value) - case "starts_with", "ends_with": - // No engine equivalent β€” contains is the honest nearest. - child.Op = OpContains - default: - return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator) - } - child.Values = []string{r.Value} - return child, nil -} - -func isTrueFalse(b bool) string { - if b { - return OpIsTrue - } - return OpIsFalse -} - -func splitRuleValues(v string) []string { - parts := strings.Split(v, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out + return s.evaluateTreeMembership(ctx, coll, opts) } func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) { @@ -621,87 +285,12 @@ func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll dat return nil, errors.New("tree collections require the search service (constructor wiring)") } return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree) -} - -// EvaluateSmartCollection resolves a smart collection's membership LIVE: -// every visible live book matched by at least one enabled rule is in. -// No stored links are consulted β€” "truly dynamic," always current. -func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) { - enabled := s.EnabledRules(rules) - if len(enabled) == 0 { - return []database.MediaItems{}, nil - } - - var visibleArg []pgtype.UUID - if len(opts.VisibleIDs) > 0 { - visibleArg = opts.VisibleIDs - } - items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{ - LibraryID: opts.LibraryID, - VisibleLibraryIds: visibleArg, - ViewerUserID: opts.ViewerUserID, - }) - if err != nil { - return nil, fmt.Errorf("failed to load candidate books: %w", err) - } - - mode := opts.MatchMode - if !ValidRuleMatchMode(mode) { - mode = RuleMatchAny - } - matches := make([]database.MediaItems, 0, len(items)) - for _, row := range items { - if s.mediaItemMatches(mode, row.MediaItems, row.ViewerPercentage, enabled) { - matches = append(matches, row.MediaItems) - } - } - return matches, nil -} - -// mediaItemMatches evaluates the enabled rules under the collection's -// match mode: "any" (OR β€” legacy) or "all" (AND β€” the briefing). -// viewerPercentage is the requesting user's progress for this item -// (0 when none) β€” the played/unplayed episode rule reads it. -func (s *CollectionService) mediaItemMatches(mode string, item database.MediaItems, viewerPercentage float64, rules []Rule) bool { - if len(rules) == 0 { - return false - } - if mode == RuleMatchAll { - for _, rule := range rules { - if !s.mediaItemMatchesRule(item, viewerPercentage, rule) { - return false - } - } - return true - } - for _, rule := range rules { - if s.mediaItemMatchesRule(item, viewerPercentage, rule) { - return true - } - } - return false -} - -// mediaItemMatchesAnyRule is the legacy OR path, kept for callers that -// predate match modes. -func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool { - return s.mediaItemMatches(RuleMatchAny, item, 0, rules) -} - -// episodeFinished is THE canonical finished definition (same predicate +} // mediaItemMatchesAnyRule is the legacy OR path, kept for callers that// episodeFinished is THE canonical finished definition (same predicate // as hide_finished): viewer progress percentage >= 0.999. func episodeFinished(viewerPercentage float64) bool { return viewerPercentage >= 0.999 } -// mediaItemMatchesRule evaluates one rule against one item. -func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, viewerPercentage float64, rule Rule) bool { - if rule.Enabled != nil && !*rule.Enabled { - return false - } - return s.FieldMatches(item, viewerPercentage, rule.Field, rule.Operator, rule.Value) -} - // FieldMatches is THE single rule evaluator (the rules-builder preview // delegates here too β€” one vocabulary, one semantics). Episode/user-state // fields (feed/played/downloaded/published_within) evaluate directly; diff --git a/internal/services/collection_service_test.go b/internal/services/collection_service_test.go index d03842d1..a1a52092 100644 --- a/internal/services/collection_service_test.go +++ b/internal/services/collection_service_test.go @@ -2,12 +2,9 @@ package services import ( "bookhoard/internal/database" - "encoding/json" "testing" "github.com/jackc/pgx/v5/pgtype" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestEvaluateRules_Equality(t *testing.T) { @@ -280,44 +277,7 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) { } } -func TestRulesToFilterTree(t *testing.T) { - t.Run("episode_rules_map_mechanically", func(t *testing.T) { - rules := []Rule{ - {ID: "1", Field: "feed", Operator: "in", Value: "feed-a, feed-b"}, - {ID: "2", Field: "played", Operator: "equals", Value: "false"}, - {ID: "3", Field: "downloaded", Operator: "equals", Value: "true"}, - {ID: "4", Field: "published_within", Operator: "greater_than", Value: "7"}, - } - tree, err := RulesToFilterTree(rules, RuleMatchAll) - require.NoError(t, err) - assert.Equal(t, RuleMatchAll, tree.Mode, "the collection's match mode becomes the tree mode") - assert.Equal(t, ScopeEpisodes, tree.Scope, "episode rules force the episodes scope") - require.Len(t, tree.Children, 4) - assert.Equal(t, FilterChild{Field: "feed", Op: OpIn, Values: []string{"feed-a", "feed-b"}}, tree.Children[0]) - assert.Equal(t, FilterChild{Field: "played", Op: OpIsFalse}, tree.Children[1]) - assert.Equal(t, FilterChild{Field: "downloaded", Op: OpIsTrue}, tree.Children[2]) - assert.Equal(t, FilterChild{Field: "published_date", Op: OpWithinDays, Values: []string{"7"}}, tree.Children[3]) - // The converted tree parses under the strict engine. - b, _ := json.Marshal(tree) - _, err = ParseFilterTree(string(b)) - require.NoError(t, err, "converted tree must satisfy the engine grammar") - }) - - t.Run("book_rules_map_and_stay_books_scope", func(t *testing.T) { - rules := []Rule{ - {Field: "genre", Operator: "equals", Value: "fantasy"}, - {Field: "author", Operator: "contains", Value: "Voss"}, - {Field: "copyright_year", Operator: "greater_than", Value: "1990"}, - {Field: "tags", Operator: "starts_with", Value: "dyst"}, - } - tree, err := RulesToFilterTree(rules, RuleMatchAny) - require.NoError(t, err) - assert.Equal(t, "", tree.Scope, "no episode rules β†’ the books scope (legacy default)") - assert.Equal(t, RuleMatchAny, tree.Mode) - assert.Equal(t, FilterChild{Field: "genre", Op: OpIs, Values: []string{"fantasy"}}, tree.Children[0]) - assert.Equal(t, FilterChild{Field: "copyright_year", Op: OpAtLeast, Values: []string{"1990"}}, tree.Children[2]) - // Documented fallback: rules ops without an engine equivalent - // (starts_with/ends_with) become contains. - assert.Equal(t, OpContains, tree.Children[3].Op) - }) -} +// TestRulesToFilterTree retired 2026-10-31 with the function it tested: +// rules-array storage is gone, the filter tree is the only smart +// membership. (Tests of deleted behavior are deleted β€” the asymmetry +// rule.) diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index d499f63f..bd0606d2 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -292,56 +292,15 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data } // The query filters excluded rows in SQL and embeds the media row, - // so the items are MediaItems as-is. - var manualNonExcluded []database.MediaItems + // so the items are MediaItems as-is. (The legacy auto-assign branch + // retired 2026-10-31 with the rules-array storage: smart user + // collections evaluate on their detail page; sections render their + // stored membership.) + items := make([]database.MediaItems, 0, len(manualItems)) for _, row := range manualItems { - manualNonExcluded = append(manualNonExcluded, row.MediaItems) + items = append(items, row.MediaItems) } - - var autoItems []database.MediaItems - if len(coll.AutoAssignRules) > 0 { - var rules []Rule - if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { - allLibraryItems, err := s.db.GetLibraryItems(ctx, libraryID) - if err == nil { - for _, item := range allLibraryItems { - alreadyInCollection := false - for _, manualItem := range manualNonExcluded { - manualUUID := uuid.UUID(manualItem.ID.Bytes) - itemUUID := uuid.UUID(item.ID.Bytes) - if manualUUID == itemUUID { - alreadyInCollection = true - break - } - } - if alreadyInCollection { - continue - } - - // Legacy dashboard auto-assign path: no viewer progress - // loaded here (the dashboard's own hide_finished handles - // queue semantics) β€” played rules evaluate unplayed, honestly. - evaluations := s.collectionService.EvaluateRules(item, 0, rules) - for _, eval := range evaluations { - if eval.Matches { - autoItems = append(autoItems, item) - break - } - } - } - } - } - } - - var finalItems []database.MediaItems - finalItems = append(finalItems, manualNonExcluded...) - finalItems = append(finalItems, autoItems...) - - if len(finalItems) > limit { - finalItems = finalItems[:limit] - } - - return finalItems, nil + return items, nil } func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) { diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index f4224f32..4bb1e3cb 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -433,7 +433,6 @@ func (s *MediaScanner) upsertPlaylistCollection(ctx context.Context, entry Serie UserID: owner, Name: entry.Name, Description: pgtype.Text{String: description, Valid: true}, - AutoAssignRules: nil, // jsonb NULL β€” an empty []byte encodes as bytea and 22P02s ViewSettings: viewSettings, ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs }) diff --git a/web/src/types/api.d.ts b/web/src/types/api.d.ts index 98ce54a3..e7ddf593 100644 --- a/web/src/types/api.d.ts +++ b/web/src/types/api.d.ts @@ -95,7 +95,6 @@ interface CollectionData { description: string; color: string; icon: string; - auto_assign_rules?: unknown; created_at: string; book_count?: number; }