feat(podcasts): the episodes authoring door + curated chain order

"Newest episodes from my selected podcasts" is now buildable in the UI,
end to end — the user could not find ANY podcast-related filter surface
because none existed: the New Collection modal could only create
book-scope smart collections, and the engine's episodes scope (item 3.1)
had no authoring door.

The scope door:
- The create modal's Smart card gains a Rules-scope segmented control
  (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart
  means "match books by rules". Choosing Episodes seeds an EMPTY
  episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the
  collection is born episode-scoped: the editor opens with the episode
  vocabulary (feed/downloaded/played/published date) and membership
  runs the episode engine from the first rule.
- ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay
  invalid) — the seed's legal shape. The root arrives at depth 1 by
  call convention, hence the depth>1 exemption.
- The composers define the empty-tree semantics: zero children matches
  NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring
  EvaluateSmartCollection's zero-enabled-rules convention. Without the
  guard the composed query degrades to the bare scope head and a fresh
  collection would swallow every episode in scope — pinned by
  TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0)
  and TestEmptyRootTreeSeedsEpisodesScope.

The curated chain order (plan §10 R2, user need: the briefing plays
NEWEST-first):
- view_settings.chain_order ("native"/"newest"/"oldest"/"reverse")
  stored on the collection — curated content, not a player-side knob.
- PUT /api/collections/:id/view-settings merges the key into the
  stored JSONB (a blind write would clobber the playlist toggles).
- buildSmartCollectionSequenceContext routes OrderEpisodes through
  handlers.CollectionChainOrder; the detail header's Play-as-Sequence
  gains the order select next to it (PlayCollectionButton), which
  PUTs and reloads. Pinned by chain_follows_the_curated_order
  (reverse flips the chain indexes and hops).

Caught by the new test, a latent clobber fixed: UpdateCollection
treated a NIL view_settings payload as "write NULL" — any edit that
carried no settings dropped every stored key (chain_order, playlist
toggles from API clients, imported provenance). Nil now means
untouched, the same convention filterTree already had.

Composer guards also cover ComposedCount, so the builder's live count
reads the same semantics. Tests: collectionChainOrder unit matrix,
empty-root parse unit pin, two integration pins (seed matches nothing;
curated order drives the chain and survives whole-map edits).
This commit is contained in:
John O'Keefe committed 2026-10-31 13:39:24 -04:00
1 parent d5dbaa48ea
commit cc0e9d7f91
16 files changed
+946 -538

No files matched your search

+70
View File
@@ -1049,6 +1049,37 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
require.NoError(t, json.Unmarshal(body, &out)) require.NoError(t, json.Unmarshal(body, &out))
assert.NotNil(t, out.Tree, "an edit that sends no tree keeps the stored one") assert.NotNil(t, out.Tree, "an edit that sends no tree keeps the stored one")
}) })
// Plan §10 R2 + the 2026-10-31 chain-order option: the collection's
// CURATED listening order (view_settings.chain_order) governs the
// chain. reverse flips the feed positions, so the episode that was
// 1/2 under native becomes 2/2 and gains a prev hop.
t.Run("chain_follows_the_curated_order", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "PUT",
"/api/collections/"+collID+"/view-settings", `{"chain_order":"reverse"}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
// The edit-form save path must not clobber the merged key.
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
`{"name":"test TreeColl `+marker+`","description":"order survives edits"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"chain_order":"reverse"`,
"view_settings merge keeps chain_order across whole-map writes")
// aNew is feed position 1: FIRST under native, LAST under reverse.
code, body = podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"episode_index":2`),
"reverse order puts feed position 1 last")
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"the reversed chain hops back through feed position 5")
assert.NotContains(t, html, esc(`"next_url"`),
"the last episode of the chain has no next hop")
})
} }
// ---- download worker: state machine, idempotency, file layout ---- // ---- download worker: state machine, idempotency, file layout ----
@@ -2080,3 +2111,42 @@ func TestAdminDeleteEpisodeFiles(t *testing.T) {
assert.NoFileExists(t, pathFor(ep), "no file re-materialized without an explicit download") assert.NoFileExists(t, pathFor(ep), "no file re-materialized without an explicit download")
} }
} }
// The episodes scope door (2026-10-31): the create modal seeds an EMPTY
// episodes root so a fresh Episodes smart collection has a stored scope
// before its first rule. The seed must be accepted at write, and the
// collection must match NOTHING — the composed query must never degrade
// to the bare scope head, which would swallow every episode in scope.
func TestEpisodeCollectionsEmptySeedMatchesNothing(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
marker := fmt.Sprintf("emptyseed%d", time.Now().UnixNano()%100000)
seed := `{"scope":"episodes","mode":"all","children":[]}`
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test EmptySeed `+marker+`","collection_type":"smart","filter_tree":`+seed+`}`)
require.Equal(t, http.StatusCreated, code, "the empty seed must parse at write: %s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"is_smart":true`,
"a seeded tree collection IS smart without any rules array")
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
BookCount int `json:"book_count"`
Books []interface{} `json:"books"`
}
require.NoError(t, json.Unmarshal(body, &out))
assert.Equal(t, 0, out.BookCount,
"an empty-seed episodes collection matches nothing — a composer that degraded to the bare scope head would report the whole library here")
assert.Empty(t, out.Books)
}
+84 -2
View File
@@ -86,10 +86,10 @@ func parseRequestFilterTree(payload any) ([]byte, error) {
} }
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" // Smart-collection match semantics (item 2.4): "any" (OR) | "all"
// (AND). Empty/invalid keeps the stored value. // (AND). Empty/invalid keeps the stored value.
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"` RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"`
// The unified filter tree (item 3.2): when present the collection's // The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a // smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler); a tree supersedes // 400 (ParseFilterTree at the handler); a tree supersedes
@@ -547,6 +547,88 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
return c.JSON(http.StatusCreated, response) return c.JSON(http.StatusCreated, response)
} }
// ValidChainOrders — the collection chain's listening-order vocabulary
// (plan §10 R2: a collection's playback order is CURATED on the
// collection, not a player-side knob). Mirrors OrderEpisodes' orders;
// "" means native (the pre-chain-order default).
var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true}
// CollectionChainOrder resolves a collection row's curated chain order
// from its view_settings JSONB — "" (native) when absent/unknown. The
// episode chain producer routes OrderEpisodes through this; the
// view-settings PUT writes the key (SetCollectionChainOrder).
func CollectionChainOrder(viewSettings []byte) string {
if len(viewSettings) == 0 {
return ""
}
var vs map[string]interface{}
if json.Unmarshal(viewSettings, &vs) != nil {
return ""
}
if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] {
return s
}
return ""
}
// SetCollectionChainOrder — PUT /api/collections/:id/view-settings with
// {"chain_order": "..."}: merges the key into the collection's stored
// view_settings (the playlist toggles share that JSONB — a blind write
// would clobber them). The episode chain producer
// (buildSmartCollectionSequenceContext) honors it.
func (h *CollectionHandler) SetCollectionChainOrder(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"})
}
var req struct {
ChainOrder string `json:"chain_order"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if !ValidChainOrders[req.ChainOrder] {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "chain_order must be one of: native, newest, oldest, reverse"})
}
collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
settings := map[string]interface{}{}
if len(collection.ViewSettings) > 0 {
if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"})
}
}
if req.ChainOrder == "" || req.ChainOrder == "native" {
// Both spellings mean the default — drop the key entirely so the
// stored doc stays clean.
delete(settings, "chain_order")
} else {
settings["chain_order"] = req.ChainOrder
}
blob, err := json.Marshal(settings)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
ViewSettings: blob,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"chain_order": req.ChainOrder,
"view_settings": settings,
})
}
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error { func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id")) collectionID, err := uuid.Parse(c.Param("id"))
if err != nil { if err != nil {
@@ -0,0 +1,30 @@
package handlers
import "testing"
// The collection chain's curated order (plan §10 R2): the resolver
// reads view_settings.chain_order, tolerates absent/corrupt docs, and
// refuses unknown orders (they mean native).
func TestCollectionChainOrder(t *testing.T) {
cases := []struct {
name string
vs string
want string
}{
{"absent doc", "", ""},
{"empty object", `{}`, ""},
{"reverse", `{"chain_order":"reverse"}`, "reverse"},
{"newest", `{"chain_order":"newest"}`, "newest"},
{"merged with toggles", `{"hide_finished":true,"chain_order":"oldest"}`, "oldest"},
{"unknown order means native", `{"chain_order":"shuffle"}`, ""},
{"non-string means native", `{"chain_order":3}`, ""},
{"corrupt doc means native", `not json`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := CollectionChainOrder([]byte(tc.vs)); got != tc.want {
t.Fatalf("CollectionChainOrder(%s) = %q, want %q", tc.vs, got, tc.want)
}
})
}
}
+3
View File
@@ -18,6 +18,9 @@ func registerCollectionsRoutes(cfg *Config) {
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.PUT("/:id/match-mode", cfg.CollectionHandler.SetMatchMode)
// The chain's listening order lives ON the collection (plan §10 R2):
// merges into view_settings; the episode chain producer honors it.
collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder)
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)
+11
View File
@@ -2,6 +2,7 @@ package router
import ( import (
"context" "context"
"encoding/json"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@@ -270,6 +271,15 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
// collectionDataFromRow builds the template collection payload shared by // collectionDataFromRow builds the template collection payload shared by
// the collection detail page and its books-grid partial. // the collection detail page and its books-grid partial.
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData { func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
chainOrder := ""
if len(collection.ViewSettings) > 0 {
var vs map[string]interface{}
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
if s, ok := vs["chain_order"].(string); ok {
chainOrder = s
}
}
}
return templates.CollectionData{ return templates.CollectionData{
ID: collectionID, ID: collectionID,
Name: collection.Name, Name: collection.Name,
@@ -279,6 +289,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
IsSystem: collection.IsSystemCollection.Bool, IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart, IsSmart: isSmart,
MatchMode: collection.RuleMatchMode, MatchMode: collection.RuleMatchMode,
ChainOrder: chainOrder,
BookCount: bookCount, BookCount: bookCount,
} }
} }
+4 -1
View File
@@ -374,7 +374,10 @@ func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *dat
if len(ordered) == 0 { if len(ordered) == 0 {
return nil return nil
} }
services.OrderEpisodes(ordered, "native", // Plan §10 R2: the collection's CURATED listening order (stored in
// view_settings.chain_order, set from the detail header) — not a
// player-side knob. "" / native / unknown all mean feed order.
services.OrderEpisodes(ordered, handlers.CollectionChainOrder(coll.ViewSettings),
func(e seqEp) int32 { return e.pos }, func(e seqEp) int32 { return e.pos },
func(e seqEp) string { return e.date }, func(e seqEp) string { return e.date },
) )
+11 -2
View File
@@ -189,6 +189,17 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
viewSettings = merged viewSettings = merged
} }
} }
// Same convention as filterTree: a NIL view_settings payload
// means untouched — keep the stored doc. Without this, an edit
// that carries no settings wrote NULL over the row and silently
// dropped every stored key (chain_order, imported provenance);
// the 2026-10-31 chain-order test pinned exactly this clobber.
if viewSettings == nil && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil && len(stored) > 0 {
viewSettings = stored
}
}
} }
if !ValidRuleMatchMode(ruleMatchMode) { if !ValidRuleMatchMode(ruleMatchMode) {
ruleMatchMode = existingMode ruleMatchMode = existingMode
@@ -493,8 +504,6 @@ func IsCollectionSmart(coll database.Collections) bool {
return len(rules) > 0 return len(rules) > 0
} }
// EvaluateSmartMembership is THE single smart evaluator (item 3.2): // EvaluateSmartMembership is THE single smart evaluator (item 3.2):
// tree-bearing collections evaluate through the unified filter engine; // tree-bearing collections evaluate through the unified filter engine;
// legacy rule arrays keep per-item evaluation until rewritten. One // legacy rule arrays keep per-item evaluation until rewritten. One
+14
View File
@@ -883,6 +883,15 @@ func composedQueryBlock(qBind, exactBind string) string {
// Sqlc embed for tree collections; ordering matches the legacy // Sqlc embed for tree collections; ordering matches the legacy
// candidate query (title ASC). // candidate query (title ASC).
func (s *SearchService) ComposedLiveMedia(ctx context.Context, userID, libraryID pgtype.UUID, visibleIDs []pgtype.UUID, tree *FilterTree) ([]database.MediaItems, error) { func (s *SearchService) ComposedLiveMedia(ctx context.Context, userID, libraryID pgtype.UUID, visibleIDs []pgtype.UUID, tree *FilterTree) ([]database.MediaItems, error) {
// A rules-LESS tree (root with zero children — legal since the
// episodes scope door seeds one at create-modal time) matches
// NOTHING, mirroring the legacy engine's zero-enabled-rules → empty
// convention (EvaluateSmartCollection). Without this guard the
// composed query degrades to the bare scope head below and a fresh
// Episodes collection would swallow every episode in the library.
if len(tree.Children) == 0 {
return []database.MediaItems{}, nil
}
b := newFilterBuilder(userID) b := newFilterBuilder(userID)
head := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b) head := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b)
visibleClause := "" visibleClause := ""
@@ -913,6 +922,11 @@ ORDER BY mi.title ASC`
// ComposedCount — the live match count for the builder (rules + scope, // ComposedCount — the live match count for the builder (rules + scope,
// no pagination): a true COUNT(*), not the page length. // no pagination): a true COUNT(*), not the page length.
func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) { func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) {
// Same empty-tree convention as ComposedLiveMedia: rules-less means
// zero matches, not "everything".
if len(tree.Children) == 0 {
return 0, nil
}
b := newFilterBuilder(userID) b := newFilterBuilder(userID)
libBind := b.bind(libraryID) libBind := b.bind(libraryID)
whereSQL := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b) + ` whereSQL := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b) + `
+17 -10
View File
@@ -222,14 +222,14 @@ var filterOps = map[string]FilterOpWire{
// FilterFieldWire is one field's presentation entry (the registry's // FilterFieldWire is one field's presentation entry (the registry's
// wire face): the frontend menu is scope-filtered OFF this list. // wire face): the frontend menu is scope-filtered OFF this list.
type FilterFieldWire struct { type FilterFieldWire struct {
Key string `json:"key"` Key string `json:"key"`
Label string `json:"label"` Label string `json:"label"`
Group string `json:"group"` Group string `json:"group"`
ValueKind string `json:"valueKind"` ValueKind string `json:"valueKind"`
Ops []FilterOpWire `json:"ops"` Ops []FilterOpWire `json:"ops"`
DefaultOp string `json:"defaultOp"` DefaultOp string `json:"defaultOp"`
Suggest bool `json:"suggest"` Suggest bool `json:"suggest"`
Options []string `json:"options,omitempty"` Options []string `json:"options,omitempty"`
} }
// valueKinds — what the value cell renders (the registry's Kind mapped // valueKinds — what the value cell renders (the registry's Kind mapped
@@ -633,7 +633,15 @@ func validateGroup(g *FilterChild, depth int, scope string) error {
if g.Mode != "all" && g.Mode != "any" { if g.Mode != "all" && g.Mode != "any" {
return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode) return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
} }
if len(g.Children) == 0 { // The ROOT arrives as depth 1 (call convention below), so "depth > 1"
// exempts exactly the root: an EMPTY ROOT is legal as of the episodes
// scope door — the create modal seeds
// `{"scope":"episodes","mode":"all","children":[]}` so a fresh
// Episodes smart collection has a stored scope before its first
// rule. Nested empty groups stay invalid, and the composers define
// the semantics: zero children matches nothing (mirrors
// EvaluateSmartCollection's zero-rules convention).
if len(g.Children) == 0 && depth > 1 {
return fmt.Errorf("filter group has no children") return fmt.Errorf("filter group has no children")
} }
for _, c := range g.Children { for _, c := range g.Children {
@@ -650,7 +658,6 @@ func validateGroup(g *FilterChild, depth int, scope string) error {
return nil return nil
} }
// numericParsers map each numeric field's wire values to the Go value // numericParsers map each numeric field's wire values to the Go value
// bound into SQL — validation and clause-building share one parser per // bound into SQL — validation and clause-building share one parser per
// field, so a value that passes validation always binds. // field, so a value that passes validation always binds.
+25
View File
@@ -171,3 +171,28 @@ func TestFilterFieldsByGroupOrdering(t *testing.T) {
require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group) require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
} }
} }
// The episodes scope door (2026-10-31): the create modal seeds an EMPTY
// episodes root so a fresh Episodes smart collection has a stored scope
// before its first rule. The parser accepts an empty ROOT (nested empty
// groups stay invalid), and the composers define the semantics — zero
// children matches NOTHING, mirroring EvaluateSmartCollection's
// zero-rules convention (a bare scope head would match everything).
func TestEmptyRootTreeSeedsEpisodesScope(t *testing.T) {
tree, err := ParseFilterTree(`{"scope":"episodes","mode":"all","children":[]}`)
if err != nil {
t.Fatalf("ParseFilterTree(seed): %v", err)
}
if got := tree.FilterScope(); got != ScopeEpisodes {
t.Fatalf("FilterScope = %q, want %q", got, ScopeEpisodes)
}
if len(tree.Children) != 0 {
t.Fatalf("children = %d, want 0", len(tree.Children))
}
// Nested empty groups remain invalid.
nested := `{"mode":"all","children":[{"mode":"any","children":[]}]}`
if _, err := ParseFilterTree(nested); err == nil {
t.Fatal("nested empty group must stay invalid")
}
}
+27 -4
View File
@@ -99,21 +99,44 @@ templ CollectionModal(collection CollectionData) {
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Type</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Type</label>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<label class="relative cursor-pointer"> <label class="relative cursor-pointer">
<input type="radio" name="collection_type" value="static" checked class="peer sr-only"/> <input type="radio" name="collection_type" value="static" checked @change="setCollectionType($event.target.value)" class="peer sr-only"/>
<div class="p-3 rounded-lg border text-center peer-checked:border-blue-500" style="background-color: var(--bg-primary); border-color: var(--border);"> <div class="p-3 rounded-lg border text-center peer-checked:border-blue-500" style="background-color: var(--bg-primary); border-color: var(--border);">
<span class="font-semibold text-sm" style="color: var(--text-primary)">Static</span> <span class="font-semibold text-sm" style="color: var(--text-primary)">Static</span>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Pick books by hand</p> <p class="text-xs mt-1" style="color: var(--text-secondary)">Pick items by hand</p>
</div> </div>
</label> </label>
<label class="relative cursor-pointer"> <label class="relative cursor-pointer">
<input type="radio" name="collection_type" value="smart" class="peer sr-only"/> <input type="radio" name="collection_type" value="smart" @change="setCollectionType($event.target.value)" class="peer sr-only"/>
<div class="p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400" style="background-color: var(--bg-primary); border-color: var(--border);"> <div class="p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400" style="background-color: var(--bg-primary); border-color: var(--border);">
<span class="font-semibold text-sm" style="color: var(--text-primary)">⚡ Smart</span> <span class="font-semibold text-sm" style="color: var(--text-primary)">⚡ Smart</span>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Match books by rules — always live</p> <p class="text-xs mt-1" style="color: var(--text-secondary)">Match by rules — always live</p>
</div> </div>
</label> </label>
</div> </div>
</div> </div>
<div class="mb-4" x-show="collectionType === 'smart'" x-cloak>
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Rules scope</label>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
@click="setSmartScope('books')"
:class="smartScope === 'books' ? 'btn btn-secondary' : 'btn btn-ghost'"
>
📚 Books
</button>
<button
type="button"
@click="setSmartScope('episodes')"
:class="smartScope === 'episodes' ? 'btn btn-secondary' : 'btn btn-ghost'"
>
🎧 Podcast episodes
</button>
</div>
<p class="text-xs mt-2" style="color: var(--text-secondary)">
Episodes collections match your podcast subscriptions' episodes (feed, downloaded, played, published date) and play as a sequence.
</p>
<input type="hidden" name="filter_tree" id="collection-tree-seed" value=""/>
</div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Name</label>
<input <input
+2 -2
View File
@@ -168,14 +168,14 @@ func CollectionModal(collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked @change=\"setCollectionType($event.target.value)\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick items by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" @change=\"setCollectionType($event.target.value)\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match by rules — always live</p></div></label></div></div><div class=\"mb-4\" x-show=\"collectionType === 'smart'\" x-cloak><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Rules scope</label><div class=\"grid grid-cols-2 gap-2\"><button type=\"button\" @click=\"setSmartScope('books')\" :class=\"smartScope === 'books' ? 'btn btn-secondary' : 'btn btn-ghost'\">📚 Books</button> <button type=\"button\" @click=\"setSmartScope('episodes')\" :class=\"smartScope === 'episodes' ? 'btn btn-secondary' : 'btn btn-ghost'\">🎧 Podcast episodes</button></div><p class=\"text-xs mt-2\" style=\"color: var(--text-secondary)\">Episodes collections match your podcast subscriptions' episodes (feed, downloaded, played, published date) and play as a sequence.</p><input type=\"hidden\" name=\"filter_tree\" id=\"collection-tree-seed\" value=\"\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 147, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 170, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+28 -11
View File
@@ -29,17 +29,34 @@ func firstPlayableEpisodeURL(collectionID string, books []handlers.BookInfo) str
} }
// PlayCollectionButton renders the header's sequence-start button when // PlayCollectionButton renders the header's sequence-start button when
// the collection holds at least one downloaded episode. // the collection holds at least one downloaded episode — alongside the
templ PlayCollectionButton(collectionID string, books []handlers.BookInfo) { // collection's CURATED listening order select (plan §10 R2: the chain
// order lives on the collection, honored by the chain producer; the
// player shows no order control for collection sources).
templ PlayCollectionButton(collectionID string, chainOrder string, books []handlers.BookInfo) {
if url := firstPlayableEpisodeURL(collectionID, books); url != "" { if url := firstPlayableEpisodeURL(collectionID, books); url != "" {
<a <div class="flex items-center gap-2">
href={ url } <a
class="btn btn-primary" href={ url }
title="Play the collection's episodes back-to-back in list order" class="btn btn-primary"
> title="Play the collection's episodes back-to-back"
<span>▶</span> >
<span>Play as Sequence</span> <span>▶</span>
</a> <span>Play as Sequence</span>
</a>
<select
id="chain-order-select"
class="input !w-auto"
aria-label="Listening order"
title="The order the sequence plays in — stored on this collection"
@change={ "setChainOrder('" + collectionID + "', $event.target.value)" }
>
<option value="native" selected?={ chainOrder == "" || chainOrder == "native" }>Feed order</option>
<option value="reverse" selected?={ chainOrder == "reverse" }>Feed order (reversed)</option>
<option value="oldest" selected?={ chainOrder == "oldest" }>Oldest first (pub date)</option>
<option value="newest" selected?={ chainOrder == "newest" }>Newest first (pub date)</option>
</select>
</div>
} }
} }
@@ -389,7 +406,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
<span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span> <span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span>
</div> </div>
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
@PlayCollectionButton(collection.ID, books) @PlayCollectionButton(collection.ID, collection.ChainOrder, books)
<div class="flex-1 min-w-[200px] max-w-md"> <div class="flex-1 min-w-[200px] max-w-md">
<input <input
type="text" type="text"
File diff suppressed because it is too large. Load diff
+4
View File
@@ -54,6 +54,10 @@ type CollectionData struct {
// MatchMode is the smart ruleset's semantics (item 2.4): "any" (OR) // MatchMode is the smart ruleset's semantics (item 2.4): "any" (OR)
// or "all" (AND). Static collections ignore it. // or "all" (AND). Static collections ignore it.
MatchMode string MatchMode string
// ChainOrder is the episode sequence's curated listening order (plan
// §10 R2): "" / native / newest / oldest / reverse — stored in
// view_settings, honored by the chain producer.
ChainOrder 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
+54
View File
@@ -380,11 +380,65 @@ Alpine.data("collections", () => ({
showAllIcons, showAllIcons,
toggleSelection, toggleSelection,
// ── create-modal: type + smart scope (the episodes scope door) ──
// Static/Smart radio state and, for smart collections, which item
// kind the rules match. The modal copy no longer claims smart means
// "books": episodes-scope smart collections are first-class.
collectionType: "static" as string,
smartScope: "books" as string,
setCollectionType(this: any, t: string) {
this.collectionType = t === "smart" ? "smart" : "static";
this.syncTreeSeed();
},
setSmartScope(this: any, s: string) {
this.smartScope = s === "episodes" ? "episodes" : "books";
this.syncTreeSeed();
},
// syncTreeSeed — the create form's hidden filter_tree input. An
// Episodes-scope smart collection seeds an EMPTY episodes tree so the
// editor opens with the episode vocabulary and membership runs the
// episode engine from birth; Books stays tree-less (the editor's
// books default).
syncTreeSeed(this: any) {
const seed =
this.collectionType === "smart" && this.smartScope === "episodes"
? '{"scope":"episodes","mode":"all","children":[]}'
: "";
const input = document.getElementById("collection-tree-seed") as HTMLInputElement | null;
if (input) input.value = seed;
},
showConfirm: false, showConfirm: false,
confirmMessage: "", confirmMessage: "",
confirmLabel: "Remove", confirmLabel: "Remove",
pendingAction: null as null | (() => void), pendingAction: null as null | (() => void),
// setChainOrder — the detail header's listening-order select (plan
// §10 R2: the collection's chain order is curated HERE, not on the
// player). Merges into view_settings server-side, then reloads so
// the chain producer's stored order takes effect.
setChainOrder(this: any, collectionID: string, order: string) {
const token = localStorage.getItem("token");
if (!token) return;
void fetch("/api/collections/" + collectionID + "/view-settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ chain_order: order }),
}).then((r) => {
if (r.ok) {
window.location.reload();
} else {
void import("./toast-error").then((m) => m.showErrorToast("Could not save the listening order"));
}
});
},
requestRemoveBook(this: any, id: string) { requestRemoveBook(this: any, id: string) {
this.confirmMessage = "Remove this book from the collection?"; this.confirmMessage = "Remove this book from the collection?";
this.pendingAction = () => doRemoveBook(id); this.pendingAction = () => doRemoveBook(id);