diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 3bb94772..ad2a168c 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -943,6 +943,48 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) { // Sequence config rides a data-json attribute — templ escapes quotes. esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) } + // MULTI-PICK wire shape (2026-11-01 user report): the builder's pick + // loop stores several feeds as ONE comma-joined value — "feeds in + // [A, B]" must match BOTH feeds' episodes, not zero. + feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ + LibraryID: lib.ID, + FeedUrl: "https://feed.invalid/" + marker + "-b.xml", + Title: "TreeColl Feed B " + marker, + }) + require.NoError(t, err) + bEp := upsertTestEpisode(t, setup, feedB, marker+"-b", "TreeColl B Ep", 1) + adminScopeExec(t, setup, adminCtxValue(t, setup), ` + UPDATE media_items SET download_state = 'downloaded', file_size = 1000, + date_published = CURRENT_DATE WHERE id = $1`, bEp.ID) + subscribeRaw(t, setup, regUUID, feedB.ID) + + t.Run("comma_joined_multi_feed_matches_both", func(t *testing.T) { + joined := feedAID + "," + uuid.UUID(feedB.ID.Bytes).String() + multi, _ := json.Marshal(map[string]interface{}{ + "scope": "episodes", "mode": "any", + "children": []map[string]interface{}{ + {"field": "feed", "op": "in", "values": []string{joined}}, + }, + }) + code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", + `{"name":"test TreeColl Multi `+marker+`","filter_tree":`+string(multi)+`}`) + require.Equal(t, http.StatusCreated, code, "%s", string(body)) + var coll struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal([]byte(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) + h := string(body) + assert.Contains(t, h, "TreeColl New", "feed A's episodes match") + assert.Contains(t, h, "TreeColl B Ep", "feed B's episodes match") + assert.Contains(t, h, "TreeColl Old", "no date rule — every episode of both feeds") + }) + // The Daily Fiction briefing AS A TREE (item 2.5's shape, unified DSL). briefing, _ := json.Marshal(map[string]interface{}{ "scope": "episodes", "mode": "all", diff --git a/internal/services/filter_query.go b/internal/services/filter_query.go index 483281cd..f3096fcb 100644 --- a/internal/services/filter_query.go +++ b/internal/services/filter_query.go @@ -379,6 +379,24 @@ func clauseFileAvailability(r FilterRule, b *filterBuilder) string { } // clauseAddedBy — the admin who added the book. +// parseUUIDValues — the wire shape for id-valued rules: the builder's +// pick loop stores picked ids as ONE comma-joined value +// (["id1,id2"]), while hand-written trees may carry separate elements. +// Both are legal; split on commas, trim, and keep what parses. An +// unsplit parse matched NOTHING for multi-picks (2026-11-01 user +// report: two feeds in "feeds in" matched zero episodes). +func parseUUIDValues(values []string) []uuid.UUID { + ids := make([]uuid.UUID, 0, len(values)) + for _, v := range values { + for _, part := range strings.Split(v, ",") { + if id, err := uuid.Parse(strings.TrimSpace(part)); err == nil { + ids = append(ids, id) + } + } + } + return ids +} + func clauseAddedBy(r FilterRule, b *filterBuilder) string { switch r.Op { case OpIs: @@ -610,12 +628,7 @@ func clauseCollection(r FilterRule, b *filterBuilder) string { } return "(NOT " + ex + ")" } - ids := make([]uuid.UUID, 0, len(r.Values)) - for _, v := range r.Values { - if id, err := uuid.Parse(v); err == nil { - ids = append(ids, id) - } - } + ids := parseUUIDValues(r.Values) ex := fmt.Sprintf(`(EXISTS ( SELECT 1 FROM collection_items ci JOIN collections c ON c.id = ci.collection_id @@ -633,12 +646,7 @@ func clauseCollection(r FilterRule, b *filterBuilder) string { // library IDs; visibility is enforced by the mandatory head regardless // (a rule naming a hidden library simply matches nothing). func clauseLibrary(r FilterRule, b *filterBuilder) string { - ids := make([]uuid.UUID, 0, len(r.Values)) - for _, v := range r.Values { - if id, err := uuid.Parse(v); err == nil { - ids = append(ids, id) - } - } + ids := parseUUIDValues(r.Values) if r.Op == OpIs { return "(l.id = ANY(" + b.bind(ids) + "::uuid[]))" } @@ -653,12 +661,7 @@ func clauseLibrary(r FilterRule, b *filterBuilder) string { // naming an unsubscribed feed matches nothing). func clauseFeed(r FilterRule, b *filterBuilder) string { b.needJoin("pf", "LEFT JOIN podcast_feeds pf ON pf.id = mi.source_feed_id") - ids := make([]uuid.UUID, 0, len(r.Values)) - for _, v := range r.Values { - if id, err := uuid.Parse(v); err == nil { - ids = append(ids, id) - } - } + ids := parseUUIDValues(r.Values) in := "(pf.id = ANY(" + b.bind(ids) + "::uuid[]))" switch r.Op { case OpIs, OpIn: diff --git a/templates/filter_builder.templ b/templates/filter_builder.templ index 71681815..1aeb4e0b 100644 --- a/templates/filter_builder.templ +++ b/templates/filter_builder.templ @@ -60,7 +60,7 @@ templ FilterBuilderRows(countNoun string) {
{ ); c.resolveIdLabels(); await vi.waitFor(() => expect(c.idLabels["lib-9"]).toBe("Ebooks")); - expect(url).toContain("library=lib-9", "the stored id rides as the typed lookup"); + // the stored id rides as the typed lookup + expect(url).toContain("library=lib-9"); expect(c.valueCellText([0])).toBe("Ebooks"); - expect(c.suggestionsOpen).toBe("", "label resolution never opens a menu"); + // label resolution never opens a menu + expect(c.suggestionsOpen).toBe(""); vi.unstubAllGlobals(); }); diff --git a/web/src/filter_builder.ts b/web/src/filter_builder.ts index 47e38b6c..26fb0376 100644 --- a/web/src/filter_builder.ts +++ b/web/src/filter_builder.ts @@ -727,6 +727,15 @@ export function filterBuilderData() { this.filterTree.children.forEach((child, idx) => visit(child, [idx])); }, + // valuePlaceholderFor — the value cell's hint. Id-kind cells are + // the few MULTI-VALUE boxes (pick several; the menu keeps toggling + // them in) — say so, because nothing else on the page does. + valuePlaceholderFor(rule: FilterRuleState): string { + if (this.fieldIsDays(rule.field)) return "days"; + if (FIELD_BY_KEY.get(rule.field)?.valueKind === "id") return "pick one or more"; + return "value"; + }, + // isIdValueRow — the value cell stores IDS (library/collection/ // feed/added_by): display needs the label map, typing filters NAMES. isIdValueRow(path: number[]): boolean {