refactor(collections): retire the legacy rules surface — tree-only storage
The pre-tree rules machinery is deleted: no row ever carried a rules array or a non-default match mode (verified in the dev DB), the web never read the envelope keys, and the Android app consumes none of it. Dead code gets no documentation. Removed: - PUT /api/collections/:id/match-mode — endpoint, route, and the UpdateCollectionMatchMode query. The tree's root mode owns any|all. - auto_assign_rules / rule_match_mode from every envelope (create/list/detail/update), from the create/update request shapes, and from the schema: both columns DROPPED (schema.sql carries idempotent DROP COLUMN IF EXISTS heals for existing DBs; sqlc regenerated). - The rules CRUD service layer (AddRule/SetRuleEnabled/RemoveRule + validateRule + UpdateCollectionRulesJSON), ParseRules / EnabledRules / IsSmartCollection, RulesToFilterTree (+ the SSR preload branch), the legacy branch of EvaluateSmartMembership, and SmartEvalOptions.MatchMode. IsCollectionSmart = tree presence; create-path isSmart = collection_type=smart OR a filter_tree. Kept (live consumers): POST /api/collections/preview still evaluates rules payloads in memory (the custom-section builder's preview), and include_auto stays on the list (the Android app sends it). The custom-section builder's SAVE was already broken — its stringified auto_assign_rules payload failed request binding (probed live: 400 invalid request). Create now rejects that payload loudly with a 400 naming the retirement, instead of silently creating a hollow static section; custom-section-builder.md carries the flag. The builder's tree migration remains the flagged follow-up. Test-side (asymmetry rule — deleted behavior, deleted tests): TestPodcastSmartCollections rewritten onto trees (mode lives in the tree root), match-mode subtests and tree_wins_over_legacy_rules deleted, TestRulesToFilterTree deleted, contract-pin envelope keys retracted per the contract_pins 2026-10-31 retirement entry. Mirrors hand-synced: openapi.yaml, bruno create/update, docs/api/*.md, docs/schema.go, api.d.ts. Full container suite green (155s).
This commit is contained in:
1 parent
ab98015190
commit
a86bdac87e
27 files changed
+158
-1012
No files matched your search
@@ -16,7 +16,6 @@ http:
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } }
|
"view_settings": { "kobo": { "view_mode": "grid" } }
|
||||||
}
|
}
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ http:
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "hide_finished": true },
|
"view_settings": { "hide_finished": true },
|
||||||
"hide_finished": ["true"],
|
"hide_finished": ["true"],
|
||||||
"show_up_next": ["true"]
|
"show_up_next": ["true"]
|
||||||
|
|||||||
@@ -500,9 +500,8 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
|||||||
// payload; docs/contract_pins.md changelog).
|
// payload; docs/contract_pins.md changelog).
|
||||||
envelopeKeys := []string{
|
envelopeKeys := []string{
|
||||||
"id", "user_id", "name", "description", "color", "icon",
|
"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",
|
"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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,11 +565,14 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
|||||||
assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys)
|
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{}{
|
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
|
||||||
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
|
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
|
||||||
"auto_assign_rules": []map[string]interface{}{
|
"filter_tree": map[string]interface{}{
|
||||||
{"id": "rule-1", "field": "genre", "operator": "equals", "value": "Science Fiction", "priority": 5},
|
"scope": "books", "mode": "any",
|
||||||
|
"children": []map[string]interface{}{
|
||||||
|
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, setup.Token)
|
}, setup.Token)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|||||||
@@ -772,7 +772,6 @@ func TestPodcastSmartCollections(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
regUUID := getRegularUserID(t, setup.DB)
|
regUUID := getRegularUserID(t, setup.DB)
|
||||||
regToken := setup.RegularToken
|
regToken := setup.RegularToken
|
||||||
adminToken := setup.Token
|
|
||||||
|
|
||||||
lib, _ := createPodcastsLibrary(t, setup, "test SmartColl Podcasts")
|
lib, _ := createPodcastsLibrary(t, setup, "test SmartColl Podcasts")
|
||||||
marker := fmt.Sprintf("smartcoll%d", time.Now().UnixNano()%100000)
|
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, `"`, """) }
|
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
||||||
|
|
||||||
// Smart collection with the briefing's AND shape, created through
|
// 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",
|
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
|
||||||
`{"name":"test Daily Fiction `+marker+`","collection_type":"smart","auto_assign_rules":[
|
`{"name":"test Daily Fiction `+marker+`","collection_type":"smart","filter_tree":`+string(briefing)+`}`)
|
||||||
{"field":"feed","operator":"in","value":"`+feedAID+`"},
|
|
||||||
{"field":"played","operator":"equals","value":"false"},
|
|
||||||
{"field":"published_within","operator":"greater_than","value":"7"}
|
|
||||||
]}`)
|
|
||||||
require.Equal(t, http.StatusCreated, code, "%s", string(body))
|
require.Equal(t, http.StatusCreated, code, "%s", string(body))
|
||||||
var coll struct {
|
var coll struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -830,17 +834,15 @@ func TestPodcastSmartCollections(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(body, &coll))
|
require.NoError(t, json.Unmarshal(body, &coll))
|
||||||
collID := coll.ID
|
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) {
|
t.Run("all_mode_is_the_briefing", func(t *testing.T) {
|
||||||
code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
|
code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
|
||||||
require.Equal(t, http.StatusOK, code)
|
require.Equal(t, http.StatusOK, code)
|
||||||
var out struct {
|
var out struct {
|
||||||
Books []struct{ Title string `json:"title"` } `json:"books"`
|
Books []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"books"`
|
||||||
IsSmart bool `json:"is_smart"`
|
IsSmart bool `json:"is_smart"`
|
||||||
MatchAll string `json:"rule_match_mode"`
|
Tree map[string]interface{} `json:"filter_tree"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(body, &out))
|
require.NoError(t, json.Unmarshal(body, &out))
|
||||||
require.True(t, out.IsSmart)
|
require.True(t, out.IsSmart)
|
||||||
@@ -848,34 +850,11 @@ func TestPodcastSmartCollections(t *testing.T) {
|
|||||||
for _, b := range out.Books {
|
for _, b := range out.Books {
|
||||||
titles[b.Title] = true
|
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.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")
|
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 ----
|
// ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ----
|
||||||
|
|
||||||
// One more briefing match so the chain has a next hop.
|
// 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)
|
date_published = CURRENT_DATE WHERE id = $1`, aNew2.ID)
|
||||||
|
|
||||||
t.Run("acceptance_briefing_plays_back_to_back", func(t *testing.T) {
|
t.Run("acceptance_briefing_plays_back_to_back", func(t *testing.T) {
|
||||||
// Back to ALL: today's unplayed feed-A episodes = aNew + aNew2,
|
// ALL mode is the stored tree root: today's unplayed feed-A
|
||||||
// chained in native feed order. Playing the FIRST match (the
|
// episodes = aNew + aNew2, chained in native feed order. Playing
|
||||||
// header's Play-as-Sequence target) must chain into the second.
|
// the FIRST match (the header's Play-as-Sequence target) must
|
||||||
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
|
// chain into the second.
|
||||||
require.Equal(t, http.StatusOK, code)
|
|
||||||
|
|
||||||
// The detail page (smart branch, All Libraries view) renders
|
// The detail page (smart branch, All Libraries view) renders
|
||||||
// episode cards with the play affordance + the header button.
|
// 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
|
// TestEpisodeCollectionsFilterTree pins item 3.2: smart collections
|
||||||
// WITH A FILTER TREE evaluate through the unified engine; the briefing
|
// evaluate through the unified engine; the briefing rules ride the qf
|
||||||
// rules ride the qf DSL; the reader chain follows the tree's live
|
// DSL; the reader chain follows the tree's live membership; edits
|
||||||
// membership; the tree wins when legacy rules coexist; edits without a
|
// without a tree keep the stored one.
|
||||||
// tree keep the stored one.
|
|
||||||
func TestEpisodeCollectionsFilterTree(t *testing.T) {
|
func TestEpisodeCollectionsFilterTree(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
regUUID := getRegularUserID(t, setup.DB)
|
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))
|
"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) {
|
t.Run("edit_form_without_tree_keeps_it", func(t *testing.T) {
|
||||||
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
|
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
|
||||||
`{"name":"test TreeColl `+marker+`","description":"kept the tree"}`)
|
`{"name":"test TreeColl `+marker+`","description":"kept the tree"}`)
|
||||||
|
|||||||
@@ -1341,14 +1341,8 @@ CREATE TABLE IF NOT EXISTS collections (
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
color VARCHAR(7),
|
color VARCHAR(7),
|
||||||
icon VARCHAR(50),
|
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
|
-- 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,
|
filter_tree JSONB,
|
||||||
view_settings JSONB,
|
view_settings JSONB,
|
||||||
show_on_dashboard BOOLEAN DEFAULT false,
|
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.
|
-- the row id (and everything referencing it) never churn.
|
||||||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
-- Smart-collection match mode (plan item 2.4): 'any' (OR, legacy
|
-- Legacy rules-array columns RETIRED (2026-10-31): the unified filter
|
||||||
-- default) | 'all' (AND — the daily briefing). Idempotent; NOT NULL
|
-- tree is the only smart-membership storage. No production row ever
|
||||||
-- DEFAULT covers existing rows.
|
-- carried a rules array or a non-default match mode (verified), so the
|
||||||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS rule_match_mode TEXT NOT NULL DEFAULT 'any';
|
-- 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
|
-- Smart collections in the UNIFIED filter engine (plan item 3.2): the
|
||||||
-- qf tree (same DSL as saved filters, See FilterTree). NULL = no tree
|
-- qf tree (same DSL as saved filters, See FilterTree). NULL = static.
|
||||||
-- (the legacy rules array governs until the collection is rewritten).
|
|
||||||
-- Legacy rule_match_mode stays authoritative for rules-array
|
|
||||||
-- collections; a tree's root mode owns its semantics.
|
|
||||||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB;
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
||||||
|
|||||||
@@ -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
|
fixture writes to non-system collections run as the row's OWNER
|
||||||
(scopeExecAs/ownerSeedCtx) because the admin write-escape covers
|
(scopeExecAs/ownerSeedCtx) because the admin write-escape covers
|
||||||
system collections only.
|
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.
|
||||||
@@ -23,7 +23,6 @@ Creating a collection whose name already exists for the user returns **409**.
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } }
|
"view_settings": { "kobo": { "view_mode": "grid" } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -39,7 +38,6 @@ Creating a collection whose name already exists for the user returns **409**.
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } },
|
"view_settings": { "kobo": { "view_mode": "grid" } },
|
||||||
"book_count": 0,
|
"book_count": 0,
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
"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 | |
|
| description | string | no | |
|
||||||
| color | string | no | hex color, e.g. `"#FF5733"` |
|
| color | string | no | hex color, e.g. `"#FF5733"` |
|
||||||
| icon | string | no | emoji |
|
| icon | string | no | emoji |
|
||||||
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by non-empty `auto_assign_rules` |
|
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by a `filter_tree` |
|
||||||
| auto_assign_rules | array | no | rule objects, see below |
|
|
||||||
| view_settings | object | no | per-device display preferences |
|
| view_settings | object | no | per-device display preferences |
|
||||||
|
| filter_tree | object | no | the unified filter tree (qf DSL); presence makes the collection smart |
|
||||||
### 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) |
|
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
|
|||||||
"description": "Books I want to read soon",
|
"description": "Books I want to read soon",
|
||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } },
|
"view_settings": { "kobo": { "view_mode": "grid" } },
|
||||||
"books": [],
|
"books": [],
|
||||||
"book_count": 0,
|
"book_count": 0,
|
||||||
@@ -57,8 +56,8 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
|
|||||||
| description | string | |
|
| description | string | |
|
||||||
| color | string | |
|
| color | string | |
|
||||||
| icon | string | |
|
| icon | string | |
|
||||||
| auto_assign_rules | array \| null | |
|
|
||||||
| view_settings | object | |
|
| view_settings | object | |
|
||||||
|
| filter_tree | object \| null | the unified filter tree (qf DSL); null = static |
|
||||||
| books | array | book cards in this collection |
|
| books | array | book cards in this collection |
|
||||||
| book_count | number | length of `books` |
|
| book_count | number | length of `books` |
|
||||||
| is_smart | boolean | |
|
| is_smart | boolean | |
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ GET /api/collections?include_auto=true&sort_by=name
|
|||||||
"description": "Books I want to read soon",
|
"description": "Books I want to read soon",
|
||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
|
||||||
"book_count": 12,
|
"book_count": 12,
|
||||||
"is_smart": false
|
"is_smart": false
|
||||||
@@ -65,7 +64,6 @@ GET /api/collections?include_auto=true&sort_by=name
|
|||||||
| description | string | |
|
| description | string | |
|
||||||
| color | string | |
|
| color | string | |
|
||||||
| icon | string | |
|
| icon | string | |
|
||||||
| auto_assign_rules | array \| null | raw rules JSON; non-null implies smart |
|
|
||||||
| created_at | string | Go `time.String()` format |
|
| created_at | string | Go `time.String()` format |
|
||||||
| book_count | number | live count; respects `library_id` for smart collections |
|
| book_count | number | live count; respects `library_id` for smart collections |
|
||||||
| is_smart | boolean | |
|
| is_smart | boolean | |
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "hide_finished": true },
|
"view_settings": { "hide_finished": true },
|
||||||
"hide_finished": ["true"],
|
"hide_finished": ["true"],
|
||||||
"show_up_next": ["true"]
|
"show_up_next": ["true"]
|
||||||
@@ -41,7 +40,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": null,
|
|
||||||
"view_settings": { "hide_finished": true, "show_up_next": true },
|
"view_settings": { "hide_finished": true, "show_up_next": true },
|
||||||
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
"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 | |
|
| description | string | no | |
|
||||||
| color | string | no | |
|
| color | string | no | |
|
||||||
| icon | 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 |
|
| 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 |
|
| hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings |
|
||||||
| show_up_next | array | no | same shape as `hide_finished` |
|
| show_up_next | array | no | same shape as `hide_finished` |
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ Creates a new custom collection with filter rules and/or manual book selection.
|
|||||||
|
|
||||||
**Request Body:**
|
**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
|
```json
|
||||||
{
|
{
|
||||||
"name": "My Custom Section",
|
"name": "My Custom Section",
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ paths:
|
|||||||
"color": "#00FF00",
|
"color": "#00FF00",
|
||||||
"icon": "📖",
|
"icon": "📖",
|
||||||
"collection_type": "static",
|
"collection_type": "static",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "kobo": { "view_mode": "grid" } }
|
"view_settings": { "kobo": { "view_mode": "grid" } }
|
||||||
}
|
}
|
||||||
responses:
|
responses:
|
||||||
@@ -308,7 +307,6 @@ paths:
|
|||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#33CCFF",
|
"color": "#33CCFF",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": { "hide_finished": true },
|
"view_settings": { "hide_finished": true },
|
||||||
"hide_finished": ["true"],
|
"hide_finished": ["true"],
|
||||||
"show_up_next": ["true"]
|
"show_up_next": ["true"]
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ type Collections struct {
|
|||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
Color pgtype.Text `db:"color" json:"color"`
|
Color pgtype.Text `db:"color" json:"color"`
|
||||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
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"`
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||||
|
|||||||
@@ -599,14 +599,6 @@ type Querier interface {
|
|||||||
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
|
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, 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)
|
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||||
// device's user_id before calling this).
|
// device's user_id before calling this).
|
||||||
@@ -828,12 +820,6 @@ type Querier interface {
|
|||||||
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
// Update collection
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
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
|
// A2c §2.4: provenance + playlist flags on an existing collection without
|
||||||
// touching the rest of the row.
|
// touching the rest of the row.
|
||||||
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
|
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
|
||||||
|
|||||||
@@ -671,9 +671,9 @@ func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg Create
|
|||||||
|
|
||||||
const CreateCollection = `-- name: CreateCollection :one
|
const CreateCollection = `-- name: CreateCollection :one
|
||||||
|
|
||||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, filter_tree)
|
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, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
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 CreateCollectionParams struct {
|
type CreateCollectionParams struct {
|
||||||
@@ -682,7 +682,6 @@ type CreateCollectionParams struct {
|
|||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
Color pgtype.Text `db:"color" json:"color"`
|
Color pgtype.Text `db:"color" json:"color"`
|
||||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
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"`
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||||
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||||
@@ -697,7 +696,6 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
|
|||||||
arg.Description,
|
arg.Description,
|
||||||
arg.Color,
|
arg.Color,
|
||||||
arg.Icon,
|
arg.Icon,
|
||||||
arg.AutoAssignRules,
|
|
||||||
arg.ViewSettings,
|
arg.ViewSettings,
|
||||||
arg.ShowOnDashboard,
|
arg.ShowOnDashboard,
|
||||||
arg.FilterTree,
|
arg.FilterTree,
|
||||||
@@ -710,8 +708,6 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -2254,9 +2250,9 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreateSystemCollection = `-- name: CreateSystemCollection :one
|
const CreateSystemCollection = `-- name: CreateSystemCollection :one
|
||||||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
|
||||||
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 CreateSystemCollectionParams struct {
|
type CreateSystemCollectionParams struct {
|
||||||
@@ -2289,8 +2285,6 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -4389,7 +4383,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetCollection = `-- name: GetCollection :one
|
const GetCollection = `-- name: GetCollection :one
|
||||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
|
// Get collection
|
||||||
@@ -4403,8 +4397,6 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -4418,7 +4410,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one
|
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one
|
||||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
|
WHERE user_id = $1
|
||||||
AND view_settings->>'imported_from_series' = $2::text
|
AND view_settings->>'imported_from_series' = $2::text
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
@@ -4443,8 +4435,6 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -4458,7 +4448,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one
|
const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one
|
||||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
|
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -4478,8 +4468,6 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -4973,7 +4961,7 @@ func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNe
|
|||||||
|
|
||||||
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
|
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.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
|
COUNT(ci.id) as book_count
|
||||||
FROM collections c
|
FROM collections c
|
||||||
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
||||||
@@ -4988,8 +4976,6 @@ type GetCollectionWithBookCountRow struct {
|
|||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
Color pgtype.Text `db:"color" json:"color"`
|
Color pgtype.Text `db:"color" json:"color"`
|
||||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
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"`
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||||
@@ -5012,8 +4998,6 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -5028,7 +5012,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
|
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
|
||||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
|
// Get collections by user
|
||||||
@@ -5048,8 +5032,6 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -5070,7 +5052,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
|
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
|
||||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.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
|
FROM collections c
|
||||||
JOIN collection_items ci ON c.id = ci.collection_id
|
JOIN collection_items ci ON c.id = ci.collection_id
|
||||||
WHERE ci.media_item_id = $1
|
WHERE ci.media_item_id = $1
|
||||||
@@ -5094,8 +5076,6 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -10801,7 +10781,7 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
|
|||||||
|
|
||||||
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
|
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
|
||||||
|
|
||||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
|
WHERE user_id = $1
|
||||||
AND query_type = $2
|
AND query_type = $2
|
||||||
AND is_system_collection = true
|
AND is_system_collection = true
|
||||||
@@ -10827,8 +10807,6 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -10842,7 +10820,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
|
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
|
||||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
|
WHERE user_id = $1
|
||||||
AND is_system_collection = true
|
AND is_system_collection = true
|
||||||
AND show_on_dashboard = true
|
AND show_on_dashboard = true
|
||||||
@@ -10867,8 +10845,6 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -11575,7 +11551,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
|
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
|
||||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.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
|
WHERE c.user_id = $1
|
||||||
AND c.is_system_collection = false
|
AND c.is_system_collection = false
|
||||||
AND c.deleted_at IS NULL
|
AND c.deleted_at IS NULL
|
||||||
@@ -11602,8 +11578,6 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -12364,146 +12338,6 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp
|
|||||||
return items, nil
|
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
|
const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
SELECT 1 FROM sync_conflicts
|
SELECT 1 FROM sync_conflicts
|
||||||
@@ -16484,7 +16318,7 @@ SET name = $2,
|
|||||||
show_on_dashboard = true,
|
show_on_dashboard = true,
|
||||||
deleted_at = NULL
|
deleted_at = NULL
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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 {
|
type RestoreSystemCollectionRowParams struct {
|
||||||
@@ -16515,8 +16349,6 @@ func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSys
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&i.Icon,
|
||||||
&i.AutoAssignRules,
|
|
||||||
&i.RuleMatchMode,
|
|
||||||
&i.FilterTree,
|
&i.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
@@ -18230,12 +18062,10 @@ SET
|
|||||||
description = $3,
|
description = $3,
|
||||||
color = $4,
|
color = $4,
|
||||||
icon = $5,
|
icon = $5,
|
||||||
auto_assign_rules = $6,
|
view_settings = $6,
|
||||||
view_settings = $7,
|
filter_tree = $7
|
||||||
rule_match_mode = $8,
|
|
||||||
filter_tree = $9
|
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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 {
|
type UpdateCollectionParams struct {
|
||||||
@@ -18244,9 +18074,7 @@ type UpdateCollectionParams struct {
|
|||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
Color pgtype.Text `db:"color" json:"color"`
|
Color pgtype.Text `db:"color" json:"color"`
|
||||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
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"`
|
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"`
|
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18258,9 +18086,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
|||||||
arg.Description,
|
arg.Description,
|
||||||
arg.Color,
|
arg.Color,
|
||||||
arg.Icon,
|
arg.Icon,
|
||||||
arg.AutoAssignRules,
|
|
||||||
arg.ViewSettings,
|
arg.ViewSettings,
|
||||||
arg.RuleMatchMode,
|
|
||||||
arg.FilterTree,
|
arg.FilterTree,
|
||||||
)
|
)
|
||||||
var i Collections
|
var i Collections
|
||||||
@@ -18271,59 +18097,6 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
|||||||
&i.Description,
|
&i.Description,
|
||||||
&i.Color,
|
&i.Color,
|
||||||
&i.Icon,
|
&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.FilterTree,
|
||||||
&i.ViewSettings,
|
&i.ViewSettings,
|
||||||
&i.ShowOnDashboard,
|
&i.ShowOnDashboard,
|
||||||
|
|||||||
@@ -3489,8 +3489,8 @@ DELETE FROM device_file_aliases WHERE id = $1;
|
|||||||
|
|
||||||
-- Create collection
|
-- Create collection
|
||||||
-- name: CreateCollection :one
|
-- name: CreateCollection :one
|
||||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, filter_tree)
|
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, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
||||||
@@ -3502,11 +3502,6 @@ RETURNING *;
|
|||||||
-- dropping the phantom column write.)
|
-- dropping the phantom column write.)
|
||||||
UPDATE collections SET view_settings = $2 WHERE id = $1;
|
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
|
-- Get collection
|
||||||
-- name: GetCollection :one
|
-- name: GetCollection :one
|
||||||
SELECT * FROM collections WHERE id = $1;
|
SELECT * FROM collections WHERE id = $1;
|
||||||
@@ -3533,18 +3528,11 @@ SET
|
|||||||
description = $3,
|
description = $3,
|
||||||
color = $4,
|
color = $4,
|
||||||
icon = $5,
|
icon = $5,
|
||||||
auto_assign_rules = $6,
|
view_settings = $6,
|
||||||
view_settings = $7,
|
filter_tree = $7
|
||||||
rule_match_mode = $8,
|
|
||||||
filter_tree = $9
|
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
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
|
-- Delete collection
|
||||||
-- name: DeleteCollection :exec
|
-- name: DeleteCollection :exec
|
||||||
DELETE FROM collections WHERE id = $1;
|
DELETE FROM collections WHERE id = $1;
|
||||||
@@ -4034,8 +4022,8 @@ RETURNING *;
|
|||||||
DELETE FROM collection_items WHERE collection_id = $1;
|
DELETE FROM collection_items WHERE collection_id = $1;
|
||||||
|
|
||||||
-- name: CreateSystemCollection :one
|
-- name: CreateSystemCollection :one
|
||||||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ResetSystemCollectionMetadata :exec
|
-- name: ResetSystemCollectionMetadata :exec
|
||||||
@@ -4151,22 +4139,6 @@ LIMIT sqlc.narg('limit');
|
|||||||
-- name: CountCollectionItems :one
|
-- name: CountCollectionItems :one
|
||||||
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
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
|
-- Live item count for a STATIC collection (rule-free cards on the
|
||||||
-- collections page); same live filter as GetCollectionItems, plus the
|
-- collections page); same live filter as GetCollectionItems, plus the
|
||||||
-- future-hybrid excluded exclusion and an optional library scope.
|
-- future-hybrid excluded exclusion and an optional library scope.
|
||||||
|
|||||||
+3
-12
@@ -49,7 +49,7 @@ var Schemas = map[string]Schema{
|
|||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "collection_type": "",
|
"color": "", "icon": "", "collection_type": "",
|
||||||
"auto_assign_rules": null, "view_settings": {},
|
"view_settings": {},
|
||||||
"book_count": 0, "created_at": ""
|
"book_count": 0, "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
@@ -62,7 +62,7 @@ var Schemas = map[string]Schema{
|
|||||||
"GET /api/collections/:id": {
|
"GET /api/collections/:id": {
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "auto_assign_rules": null,
|
"color": "", "icon": "",
|
||||||
"view_settings": {}, "books": [], "book_count": 0,
|
"view_settings": {}, "books": [], "book_count": 0,
|
||||||
"is_smart": false, "up_next": [], "created_at": ""
|
"is_smart": false, "up_next": [], "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
@@ -71,7 +71,7 @@ var Schemas = map[string]Schema{
|
|||||||
Request: handlers.UpdateCollectionRequest{},
|
Request: handlers.UpdateCollectionRequest{},
|
||||||
Response: json.RawMessage(`{
|
Response: json.RawMessage(`{
|
||||||
"id": "", "user_id": "", "name": "", "description": "",
|
"id": "", "user_id": "", "name": "", "description": "",
|
||||||
"color": "", "icon": "", "auto_assign_rules": null,
|
"color": "", "icon": "",
|
||||||
"view_settings": {}, "created_at": ""
|
"view_settings": {}, "created_at": ""
|
||||||
}`),
|
}`),
|
||||||
},
|
},
|
||||||
@@ -609,15 +609,6 @@ var Schemas = map[string]Schema{
|
|||||||
"id": "", "chain_order": "", "view_settings": {}
|
"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 ---
|
// --- Beam batch 7: scanner ---
|
||||||
"POST /api/scanner/scan": {
|
"POST /api/scanner/scan": {
|
||||||
Request: handlers.ScanLibraryRequest{},
|
Request: handlers.ScanLibraryRequest{},
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ type CreateCollectionRequest struct {
|
|||||||
Description string `form:"description" json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
Color string `form:"color" json:"color"`
|
Color string `form:"color" json:"color"`
|
||||||
Icon string `form:"icon" json:"icon"`
|
Icon string `form:"icon" json:"icon"`
|
||||||
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
|
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart"
|
||||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
// 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"`
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||||
// The unified filter tree (item 3.2): when present the collection's
|
// The unified filter tree (item 3.2): when present the collection's
|
||||||
// smart membership runs through the qf engine. INVALID trees are a
|
// smart membership runs through the qf engine. INVALID trees are a
|
||||||
@@ -105,13 +110,9 @@ func resolveTreePayload(jsonTree any, formTree string) any {
|
|||||||
|
|
||||||
type UpdateCollectionRequest struct {
|
type UpdateCollectionRequest struct {
|
||||||
Name string `form:"name" json:"name" validate:"required"`
|
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
|
// The unified filter tree (item 3.2): when present the collection's
|
||||||
// smart membership runs through the qf engine. INVALID trees are a
|
// smart membership runs through the qf engine. INVALID trees are a
|
||||||
// 400 (ParseFilterTree at the handler); a tree supersedes
|
// 400 (ParseFilterTree at the handler). JSON bodies bind FilterTree; form bodies bind
|
||||||
// auto_assign_rules. JSON bodies bind FilterTree; form bodies bind
|
|
||||||
// FilterTreeForm (echo's binder cannot set an any-typed field from
|
// FilterTreeForm (echo's binder cannot set an any-typed field from
|
||||||
// a form string — see CreateCollectionRequest).
|
// a form string — see CreateCollectionRequest).
|
||||||
FilterTree any `json:"filter_tree"`
|
FilterTree any `json:"filter_tree"`
|
||||||
@@ -119,7 +120,6 @@ type UpdateCollectionRequest struct {
|
|||||||
Description string `form:"description" json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
Color string `form:"color" json:"color"`
|
Color string `form:"color" json:"color"`
|
||||||
Icon string `form:"icon" json:"icon"`
|
Icon string `form:"icon" json:"icon"`
|
||||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
|
||||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||||
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
||||||
// (nil = untouched). []string because the form posts a hidden "false"
|
// (nil = untouched). []string because the form posts a hidden "false"
|
||||||
@@ -163,7 +163,6 @@ type CollectionSummary struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
BookCount int `json:"book_count"`
|
BookCount int `json:"book_count"`
|
||||||
IsSmart bool `json:"is_smart"`
|
IsSmart bool `json:"is_smart"`
|
||||||
@@ -207,6 +206,14 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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))
|
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
|
||||||
if treeErr != nil {
|
if treeErr != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
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.Description,
|
||||||
req.Color,
|
req.Color,
|
||||||
req.Icon,
|
req.Icon,
|
||||||
req.AutoAssignRules,
|
|
||||||
req.ViewSettings,
|
req.ViewSettings,
|
||||||
treeJSON,
|
treeJSON,
|
||||||
)
|
)
|
||||||
@@ -230,7 +236,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bookCount := int32(0)
|
bookCount := int32(0)
|
||||||
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
|
isSmart := req.CollectionType == "smart" || treeJSON != nil
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||||
@@ -239,7 +245,6 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
||||||
"auto_assign_rules": collection.AutoAssignRules,
|
|
||||||
"view_settings": collection.ViewSettings,
|
"view_settings": collection.ViewSettings,
|
||||||
"book_count": bookCount,
|
"book_count": bookCount,
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
@@ -292,7 +297,6 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
|
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
MatchMode: col.RuleMatchMode,
|
|
||||||
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
||||||
})
|
})
|
||||||
if evalErr == nil {
|
if evalErr == nil {
|
||||||
@@ -315,7 +319,6 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
Description: utils.PgTextString(col.Description),
|
Description: utils.PgTextString(col.Description),
|
||||||
Color: utils.PgTextString(col.Color),
|
Color: utils.PgTextString(col.Color),
|
||||||
Icon: utils.PgTextString(col.Icon),
|
Icon: utils.PgTextString(col.Icon),
|
||||||
AutoAssignRules: col.AutoAssignRules,
|
|
||||||
CreatedAt: col.CreatedAt.Time.String(),
|
CreatedAt: col.CreatedAt.Time.String(),
|
||||||
BookCount: bookCount,
|
BookCount: bookCount,
|
||||||
IsSmart: isSmart,
|
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{
|
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
MatchMode: collection.RuleMatchMode,
|
|
||||||
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
||||||
})
|
})
|
||||||
if evalErr != nil {
|
if evalErr != nil {
|
||||||
@@ -485,9 +487,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
"description": utils.PgTextString(collection.Description),
|
"description": utils.PgTextString(collection.Description),
|
||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
|
||||||
"view_settings": viewSettings,
|
"view_settings": viewSettings,
|
||||||
"rule_match_mode": collection.RuleMatchMode,
|
|
||||||
"filter_tree": treeEnvelope(collection.FilterTree),
|
"filter_tree": treeEnvelope(collection.FilterTree),
|
||||||
"books": bookList,
|
"books": bookList,
|
||||||
"book_count": len(bookList),
|
"book_count": len(bookList),
|
||||||
@@ -539,9 +539,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
req.Description,
|
req.Description,
|
||||||
req.Color,
|
req.Color,
|
||||||
req.Icon,
|
req.Icon,
|
||||||
req.AutoAssignRules,
|
|
||||||
req.ViewSettings,
|
req.ViewSettings,
|
||||||
req.RuleMatchMode,
|
|
||||||
treeJSON,
|
treeJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -561,7 +559,6 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|||||||
"description": utils.PgTextString(collection.Description),
|
"description": utils.PgTextString(collection.Description),
|
||||||
"color": utils.PgTextString(collection.Color),
|
"color": utils.PgTextString(collection.Color),
|
||||||
"icon": utils.PgTextString(collection.Icon),
|
"icon": utils.PgTextString(collection.Icon),
|
||||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
|
||||||
"view_settings": json.RawMessage(collection.ViewSettings),
|
"view_settings": json.RawMessage(collection.ViewSettings),
|
||||||
"created_at": collection.CreatedAt.Time.String(),
|
"created_at": collection.CreatedAt.Time.String(),
|
||||||
}
|
}
|
||||||
@@ -624,9 +621,9 @@ func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
}
|
}
|
||||||
// Same owner gate as the sibling config mutations (SetMatchMode,
|
// Same owner gate as the sibling config mutation (ReorderItems) —
|
||||||
// ReorderItems) — my endpoint writes the row, so it must not let a
|
// my endpoint writes the row, so it must not let a non-owner flip
|
||||||
// non-owner flip someone else's chain order.
|
// someone else's chain order.
|
||||||
if u, ok := c.Get("user").(database.Users); ok {
|
if u, ok := c.Get("user").(database.Users); ok {
|
||||||
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes))
|
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes))
|
||||||
if err != nil || !owns {
|
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")
|
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
||||||
}
|
}
|
||||||
if services.IsCollectionSmart(collection) {
|
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
|
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
|
// 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 {
|
func treeEnvelope(t []byte) any {
|
||||||
if len(t) == 0 {
|
if len(t) == 0 {
|
||||||
return nil
|
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 —
|
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
|
||||||
// ids omitted keep their relative order appended after (the service never
|
// ids omitted keep their relative order appended after (the service never
|
||||||
// deletes membership).
|
// 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 {
|
func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
|
||||||
collectionID, err := uuid.Parse(c.Param("id"))
|
collectionID, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ func registerCollectionsRoutes(cfg *Config) {
|
|||||||
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
||||||
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
||||||
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
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):
|
// The chain's listening order lives ON the collection (plan §10 R2):
|
||||||
// merges into view_settings; the episode chain producer honors it.
|
// merges into view_settings; the episode chain producer honors it.
|
||||||
collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder)
|
collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder)
|
||||||
|
|||||||
@@ -569,7 +569,6 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
col, services.SmartEvalOptions{
|
col, services.SmartEvalOptions{
|
||||||
LibraryID: libScope,
|
LibraryID: libScope,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
MatchMode: col.RuleMatchMode,
|
|
||||||
ViewerUserID: func() pgtype.UUID {
|
ViewerUserID: func() pgtype.UUID {
|
||||||
uid, _ := uuid.Parse(user.ID)
|
uid, _ := uuid.Parse(user.ID)
|
||||||
return uuidToPGType(uid)
|
return uuidToPGType(uid)
|
||||||
@@ -702,9 +701,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The editor's vocabulary + prefill (item 3.4): the unified
|
// The editor's vocabulary + prefill (item 3.4): the unified
|
||||||
// builder in the collection's scope. A stored tree renders as-is;
|
// builder in the collection's scope. A stored tree renders
|
||||||
// a legacy rules array preloads as its CONVERTED tree
|
// as-is. (The legacy rules-array preload retired 2026-10-31
|
||||||
// (RulesToFilterTree) — the upgrade lands at the first Apply.
|
// with the rules-array storage — the tree is the only form.)
|
||||||
scope := "books"
|
scope := "books"
|
||||||
treeJSON := "{}"
|
treeJSON := "{}"
|
||||||
if len(collection.FilterTree) > 0 {
|
if len(collection.FilterTree) > 0 {
|
||||||
@@ -714,13 +713,6 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
treeJSON = string(b)
|
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))
|
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
|
||||||
if merr != nil {
|
if merr != nil {
|
||||||
|
|||||||
@@ -288,7 +288,6 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
|||||||
Icon: collection.Icon.String,
|
Icon: collection.Icon.String,
|
||||||
IsSystem: collection.IsSystemCollection.Bool,
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
IsSmart: isSmart,
|
IsSmart: isSmart,
|
||||||
MatchMode: collection.RuleMatchMode,
|
|
||||||
ChainOrder: chainOrder,
|
ChainOrder: chainOrder,
|
||||||
BookCount: bookCount,
|
BookCount: bookCount,
|
||||||
}
|
}
|
||||||
@@ -312,7 +311,6 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
|
|||||||
collection, services.SmartEvalOptions{
|
collection, services.SmartEvalOptions{
|
||||||
LibraryID: libScope,
|
LibraryID: libScope,
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
MatchMode: collection.RuleMatchMode,
|
|
||||||
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if evalErr != nil {
|
if evalErr != nil {
|
||||||
|
|||||||
@@ -341,7 +341,6 @@ func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *dat
|
|||||||
}
|
}
|
||||||
matches, err := colService.EvaluateSmartMembership(ctx, coll, services.SmartEvalOptions{
|
matches, err := colService.EvaluateSmartMembership(ctx, coll, services.SmartEvalOptions{
|
||||||
VisibleIDs: visibleIDs,
|
VisibleIDs: visibleIDs,
|
||||||
MatchMode: coll.RuleMatchMode,
|
|
||||||
ViewerUserID: userID,
|
ViewerUserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -32,42 +32,6 @@ type Rule struct {
|
|||||||
Enabled *bool `json:"enabled,omitempty"`
|
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
|
// RuleEvaluation represents the result of evaluating a rule
|
||||||
type RuleEvaluation struct {
|
type RuleEvaluation struct {
|
||||||
RuleID string `json:"rule_id"`
|
RuleID string `json:"rule_id"`
|
||||||
@@ -96,20 +60,10 @@ func NewCollectionService(db *database.Queries, search ...*SearchService) *Colle
|
|||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCollection creates a new collection. filterTree (item 3.2)
|
// CreateCollection creates a new collection. filterTree (item 3.2), when
|
||||||
// replaces autoAssignRules as the smart engine's source when present —
|
// present, makes it smart — the qf engine is the only membership source
|
||||||
// both may coexist on the row (tree wins at evaluation).
|
// (the legacy rules-array storage retired 2026-10-31).
|
||||||
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) {
|
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 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)
|
// Convert view settings to JSONB ([]byte)
|
||||||
var settingsJSON []byte
|
var settingsJSON []byte
|
||||||
if len(viewSettings) > 0 {
|
if len(viewSettings) > 0 {
|
||||||
@@ -126,7 +80,6 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
|
|||||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||||
AutoAssignRules: rulesJSON,
|
|
||||||
ViewSettings: settingsJSON,
|
ViewSettings: settingsJSON,
|
||||||
FilterTree: filterTree,
|
FilterTree: filterTree,
|
||||||
})
|
})
|
||||||
@@ -164,18 +117,13 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
|
|||||||
return collections, nil
|
return collections, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCollection updates a collection
|
// UpdateCollection rewrites the collection row. filterTree semantics:
|
||||||
// UpdateCollection rewrites the collection row. ruleMatchMode empty or
|
// nil = leave the stored tree untouched (forms don't send trees).
|
||||||
// invalid = keep the stored value (the edit form only sends the toggles
|
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
|
||||||
// it renders — plan item 2.4). filterTree semantics: nil = leave the
|
|
||||||
// stored tree untouched.
|
|
||||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string, filterTree []byte) (database.Collections, error) {
|
|
||||||
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
||||||
// them OVER the stored view_settings so provenance keys
|
// them OVER the stored view_settings so provenance keys
|
||||||
// (imported_from_series) and anything else on the row survive.
|
// (imported_from_series) and anything else on the row survive.
|
||||||
existingMode := RuleMatchAny
|
|
||||||
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
|
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
|
||||||
existingMode = existing.RuleMatchMode
|
|
||||||
if filterTree == nil {
|
if filterTree == nil {
|
||||||
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
|
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)
|
// Convert view settings to JSONB ([]byte)
|
||||||
var settingsJSON []byte
|
var settingsJSON []byte
|
||||||
if len(viewSettings) > 0 {
|
if len(viewSettings) > 0 {
|
||||||
@@ -231,9 +165,7 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
|||||||
Description: pgtype.Text{String: description, Valid: description != ""},
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
||||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||||
AutoAssignRules: rulesJSON,
|
|
||||||
ViewSettings: settingsJSON,
|
ViewSettings: settingsJSON,
|
||||||
RuleMatchMode: ruleMatchMode,
|
|
||||||
FilterTree: filterTree,
|
FilterTree: filterTree,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -316,162 +248,7 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID
|
|||||||
}
|
}
|
||||||
|
|
||||||
return collections, nil
|
return collections, nil
|
||||||
}
|
} // 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.
|
||||||
|
|
||||||
// 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.
|
|
||||||
type SmartEvalOptions struct {
|
type SmartEvalOptions struct {
|
||||||
// LibraryID, when valid, restricts matching to one library (the
|
// LibraryID, when valid, restricts matching to one library (the
|
||||||
// detail page's library switcher).
|
// detail page's library switcher).
|
||||||
@@ -479,10 +256,6 @@ type SmartEvalOptions struct {
|
|||||||
// VisibleIDs is the requesting user's visible-library set (family
|
// VisibleIDs is the requesting user's visible-library set (family
|
||||||
// lens). Empty means no visible-library filter.
|
// lens). Empty means no visible-library filter.
|
||||||
VisibleIDs []pgtype.UUID
|
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
|
// ViewerUserID scopes the candidate query's progress join — the
|
||||||
// played/unplayed rule is per-user. Zero UUID = no viewer (played
|
// played/unplayed rule is per-user. Zero UUID = no viewer (played
|
||||||
// rules then see "unplayed" for everything, honestly).
|
// rules then see "unplayed" for everything, honestly).
|
||||||
@@ -490,127 +263,18 @@ type SmartEvalOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsCollectionSmart is the ONE smart detection: a collection is smart
|
// IsCollectionSmart is the ONE smart detection: a collection is smart
|
||||||
// when it carries the unified filter tree (item 3.2) or a legacy rules
|
// when it carries the unified filter tree (item 3.2). Every surface
|
||||||
// array (item 2's shape). Every surface routes through this — rules
|
// routes through this. (The legacy rules-array branch retired
|
||||||
// presence alone misses tree collections.
|
// 2026-10-31 — the tree is the only smart storage.)
|
||||||
func IsCollectionSmart(coll database.Collections) bool {
|
func IsCollectionSmart(coll database.Collections) bool {
|
||||||
if len(coll.FilterTree) > 0 {
|
return len(coll.FilterTree) > 0
|
||||||
return true
|
|
||||||
}
|
|
||||||
var rules []Rule
|
|
||||||
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return len(rules) > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
||||||
// tree-bearing collections evaluate through the unified filter engine;
|
// tree-bearing collections evaluate through the unified filter engine.
|
||||||
// legacy rule arrays keep per-item evaluation until rewritten. One
|
|
||||||
// call site surface, two storage epochs.
|
|
||||||
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
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.evaluateTreeMembership(ctx, coll, opts)
|
||||||
}
|
}
|
||||||
return s.EvaluateSmartCollection(ctx, s.ParseRules(coll.AutoAssignRules), opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RulesToFilterTree converts a legacy rules array + its match mode
|
|
||||||
// into a qf tree so a collection upgrades to the unified engine at its
|
|
||||||
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
|
|
||||||
// upgrade at write. Episode rules map mechanically (feed→in-list,
|
|
||||||
// played/downloaded→booleans, published_within→published_date
|
|
||||||
// within_days); legacy operators without an engine equivalent
|
|
||||||
// (starts_with/ends_with) fall back to contains — documented, and the
|
|
||||||
// operator labels stay in the rules docs.
|
|
||||||
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
|
|
||||||
children := make([]FilterChild, 0, len(rules))
|
|
||||||
scope := ""
|
|
||||||
for _, r := range rules {
|
|
||||||
child, err := ruleToFilterChild(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if episodeOnlyFields[r.Field] {
|
|
||||||
scope = ScopeEpisodes
|
|
||||||
}
|
|
||||||
children = append(children, child)
|
|
||||||
}
|
|
||||||
if len(children) == 0 {
|
|
||||||
return nil, fmt.Errorf("no rules to convert")
|
|
||||||
}
|
|
||||||
mode := RuleMatchAny
|
|
||||||
if matchMode == RuleMatchAll {
|
|
||||||
mode = RuleMatchAll
|
|
||||||
}
|
|
||||||
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ruleToFilterChild(r Rule) (FilterChild, error) {
|
|
||||||
child := FilterChild{Field: r.Field}
|
|
||||||
switch r.Field {
|
|
||||||
case "feed":
|
|
||||||
// The rules format stores comma-separated feed IDs in one value.
|
|
||||||
child.Op = OpIn
|
|
||||||
child.Values = splitRuleValues(r.Value)
|
|
||||||
return child, nil
|
|
||||||
case "downloaded":
|
|
||||||
child.Op = isTrueFalse(r.Value == "true")
|
|
||||||
return child, nil
|
|
||||||
case "played":
|
|
||||||
child.Op = isTrueFalse(r.Value == "true")
|
|
||||||
return child, nil
|
|
||||||
case "published_within":
|
|
||||||
child.Field = "published_date"
|
|
||||||
child.Op = OpWithinDays
|
|
||||||
child.Values = []string{r.Value}
|
|
||||||
return child, nil
|
|
||||||
}
|
|
||||||
// Registry fields: legacy operators → engine operators.
|
|
||||||
switch r.Operator {
|
|
||||||
case "equals":
|
|
||||||
child.Op = OpIs
|
|
||||||
case "not_equals":
|
|
||||||
child.Op = OpIsNot
|
|
||||||
case "contains":
|
|
||||||
child.Op = OpContains
|
|
||||||
case "not_contains":
|
|
||||||
child.Op = OpNotContains
|
|
||||||
case "greater_than":
|
|
||||||
child.Op = OpAtLeast
|
|
||||||
case "less_than":
|
|
||||||
child.Op = OpAtMost
|
|
||||||
case "in":
|
|
||||||
child.Op = OpIn
|
|
||||||
child.Values = splitRuleValues(r.Value)
|
|
||||||
case "starts_with", "ends_with":
|
|
||||||
// No engine equivalent — contains is the honest nearest.
|
|
||||||
child.Op = OpContains
|
|
||||||
default:
|
|
||||||
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
|
|
||||||
}
|
|
||||||
child.Values = []string{r.Value}
|
|
||||||
return child, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isTrueFalse(b bool) string {
|
|
||||||
if b {
|
|
||||||
return OpIsTrue
|
|
||||||
}
|
|
||||||
return OpIsFalse
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitRuleValues(v string) []string {
|
|
||||||
parts := strings.Split(v, ",")
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p != "" {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
||||||
tree, err := ParseFilterTree(string(coll.FilterTree))
|
tree, err := ParseFilterTree(string(coll.FilterTree))
|
||||||
@@ -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 nil, errors.New("tree collections require the search service (constructor wiring)")
|
||||||
}
|
}
|
||||||
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
|
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
|
||||||
}
|
} // mediaItemMatchesAnyRule is the legacy OR path, kept for callers that// episodeFinished is THE canonical finished definition (same predicate
|
||||||
|
|
||||||
// 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
|
|
||||||
// as hide_finished): viewer progress percentage >= 0.999.
|
// as hide_finished): viewer progress percentage >= 0.999.
|
||||||
func episodeFinished(viewerPercentage float64) bool {
|
func episodeFinished(viewerPercentage float64) bool {
|
||||||
return viewerPercentage >= 0.999
|
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
|
// FieldMatches is THE single rule evaluator (the rules-builder preview
|
||||||
// delegates here too — one vocabulary, one semantics). Episode/user-state
|
// delegates here too — one vocabulary, one semantics). Episode/user-state
|
||||||
// fields (feed/played/downloaded/published_within) evaluate directly;
|
// fields (feed/played/downloaded/published_within) evaluate directly;
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEvaluateRules_Equality(t *testing.T) {
|
func TestEvaluateRules_Equality(t *testing.T) {
|
||||||
@@ -280,44 +277,7 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRulesToFilterTree(t *testing.T) {
|
// TestRulesToFilterTree retired 2026-10-31 with the function it tested:
|
||||||
t.Run("episode_rules_map_mechanically", func(t *testing.T) {
|
// rules-array storage is gone, the filter tree is the only smart
|
||||||
rules := []Rule{
|
// membership. (Tests of deleted behavior are deleted — the asymmetry
|
||||||
{ID: "1", Field: "feed", Operator: "in", Value: "feed-a, feed-b"},
|
// rule.)
|
||||||
{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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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,
|
// The query filters excluded rows in SQL and embeds the media row,
|
||||||
// so the items are MediaItems as-is.
|
// so the items are MediaItems as-is. (The legacy auto-assign branch
|
||||||
var manualNonExcluded []database.MediaItems
|
// 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 {
|
for _, row := range manualItems {
|
||||||
manualNonExcluded = append(manualNonExcluded, row.MediaItems)
|
items = append(items, row.MediaItems)
|
||||||
}
|
}
|
||||||
|
return items, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
||||||
|
|||||||
@@ -433,7 +433,6 @@ func (s *MediaScanner) upsertPlaylistCollection(ctx context.Context, entry Serie
|
|||||||
UserID: owner,
|
UserID: owner,
|
||||||
Name: entry.Name,
|
Name: entry.Name,
|
||||||
Description: pgtype.Text{String: description, Valid: true},
|
Description: pgtype.Text{String: description, Valid: true},
|
||||||
AutoAssignRules: nil, // jsonb NULL — an empty []byte encodes as bytea and 22P02s
|
|
||||||
ViewSettings: viewSettings,
|
ViewSettings: viewSettings,
|
||||||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs
|
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
-1
@@ -95,7 +95,6 @@ interface CollectionData {
|
|||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
auto_assign_rules?: unknown;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
book_count?: number;
|
book_count?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user