diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 5de835ba..8f42749d 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -2208,3 +2208,129 @@ func TestEpisodeCollectionsFormSeedCreate(t *testing.T) { require.Equal(t, http.StatusOK, code) assert.Contains(t, string(body), `"scope":"episodes"`, "the seeded scope round-trips") } + +// ── SSR id-label resolution (2026-11-01) ───────────────────────────── +// Pages rendering a filter tree embed the id→name map at render time +// (data-id-labels). The pre-fix resolution path — the media-constrained +// suggest facet — could only label ids whose media matches the tree's +// SIBLING rules: live repro "feed in [A,B] AND title contains X" +// labeled one feed of three. The resolver reads the entity tables: a +// feed with ZERO episodes labels fine; a hidden library does not. + +func TestRulesPageEmbedsIdLabels(t *testing.T) { + setup := setupTestServer(t) + regUUID := getRegularUserID(t, setup.DB) + regToken := setup.RegularToken + + lib, _ := createPodcastsLibrary(t, setup, "test Labels Podcasts") + marker := fmt.Sprintf("labelsembed%d", time.Now().UnixNano()%100000) + + // Feed A: subscribed, one downloaded episode matching the sibling + // rule. Feed B: subscribed, ZERO episodes — no media row exists, so + // the facet path structurally could never label it. + feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ + LibraryID: lib.ID, + FeedUrl: "https://feed.invalid/" + marker + "-a.xml", + Title: "Labels 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: "Labels Feed B " + marker, + }) + require.NoError(t, err) + aEp := upsertTestEpisode(t, setup, feedA, marker+"-a", "Labels A Ep "+marker, 1) + adminScopeExec(t, setup, adminCtxValue(t, setup), ` + UPDATE media_items SET download_state = 'downloaded', file_size = 1000 + WHERE id = $1`, aEp.ID) + subscribeRaw(t, setup, regUUID, feedA.ID) + subscribeRaw(t, setup, regUUID, feedB.ID) + + feedAID := uuid.UUID(feedA.ID.Bytes).String() + feedBID := uuid.UUID(feedB.ID.Bytes).String() + feedBTitle := "Labels Feed B " + marker + feedATitle := "Labels Feed A " + marker + + // The user's exact shape: feed in [A,B] AND title contains "…". + tree := map[string]interface{}{ + "scope": "episodes", "mode": "all", + "children": []map[string]interface{}{ + {"field": "feed", "op": "in", "values": []string{feedAID + "," + feedBID}}, + {"field": "title", "op": "contains", "values": []string{"Labels A Ep"}}, + }, + } + treeJSON, _ := json.Marshal(tree) + code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", + `{"name":"test Labels Coll `+marker+`","filter_tree":`+string(treeJSON)+`}`) + require.Equal(t, http.StatusCreated, code, "%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, "") + }) + + t.Run("rules_page_labels_zero_episode_feed", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+coll.ID+"/rules", nil) + req.Header.Set("Authorization", "Bearer "+regToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + pageBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + html := string(pageBody) + require.Contains(t, html, "data-id-labels", "the embed attribute renders") + // Feed B's title can ONLY come from the labels embed — the feed + // has no media, so nothing else on the page can name it. + assert.Contains(t, html, feedBTitle, + "a zero-episode feed resolves its label at render") + assert.Contains(t, html, feedATitle) + }) + + // Books-scope: a URL-carried qf library rule embeds the library's + // name — until the family lens hides that library, at which point + // the resolver must NOT name it (the facet's visibility shape). + // Library creation is admin-only; the regular user still SEES the + // library (default-ALLOW visibility) until the subtest hides it. + booksLibID := createTestLibrary(t, setup.Server, setup.Token, "test Labels Books Lib "+marker) + libUUID := uuid.MustParse(booksLibID) + qf := url.QueryEscape(`{"scope":"books","mode":"all","children":[` + + `{"field":"library","op":"is","values":["` + booksLibID + `"]}]}`) + + bookshelf := func() string { + req, _ := http.NewRequest("GET", setup.Server.URL+"/bookshelf?qf="+qf, nil) + req.Header.Set("Authorization", "Bearer "+regToken) + req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"}) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + pageBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + return string(pageBody) + } + + t.Run("bookshelf_labels_url_qf_library", func(t *testing.T) { + html := bookshelf() + require.Contains(t, html, "data-id-labels") + assert.Contains(t, html, "test Labels Books Lib "+marker, + "the qf library rule's id resolves to its name at render") + }) + + t.Run("hidden_library_not_labeled", func(t *testing.T) { + seedExec(t, setup.DBPool, + `INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`, + pgtype.UUID{Bytes: libUUID, Valid: true}, + pgtype.UUID{Bytes: [16]byte(regUUID), Valid: true}) + t.Cleanup(func() { + seedExec(t, setup.DBPool, + `DELETE FROM library_visibility WHERE library_id = $1 AND user_id = $2`, + pgtype.UUID{Bytes: libUUID, Valid: true}, + pgtype.UUID{Bytes: [16]byte(regUUID), Valid: true}) + }) + html := bookshelf() + assert.NotContains(t, html, "test Labels Books Lib "+marker, + "a family-lens-hidden library is not named, even by explicit id") + }) +} diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 6cd10bf2..be2da982 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -619,3 +619,33 @@ offered-vocabulary narrowing, not a parse change: - Client id-cells are uniform: every operator on an id-valued field accepts a set, so all id cells say "pick one or more" and accumulate picks — no per-operator placeholder/pick distinction to explain. + +## 2026-11-01 — id labels resolve at render (SSR embed): refresh keeps names + +Pages that render a filter tree embed the tree's id→name map at render +time (`data-id-labels` on the builder mount) — the collection editor +(stored tree) and the bookshelf (URL `qf`). Before this, refresh showed +raw uuids: only ids persist in trees; the client's id→label cache dies +with the page, and the quiet suggest fetch reused the MEDIA-CONSTRAINED +facet, so an id whose media doesn't match the tree's sibling rules got +no row and no label (live repro: `feed in [A,B,C] AND title contains +Chapter` labeled one feed of three — two feeds had no "Chapter" +episodes). + +- `SearchService.FilterTreeLabels` reads the ENTITY tables (no media + join, no tree constraint): podcast_feeds and collections are + RLS-governed through the scoped handle (invisible ids resolve to + nothing); libraries mirror the facet's visibility join; added_by is a + plain username lookup. A zero-episode feed resolves its title — + pinned (TestRulesPageEmbedsIdLabels), as is the hidden-library + non-resolution. +- The bookshelf drawer also HYDRATES at component init now (no + data-tree mount → read the form): the parent's x-init + (initBookshelf) runs BEFORE the nested builder component exists + (Alpine initializes parents first), so its builderData() call was + null and a URL-qf refresh left the drawer with an empty seeded rule + while the grid stayed filtered. Init-order contract pinned in + filter_builder.test.ts. +- The quiet suggest fetch remains only as a fallback for trees the SSR + never saw (client-side apply flows); embedded labels make it a no-op + on refresh. diff --git a/internal/handlers/media.go b/internal/handlers/media.go index bc854e3f..5ec9bc13 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -243,6 +243,11 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se // ExecuteSearch performs search and returns results with count // Public wrapper for shared search logic used by both JSON and HTML endpoints +// SearchService exposes the shared search engine for SSR surfaces +// that render a filter tree (the collection editor and bookshelf pages +// resolve id labels through it — 2026-11-01). +func (h *MediaHandler) SearchService() *services.SearchService { return h.searchService } + func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) { return h.searchService.ExecuteSearch(ctx, params) } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 17c1cb28..345799ff 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -409,9 +409,19 @@ func registerFrontendRoutes(cfg *Config) { } } + // SSR id-label resolution for a URL-carried qf tree: the drawer's + // value cells show NAMES from first paint instead of raw uuids + // (2026-11-01 — same story as the collection editor above). + labelsJSON := "{}" + if qfRaw := c.QueryParam("qf"); qfRaw != "" { + if t, perr := services.ParseFilterTree(qfRaw); perr == nil { + labelsJSON = resolveTreeLabelsJSON(c, cfg, user.ID, t) + } + } + var buf bytes.Buffer bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList) - err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf) + err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount, labelsJSON).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -706,12 +716,14 @@ func registerFrontendRoutes(cfg *Config) { // with the rules-array storage — the tree is the only form.) scope := "books" treeJSON := "{}" + var parsedTree *services.FilterTree if len(collection.FilterTree) > 0 { if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil { scope = t.FilterScope() if b, merr := json.Marshal(t); merr == nil { treeJSON = string(b) } + parsedTree = t } } fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope)) @@ -720,6 +732,13 @@ func registerFrontendRoutes(cfg *Config) { } fieldsJSON := string(fieldsPayload) + // SSR id-label resolution: the stored tree arrives as bare ids; + // embed the id→name map so value cells render NAMES at first + // paint (2026-11-01: the client's pick-time cache dies with the + // page, and the quiet suggest fetch can't label ids whose media + // doesn't match the tree's sibling rules — raw uuids on refresh). + labelsJSON := resolveTreeLabelsJSON(c, cfg, user.ID, parsedTree) + colData := templates.CollectionData{ ID: collectionID, Name: collection.Name, @@ -731,7 +750,7 @@ func registerFrontendRoutes(cfg *Config) { } var buf bytes.Buffer - err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope).Render(c.Request().Context(), &buf) + err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope, labelsJSON).Render(c.Request().Context(), &buf) if err != nil { return err } diff --git a/internal/router/helpers.go b/internal/router/helpers.go index cf046d86..40b106f0 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -297,6 +297,32 @@ func collectionDataFromRow(collection database.Collections, collectionID string, // detail page and the books-grid partial: smart evaluation, a dashboard // query section, or manual membership. Callers resolve the scope: // libUUID/libScope/isAll/scoped per their own resolution rules. +// resolveTreeLabelsJSON — SSR id-label resolution for a page-rendered +// filter tree: walks the tree's id-kind rules, resolves uuids to display +// names through the search engine (RLS/visibility ride the request +// identity), and returns the JSON map embedded into the builder mount. +// Best-effort: any failure yields "{}" and the page renders with the +// client's fallbacks intact (2026-11-01, filter_labels.go for the WHY). +func resolveTreeLabelsJSON(c *echo.Context, cfg *Config, userID string, tree *services.FilterTree) string { + if tree == nil || len(tree.Children) == 0 { + return "{}" + } + userUUID, err := uuid.Parse(userID) + if err != nil { + return "{}" + } + labels := cfg.MediaHandler.SearchService().FilterTreeLabels( + c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}, tree) + if len(labels) == 0 { + return "{}" + } + b, merr := json.Marshal(labels) + if merr != nil { + return "{}" + } + return string(b) +} + func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) { colService := cfg.CollectionHandler.CollectionService() isSmart = services.IsCollectionSmart(collection) diff --git a/internal/services/filter_labels.go b/internal/services/filter_labels.go new file mode 100644 index 00000000..8b3a8744 --- /dev/null +++ b/internal/services/filter_labels.go @@ -0,0 +1,121 @@ +package services + +import ( + "context" + "log" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +// ════════════════════════════════════════════════════════════════════ +// SSR id-label resolution (2026-11-01): pages that render a filter tree +// (the collection editor's stored tree, the bookshelf's URL qf) embed +// the id→name map so value cells show NAMES at first paint. +// +// Why not reuse the suggest facet: its rows are media-constrained +// (INNER JOIN media_items + the other rules as a constraint), so an id +// whose media doesn't match the tree's sibling rules returns NO row and +// its label is lost — live repro: feed-in-3 + title-contains-Chapter +// labeled one feed of three. A label is a property of the ENTITY row, +// not of what currently matches: resolution reads the source tables +// directly, no media join, no tree constraint. +// +// Scoping rides the request identity through s.raw (the ScopedDB — +// every statement declares app.user_id): podcast_feeds and collections +// are RLS-governed (an invisible feed/collection resolves to nothing), +// libraries mirror the facet's explicit visibility join, and users +// (added_by) are plain lookups — the viewer already holds the id from +// a legal pick, the resolver only names it. +// ════════════════════════════════════════════════════════════════════ + +// maxResolveIDs bounds one field's resolution query (picks accumulate +// per rule; menus cap far below this). +const maxResolveIDs = 100 + +// FilterTreeLabels resolves every id-kind rule value in the tree to its +// display name. Best-effort by design: a resolve failure degrades to +// fewer labels (cells fall back to raw ids — the pre-fix behavior), +// never a failed page render. Returns uuid → display. +func (s *SearchService) FilterTreeLabels(ctx context.Context, userID pgtype.UUID, tree *FilterTree) map[string]string { + labels := map[string]string{} + if tree == nil { + return labels + } + + // Collect ids per field, split comma-joined picks (the builder + // stores one comma-joined value per multi-pick), cap the query. + byField := map[string][]uuid.UUID{} + var walk func(children []FilterChild) + walk = func(children []FilterChild) { + for _, c := range children { + if len(c.Children) > 0 { + walk(c.Children) + continue + } + if c.Field == "" || len(c.Values) == 0 { + continue + } + f, ok := FilterFieldByKey(c.Field) + if !ok || f.Suggest == "" { + continue + } + if valueKindFor(f.Kind, c.Field) != VKId { + continue + } + for _, id := range parseUUIDValues(c.Values) { + if len(byField[c.Field]) >= maxResolveIDs { + return + } + byField[c.Field] = append(byField[c.Field], id) + } + } + } + walk(tree.Children) + + for field, ids := range byField { + f, _ := FilterFieldByKey(field) + idArgs := make([]uuid.UUID, len(ids)) + copy(idArgs, ids) + var sqlStr string + var args []any + switch f.Suggest { + case suggestLibrary: + // The facet's visibility shape, minus the media join: a + // hidden library is invisible even by id (family lens). + sqlStr = `SELECT l.id::text, l.name +FROM libraries l +LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2 +WHERE COALESCE(lv.is_visible, true) = true AND l.id = ANY($1)` + args = []any{idArgs, userID} + case suggestFeed: + // RLS-governed (subscription + visibility predicate): an + // unsubscribed feed id resolves to nothing. + sqlStr = `SELECT id::text, title FROM podcast_feeds WHERE id = ANY($1)` + args = []any{idArgs} + case suggestCollection: + // RLS-governed (app_can_read_collection). + sqlStr = `SELECT id::text, name FROM collections WHERE id = ANY($1)` + args = []any{idArgs} + case suggestAddedBy: + sqlStr = `SELECT id::text, username FROM users WHERE id = ANY($1)` + args = []any{idArgs} + default: + continue + } + rows, err := s.raw.Query(ctx, sqlStr, args...) + if err != nil { + log.Printf("[filter-labels] resolve %q failed: %v", field, err) + continue + } + for rows.Next() { + var v, display string + if err := rows.Scan(&v, &display); err != nil { + break + } + labels[v] = display + } + rows.Close() + } + return labels +} diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index e224aeea..3f5dfeb7 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -17,6 +17,7 @@ templ BookShelf( limit int, offset int, count int, + labelsJSON string, ) { @@ -178,6 +179,7 @@ templ BookShelf( the tree (depth = indent); groups nest via + group. -->
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 221, Col: 59} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "