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:
1 parent
f68fb19afb
commit
abb7c84833
17 files changed
+694
-204
No files matched your search
@@ -10,7 +10,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</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")
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
@@ -30,8 +30,26 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
</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 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),
|
||||
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="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);">
|
||||
@@ -62,6 +80,14 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
<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>
|
||||
@@ -80,6 +106,7 @@ templ CollectionRules(user User, collection CollectionData) {
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user