From abb7c84833502e19a2341f5f71c37c696a28d5a9 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 16:54:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(podcasts):=20smart=20episode=20collections?= =?UTF-8?q?=20=E2=80=94=20ALL/ANY=20match=20modes=20+=20episode=20rule=20f?= =?UTF-8?q?ields=20(item=202.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- cmd/server/tests/podcast_test.go | 129 +++++++++ database/schema/schema.sql | 10 + internal/database/models.go | 1 + internal/database/querier.go | 9 +- internal/database/queries.sql.go | 270 +++++++++++-------- internal/database/queries/queries.sql | 15 +- internal/handlers/collections.go | 99 ++++++- internal/router/collections.go | 1 + internal/router/frontend.go | 9 +- internal/router/helpers.go | 11 +- internal/services/collection_service.go | 178 ++++++++---- internal/services/collection_service_test.go | 16 +- internal/services/dashboard_service.go | 5 +- templates/collection_rules.templ | 33 ++- templates/collection_rules_templ.go | 61 ++++- templates/types.go | 3 + web/src/collection-rules.ts | 48 +++- 17 files changed, 694 insertions(+), 204 deletions(-) diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 5744b2a9..5084520b 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -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 ---- func TestPodcastDownloadWorker(t *testing.T) { diff --git a/database/schema/schema.sql b/database/schema/schema.sql index ac99748e..1acf5334 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -1342,6 +1342,11 @@ CREATE TABLE IF NOT EXISTS collections ( color VARCHAR(7), icon VARCHAR(50), auto_assign_rules JSONB, + -- Smart-collection match semantics (plan item 2.4): 'any' = OR + -- (the original behavior — every legacy row's default), 'all' = + -- AND (the daily briefing needs feed∈{…} ∧ unplayed ∧ <7d). + -- Applied only when rules exist; static collections ignore it. + rule_match_mode TEXT NOT NULL DEFAULT 'any', view_settings JSONB, show_on_dashboard BOOLEAN DEFAULT false, query_type TEXT DEFAULT 'filter', @@ -1357,6 +1362,11 @@ CREATE TABLE IF NOT EXISTS collections ( -- the row id (and everything referencing it) never churn. ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +-- Smart-collection match mode (plan item 2.4): 'any' (OR, legacy +-- default) | 'all' (AND — the daily briefing). Idempotent; NOT NULL +-- DEFAULT covers existing rows. +ALTER TABLE collections ADD COLUMN IF NOT EXISTS rule_match_mode TEXT NOT NULL DEFAULT 'any'; + CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); -- Index for dashboard queries diff --git a/internal/database/models.go b/internal/database/models.go index c1a63b4b..d429b670 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -35,6 +35,7 @@ type Collections struct { Color pgtype.Text `db:"color" json:"color"` Icon pgtype.Text `db:"icon" json:"icon"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` + RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"` ViewSettings []byte `db:"view_settings" json:"view_settings"` ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` QueryType pgtype.Text `db:"query_type" json:"query_type"` diff --git a/internal/database/querier.go b/internal/database/querier.go index 1450e5a5..a4acd597 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -603,8 +603,10 @@ type Querier interface { // membership comes from the candidate book set instead of collection_items. // The set is scoped to the requesting user's visible libraries (family // lens enforced at the source) and live items only — the same filters the - // static collection paths apply. - GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) + // static collection paths apply. The viewer's progress rides along (plan + // item 2.4): the played/unplayed episode rule evaluates per-user, exactly + // like the hide_finished predicate (percentage >= 0.999 = finished). + GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]GetVisibleLiveMediaForRulesRow, error) HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error) // Soft-dismiss one row (ownership is checked by the caller against the // device's user_id before calling this). @@ -826,6 +828,9 @@ type Querier interface { UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error // Update collection UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) + // Smart-collection match semantics (plan item 2.4): flip any|all on its + // own channel — the rules page toggle writes exactly this column. + UpdateCollectionMatchMode(ctx context.Context, arg UpdateCollectionMatchModeParams) error // Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write // of the JSONB without touching metadata stale clients did not send). UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 270ed699..90f58889 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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) 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 { @@ -709,6 +709,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -2252,7 +2253,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt const CreateSystemCollection = `-- name: CreateSystemCollection :one INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb) -RETURNING id, user_id, name, description, color, icon, auto_assign_rules, 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 { @@ -2286,6 +2287,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -4383,7 +4385,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([ } const GetCollection = `-- name: GetCollection :one -SELECT id, user_id, name, description, color, icon, auto_assign_rules, 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 @@ -4398,6 +4400,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -4410,7 +4413,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection } const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one -SELECT id, user_id, name, description, color, icon, auto_assign_rules, 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 view_settings->>'imported_from_series' = $2::text AND deleted_at IS NULL @@ -4436,6 +4439,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -4448,7 +4452,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl } const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one -SELECT id, user_id, name, description, color, icon, auto_assign_rules, 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 LIMIT 1 ` @@ -4469,6 +4473,7 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -4961,7 +4966,7 @@ func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNe const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one SELECT - c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.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 FROM collections c 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"` Icon pgtype.Text `db:"icon" json:"icon"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` + RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"` ViewSettings []byte `db:"view_settings" json:"view_settings"` ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"` QueryType pgtype.Text `db:"query_type" json:"query_type"` @@ -4999,6 +5005,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -5012,7 +5019,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID } const GetCollectionsByUser = `-- name: GetCollectionsByUser :many -SELECT id, user_id, name, description, color, icon, auto_assign_rules, 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 @@ -5033,6 +5040,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID) &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -5052,7 +5060,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID) } const GetCollectionsForBook = `-- name: GetCollectionsForBook :many -SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.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 JOIN collection_items ci ON c.id = ci.collection_id WHERE ci.media_item_id = $1 @@ -5077,6 +5085,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype. &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -10781,7 +10790,7 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) ( const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one -SELECT id, user_id, name, description, color, icon, auto_assign_rules, 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 query_type = $2 AND is_system_collection = true @@ -10808,6 +10817,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -10820,7 +10830,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys } const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many -SELECT id, user_id, name, description, color, icon, auto_assign_rules, 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 is_system_collection = true AND show_on_dashboard = true @@ -10846,6 +10856,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -11551,7 +11562,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs } const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many -SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.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 AND c.is_system_collection = false AND c.deleted_at IS NULL @@ -11579,6 +11590,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -12339,124 +12351,134 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp } 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 -WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) - AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[])) +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, COALESCE(rp.percentage, 0) AS viewer_percentage FROM media_items mi +LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid +WHERE ($2::uuid IS NULL OR mi.library_id = $2::uuid) + AND ($3::uuid[] IS NULL OR mi.library_id = ANY($3::uuid[])) AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 ORDER BY mi.title ASC ` type GetVisibleLiveMediaForRulesParams struct { + ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` } +type GetVisibleLiveMediaForRulesRow struct { + MediaItems MediaItems `db:"media_items" json:"media_items"` + ViewerPercentage float64 `db:"viewer_percentage" json:"viewer_percentage"` +} + // Smart ("dynamic") collections: rules are evaluated LIVE at render, so // membership comes from the candidate book set instead of collection_items. // The set is scoped to the requesting user's visible libraries (family // lens enforced at the source) and live items only — the same filters the -// static collection paths apply. -func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) { - rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.LibraryID, arg.VisibleLibraryIds) +// static collection paths apply. The viewer's progress rides along (plan +// item 2.4): the played/unplayed episode rule evaluates per-user, exactly +// like the hide_finished predicate (percentage >= 0.999 = finished). +func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]GetVisibleLiveMediaForRulesRow, error) { + rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.ViewerUserID, arg.LibraryID, arg.VisibleLibraryIds) if err != nil { return nil, err } defer rows.Close() - items := []MediaItems{} + items := []GetVisibleLiveMediaForRulesRow{} for rows.Next() { - var i MediaItems + var i GetVisibleLiveMediaForRulesRow if err := rows.Scan( - &i.ID, - &i.LibraryID, - &i.Title, - &i.Subtitle, - &i.Author, - &i.Isbn, - &i.Description, - &i.FilePath, - &i.FileSize, - &i.MimeType, - &i.CoverImagePath, - &i.Series, - &i.SeriesNumber, - &i.Tags, - &i.Narrators, - &i.Authors, - &i.AuthorsSearch, - &i.Publishers, - &i.PublishersSearch, - &i.Abridged, - &i.Explicit, - &i.Asin, - &i.DatePublished, - &i.Publisher, - &i.Contributors, - &i.Language, - &i.Edition, - &i.PageCount, - &i.Genre, - &i.CopyrightYear, - &i.GoodreadsID, - &i.OpenlibraryID, - &i.GoogleBooksID, - &i.AddedByAdminID, - &i.CreatedAt, - &i.ImportedAt, - &i.UpdatedAt, - &i.FormatGroup, - &i.FormatMimetype, - &i.IsReflowable, - &i.HasFixedLayout, - &i.TotalCharacters, - &i.ChapterCount, - &i.EntitlementID, - &i.RevisionNumber, - &i.KoboContentID, - &i.KoboMetadata, - &i.MangaType, - &i.ReadingDirection, - &i.SeriesCount, - &i.Volume, - &i.Imprint, - &i.AgeRating, - &i.WebUrl, - &i.StoryArc, - &i.IsBlackAndWhite, - &i.MetadataNotes, - &i.CommunityRating, - &i.AlternateInfo, - &i.ScanInformation, - &i.Summary, - &i.MetadataOverrides, - &i.MissingScanCount, - &i.ArchivedAt, - &i.ChapterMetadata, - &i.LibraryTypeName, - &i.AudioMetadata, - &i.TagsSearch, - &i.ContributorsSearch, - &i.NarratorsSearch, - &i.FileSha256, - &i.OpfIdentifier, - &i.OpfUuid, - &i.HashConfidence, - &i.Source, - &i.SourceFeedID, - &i.EpisodeGuid, - &i.FeedPosition, - &i.SeasonNumber, - &i.EpisodeNumber, - &i.EnclosureUrl, - &i.DeclaredDurationSeconds, - &i.TranscriptUrl, - &i.ChaptersUrl, - &i.Hosts, - &i.Guests, - &i.DownloadState, - &i.DownloadError, - &i.DownloadAttempts, - &i.DownloadQueuePos, + &i.MediaItems.ID, + &i.MediaItems.LibraryID, + &i.MediaItems.Title, + &i.MediaItems.Subtitle, + &i.MediaItems.Author, + &i.MediaItems.Isbn, + &i.MediaItems.Description, + &i.MediaItems.FilePath, + &i.MediaItems.FileSize, + &i.MediaItems.MimeType, + &i.MediaItems.CoverImagePath, + &i.MediaItems.Series, + &i.MediaItems.SeriesNumber, + &i.MediaItems.Tags, + &i.MediaItems.Narrators, + &i.MediaItems.Authors, + &i.MediaItems.AuthorsSearch, + &i.MediaItems.Publishers, + &i.MediaItems.PublishersSearch, + &i.MediaItems.Abridged, + &i.MediaItems.Explicit, + &i.MediaItems.Asin, + &i.MediaItems.DatePublished, + &i.MediaItems.Publisher, + &i.MediaItems.Contributors, + &i.MediaItems.Language, + &i.MediaItems.Edition, + &i.MediaItems.PageCount, + &i.MediaItems.Genre, + &i.MediaItems.CopyrightYear, + &i.MediaItems.GoodreadsID, + &i.MediaItems.OpenlibraryID, + &i.MediaItems.GoogleBooksID, + &i.MediaItems.AddedByAdminID, + &i.MediaItems.CreatedAt, + &i.MediaItems.ImportedAt, + &i.MediaItems.UpdatedAt, + &i.MediaItems.FormatGroup, + &i.MediaItems.FormatMimetype, + &i.MediaItems.IsReflowable, + &i.MediaItems.HasFixedLayout, + &i.MediaItems.TotalCharacters, + &i.MediaItems.ChapterCount, + &i.MediaItems.EntitlementID, + &i.MediaItems.RevisionNumber, + &i.MediaItems.KoboContentID, + &i.MediaItems.KoboMetadata, + &i.MediaItems.MangaType, + &i.MediaItems.ReadingDirection, + &i.MediaItems.SeriesCount, + &i.MediaItems.Volume, + &i.MediaItems.Imprint, + &i.MediaItems.AgeRating, + &i.MediaItems.WebUrl, + &i.MediaItems.StoryArc, + &i.MediaItems.IsBlackAndWhite, + &i.MediaItems.MetadataNotes, + &i.MediaItems.CommunityRating, + &i.MediaItems.AlternateInfo, + &i.MediaItems.ScanInformation, + &i.MediaItems.Summary, + &i.MediaItems.MetadataOverrides, + &i.MediaItems.MissingScanCount, + &i.MediaItems.ArchivedAt, + &i.MediaItems.ChapterMetadata, + &i.MediaItems.LibraryTypeName, + &i.MediaItems.AudioMetadata, + &i.MediaItems.TagsSearch, + &i.MediaItems.ContributorsSearch, + &i.MediaItems.NarratorsSearch, + &i.MediaItems.FileSha256, + &i.MediaItems.OpfIdentifier, + &i.MediaItems.OpfUuid, + &i.MediaItems.HashConfidence, + &i.MediaItems.Source, + &i.MediaItems.SourceFeedID, + &i.MediaItems.EpisodeGuid, + &i.MediaItems.FeedPosition, + &i.MediaItems.SeasonNumber, + &i.MediaItems.EpisodeNumber, + &i.MediaItems.EnclosureUrl, + &i.MediaItems.DeclaredDurationSeconds, + &i.MediaItems.TranscriptUrl, + &i.MediaItems.ChaptersUrl, + &i.MediaItems.Hosts, + &i.MediaItems.Guests, + &i.MediaItems.DownloadState, + &i.MediaItems.DownloadError, + &i.MediaItems.DownloadAttempts, + &i.MediaItems.DownloadQueuePos, + &i.ViewerPercentage, ); err != nil { return nil, err } @@ -16448,7 +16470,7 @@ SET name = $2, show_on_dashboard = true, deleted_at = NULL WHERE id = $1 -RETURNING id, user_id, name, description, color, icon, auto_assign_rules, 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 { @@ -16480,6 +16502,7 @@ func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSys &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -18193,9 +18216,10 @@ SET color = $4, icon = $5, auto_assign_rules = $6, - view_settings = $7 + view_settings = $7, + rule_match_mode = $8 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 { @@ -18206,6 +18230,7 @@ type UpdateCollectionParams struct { Icon pgtype.Text `db:"icon" json:"icon"` AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` ViewSettings []byte `db:"view_settings" json:"view_settings"` + RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"` } // Update collection @@ -18218,6 +18243,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara arg.Icon, arg.AutoAssignRules, arg.ViewSettings, + arg.RuleMatchMode, ) var i Collections err := row.Scan( @@ -18228,6 +18254,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, @@ -18239,8 +18266,24 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara return i, err } +const UpdateCollectionMatchMode = `-- name: UpdateCollectionMatchMode :exec +UPDATE collections SET rule_match_mode = $2 WHERE id = $1 +` + +type UpdateCollectionMatchModeParams struct { + ID pgtype.UUID `db:"id" json:"id"` + RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"` +} + +// Smart-collection match semantics (plan item 2.4): flip any|all on its +// own channel — the rules page toggle writes exactly this column. +func (q *Queries) UpdateCollectionMatchMode(ctx context.Context, arg UpdateCollectionMatchModeParams) error { + _, err := q.db.Exec(ctx, UpdateCollectionMatchMode, arg.ID, arg.RuleMatchMode) + return err +} + const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one -UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, 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 { @@ -18261,6 +18304,7 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle &i.Color, &i.Icon, &i.AutoAssignRules, + &i.RuleMatchMode, &i.ViewSettings, &i.ShowOnDashboard, &i.QueryType, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index b046a666..3433ad6e 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -3498,6 +3498,11 @@ RETURNING *; -- name: UpdateCollectionViewSettings :exec 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 -- name: GetCollection :one SELECT * FROM collections WHERE id = $1; @@ -3525,7 +3530,8 @@ SET color = $4, icon = $5, auto_assign_rules = $6, - view_settings = $7 + view_settings = $7, + rule_match_mode = $8 WHERE id = $1 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. -- The set is scoped to the requesting user's visible libraries (family -- lens enforced at the source) and live items only — the same filters the --- static collection paths apply. +-- static collection paths apply. The viewer's progress rides along (plan +-- item 2.4): the played/unplayed episode rule evaluates per-user, exactly +-- like the hide_finished predicate (percentage >= 0.999 = finished). -- name: GetVisibleLiveMediaForRules :many -SELECT 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) 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 diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index b5bb7958..87095634 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -47,6 +47,9 @@ type CreateCollectionRequest struct { type UpdateCollectionRequest struct { Name string `form:"name" json:"name" validate:"required"` + // Smart-collection match semantics (item 2.4): "any" (OR) | "all" + // (AND). Empty/invalid keeps the stored value. + RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"` Description string `form:"description" json:"description"` Color string `form:"color" json:"color"` Icon string `form:"icon" json:"icon"` @@ -217,8 +220,10 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { visibleIDs, visErr := h.userVisibleLibraryIDs(c) if visErr == nil { matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ - LibraryID: libUUID, - VisibleIDs: visibleIDs, + LibraryID: libUUID, + VisibleIDs: visibleIDs, + MatchMode: col.RuleMatchMode, + ViewerUserID: MustGetAuthenticatedUser(c).ID, }) if evalErr == nil { bookCount = len(matches) @@ -295,8 +300,10 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ - LibraryID: libUUID, - VisibleIDs: visibleIDs, + LibraryID: libUUID, + VisibleIDs: visibleIDs, + MatchMode: collection.RuleMatchMode, + ViewerUserID: MustGetAuthenticatedUser(c).ID, }) if evalErr != nil { 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), "auto_assign_rules": json.RawMessage(collection.AutoAssignRules), "view_settings": viewSettings, + "rule_match_mode": collection.RuleMatchMode, "books": bookList, "book_count": len(bookList), "is_smart": isSmart, @@ -459,6 +467,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error { req.Icon, req.AutoAssignRules, req.ViewSettings, + req.RuleMatchMode, ) if err != nil { 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 { 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 { @@ -1004,18 +1016,26 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error { if len(visibleIDs) > 0 { 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{ LibraryID: pgtype.UUID{}, VisibleLibraryIds: visibleArg, + ViewerUserID: viewerID, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"}) } + matchAll := req.MatchMode == services.RuleMatchAll + var matches []BookMatch - for _, item := range mediaItems { - matchReason := h.checkRulesAgainstBook(item, req.Rules) + for _, row := range mediaItems { + matchReason := h.checkRulesAgainstBook(row.MediaItems, row.ViewerPercentage, req.Rules, matchAll) if matchReason != "" { + item := row.MediaItems author := "" if item.Author.Valid { 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 { field, _ := rule["field"].(string) operator, _ := rule["operator"].(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) } } @@ -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"}) } + // 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 for _, item := range allItems { - evaluations := h.collectionService.EvaluateRules(item, req.Rules) + evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules) for _, eval := range evaluations { if eval.Matches { 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 — // ids omitted keep their relative order appended after (the service never // deletes membership). +// SetMatchMode handles PUT /api/collections/:id/match-mode (plan item +// 2.4): flips the smart ruleset's semantics any|all on its own channel +// — the rules page toggle writes exactly this column, nothing else. +func (h *CollectionHandler) SetMatchMode(c *echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + user, ok := c.Get("user").(database.Users) + if !ok { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + } + + var req struct { + Mode string `json:"mode" form:"mode" validate:"required,oneof=any all"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "mode must be any or all"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes)) + if err != nil || !owns { + return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"}) + } + + if err := h.db.UpdateCollectionMatchMode(c.Request().Context(), database.UpdateCollectionMatchModeParams{ + ID: pgtype.UUID{Bytes: collectionID, Valid: true}, + RuleMatchMode: req.Mode, + }); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"mode": req.Mode}) +} + func (h *CollectionHandler) ReorderItems(c *echo.Context) error { collectionID, err := uuid.Parse(c.Param("id")) if err != nil { diff --git a/internal/router/collections.go b/internal/router/collections.go index ec228df2..9586cb15 100644 --- a/internal/router/collections.go +++ b/internal/router/collections.go @@ -17,6 +17,7 @@ func registerCollectionsRoutes(cfg *Config) { collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections) collections.POST("/:id/books", cfg.CollectionHandler.AddBooks) collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems) + collections.PUT("/:id/match-mode", cfg.CollectionHandler.SetMatchMode) collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook) collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks) collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index bc59bb48..be7199cc 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -567,8 +567,13 @@ func registerFrontendRoutes(cfg *Config) { if isSmart { matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{ - LibraryID: libScope, - VisibleIDs: visibleIDs, + LibraryID: libScope, + VisibleIDs: visibleIDs, + MatchMode: col.RuleMatchMode, + ViewerUserID: func() pgtype.UUID { + uid, _ := uuid.Parse(user.ID) + return uuidToPGType(uid) + }(), }) if evalErr == nil { bookCount = len(matches) diff --git a/internal/router/helpers.go b/internal/router/helpers.go index c4e2c321..20777105 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -278,6 +278,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string, Icon: collection.Icon.String, IsSystem: collection.IsSystemCollection.Bool, IsSmart: isSmart, + MatchMode: collection.RuleMatchMode, BookCount: bookCount, } } @@ -293,11 +294,15 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, if isSmart { // 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(), colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{ - LibraryID: libScope, - VisibleIDs: visibleIDs, + LibraryID: libScope, + VisibleIDs: visibleIDs, + MatchMode: collection.RuleMatchMode, + ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true}, }) if evalErr != nil { return nil, isSmart, "books_load_error" diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index 78237c3e..b6eeadaf 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -6,7 +6,9 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -32,14 +34,32 @@ type Rule struct { // Known rule fields and operators — the rules builder UI and the // persisted JSONB documents must agree with this vocabulary. +// Episode fields (plan item 2.4): feed/downloaded/published_within ride +// the media row; played reads the VIEWER's progress (the candidate +// query carries it — SmartEvalOptions) so "unplayed" is per-user. var ruleFields = map[string]bool{ "genre": true, "series": true, "author": true, "language": true, "publisher": true, "copyright_year": true, "tags": true, + // Episode collections (plan item 2.4): + "title": true, "description": true, + "feed": true, "played": true, "downloaded": true, "published_within": true, } var ruleOperators = map[string]bool{ "equals": true, "not_equals": true, "contains": true, "not_contains": true, "starts_with": true, "ends_with": true, "greater_than": true, "less_than": true, + "in": true, // multi-value (comma-separated) — the feed multi-select +} + +// Smart-collection match semantics (plan item 2.4): 'any' = OR (the +// legacy behavior), 'all' = AND (the daily briefing). +const ( + RuleMatchAny = "any" + RuleMatchAll = "all" +) + +func ValidRuleMatchMode(m string) bool { + return m == RuleMatchAny || m == RuleMatchAll } // RuleValidationError reports a rule payload the evaluator could never @@ -133,13 +153,17 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid. } // 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 // them OVER the stored view_settings so provenance keys // (imported_from_series) and anything else on the row survive. - if len(viewSettings) > 0 { - existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}) - if err == nil && len(existing.ViewSettings) > 0 { + existingMode := RuleMatchAny + if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil { + existingMode = existing.RuleMatchMode + if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 { var stored map[string]any if json.Unmarshal(existing.ViewSettings, &stored) == nil { 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) var rulesJSON []byte @@ -179,6 +206,7 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u Icon: pgtype.Text{String: icon, Valid: icon != ""}, AutoAssignRules: rulesJSON, ViewSettings: settingsJSON, + RuleMatchMode: ruleMatchMode, }) if err != nil { @@ -423,6 +451,14 @@ type SmartEvalOptions struct { // VisibleIDs is the requesting user's visible-library set (family // lens). Empty means no visible-library filter. VisibleIDs []pgtype.UUID + // MatchMode is the collection's rule_match_mode: "any" (OR — + // legacy default) or "all" (AND — the daily briefing). Empty + // resolves to "any". + MatchMode string + // ViewerUserID scopes the candidate query's progress join — the + // played/unplayed rule is per-user. Zero UUID = no viewer (played + // rules then see "unplayed" for everything, honestly). + ViewerUserID pgtype.UUID } // 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{ LibraryID: opts.LibraryID, VisibleLibraryIds: visibleArg, + ViewerUserID: opts.ViewerUserID, }) if err != nil { return nil, fmt.Errorf("failed to load candidate books: %w", err) } + mode := opts.MatchMode + if !ValidRuleMatchMode(mode) { + mode = RuleMatchAny + } matches := make([]database.MediaItems, 0, len(items)) - for _, item := range items { - if s.mediaItemMatchesAnyRule(item, enabled) { - matches = append(matches, item) + for _, row := range items { + if s.mediaItemMatches(mode, row.MediaItems, row.ViewerPercentage, enabled) { + matches = append(matches, row.MediaItems) } } 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 { + if !s.mediaItemMatchesRule(item, viewerPercentage, rule) { + return false + } + } + return true + } for _, rule := range rules { - if s.mediaItemMatchesRule(item, rule) { + if s.mediaItemMatchesRule(item, viewerPercentage, rule) { return true } } 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 { 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 - switch rule.Field { + switch field { + case "title": + fieldValue = item.Title + case "description": + if item.Description.Valid { + fieldValue = item.Description.String + } case "genre": if item.Genre.Valid { 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 -func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules []Rule) []RuleEvaluation { +// EvaluateRules evaluates auto-assign rules for a media item — the +// 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 // Sort rules by priority (higher priority first) @@ -527,40 +642,7 @@ func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules [ } eval := RuleEvaluation{ RuleID: rule.ID, - Matches: false, - } - - 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) - } + Matches: s.FieldMatches(mediaItem, viewerPercentage, rule.Field, rule.Operator, rule.Value), } // Calculate confidence based on rule type and priority diff --git a/internal/services/collection_service_test.go b/internal/services/collection_service_test.go index 7d569d83..22cff8b1 100644 --- a/internal/services/collection_service_test.go +++ b/internal/services/collection_service_test.go @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { t.Fatalf("expected 2 evaluations, got %d", len(evaluations)) diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index e5490efc..9c0dc4b7 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -318,7 +318,10 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data 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 { if eval.Matches { autoItems = append(autoItems, item) diff --git a/templates/collection_rules.templ b/templates/collection_rules.templ index 0c9505ca..f58d14af 100644 --- a/templates/collection_rules.templ +++ b/templates/collection_rules.templ @@ -10,7 +10,7 @@ templ CollectionRules(user User, collection CollectionData) { - + @Header(user, "/collections")
@@ -30,8 +30,26 @@ templ CollectionRules(user User, collection CollectionData) {
-

Rules

-

A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.

+
+

Rules

+ +
+ Match + + +
+
+

+ An item is in the collection while it matches ANY of the enabled rules. Saving here takes effect immediately, everywhere the collection renders. +

@@ -62,6 +80,14 @@ templ CollectionRules(user User, collection CollectionData) { + + + + + + + +
@@ -80,6 +106,7 @@ templ CollectionRules(user User, collection CollectionData) { +
diff --git a/templates/collection_rules_templ.go b/templates/collection_rules_templ.go index c5947880..fb180de4 100644 --- a/templates/collection_rules_templ.go +++ b/templates/collection_rules_templ.go @@ -69,9 +69,9 @@ func CollectionRules(user User, collection CollectionData) templ.Component { return templ_7745c5c3_Err } 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 { - 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) if templ_7745c5c3_Err != nil { @@ -132,7 +132,46 @@ func CollectionRules(user User, collection CollectionData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

⚡ Smart Rules evaluated live — the collection is always current, no refresh needed

Rules

A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

⚡ Smart Rules evaluated live — the collection is always current, no refresh needed

Rules

Match

An item is in the collection while it matches ANY of the enabled rules. Saving here takes effect immediately, everywhere the collection renders.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -140,20 +179,20 @@ func CollectionRules(user User, collection CollectionData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

No Rules Yet

Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.

Create New Rule

No Rules Yet

Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.

Create New Rule

Uncheck to disable without deleting

Rule Test Results

Books that would be added by this rule:

Rule Examples

1

Add all Science Fiction books

Field: genre
Operator: equals
Value: Science Fiction
2

Add books from a specific series

Field: series
Operator: starts with
Value: Harry Potter
3

Add books published in a year range

Field: copyright_year
Operator: greater than
Value: 2020
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Add Rule

Rule Test Results

Books that would be added by this rule:

Rule Examples

1

Add all Science Fiction books

Field: genre
Operator: equals
Value: Science Fiction
2

Add books from a specific series

Field: series
Operator: starts with
Value: Harry Potter
3

Add books published in a year range

Field: copyright_year
Operator: greater than
Value: 2020
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index e05afb58..ff270ae6 100644 --- a/templates/types.go +++ b/templates/types.go @@ -51,6 +51,9 @@ type CollectionData struct { // IsSmart marks rule-based ("dynamic") collections: membership is // evaluated live at render, not stored in collection_items. 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 // collections, stored links for static ones). BookCount int diff --git a/web/src/collection-rules.ts b/web/src/collection-rules.ts index cb27cae3..8baa3881 100644 --- a/web/src/collection-rules.ts +++ b/web/src/collection-rules.ts @@ -8,12 +8,54 @@ import { showToast } from "./toast"; let collectionId = ""; -function initCollectionRules(id: string): void { +let matchMode: "any" | "all" = "any"; + +function initCollectionRules(id: string, mode: string): void { collectionId = id; + if (mode === "all") matchMode = "all"; loadRules(); 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 { + 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(".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 { const container = document.getElementById("rules-container"); if (!container) return; @@ -398,6 +440,10 @@ Alpine.data("collectionRules", () => ({ initCollectionRules, loadRules, logout, + setMatchMode, + get matchMode() { + return matchMode; + }, renderRules, setupEventDelegation, testRule,