feat(podcasts): smart episode collections — ALL/ANY match modes + episode rule fields (item 2.4)

The daily briefing needs AND: today's rules engine was OR-only. The
match semantics become a per-collection column
(collections.rule_match_mode, 'any' legacy default | 'all'), flipped on
its own endpoint (PUT /api/collections/:id/match-mode, owner-only —
nothing else on the row changes) and surfaced in the rules page as an
ANY/ALL toggle with matching copy.

Episode rule fields join the vocabulary (plan item 2.4): feed (the
multi-select rides the new `in` operator — comma-separated feed ids),
played (the canonical finished predicate — viewer progress ≥ 0.999,
same as hide_finished), downloaded, published_within (N days), plus
title/description contains. The rules candidate query now carries the
viewer's progress (LEFT JOIN reading_progress) so played/unplayed is
per-user, and TestRules/preview speak the SAME evaluator: the handler's
duplicated field switch is deleted and delegates to
CollectionService.FieldMatches — one vocabulary, one semantics.

Legitimate semantics note: in ANY mode `played:false` matches unplayed
BOOKS too (everything with no progress row) — correct per-rule
behavior, and exactly why the briefing mode exists.

Backward compatibility: every legacy row defaults to 'any' (the old
OR), UpdateCollection keeps the stored mode when the form doesn't send
one, and the evaluator treats absent data honestly (no date → no
match; no progress → unplayed).

Pinned by TestPodcastSmartCollections: the AND-mode briefing
(feed∈{A} ∧ unplayed ∧ <7d = exactly the new episode), OR-mode
widening, owner-only flips, the preview honoring the mode, and
downloaded/contains rules. Also fixes a latent bug the new query
surfaced: UpdateCollectionViewSettings referenced a nonexistent
updated_at column (never exercised until now — the new sibling query
was written without it).
This commit is contained in:
John O'Keefe committed 2026-10-30 16:54:36 -04:00
1 parent f68fb19afb
commit abb7c84833
17 files changed
+686 -196

No files matched your search

+129
View File
@@ -765,6 +765,135 @@ func TestPodcastEpisodeCollections(t *testing.T) {
}) })
} }
// ---- item 2.4: smart episode collections — episode rule fields +
// the ANY/ALL match modes (the Daily Fiction building blocks) ----
func TestPodcastSmartCollections(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
adminToken := setup.Token
lib, _ := createPodcastsLibrary(t, setup, "test SmartColl Podcasts")
marker := fmt.Sprintf("smartcoll%d", time.Now().UnixNano()%100000)
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "SmartColl Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "SmartColl Feed B " + marker,
})
require.NoError(t, err)
aNew := upsertTestEpisode(t, setup, feedA, marker+"-new", "SmartColl A New", 1)
aOld := upsertTestEpisode(t, setup, feedA, marker+"-old", "SmartColl A Old", 2)
aPlayed := upsertTestEpisode(t, setup, feedA, marker+"-played", "SmartColl A Played", 3)
bEp := upsertTestEpisode(t, setup, feedB, marker+"-b", "SmartColl B Ep", 1)
// Seed the episode facts the rules read: publication dates + the
// played marker (viewer progress ≥ 0.999 = the canonical finished
// definition, same predicate as hide_finished).
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id IN ($1, $2, $3)`, aNew.ID, aPlayed.ID, bEp.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE - 30 WHERE id = $1`, aOld.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
INSERT INTO reading_progress (media_item_id, user_id, percentage)
VALUES ($1, $2, 1.0) ON CONFLICT (media_item_id, user_id)
DO UPDATE SET percentage = 1.0`, aPlayed.ID, regUUID)
subscribeRaw(t, setup, regUUID, feedA.ID)
feedAID := uuid.UUID(feedA.ID.Bytes).String()
// Smart collection with the briefing's AND shape, created through
// the real API.
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Daily Fiction `+marker+`","collection_type":"smart","auto_assign_rules":[
{"field":"feed","operator":"in","value":"`+feedAID+`"},
{"field":"played","operator":"equals","value":"false"},
{"field":"published_within","operator":"greater_than","value":"7"}
]}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
collID := coll.ID
// ALL mode (AND): the daily briefing — feed ∈ {A} ∧ unplayed ∧ <7d.
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
t.Run("all_mode_is_the_briefing", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
Books []struct{ Title string `json:"title"` } `json:"books"`
IsSmart bool `json:"is_smart"`
MatchAll string `json:"rule_match_mode"`
}
require.NoError(t, json.Unmarshal(body, &out))
require.True(t, out.IsSmart)
titles := map[string]bool{}
for _, b := range out.Books {
titles[b.Title] = true
}
assert.Equal(t, "all", out.MatchAll, "the match mode round-trips")
assert.True(t, titles["SmartColl A New"], "feed-A + unplayed + <7d matches")
assert.Len(t, out.Books, 1, "exactly the briefing set — aOld fails <7d, aPlayed fails unplayed, bEp is RLS-dropped")
})
t.Run("any_mode_widens_to_or", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"any"}`)
require.Equal(t, http.StatusOK, code)
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), "SmartColl A Old", "OR: aOld matches unplayed even though it's 30d old")
assert.Contains(t, string(body), "SmartColl A New")
assert.Contains(t, string(body), "SmartColl A Played", "OR: aPlayed matches via the feed rule alone")
assert.NotContains(t, string(body), "SmartColl B Ep", "unsubscribed feed's episode never reaches evaluation (RLS)")
})
t.Run("match_mode_is_owner_only", func(t *testing.T) {
code, body := podcastAuthed(t, setup, adminToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
assert.Equal(t, http.StatusForbidden, code, "only the owner flips the mode: %s", string(body))
})
t.Run("test_rules_preview_honors_mode", func(t *testing.T) {
// The rules page preview speaks the same evaluator: all-mode
// requires every rule (aNew passes feed∧unplayed∧<7d; aOld
// fails published_within).
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections/test-rules",
`{"match_mode":"all","rules":[
{"field":"feed","operator":"in","value":"`+feedAID+`"},
{"field":"played","operator":"equals","value":"false"},
{"field":"published_within","operator":"greater_than","value":"7"}
]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
assert.Contains(t, string(body), "SmartColl A New")
assert.NotContains(t, string(body), "SmartColl A Old")
})
t.Run("downloaded_and_contains_rules", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections/test-rules",
`{"match_mode":"all","rules":[
{"field":"downloaded","operator":"equals","value":"true"},
{"field":"title","operator":"contains","value":"SmartColl A"}
]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
assert.Contains(t, string(body), "SmartColl A New")
assert.Contains(t, string(body), "SmartColl A Played", "played but downloaded — the downloaded rule alone doesn't exclude it")
assert.NotContains(t, string(body), "SmartColl B Ep")
})
}
// ---- download worker: state machine, idempotency, file layout ---- // ---- download worker: state machine, idempotency, file layout ----
func TestPodcastDownloadWorker(t *testing.T) { func TestPodcastDownloadWorker(t *testing.T) {
+10
View File
@@ -1342,6 +1342,11 @@ CREATE TABLE IF NOT EXISTS collections (
color VARCHAR(7), color VARCHAR(7),
icon VARCHAR(50), icon VARCHAR(50),
auto_assign_rules JSONB, 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',
view_settings JSONB, view_settings JSONB,
show_on_dashboard BOOLEAN DEFAULT false, show_on_dashboard BOOLEAN DEFAULT false,
query_type TEXT DEFAULT 'filter', query_type TEXT DEFAULT 'filter',
@@ -1357,6 +1362,11 @@ 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
-- default) | 'all' (AND — the daily briefing). Idempotent; NOT NULL
-- DEFAULT covers existing rows.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS rule_match_mode TEXT NOT NULL DEFAULT 'any';
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Index for dashboard queries -- Index for dashboard queries
+1
View File
@@ -35,6 +35,7 @@ type Collections struct {
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"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
ViewSettings []byte `db:"view_settings" json:"view_settings"` ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
QueryType pgtype.Text `db:"query_type" json:"query_type"` QueryType pgtype.Text `db:"query_type" json:"query_type"`
+7 -2
View File
@@ -603,8 +603,10 @@ type Querier interface {
// membership comes from the candidate book set instead of collection_items. // membership comes from the candidate book set instead of collection_items.
// The set is scoped to the requesting user's visible libraries (family // 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 // lens enforced at the source) and live items only — the same filters the
// static collection paths apply. // static collection paths apply. The viewer's progress rides along (plan
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) // 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).
@@ -826,6 +828,9 @@ 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 // Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
// of the JSONB without touching metadata stale clients did not send). // of the JSONB without touching metadata stale clients did not send).
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
+157 -113
View File
@@ -673,7 +673,7 @@ const CreateCollection = `-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard) INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
` `
type CreateCollectionParams struct { type CreateCollectionParams struct {
@@ -709,6 +709,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -2252,7 +2253,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
const CreateSystemCollection = `-- name: CreateSystemCollection :one const CreateSystemCollection = `-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
` `
type CreateSystemCollectionParams struct { type CreateSystemCollectionParams struct {
@@ -2286,6 +2287,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -4383,7 +4385,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, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1 SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
` `
// Get collection // Get collection
@@ -4398,6 +4400,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -4410,7 +4413,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, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
@@ -4436,6 +4439,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -4448,7 +4452,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, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
` `
@@ -4469,6 +4473,7 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -4961,7 +4966,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.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at, c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.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
@@ -4977,6 +4982,7 @@ type GetCollectionWithBookCountRow struct {
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"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
ViewSettings []byte `db:"view_settings" json:"view_settings"` ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
QueryType pgtype.Text `db:"query_type" json:"query_type"` QueryType pgtype.Text `db:"query_type" json:"query_type"`
@@ -4999,6 +5005,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -5012,7 +5019,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, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
@@ -5033,6 +5040,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -5052,7 +5060,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.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.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
@@ -5077,6 +5085,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -10781,7 +10790,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, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
@@ -10808,6 +10817,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -10820,7 +10830,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, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, 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
@@ -10846,6 +10856,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -11551,7 +11562,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.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.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
@@ -11579,6 +11590,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -12339,124 +12351,134 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp
} }
const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many 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 FROM media_items mi 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
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::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.archived_at IS NULL
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
ORDER BY mi.title ASC ORDER BY mi.title ASC
` `
type GetVisibleLiveMediaForRulesParams struct { type GetVisibleLiveMediaForRulesParams struct {
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` 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 // Smart ("dynamic") collections: rules are evaluated LIVE at render, so
// membership comes from the candidate book set instead of collection_items. // membership comes from the candidate book set instead of collection_items.
// The set is scoped to the requesting user's visible libraries (family // 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 // lens enforced at the source) and live items only — the same filters the
// static collection paths apply. // static collection paths apply. The viewer's progress rides along (plan
func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) { // item 2.4): the played/unplayed episode rule evaluates per-user, exactly
rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.LibraryID, arg.VisibleLibraryIds) // 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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
items := []MediaItems{} items := []GetVisibleLiveMediaForRulesRow{}
for rows.Next() { for rows.Next() {
var i MediaItems var i GetVisibleLiveMediaForRulesRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.MediaItems.ID,
&i.LibraryID, &i.MediaItems.LibraryID,
&i.Title, &i.MediaItems.Title,
&i.Subtitle, &i.MediaItems.Subtitle,
&i.Author, &i.MediaItems.Author,
&i.Isbn, &i.MediaItems.Isbn,
&i.Description, &i.MediaItems.Description,
&i.FilePath, &i.MediaItems.FilePath,
&i.FileSize, &i.MediaItems.FileSize,
&i.MimeType, &i.MediaItems.MimeType,
&i.CoverImagePath, &i.MediaItems.CoverImagePath,
&i.Series, &i.MediaItems.Series,
&i.SeriesNumber, &i.MediaItems.SeriesNumber,
&i.Tags, &i.MediaItems.Tags,
&i.Narrators, &i.MediaItems.Narrators,
&i.Authors, &i.MediaItems.Authors,
&i.AuthorsSearch, &i.MediaItems.AuthorsSearch,
&i.Publishers, &i.MediaItems.Publishers,
&i.PublishersSearch, &i.MediaItems.PublishersSearch,
&i.Abridged, &i.MediaItems.Abridged,
&i.Explicit, &i.MediaItems.Explicit,
&i.Asin, &i.MediaItems.Asin,
&i.DatePublished, &i.MediaItems.DatePublished,
&i.Publisher, &i.MediaItems.Publisher,
&i.Contributors, &i.MediaItems.Contributors,
&i.Language, &i.MediaItems.Language,
&i.Edition, &i.MediaItems.Edition,
&i.PageCount, &i.MediaItems.PageCount,
&i.Genre, &i.MediaItems.Genre,
&i.CopyrightYear, &i.MediaItems.CopyrightYear,
&i.GoodreadsID, &i.MediaItems.GoodreadsID,
&i.OpenlibraryID, &i.MediaItems.OpenlibraryID,
&i.GoogleBooksID, &i.MediaItems.GoogleBooksID,
&i.AddedByAdminID, &i.MediaItems.AddedByAdminID,
&i.CreatedAt, &i.MediaItems.CreatedAt,
&i.ImportedAt, &i.MediaItems.ImportedAt,
&i.UpdatedAt, &i.MediaItems.UpdatedAt,
&i.FormatGroup, &i.MediaItems.FormatGroup,
&i.FormatMimetype, &i.MediaItems.FormatMimetype,
&i.IsReflowable, &i.MediaItems.IsReflowable,
&i.HasFixedLayout, &i.MediaItems.HasFixedLayout,
&i.TotalCharacters, &i.MediaItems.TotalCharacters,
&i.ChapterCount, &i.MediaItems.ChapterCount,
&i.EntitlementID, &i.MediaItems.EntitlementID,
&i.RevisionNumber, &i.MediaItems.RevisionNumber,
&i.KoboContentID, &i.MediaItems.KoboContentID,
&i.KoboMetadata, &i.MediaItems.KoboMetadata,
&i.MangaType, &i.MediaItems.MangaType,
&i.ReadingDirection, &i.MediaItems.ReadingDirection,
&i.SeriesCount, &i.MediaItems.SeriesCount,
&i.Volume, &i.MediaItems.Volume,
&i.Imprint, &i.MediaItems.Imprint,
&i.AgeRating, &i.MediaItems.AgeRating,
&i.WebUrl, &i.MediaItems.WebUrl,
&i.StoryArc, &i.MediaItems.StoryArc,
&i.IsBlackAndWhite, &i.MediaItems.IsBlackAndWhite,
&i.MetadataNotes, &i.MediaItems.MetadataNotes,
&i.CommunityRating, &i.MediaItems.CommunityRating,
&i.AlternateInfo, &i.MediaItems.AlternateInfo,
&i.ScanInformation, &i.MediaItems.ScanInformation,
&i.Summary, &i.MediaItems.Summary,
&i.MetadataOverrides, &i.MediaItems.MetadataOverrides,
&i.MissingScanCount, &i.MediaItems.MissingScanCount,
&i.ArchivedAt, &i.MediaItems.ArchivedAt,
&i.ChapterMetadata, &i.MediaItems.ChapterMetadata,
&i.LibraryTypeName, &i.MediaItems.LibraryTypeName,
&i.AudioMetadata, &i.MediaItems.AudioMetadata,
&i.TagsSearch, &i.MediaItems.TagsSearch,
&i.ContributorsSearch, &i.MediaItems.ContributorsSearch,
&i.NarratorsSearch, &i.MediaItems.NarratorsSearch,
&i.FileSha256, &i.MediaItems.FileSha256,
&i.OpfIdentifier, &i.MediaItems.OpfIdentifier,
&i.OpfUuid, &i.MediaItems.OpfUuid,
&i.HashConfidence, &i.MediaItems.HashConfidence,
&i.Source, &i.MediaItems.Source,
&i.SourceFeedID, &i.MediaItems.SourceFeedID,
&i.EpisodeGuid, &i.MediaItems.EpisodeGuid,
&i.FeedPosition, &i.MediaItems.FeedPosition,
&i.SeasonNumber, &i.MediaItems.SeasonNumber,
&i.EpisodeNumber, &i.MediaItems.EpisodeNumber,
&i.EnclosureUrl, &i.MediaItems.EnclosureUrl,
&i.DeclaredDurationSeconds, &i.MediaItems.DeclaredDurationSeconds,
&i.TranscriptUrl, &i.MediaItems.TranscriptUrl,
&i.ChaptersUrl, &i.MediaItems.ChaptersUrl,
&i.Hosts, &i.MediaItems.Hosts,
&i.Guests, &i.MediaItems.Guests,
&i.DownloadState, &i.MediaItems.DownloadState,
&i.DownloadError, &i.MediaItems.DownloadError,
&i.DownloadAttempts, &i.MediaItems.DownloadAttempts,
&i.DownloadQueuePos, &i.MediaItems.DownloadQueuePos,
&i.ViewerPercentage,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -16448,7 +16470,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, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
` `
type RestoreSystemCollectionRowParams struct { type RestoreSystemCollectionRowParams struct {
@@ -16480,6 +16502,7 @@ func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSys
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -18193,9 +18216,10 @@ SET
color = $4, color = $4,
icon = $5, icon = $5,
auto_assign_rules = $6, auto_assign_rules = $6,
view_settings = $7 view_settings = $7,
rule_match_mode = $8
WHERE id = $1 WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
` `
type UpdateCollectionParams struct { type UpdateCollectionParams struct {
@@ -18206,6 +18230,7 @@ type UpdateCollectionParams struct {
Icon pgtype.Text `db:"icon" json:"icon"` Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
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"`
} }
// Update collection // Update collection
@@ -18218,6 +18243,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
arg.Icon, arg.Icon,
arg.AutoAssignRules, arg.AutoAssignRules,
arg.ViewSettings, arg.ViewSettings,
arg.RuleMatchMode,
) )
var i Collections var i Collections
err := row.Scan( err := row.Scan(
@@ -18228,6 +18254,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
@@ -18239,8 +18266,24 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
return i, err 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 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, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
` `
type UpdateCollectionRulesJSONParams struct { type UpdateCollectionRulesJSONParams struct {
@@ -18261,6 +18304,7 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle
&i.Color, &i.Color,
&i.Icon, &i.Icon,
&i.AutoAssignRules, &i.AutoAssignRules,
&i.RuleMatchMode,
&i.ViewSettings, &i.ViewSettings,
&i.ShowOnDashboard, &i.ShowOnDashboard,
&i.QueryType, &i.QueryType,
+12 -3
View File
@@ -3498,6 +3498,11 @@ RETURNING *;
-- name: UpdateCollectionViewSettings :exec -- name: UpdateCollectionViewSettings :exec
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1; UPDATE collections SET view_settings = $2, updated_at = NOW() 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;
@@ -3525,7 +3530,8 @@ SET
color = $4, color = $4,
icon = $5, icon = $5,
auto_assign_rules = $6, auto_assign_rules = $6,
view_settings = $7 view_settings = $7,
rule_match_mode = $8
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
@@ -4144,9 +4150,12 @@ SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
-- membership comes from the candidate book set instead of collection_items. -- membership comes from the candidate book set instead of collection_items.
-- The set is scoped to the requesting user's visible libraries (family -- 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 -- lens enforced at the source) and live items only — the same filters the
-- static collection paths apply. -- 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 -- name: GetVisibleLiveMediaForRules :many
SELECT mi.* FROM media_items mi 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) 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 (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.archived_at IS NULL
+86 -5
View File
@@ -47,6 +47,9 @@ type CreateCollectionRequest struct {
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"`
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"`
@@ -219,6 +222,8 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
LibraryID: libUUID, LibraryID: libUUID,
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: col.RuleMatchMode,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
}) })
if evalErr == nil { if evalErr == nil {
bookCount = len(matches) bookCount = len(matches)
@@ -297,6 +302,8 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
LibraryID: libUUID, LibraryID: libUUID,
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: collection.RuleMatchMode,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
}) })
if evalErr != nil { if evalErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
@@ -411,6 +418,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
"icon": utils.PgTextString(collection.Icon), "icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules), "auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": viewSettings, "view_settings": viewSettings,
"rule_match_mode": collection.RuleMatchMode,
"books": bookList, "books": bookList,
"book_count": len(bookList), "book_count": len(bookList),
"is_smart": isSmart, "is_smart": isSmart,
@@ -459,6 +467,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.Icon, req.Icon,
req.AutoAssignRules, req.AutoAssignRules,
req.ViewSettings, req.ViewSettings,
req.RuleMatchMode,
) )
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -974,6 +983,9 @@ func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
type TestRulesRequest struct { type TestRulesRequest struct {
Rules []map[string]interface{} `json:"rules" validate:"required"` Rules []map[string]interface{} `json:"rules" validate:"required"`
// MatchMode previews the collection's rule_match_mode: "any" (OR,
// default) or "all" (AND — the daily briefing). Empty = any.
MatchMode string `json:"match_mode"`
} }
type BookMatch struct { type BookMatch struct {
@@ -1004,18 +1016,26 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error {
if len(visibleIDs) > 0 { if len(visibleIDs) > 0 {
visibleArg = visibleIDs visibleArg = visibleIDs
} }
viewerID := pgtype.UUID{}
if u, ok := c.Get("user").(database.Users); ok {
viewerID = u.ID
}
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{ mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
LibraryID: pgtype.UUID{}, LibraryID: pgtype.UUID{},
VisibleLibraryIds: visibleArg, VisibleLibraryIds: visibleArg,
ViewerUserID: viewerID,
}) })
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
} }
matchAll := req.MatchMode == services.RuleMatchAll
var matches []BookMatch var matches []BookMatch
for _, item := range mediaItems { for _, row := range mediaItems {
matchReason := h.checkRulesAgainstBook(item, req.Rules) matchReason := h.checkRulesAgainstBook(row.MediaItems, row.ViewerPercentage, req.Rules, matchAll)
if matchReason != "" { if matchReason != "" {
item := row.MediaItems
author := "" author := ""
if item.Author.Valid { if item.Author.Valid {
author = item.Author.String author = item.Author.String
@@ -1037,13 +1057,34 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error {
}) })
} }
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, rules []map[string]interface{}) string { // checkRulesAgainstBook evaluates the candidate against the ruleset via
// the SERVICE evaluator (one vocabulary, one semantics — the handler
// keeps only the match-reason display). matchAll=true (the collection's
// "all" mode) requires every rule to hit.
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, viewerPercentage float64, rules []map[string]interface{}, matchAll bool) string {
service := h.collectionService
if matchAll {
matched := 0
for _, rule := range rules {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string)
if !service.FieldMatches(item, viewerPercentage, field, operator, value) {
return ""
}
matched++
}
if matched > 0 {
return fmt.Sprintf("Matched all %d rules", matched)
}
return ""
}
for _, rule := range rules { for _, rule := range rules {
field, _ := rule["field"].(string) field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string) operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string) value, _ := rule["value"].(string)
if h.evaluateRule(item, field, operator, value) { if service.FieldMatches(item, viewerPercentage, field, operator, value) {
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value) return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
} }
} }
@@ -1268,9 +1309,12 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
} }
// Book-scope preview (the rules page remains books-first until the
// filter-unification pass); no viewer progress loaded here, so
// played/unplayed rules preview at the no-progress default.
var matchedItems []database.MediaItems var matchedItems []database.MediaItems
for _, item := range allItems { for _, item := range allItems {
evaluations := h.collectionService.EvaluateRules(item, req.Rules) evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
for _, eval := range evaluations { for _, eval := range evaluations {
if eval.Matches { if eval.Matches {
matchedItems = append(matchedItems, item) matchedItems = append(matchedItems, item)
@@ -1320,6 +1364,43 @@ 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 {
+1
View File
@@ -17,6 +17,7 @@ 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)
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook) collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks) collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks) collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
+5
View File
@@ -569,6 +569,11 @@ func registerFrontendRoutes(cfg *Config) {
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{ colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope, LibraryID: libScope,
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: col.RuleMatchMode,
ViewerUserID: func() pgtype.UUID {
uid, _ := uuid.Parse(user.ID)
return uuidToPGType(uid)
}(),
}) })
if evalErr == nil { if evalErr == nil {
bookCount = len(matches) bookCount = len(matches)
+6 -1
View File
@@ -278,6 +278,7 @@ 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,
BookCount: bookCount, BookCount: bookCount,
} }
} }
@@ -293,11 +294,15 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
if isSmart { if isSmart {
// Smart (dynamic) collection: membership is evaluated live — // Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches. // visible libraries, live items only, rules applied under the
// collection's match mode (item 2.4: any|all), with the
// viewer's progress feeding played/unplayed rules.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{ colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope, LibraryID: libScope,
VisibleIDs: visibleIDs, VisibleIDs: visibleIDs,
MatchMode: collection.RuleMatchMode,
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
}) })
if evalErr != nil { if evalErr != nil {
return nil, isSmart, "books_load_error" return nil, isSmart, "books_load_error"
+130 -48
View File
@@ -6,7 +6,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -32,14 +34,32 @@ type Rule struct {
// Known rule fields and operators — the rules builder UI and the // Known rule fields and operators — the rules builder UI and the
// persisted JSONB documents must agree with this vocabulary. // 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{ var ruleFields = map[string]bool{
"genre": true, "series": true, "author": true, "language": true, "genre": true, "series": true, "author": true, "language": true,
"publisher": true, "copyright_year": true, "tags": 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{ var ruleOperators = map[string]bool{
"equals": true, "not_equals": true, "contains": true, "not_contains": true, "equals": true, "not_equals": true, "contains": true, "not_contains": true,
"starts_with": true, "ends_with": true, "greater_than": true, "less_than": 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 // RuleValidationError reports a rule payload the evaluator could never
@@ -133,13 +153,17 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
} }
// UpdateCollection updates a collection // UpdateCollection updates a collection
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) { // UpdateCollection rewrites the collection row. ruleMatchMode empty or
// invalid = keep the stored value (the edit form only sends the toggles
// it renders — plan item 2.4).
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string) (database.Collections, error) {
// 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.
if len(viewSettings) > 0 { existingMode := RuleMatchAny
existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}) if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
if err == nil && len(existing.ViewSettings) > 0 { existingMode = existing.RuleMatchMode
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
var stored map[string]any var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil { if json.Unmarshal(existing.ViewSettings, &stored) == nil {
merged := stored merged := stored
@@ -150,6 +174,9 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
} }
} }
} }
if !ValidRuleMatchMode(ruleMatchMode) {
ruleMatchMode = existingMode
}
// Convert rules to JSONB ([]byte) // Convert rules to JSONB ([]byte)
var rulesJSON []byte var rulesJSON []byte
@@ -179,6 +206,7 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
Icon: pgtype.Text{String: icon, Valid: icon != ""}, Icon: pgtype.Text{String: icon, Valid: icon != ""},
AutoAssignRules: rulesJSON, AutoAssignRules: rulesJSON,
ViewSettings: settingsJSON, ViewSettings: settingsJSON,
RuleMatchMode: ruleMatchMode,
}) })
if err != nil { if err != nil {
@@ -423,6 +451,14 @@ 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
// played/unplayed rule is per-user. Zero UUID = no viewer (played
// rules then see "unplayed" for everything, honestly).
ViewerUserID pgtype.UUID
} }
// EvaluateSmartCollection resolves a smart collection's membership LIVE: // EvaluateSmartCollection resolves a smart collection's membership LIVE:
@@ -441,36 +477,112 @@ func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules [
items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{ items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{
LibraryID: opts.LibraryID, LibraryID: opts.LibraryID,
VisibleLibraryIds: visibleArg, VisibleLibraryIds: visibleArg,
ViewerUserID: opts.ViewerUserID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load candidate books: %w", err) 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)) matches := make([]database.MediaItems, 0, len(items))
for _, item := range items { for _, row := range items {
if s.mediaItemMatchesAnyRule(item, enabled) { if s.mediaItemMatches(mode, row.MediaItems, row.ViewerPercentage, enabled) {
matches = append(matches, item) matches = append(matches, row.MediaItems)
} }
} }
return matches, nil return matches, nil
} }
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool { // 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 { for _, rule := range rules {
if s.mediaItemMatchesRule(item, rule) { if !s.mediaItemMatchesRule(item, viewerPercentage, rule) {
return false
}
}
return true
}
for _, rule := range rules {
if s.mediaItemMatchesRule(item, viewerPercentage, rule) {
return true return true
} }
} }
return false return false
} }
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule Rule) bool { // 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.
func episodeFinished(viewerPercentage float64) bool {
return viewerPercentage >= 0.999
}
// mediaItemMatchesRule evaluates one rule against one item.
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, viewerPercentage float64, rule Rule) bool {
if rule.Enabled != nil && !*rule.Enabled { if rule.Enabled != nil && !*rule.Enabled {
return false return false
} }
return s.FieldMatches(item, viewerPercentage, rule.Field, rule.Operator, rule.Value)
}
// FieldMatches is THE single rule evaluator (the rules-builder preview
// delegates here too — one vocabulary, one semantics). Episode/user-state
// fields (feed/played/downloaded/published_within) evaluate directly;
// everything else extracts a string and hits the generic comparator.
func (s *CollectionService) FieldMatches(item database.MediaItems, viewerPercentage float64, field, operator, value string) bool {
switch field {
case "feed":
if !item.SourceFeedID.Valid {
return false
}
feedID := uuid.UUID(item.SourceFeedID.Bytes).String()
for _, raw := range strings.Split(value, ",") {
if strings.TrimSpace(raw) == feedID {
return true
}
}
return false
case "played":
wantPlayed := value == "true"
return episodeFinished(viewerPercentage) == wantPlayed
case "downloaded":
wantDownloaded := value == "true"
return (item.DownloadState == "downloaded") == wantDownloaded
case "published_within":
if !item.DatePublished.Valid {
return false
}
days, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || days < 0 {
return false
}
cutoff := time.Now().UTC().AddDate(0, 0, -days)
return item.DatePublished.Time.After(cutoff)
}
var fieldValue string var fieldValue string
switch rule.Field { switch field {
case "title":
fieldValue = item.Title
case "description":
if item.Description.Valid {
fieldValue = item.Description.String
}
case "genre": case "genre":
if item.Genre.Valid { if item.Genre.Valid {
fieldValue = item.Genre.String fieldValue = item.Genre.String
@@ -501,11 +613,14 @@ func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule
} }
} }
return s.evaluateRule(fieldValue, rule.Operator, rule.Value) return s.evaluateRule(fieldValue, operator, value)
} }
// EvaluateRules evaluates auto-assign rules for a media item // EvaluateRules evaluates auto-assign rules for a media item — the
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules []Rule) []RuleEvaluation { // rules-builder preview's per-rule view. viewerPercentage is the
// requesting user's progress for this item (0 = none), so played /
// unplayed rules preview honestly (plan item 2.4).
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, viewerPercentage float64, rules []Rule) []RuleEvaluation {
var evaluations []RuleEvaluation var evaluations []RuleEvaluation
// Sort rules by priority (higher priority first) // Sort rules by priority (higher priority first)
@@ -527,40 +642,7 @@ func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules [
} }
eval := RuleEvaluation{ eval := RuleEvaluation{
RuleID: rule.ID, RuleID: rule.ID,
Matches: false, Matches: s.FieldMatches(mediaItem, viewerPercentage, rule.Field, rule.Operator, rule.Value),
}
switch rule.Field {
case "genre":
if mediaItem.Genre.Valid {
eval.Matches = s.evaluateRule(mediaItem.Genre.String, rule.Operator, rule.Value)
}
case "series":
if mediaItem.Series.Valid {
eval.Matches = s.evaluateRule(mediaItem.Series.String, rule.Operator, rule.Value)
}
case "author":
if mediaItem.Author.Valid {
eval.Matches = s.evaluateRule(mediaItem.Author.String, rule.Operator, rule.Value)
}
case "language":
if mediaItem.Language.Valid {
eval.Matches = s.evaluateRule(mediaItem.Language.String, rule.Operator, rule.Value)
}
case "publisher":
if mediaItem.Publisher.Valid {
eval.Matches = s.evaluateRule(mediaItem.Publisher.String, rule.Operator, rule.Value)
}
case "copyright_year":
if mediaItem.CopyrightYear.Valid {
yearStr := fmt.Sprintf("%d", mediaItem.CopyrightYear.Int32)
eval.Matches = s.evaluateRule(yearStr, rule.Operator, rule.Value)
}
case "tags":
if len(mediaItem.Tags) > 0 {
tagsStr := strings.Join(mediaItem.Tags, ", ")
eval.Matches = s.evaluateRule(tagsStr, rule.Operator, rule.Value)
}
} }
// Calculate confidence based on rule type and priority // Calculate confidence based on rule type and priority
+8 -8
View File
@@ -24,7 +24,7 @@ func TestEvaluateRules_Equality(t *testing.T) {
}, },
} }
evaluations := service.EvaluateRules(mediaItem, rules) evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 { if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -56,7 +56,7 @@ func TestEvaluateRules_NoMatch(t *testing.T) {
}, },
} }
evaluations := service.EvaluateRules(mediaItem, rules) evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 { if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -88,7 +88,7 @@ func TestEvaluateRules_Contains(t *testing.T) {
}, },
} }
evaluations := service.EvaluateRules(mediaItem, rules) evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 { if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -128,7 +128,7 @@ func TestEvaluateRules_PriorityOrder(t *testing.T) {
}, },
} }
evaluations := service.EvaluateRules(mediaItem, rules) evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 2 { if len(evaluations) != 2 {
t.Fatalf("expected 2 evaluations, got %d", len(evaluations)) t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
@@ -164,7 +164,7 @@ func TestEvaluateRules_CopyrightYear(t *testing.T) {
}, },
} }
evaluations := service.EvaluateRules(mediaItem, rules) evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 { if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -190,7 +190,7 @@ func TestEvaluateRules_MissingField(t *testing.T) {
}, },
} }
evaluations := service.EvaluateRules(mediaItem, rules) evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 { if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -218,7 +218,7 @@ func TestEvaluateRules_CaseInsensitive(t *testing.T) {
}, },
} }
evaluations := service.EvaluateRules(mediaItem, rules) evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 { if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -253,7 +253,7 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
}, },
} }
evaluations := service.EvaluateRules(mediaItem, rules) evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 2 { if len(evaluations) != 2 {
t.Fatalf("expected 2 evaluations, got %d", len(evaluations)) t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
+4 -1
View File
@@ -318,7 +318,10 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
continue continue
} }
evaluations := s.collectionService.EvaluateRules(item, rules) // 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 { for _, eval := range evaluations {
if eval.Matches { if eval.Matches {
autoItems = append(autoItems, item) autoItems = append(autoItems, item)
+30 -3
View File
@@ -10,7 +10,7 @@ templ CollectionRules(user User, collection CollectionData) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class={ "theme-" + user.Theme } x-data="collectionRules" x-init={ "initCollectionRules('" + collection.ID + "')" }> <body class={ "theme-" + user.Theme } x-data="collectionRules" x-init={ "initCollectionRules('" + collection.ID + "', '" + collection.MatchMode + "')" }>
@Header(user, "/collections") @Header(user, "/collections")
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
@@ -30,8 +30,26 @@ templ CollectionRules(user User, collection CollectionData) {
</div> </div>
</div> </div>
<div class="mb-8"> <div class="mb-8">
<h2 class="text-2xl font-bold mb-4" style="color: var(--text-primary)">Rules</h2> <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<p class="text-sm mb-4" style="color: var(--text-secondary)">A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.</p> <h2 class="text-2xl font-bold" style="color: var(--text-primary)">Rules</h2>
<!-- Match semantics (plan item 2.4): ANY = OR (legacy),
ALL = AND (the daily briefing). Own channel — writes
only rule_match_mode. -->
<div class="flex items-center gap-2">
<span class="text-sm" style="color: var(--text-secondary)">Match</span>
<button type="button"
@click="setMatchMode('any')"
:class={ "matchMode === 'any' ? 'btn btn-secondary' : 'btn btn-ghost'" }
class="btn">ANY (or)</button>
<button type="button"
@click="setMatchMode('all')"
:class={ "matchMode === 'all' ? 'btn btn-secondary' : 'btn btn-ghost'" }
class="btn">ALL (and)</button>
</div>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary)">
An item is in the collection while it matches <span class="font-semibold" x-text={ "matchMode === 'all' ? 'ALL of' : 'ANY of'" }>ANY of</span> the enabled rules. Saving here takes effect immediately, everywhere the collection renders.
</p>
<div id="rules-container" class="space-y-4"> <div id="rules-container" class="space-y-4">
<div id="no-rules" class="card p-8 text-center"> <div id="no-rules" class="card p-8 text-center">
<span class="inline-grid place-items-center h-12 w-12 rounded-xl mb-3" style="background-color: var(--accent-muted); color: var(--accent);"> <span class="inline-grid place-items-center h-12 w-12 rounded-xl mb-3" style="background-color: var(--accent-muted); color: var(--accent);">
@@ -62,6 +80,14 @@ templ CollectionRules(user User, collection CollectionData) {
<option value="publisher">Publisher</option> <option value="publisher">Publisher</option>
<option value="copyright_year">Copyright Year</option> <option value="copyright_year">Copyright Year</option>
<option value="tags">Tags</option> <option value="tags">Tags</option>
<option value="title">Title</option>
<option value="description">Description</option>
<optgroup label="Podcast episodes">
<option value="feed">Feed (IDs, comma-separated)</option>
<option value="played">Played (true/false)</option>
<option value="downloaded">Downloaded (true/false)</option>
<option value="published_within">Published within (days)</option>
</optgroup>
</select> </select>
</div> </div>
<div> <div>
@@ -80,6 +106,7 @@ templ CollectionRules(user User, collection CollectionData) {
<option value="ends_with">ends with</option> <option value="ends_with">ends with</option>
<option value="greater_than">greater than</option> <option value="greater_than">greater than</option>
<option value="less_than">less than</option> <option value="less_than">less than</option>
<option value="in">is one of (comma-separated)</option>
</select> </select>
</div> </div>
</div> </div>
+50 -11
View File
@@ -69,9 +69,9 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "')") templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + collection.MatchMode + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 13, Col: 120} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 13, Col: 152}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -132,7 +132,46 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> Rules evaluated live — the collection is always current, no refresh needed</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rules</h2><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.</p><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-8 text-center\"><span class=\"inline-grid place-items-center h-12 w-12 rounded-xl mb-3\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> Rules evaluated live — the collection is always current, no refresh needed</p></div></div></div><div class=\"mb-8\"><div class=\"flex flex-wrap items-center justify-between gap-3 mb-4\"><h2 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">Rules</h2><!-- Match semantics (plan item 2.4): ANY = OR (legacy),\n\t\t\t\t\t\t ALL = AND (the daily briefing). Own channel — writes\n\t\t\t\t\t\t only rule_match_mode. --><div class=\"flex items-center gap-2\"><span class=\"text-sm\" style=\"color: var(--text-secondary)\">Match</span> <button type=\"button\" @click=\"setMatchMode('any')\" :class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'any' ? 'btn btn-secondary' : 'btn btn-ghost'")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 42, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"btn\">ANY (or)</button> <button type=\"button\" @click=\"setMatchMode('all')\" :class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'all' ? 'btn btn-secondary' : 'btn btn-ghost'")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 46, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"btn\">ALL (and)</button></div></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">An item is in the collection while it matches <span class=\"font-semibold\" x-text=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'all' ? 'ALL of' : 'ANY of'")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 51, Col: 132}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">ANY of</span> the enabled rules. Saving here takes effect immediately, everywhere the collection renders.</p><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-8 text-center\"><span class=\"inline-grid place-items-center h-12 w-12 rounded-xl mb-3\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -140,20 +179,20 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 48, Col: 67} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 66, Col: 67}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option></select></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option> <option value=\"title\">Title</option> <option value=\"description\">Description</option> <optgroup label=\"Podcast episodes\"><option value=\"feed\">Feed (IDs, comma-separated)</option> <option value=\"played\">Played (true/false)</option> <option value=\"downloaded\">Downloaded (true/false)</option> <option value=\"published_within\">Published within (days)</option></optgroup></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option> <option value=\"in\">is one of (comma-separated)</option></select></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -161,7 +200,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -169,7 +208,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mt-4 mb-6\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books from a specific series</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books published in a year range</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mt-4 mb-6\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books from a specific series</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books published in a year range</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+3
View File
@@ -51,6 +51,9 @@ type CollectionData struct {
// IsSmart marks rule-based ("dynamic") collections: membership is // IsSmart marks rule-based ("dynamic") collections: membership is
// evaluated live at render, not stored in collection_items. // evaluated live at render, not stored in collection_items.
IsSmart bool IsSmart bool
// MatchMode is the smart ruleset's semantics (item 2.4): "any" (OR)
// or "all" (AND). Static collections ignore it.
MatchMode string
// BookCount is the LIVE count for the card (rule matches for smart // BookCount is the LIVE count for the card (rule matches for smart
// collections, stored links for static ones). // collections, stored links for static ones).
BookCount int BookCount int
+47 -1
View File
@@ -8,12 +8,54 @@ import { showToast } from "./toast";
let collectionId = ""; let collectionId = "";
function initCollectionRules(id: string): void { let matchMode: "any" | "all" = "any";
function initCollectionRules(id: string, mode: string): void {
collectionId = id; collectionId = id;
if (mode === "all") matchMode = "all";
loadRules(); loadRules();
setupEventDelegation(); setupEventDelegation();
} }
// Match semantics (plan item 2.4): flips the collection's
// rule_match_mode on its own endpoint — writes exactly that column.
async function setMatchMode(mode: "any" | "all"): Promise<void> {
if (mode === matchMode) return;
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
try {
const response = await fetch(`/api/collections/${collectionId}/match-mode`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ mode }),
});
if (!response.ok) {
showToast("Failed to change match mode", "error");
return;
}
matchMode = mode;
// The reactive :class and the ANY/ALL copy update via the store-free
// Alpine properties below; reload not needed.
document.querySelectorAll<HTMLElement>(".match-mode-btn").forEach((btn) => {
const isAny = btn.textContent?.trim().startsWith("ANY");
btn.classList.toggle(
"btn-secondary",
(isAny && mode === "any") || (!isAny && mode === "all"),
);
btn.classList.toggle("btn-ghost", !((isAny && mode === "any") || (!isAny && mode === "all")));
});
showToast(
mode === "all" ? "Rules now must ALL match (and)" : "Rules match when ANY passes (or)",
"success",
);
} catch {
showToast("Failed to change match mode", "error");
}
}
function setupEventDelegation(): void { function setupEventDelegation(): void {
const container = document.getElementById("rules-container"); const container = document.getElementById("rules-container");
if (!container) return; if (!container) return;
@@ -398,6 +440,10 @@ Alpine.data("collectionRules", () => ({
initCollectionRules, initCollectionRules,
loadRules, loadRules,
logout, logout,
setMatchMode,
get matchMode() {
return matchMode;
},
renderRules, renderRules,
setupEventDelegation, setupEventDelegation,
testRule, testRule,