Files
bookhoard/internal/router/helpers.go
T
John O'Keefe 65f257f377 fix(filter-builder): id labels resolve at render — refresh keeps names
Refresh showed raw uuids in id-kind value cells (feed/library/
collection/added_by). Two defects, one story:

1. Only ids persist in filter trees; the client's id→label cache dies
   with the page. The quiet re-resolution fetch reused the suggest
   facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items +
   the tree's sibling rules as a constraint) — so an id whose media
   doesn't match got no row and no label. Live repro: "feed in [A,B,C]
   AND title contains Chapter" labeled one feed of three (live-request
   evidence: 200 with total:1). A label is a property of the entity
   row, not of what currently matches.

   Fix: pages that render a tree resolve labels SERVER-SIDE at render
   and embed the map on the mount (data-id-labels) — the collection
   editor (stored tree) and the bookshelf (URL qf). New
   SearchService.FilterTreeLabels reads the entity tables with no
   media join and no tree constraint; scoping rides the request
   identity through the scoped handle (podcast_feeds/collections RLS —
   an invisible id resolves to nothing; pinned), libraries mirror the
   facet's visibility join. A feed with ZERO episodes resolves its
   title — pinned; that case was structurally impossible before.

2. The bookshelf drawer never hydrated at page load at all: the only
   load-time hydrate calls live in the PARENT's x-init
   (initBookshelf), and Alpine initializes parents before nested
   components — builderData() was null there, so hydrateFromForm
   no-oped and a URL-qf refresh left the drawer with an empty seeded
   rule while the grid stayed filtered. The drawer now hydrates in its
   OWN init when the mount carries no data-tree; the init-order
   contract is pinned in vitest (185/185).

The quiet suggest fetch remains only as a fallback for trees the SSR
never saw; embedded labels make it a no-op on refresh. Interactive
menus are untouched (their constrained, count-bearing behavior is
correct for choosing).

Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render,
bookshelf qf library label, hidden library NOT labeled);
filter/episode-tree/search-contract/RLS/playlist container slices
green; contract_pins entry documents the embed contract.
2026-11-01 10:44:48 -05:00

500 lines
17 KiB
Go

package router
import (
"context"
"encoding/json"
"log"
"net/http"
"net/url"
"os"
"sync"
"time"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
)
func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, error) {
userID := c.Get("user_id").(string)
userEmail := c.Get("user_email").(string)
userUsername := c.Get("user_username").(string)
userRole := c.Get("user_role").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
log.Printf("getTemplateUserWithTheme failed: invalid UUID '%s': %v", userID, err)
return templates.User{}, err
}
userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
log.Printf("getTemplateUserWithTheme failed: database query error for user ID %s: %v", userID, err)
return templates.User{}, err
}
userTheme := "tokyo-night"
if userDB.Theme.Valid {
userTheme = userDB.Theme.String
}
userTimezone := "UTC"
if userDB.Timezone.Valid {
userTimezone = userDB.Timezone.String
}
// Extract JWT token for WebSocket authentication
token := ""
if cookie, err := c.Cookie("token"); err == nil {
token = cookie.Value
}
// Sidebar /podcasts gate (plan D13): >=1 visible podcasts library.
// One indexed COUNT per page render — the same class of per-request
// lookup the live-user guard already does.
showPodcasts := false
if n, err := cfg.Queries.CountPodcastLibrariesForUser(c.Request().Context(), uuidToPGType(userUUID)); err == nil && n > 0 {
showPodcasts = true
}
return templates.User{
ID: userID,
Email: userEmail,
Username: userUsername,
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
FirstName: utils.PgTextString(userDB.FirstName),
LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
KeepScreenAwake: userDB.KeepScreenAwake,
ShowPodcastsNav: showPodcasts,
}, nil
}
func convertPending(pending []map[string]interface{}) []templates.PendingRegistrationData {
result := make([]templates.PendingRegistrationData, len(pending))
for i, p := range pending {
result[i] = templates.PendingRegistrationData{
RegistrationID: p["registration_id"].(string),
DeviceName: p["device_name"].(string),
DeviceType: p["device_type"].(string),
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
}
}
return result
}
func pingDB(cfg *Config, ctx context.Context) error {
if cfg.DBPool != nil {
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
return pool.Ping(ctx)
}
}
return nil
}
func uuidToPGType(u uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: u, Valid: true}
}
const selectedLibraryCookie = "selectedLibrary"
const allLibrariesSentinel = "__all__"
type LibraryResolution struct {
LibraryID string
IsAll bool
LibUUID pgtype.UUID
Libraries []templates.LibraryData
FirstID string
}
// folderStatEntry caches one folder-reachability result.
type folderStatEntry struct {
exists bool
at time.Time
}
const folderStatTTL = 30 * time.Second
var folderStatCache sync.Map
// folderReachableCached stats a library folder with a short TTL cache:
// resolveLibrary runs on every SSR render, and a stat per library folder
// over a network mount adds real latency to each page. The cache trades
// ~30s of stale online/offline state for snappy renders.
func folderReachableCached(path string) bool {
if v, ok := folderStatCache.Load(path); ok {
if e, ok := v.(folderStatEntry); ok && time.Since(e.at) < folderStatTTL {
return e.exists
}
}
exists := false
if _, err := os.Stat(path); err == nil {
exists = true
}
folderStatCache.Store(path, folderStatEntry{exists: exists, at: time.Now()})
return exists
}
func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution {
res := LibraryResolution{}
userU, _ := uuid.Parse(userUUID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
if err != nil {
log.Printf("GetUserVisibleLibraries failed: %v", err)
libraries = []database.GetUserVisibleLibrariesRow{}
}
visibleSet := make(map[string]bool, len(libraries))
for _, lib := range libraries {
if !lib.ID.Valid {
continue
}
if id, err := uuid.FromBytes(lib.ID.Bytes[0:16]); err == nil {
visibleSet[id.String()] = true
}
}
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
if countErr != nil {
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
counts = []database.GetVisibleLibraryMediaCountsRow{}
}
countMap := make(map[string]int64, len(counts))
for _, mc := range counts {
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
countMap[mcUUID.String()] = mc.MediaCount
}
res.Libraries = make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
// Steam Deck SD-card behavior: a library whose folders are all
// missing (unmounted drive) renders offline - hidden contents, no
// marking, no purging - and returns when the storage does.
offline := false
folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID)
if folderErr != nil || len(folders) == 0 {
offline = true
} else {
anyFolderExists := false
for _, folder := range folders {
if folderReachableCached(folder.FolderPath) {
anyFolderExists = true
break
}
}
offline = !anyFolderExists
}
res.Libraries[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: utils.PgTextString(lib.Description),
TypeName: lib.TypeName,
TypeValue: lib.TypeName,
MediaCount: countMap[libUUID.String()],
Offline: offline,
}
}
if len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
res.FirstID = libUUID.String()
}
libraryID := c.QueryParam("library_id")
if libraryID == "" {
if cookie, err := c.Cookie(selectedLibraryCookie); err == nil {
val, _ := url.QueryUnescape(cookie.Value)
if val == allLibrariesSentinel {
res.IsAll = true
res.LibraryID = ""
return res
}
if _, parseErr := uuid.Parse(val); parseErr == nil {
libraryID = val
}
}
}
if libraryID == "" {
res.LibraryID = res.FirstID
if res.LibraryID != "" {
parsed, _ := uuid.Parse(res.LibraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
return res
}
// A requested library outside the requester's visible set is not
// honored — fall back to the first visible library instead of leaking
// hidden-library contents through a hand-typed query param or cookie.
if !visibleSet[libraryID] {
res.LibraryID = res.FirstID
if res.LibraryID != "" {
parsed, _ := uuid.Parse(res.LibraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
return res
}
res.LibraryID = libraryID
parsed, _ := uuid.Parse(libraryID)
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
return res
}
// visibleLibraryIDsFor returns the requester's visible-library id set (for
// membership checks) plus the ids themselves (for SQL ANY() filters).
func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) {
userUUIDStr, ok := c.Get("user_id").(string)
if !ok {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
userU, err := uuid.Parse(userUUIDStr)
if err != nil {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU))
}
// collectionDataFromRow builds the template collection payload shared by
// the collection detail page and its books-grid partial.
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{
ID: collectionID,
Name: collection.Name,
Description: collection.Description.String,
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
ChainOrder: chainOrder,
BookCount: bookCount,
}
}
// collectionBooksForScope runs the per-scope book loading shared by the
// 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)
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, rules applied under the
// collection's match mode (item 2.4: any|all), with the
// viewer's progress feeding played/unplayed rules.
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
collection, services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if evalErr != nil {
return nil, isSmart, "books_load_error"
}
// The visible order IS the play order: the curated listening
// order (view_settings.chain_order, §10 R2) re-orders the
// episode cards exactly as the sequence producer will play
// them, so the select's effect is visible and Play launches
// from the card the user sees first (2026-11-01 user report:
// the select changed nothing on the page).
services.OrderEpisodeMedia(matches, handlers.CollectionChainOrder(collection.ViewSettings))
books = handlers.BookCardsFromMediaItems(matches, libTypes)
// Episode-awareness for smart matches (item 2.5): episode cards
// carry the play affordance (chain via ?queue=collection:).
// BookCardsFromMediaItems preserves match order, so a parallel
// walk is enough; feed titles stay empty here (batch lookup is
// polish, not acceptance).
for i := range books {
if i < len(matches) && matches[i].Source == "podcast_episode" {
books[i].IsEpisode = true
books[i].DownloadState = matches[i].DownloadState
}
}
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries, cfg.CollectionHandler.CollectionService().Search())
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil {
return nil, isSmart, "books_load_error"
}
for _, section := range sections {
if section.CollectionID.String() == collectionID {
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
break
}
}
} else {
if scoped && libUUID.Valid {
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: collUUID,
LibraryID: libUUID,
Limit: pgtype.Int4{Int32: 1000, Valid: true},
})
if collErr != nil {
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
} else {
// The query filters excluded rows in SQL.
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
// Episode collections (plan item 2.3): the detailed query
// keeps RLS-invisible rows as Hidden cards (unsubscribed
// episodes render an "unavailable" hint) and tags episode
// rows with feed title + download state. Same position
// ordering; the legacy INNER-JOIN shape remains on the
// library-scoped branch above.
collItems, collErr := cfg.Queries.GetCollectionItemsDetailed(c.Request().Context(), collUUID)
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionDetailed(collItems, libTypes)
}
}
return books, isSmart, ""
}
// loadCollectionBooks resolves the books shown for a collection detail
// page: full library resolution (visible-library payload, folder
// reachability, counts) because the page render needs it. The books-grid
// partial uses the lighter loadCollectionBooksLite instead.
// errCode is "" on success, otherwise a renderErrorPage code.
func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, collection database.Collections, collectionID string) (books []handlers.BookInfo, libRes LibraryResolution, isSmart bool, errCode string) {
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return nil, libRes, isSmart, "invalid_id"
}
userUUID, _ := uuid.Parse(user.ID)
libRes = resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
var libScope pgtype.UUID
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
scoped := libraryID != "" && !libRes.IsAll
books, isSmart, errCode = cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID, libRes.LibUUID, libScope, scoped, visibleIDs)
return books, libRes, isSmart, errCode
}
// loadCollectionBooksLite is the books-grid partial's loader. The switcher
// refresh only needs "is the requested library visible, and its books" —
// so it validates the requested library against the caller's visible set
// directly (family-lens: out-of-set is rejected) and skips the page-only
// library resolution (folder reachability, counts, dropdown payload).
func (cfg *Config) loadCollectionBooksLite(c *echo.Context, userUUID uuid.UUID, collection database.Collections, collectionID string) (books []handlers.BookInfo, isSmart bool, errCode string) {
collUUID, parseErr := uuid.Parse(collectionID)
if parseErr != nil {
return nil, isSmart, "invalid_id"
}
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return nil, isSmart, "invalid_library_id"
}
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if setErr != nil {
return nil, isSmart, "books_load_error"
}
if !set[libraryID] {
return nil, isSmart, "library_forbidden"
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID,
libUUID, libUUID, libraryID != "", visibleIDs)
}
// libraryTypeOptionsFor loads the library_types rows for the create-library
// dropdowns (admin page + setup wizard), ordered for display. On DB failure
// it falls back to the built-in type names so the form stays usable —
// CreateLibrary resolves the name against the DB, so this is never
// authoritative.
func libraryTypeOptionsFor(ctx context.Context, cfg *Config) []database.LibraryTypes {
types, err := cfg.LibraryService.GetLibraryTypes(ctx)
if err != nil {
log.Printf("libraryTypeOptionsFor: GetLibraryTypes failed, using built-in type names: %v", err)
for _, name := range []string{
services.LibraryTypeEbooks,
services.LibraryTypeComics,
services.LibraryTypeManga,
services.LibraryTypeAudiobooks,
} {
types = append(types, database.LibraryTypes{Name: name})
}
}
return templates.OrderedLibraryTypes(types)
}