Files
bookhoard/internal/router/helpers.go
T
John O'Keefe 9e831085ef fix(ui): derive Library Type dropdowns from library_types — audiobooks was missing
The audiobook library type shipped backend-complete (LibraryTypeAudiobooks
constant, allowed extensions, MIME map, schema seed row, GET
/api/libraries/types) but neither create-library form offered it: the
admin Create Library modal and the setup wizard's add-library step both
hardcoded their <option> lists to the original three types
(ebooks/comics/manga), so an audiobook library could not be created from
the UI.

Both dropdowns now render from the same DB-backed library_types table
the create API resolves types against — a future type appears without
template edits (the exact drift the rendering-ownership rule warns
about):

- templates/utils.go: OrderedLibraryTypes pins the historical dropdown
  order (ebooks, comics, manga, audiobooks) and appends unknown future
  types by name; LibraryTypeDisplayName renders labels with a
  capitalize-the-name fallback for types added without a label
- templates/admin_library.templ: new LibraryTypeOptions fragment used
  by the modal's select; AdminLibrary takes the types list
- templates/setup.templ: Setup takes the types list, same fragment
- internal/router/helpers.go: libraryTypeOptionsFor fetches via
  LibraryService.GetLibraryTypes, falling back to the built-in type
  names on DB failure so the forms stay usable (CreateLibrary still
  resolves the name against the DB, so the fallback is never
  authoritative)
- internal/router/frontend.go, internal/router/setup.go: pass the list
  into the two pages

Tests:
- templates/utils_test.go: pure unit tests for ordering + labels
- cmd/server/tests/admin_library_page_test.go: SSR regression —
  /admin/library must offer all four library types, audiobooks exactly
  once (options come from the DB loop, not a hardcode)
2026-10-18 16:38:03 -04:00

429 lines
14 KiB
Go

package router
import (
"context"
"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
}
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,
}, 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 parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}
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 {
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,
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.
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 = colService.IsSmartCollection(collection.AutoAssignRules)
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
})
if evalErr != nil {
return nil, isSmart, "books_load_error"
}
books = handlers.BookCardsFromMediaItems(matches, libTypes)
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
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 {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionItemRows(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)
}