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
@@ -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<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 {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user