feat(collections): smart (dynamic) collections — rules evaluated live at render

Type is DERIVED, not stored: a saved ruleset makes a collection SMART,
no rules = STATIC (stored collection_items links, unchanged behavior).
No schema change — collections.auto_assign_rules JSONB already carried
the config; user's decision 2026-10-14 (handoff: live-at-render, not
refresh-on-scan; create-time Static/Smart toggle; v1 NO hybrid).

- Rule gains an enabled flag (absent = enabled, legacy rules unaffected).
- Rule CRUD: POST/PUT(DELETE) /api/collections/:id/rules[/:ruleId] — the
  survivor dead effort of the rules-builder UI finally has real
  endpoints (rules were never editable after creation; nothing ever
  re-evaluated them). First rule converts a user collection to smart;
  removing the last reverts it to static. UpdateCollectionRulesJSON
  edits ONLY the rules doc so rule CRUD cannot clobber metadata.
- Live evaluation: candidates from GetVisibleLiveMediaForRules —
  visible-library set (family lens enforced at source) + archived IS
  NULL AND missing_scan_count = 0 — any enabled rule matches, title
  order. Zero refresh hooks by design: always current by construction.
- Membership mutations (add/remove/bulk-add) are 400 on smart — v1 no
  hybrid; collection_items.excluded stays the reserved future hook.
- Rules builder page (/collections/:id/rules) first time ever routable;
  templ whole-attribute interpolation ("{ expr }" inside a quoted attr
  value is literal text, as theme-{ user.Theme } silently proved).
- UI: create modal Static/⚡Smart cards (smart create redirects into the
  rules builder), ⚡ badge + real per-card counts on the grid (smart =
  live matches, static = stored-link count), smart detail renders
  matched books with no hand-pick actions + an Edit Rules button.
- API: GET /:id gains is_smart + live books; GET /: keeps the Android
  app's default exclusion, include_auto=true surfaces smart rows with
  live counts; TestRules previews against the SAME candidates the live
  render uses (visible + live, was raw first-1000).
- FIXED pre-existing regression from 7ce7321: static collection detail
  with NO library_id returned zero books — QueryType != '' caught user
  rows ('filter' default) and routed them to dashboard sections that
  never contain them; now only real system query types go there
  (handlers + SSR). App-visible symptom: /api/collections/:id without
  library_id came back books:[] for every static collection.

Tests: collections_smart_test.go (conversion, disabled rules, mutation
guards, revert-to-static, validation, system-collection prohibition,
include_auto semantics). Full offline integration suite PASS.
This commit is contained in:
John O'Keefe committed 2026-10-14 11:59:38 -04:00
1 parent df556d8208
commit 0a7fbd6f48
17 files changed
+1766 -440

No files matched your search

+125 -1
View File
@@ -19,8 +19,10 @@ import (
"bookhoard/internal/utils"
"bookhoard/internal/version"
"bookhoard/templates"
"errors"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
@@ -496,14 +498,56 @@ func registerFrontendRoutes(cfg *Config) {
errorMsg = "Error loading collections"
}
// Live per-card counts: smart collections are rule-matched at
// render against the requesting user's visible libraries; static
// ones count their stored links. Library filter applies when a
// specific library is selected.
libRes := resolveLibrary(c, cfg, user.ID)
var libScope pgtype.UUID
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libRes.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
}
colData := make([]templates.CollectionData, len(collections))
colService := cfg.CollectionHandler.CollectionService()
for i, col := range collections {
isSmart := colService.IsSmartCollection(col.AutoAssignRules)
bookCount := 0
if isSmart {
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
})
if evalErr == nil {
bookCount = len(matches)
}
} else {
count, countErr := cfg.Queries.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
LibraryID: libScope,
})
if countErr == nil {
bookCount = int(count)
}
}
colData[i] = templates.CollectionData{
ID: uuid.UUID(col.ID.Bytes).String(),
Name: col.Name,
Description: getText(col.Description),
Color: getText(col.Color),
Icon: getText(col.Icon),
IsSystem: col.IsSystemCollection.Bool,
IsSmart: isSmart,
BookCount: bookCount,
}
}
@@ -572,6 +616,47 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
})
// Smart collection rules builder page (the smart editor). User
// collections only: system sections keep their hardwired queries.
frontendProtected.GET("/collections/:id/rules", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
collectionID := c.Param("id")
collUUID, err := uuid.Parse(collectionID)
if err != nil {
return renderErrorPage(c, "Invalid collection ID", "invalid_id")
}
collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return renderErrorPage(c, "Collection not found", "not_found")
}
return renderErrorPage(c, "Error loading collection", "collection_load_error")
}
if collection.IsSystemCollection.Bool {
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
}
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
Description: getText(collection.Description),
Color: getText(collection.Color),
Icon: getText(collection.Icon),
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
}
var buf bytes.Buffer
err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Collection detail page (works for both system and user collections)
frontendProtected.GET("/collections/:id", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
@@ -599,7 +684,44 @@ func registerFrontendRoutes(cfg *Config) {
libRes := resolveLibrary(c, cfg, user.ID)
libraryID := libRes.LibraryID
if collection.QueryType.Valid && collection.QueryType.String != "" {
var libScope pgtype.UUID
if !libRes.IsAll {
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
}
}
colService := cfg.CollectionHandler.CollectionService()
isSmart := colService.IsSmartCollection(collection.AutoAssignRules)
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if visErr != nil {
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
visibleIDs = nil
}
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 renderErrorPage(c, "Error loading books", "books_load_error")
}
bookCards := make([]handlers.BookInfo, 0, len(matches))
for _, item := range matches {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards = append(bookCards, handlers.BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: getText(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
})
}
books = bookCards
} 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, libRes.LibUUID, 1000, []string{}, []string{})
if secErr != nil {
@@ -684,6 +806,8 @@ func registerFrontendRoutes(cfg *Config) {
Color: collection.Color.String,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
BookCount: len(books),
}
var buf bytes.Buffer