feat(dashboard): custom-section builder saves again — the flagged tree migration

Archaeology, prompted by the user asking when it broke: the builder's
save NEVER worked. The page was born (2026-02-19, a92bf99a) posting
auto_assign_rules as a JSON-encoded STRING into an endpoint that has
bound []Rule since its creation (2026-01-31, 6495cc2c), plus fields
(manual_book_ids, match_type, library_id, show_on_dashboard) the
endpoint never accepted. The preview button hit a real endpoint, so
the page looked alive while Save always failed at request binding.
Verified live before this change: the page's exact payload 400'd
"invalid request" against the unmodified server.

The fix adapts the builder's payload at write time — the legacy rules
vocabulary converts to a filter tree (RulesToFilterTree restored from
the retirement commit as the single write path, unit test restored
with it):

- match_type seeds the converted tree's root mode (any default).
- manual_book_ids attach to the created collection after create
  (unparsable ids skipped — the AddBooks leniency).
- show_on_dashboard persists (new create field).
- Converted trees validate under ParseFilterTree: an unhonorable rule
  is a 400 at write naming the problem, never a stored 500 at render.

Dashboard sections: smart user sections now evaluate their tree LIVE —
GetUserCollectionItems merges stored membership with the composed
engine's matches (deduped, library-scoped, capped at the section
limit). This exposed a REAL production wiring bug: every production
NewDashboardService call omitted the search service, so tree
evaluation through the dashboard could never have worked (the test
harness wired it correctly — the gap was production-only and latent,
because nothing in the dashboard evaluated trees before this
migration). The dashboard handler and both inline sites now receive
the search service, and evaluation failures log instead of silently
emptying a section.

Supersedes the same-day loud-400 stance for the builder's payload
(contract_pins migration entry); custom-section-builder.md documents
the save contract; the create_collection API doc gains the adapted
fields. New TestCustomSectionBuilderSave/BadRule pin the payload →
tree → dashboard-render path end to end. Full container suite green
(155s).
This commit is contained in:
John O'Keefe committed 2026-10-31 19:40:43 -04:00
1 parent a86bdac87e
commit 7c7e282e79
11 files changed
+437 -28

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ func main() {
collectionSearchService := services.NewSearchService(queries, scopedDB) collectionSearchService := services.NewSearchService(queries, scopedDB)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService) collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
dashboardService := services.NewDashboardService(queries, collectionSearchService) dashboardService := services.NewDashboardService(queries, collectionSearchService)
dashboardHandler := handlers.NewDashboardHandler(queries) dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
seriesHandler := handlers.NewSeriesHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService) mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService)
@@ -0,0 +1,157 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// The custom-section builder's save, end to end (2026-10-31 tree
// migration). The page posts the legacy rules vocabulary — rules as a
// JSON-encoded ARRAY inside a STRING (it has done so since the page was
// written, 2026-02-19; the save never once bound successfully before
// the migration) — plus match_type, hand-picked book ids, and the
// dashboard pin. The server converts the rules to a filter tree at
// write time, attaches the manual picks, and the dashboard section
// renders stored membership + the tree's live matches, deduped.
func TestCustomSectionBuilderSave(t *testing.T) {
setup := setupTestServer(t)
adminToken := setup.Token
regToken := setup.RegularToken
marker := fmt.Sprintf("csb%d", time.Now().UnixNano()%100000)
libID := createTestLibraryWithFolder(t, setup.Server, adminToken, "test CSB Library "+marker, true)
libUUID, err := uuid.Parse(libID)
require.NoError(t, err)
// Two items the title rule matches, one it doesn't. Alpha is ALSO
// hand-picked — the section must render it once, not twice.
makeItem := func(title string) uuid.UUID {
id, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Authors: []string{"CSB Author"},
FilePath: fmt.Sprintf("/tmp/csb/%s-%s.epub", marker, strings.ReplaceAll(title, " ", "-")),
FileSize: pgtype.Int8{Int64: 128, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, cerr)
out, perr := uuid.FromBytes(id.ID.Bytes[:])
require.NoError(t, perr)
return out
}
alpha := makeItem("CSBRULE Alpha")
_ = makeItem("CSBRULE Beta")
manual := makeItem("CSB Manual Pick")
require.NotZero(t, alpha)
// The EXACT payload shape the builder page posts.
payload := fmt.Sprintf(`{
"library_id": %q,
"name": "test CSB Section %s",
"icon": "📚",
"description": "built by the custom-section builder",
"show_on_dashboard": true,
"auto_assign_rules": %[3]s,
"manual_book_ids": [%q, %q],
"match_type": "all"
}`, libID, marker,
`"[{\"field\":\"title\",\"operator\":\"contains\",\"value\":\"CSBRULE\"}]"`,
alpha.String(), manual.String())
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", payload)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal([]byte(body), &coll))
collID := coll.ID
require.NotEmpty(t, collID)
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+collID, "")
})
// The converted tree round-trips: root mode from match_type, the
// rule as a qf child, and the collection IS smart with no
// collection_type sent.
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var detail struct {
IsSmart bool `json:"is_smart"`
FilterTree struct {
Mode string `json:"mode"`
Children []struct {
Field string `json:"field"`
Op string `json:"op"`
Values []string `json:"values"`
} `json:"children"`
} `json:"filter_tree"`
}
require.NoError(t, json.Unmarshal([]byte(body), &detail))
assert.True(t, detail.IsSmart, "converted rules make the collection smart")
assert.Equal(t, "all", detail.FilterTree.Mode, "match_type seeds the tree's root mode")
require.Len(t, detail.FilterTree.Children, 1)
assert.Equal(t, "title", detail.FilterTree.Children[0].Field)
assert.Equal(t, []string{"CSBRULE"}, detail.FilterTree.Children[0].Values)
// The dashboard section renders stored membership + live matches,
// deduped: Alpha (rule + manual), Beta (rule), Manual (manual).
code, body = podcastAuthed(t, setup, regToken, "GET",
"/api/dashboard/sections?library_id="+libID, "")
require.Equal(t, http.StatusOK, code, "%s", string(body))
var dash struct {
Sections []struct {
Title string `json:"title"`
CollectionID string `json:"collection_id"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
} `json:"sections"`
}
require.NoError(t, json.Unmarshal([]byte(body), &dash))
var section *struct {
Title string `json:"title"`
CollectionID string `json:"collection_id"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
}
for i := range dash.Sections {
if dash.Sections[i].CollectionID == collID {
section = &dash.Sections[i]
break
}
}
require.NotNil(t, section, "the custom section renders on the dashboard: %s", string(body))
titles := map[string]int{}
for _, it := range section.Items {
titles[it.Title]++
}
assert.Equal(t, 1, titles["CSBRULE Alpha"], "rule-matched AND hand-picked renders exactly once")
assert.Equal(t, 1, titles["CSBRULE Beta"], "rule match rides the tree")
assert.Equal(t, 1, titles["CSB Manual Pick"], "manual picks are attached")
assert.Len(t, section.Items, 3, "nothing else leaks into the section")
}
// TestCustomSectionBuilderBadRule pins the adapter's rejection path: a
// rule the engine cannot honor is a 400 that names the field, never a
// stored 500 at render time.
func TestCustomSectionBuilderBadRule(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test CSB Bad `+fmt.Sprint(time.Now().UnixNano())+`","auto_assign_rules":"[{\"field\":\"not_a_field\",\"operator\":\"contains\",\"value\":\"x\"}]"}`)
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
assert.Contains(t, string(body), "section rule")
}
+1 -1
View File
@@ -623,7 +623,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService) collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
filtersHandler := handlers.NewFiltersHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries)
dashboardService := services.NewDashboardService(queries, collectionSearchService) dashboardService := services.NewDashboardService(queries, collectionSearchService)
dashboardHandler := handlers.NewDashboardHandler(queries) dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
statisticsService := services.NewStatisticsService(queries) statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService) statsHandler := handlers.NewStatsHandler(statisticsService)
seriesHandler := handlers.NewSeriesHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries)
+26
View File
@@ -577,3 +577,29 @@ KEPT (live consumers): `POST /api/collections/preview` still accepts
`rules` and evaluates them in memory (the custom-section builder's `rules` and evaluates them in memory (the custom-section builder's
preview button); `include_auto` on the list stays (the Android app preview button); `include_auto` on the list stays (the Android app
sends it). The builder's SAVE remains the flagged §12 tree migration. sends it). The builder's SAVE remains the flagged §12 tree migration.
## 2026-10-31 — custom-section builder save: the flagged tree migration lands
Supersedes the same-day retirement entry's loud-400 stance for the
builder's payload. `POST /api/collections` now ACCEPTS the builder's
shape and adapts it at write time:
- `auto_assign_rules` (JSON-encoded array — string or bare) converts to
a filter tree via the restored `RulesToFilterTree`; converted trees
validate under `ParseFilterTree`, so an unhonorable rule is a 400 at
write naming the problem.
- `match_type` seeds the tree's root mode (`any` default).
- `manual_book_ids` attach to the created collection after create
(unparsable ids skipped — the AddBooks leniency).
- `show_on_dashboard` persists (new create field).
- `library_id` remains selection context only (not stored).
Dashboard sections: smart user sections evaluate their tree LIVE —
`GetUserCollectionItems` merges stored membership with the composed
engine's matches (deduped, library-scoped, capped at the section
limit). This exposed and fixed a real wiring bug: every production
`NewDashboardService` call omitted the search service, so tree
evaluation through the dashboard could never work (the test harness
wired it correctly — the gap was production-only). The dashboard
handler and both inline sites now receive it, and evaluation failures
log instead of silently emptying a section.
@@ -58,6 +58,10 @@ RFC3339. Treat it as display text; do not parse it as an ISO timestamp.
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by a `filter_tree` | | collection_type | string | no | `"static"` (default) or `"smart"`; also implied by a `filter_tree` |
| view_settings | object | no | per-device display preferences | | view_settings | object | no | per-device display preferences |
| filter_tree | object | no | the unified filter tree (qf DSL); presence makes the collection smart | | filter_tree | object | no | the unified filter tree (qf DSL); presence makes the collection smart |
| auto_assign_rules | string \| array | no | custom-section-builder payload — converted to `filter_tree` at write (see custom-section-builder doc) |
| match_type | string | no | builder payload: `any` (default) or `all` — seeds the converted tree's root mode |
| manual_book_ids | string\[\] | no | builder payload: attached to the collection after create |
| show_on_dashboard | boolean | no | pins the collection as a dashboard section |
## Errors ## Errors
+8 -4
View File
@@ -68,10 +68,14 @@ Creates a new custom collection with filter rules and/or manual book selection.
**Request Body:** **Request Body:**
**Status (2026-10-31):** the rules-array storage this payload targets **Save contract (2026-10-31 tree migration):** the rules array travels
was retired — collections carry filter trees now. The server rejects as a JSON-encoded string and the server converts it to a filter tree at
this save with a 400 naming the retired fields; the builder needs its write time — `match_type` seeds the tree's root mode (`any` default),
flagged tree migration (§12) before saving works again. `manual_book_ids` attach to the created collection, and
`show_on_dashboard` persists. `library_id` is selection context only
(sections scope to the dashboard's active library at render). Invalid
rules are a 400 at write naming the problem; the section renders stored
picks + the tree's live matches, deduped.
```json ```json
{ {
+70 -13
View File
@@ -40,12 +40,16 @@ type CreateCollectionRequest struct {
Color string `form:"color" json:"color"` Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"` Icon string `form:"icon" json:"icon"`
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart"
// Retired custom-section-builder payload (2026-10-31): the rules // Custom-section builder payload (adapted 2026-10-31): the page
// ARRAY storage is gone — the filter tree is the only smart // posts the legacy rules vocabulary — rules as a JSON-encoded array
// membership. The field exists ONLY so the builder's legacy save // (a string), a match_type select, hand-picked book ids, and the
// fails LOUDLY (400) instead of silently creating a hollow static // dashboard pin. The server converts rules → filter tree at write
// section. Remove it at the builder's tree migration. // time and attaches the manual picks; storage stays tree-only.
AutoAssignRules json.RawMessage `json:"auto_assign_rules"` AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
MatchType string `json:"match_type"`
ManualBookIDs []string `json:"manual_book_ids"`
LibraryID string `json:"library_id"` // selection context only — not stored
ShowOnDashboard bool `json:"show_on_dashboard"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// The unified filter tree (item 3.2): when present the collection's // The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a // smart membership runs through the qf engine. INVALID trees are a
@@ -63,6 +67,28 @@ type CreateCollectionRequest struct {
FilterTreeForm string `form:"filter_tree"` FilterTreeForm string `form:"filter_tree"`
} }
// parseBuilderRules decodes the custom-section builder's rules payload:
// the page posts JSON.stringify(rules) — a JSON string CONTAINING the
// array (it has done so since the page was written, 2026-02-19) — while
// JSON clients may post the array directly. Null/empty = no rules.
func parseBuilderRules(raw json.RawMessage) ([]services.Rule, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var rules []services.Rule
if raw[0] == '"' {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return nil, err
}
if strings.TrimSpace(encoded) == "" {
return nil, nil
}
return rules, json.Unmarshal([]byte(encoded), &rules)
}
return rules, json.Unmarshal(raw, &rules)
}
// treeFilterJSON returns the request's filter tree as JSON storage // treeFilterJSON returns the request's filter tree as JSON storage
// bytes (nil when none): object payloads marshal; strings (a legacy // bytes (nil when none): object payloads marshal; strings (a legacy
// double-encoded client, the custom-section builder's known bug class) // double-encoded client, the custom-section builder's known bug class)
@@ -206,18 +232,37 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
// The custom-section builder still posts the RETIRED rules payload // Custom-section builder adapter: convert the posted rules to a
// (its save was already failing to bind — verified 2026-10-31). // filter tree at WRITE time (the save-fix — the page's payload had
// Reject loudly rather than silently creating a hollow section. // never once bound successfully before this).
if len(req.AutoAssignRules) > 0 { builderRules, rulesErr := parseBuilderRules(req.AutoAssignRules)
return c.JSON(http.StatusBadRequest, map[string]string{ if rulesErr != nil {
"error": "auto_assign_rules was retired — collections carry filter trees now (the custom-section builder needs its flagged tree migration)", return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_assign_rules: " + rulesErr.Error()})
}) }
matchMode := req.MatchType
if matchMode != services.RuleMatchAll && matchMode != services.RuleMatchAny {
matchMode = services.RuleMatchAny
} }
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm)) treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
if treeErr != nil { if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
} }
if treeJSON == nil && len(builderRules) > 0 {
tree, cerr := services.RulesToFilterTree(builderRules, matchMode)
if cerr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + cerr.Error()})
}
b, merr := json.Marshal(tree)
if merr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": merr.Error()})
}
// The strict-parse guarantee holds for converted trees too: an
// invalid tree is a 400 at write, never a stored 500 at render.
if _, verr := services.ParseFilterTree(string(b)); verr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + verr.Error()})
}
treeJSON = b
}
collection, err := h.collectionService.CreateCollection( collection, err := h.collectionService.CreateCollection(
c.Request().Context(), c.Request().Context(),
userUUID, userUUID,
@@ -226,6 +271,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
req.Color, req.Color,
req.Icon, req.Icon,
req.ViewSettings, req.ViewSettings,
req.ShowOnDashboard,
treeJSON, treeJSON,
) )
if err != nil { if err != nil {
@@ -235,6 +281,17 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Manual picks: a section = rules + hand-picked books. Attach after
// create; unparsable ids are skipped (same leniency as AddBooks).
for _, bookIDStr := range req.ManualBookIDs {
bookID, perr := uuid.Parse(bookIDStr)
if perr != nil {
continue
}
_ = h.collectionService.AddBookToCollection(c.Request().Context(),
uuid.UUID(collection.ID.Bytes), bookID, userUUID)
}
bookCount := int32(0) bookCount := int32(0)
isSmart := req.CollectionType == "smart" || treeJSON != nil isSmart := req.CollectionType == "smart" || treeJSON != nil
response := map[string]interface{}{ response := map[string]interface{}{
@@ -383,7 +440,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
user := c.Get("user").(database.Users) user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes) userUUID := uuid.UUID(user.ID.Bytes)
dashboardSvc := services.NewDashboardService(h.db) dashboardSvc := services.NewDashboardService(h.db, h.collectionService.Search())
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{}) sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil { if secErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
+2 -2
View File
@@ -18,10 +18,10 @@ type DashboardHandler struct {
dashboardService *services.DashboardService dashboardService *services.DashboardService
} }
func NewDashboardHandler(db *database.Queries) *DashboardHandler { func NewDashboardHandler(db *database.Queries, search ...*services.SearchService) *DashboardHandler {
return &DashboardHandler{ return &DashboardHandler{
db: db, db: db,
dashboardService: services.NewDashboardService(db), dashboardService: services.NewDashboardService(db, search...),
} }
} }
+1 -1
View File
@@ -329,7 +329,7 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
} }
} }
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries) dashboardSvc := services.NewDashboardService(cfg.Queries, cfg.CollectionHandler.CollectionService().Search())
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{}) sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil { if secErr != nil {
return nil, isSmart, "books_load_error" return nil, isSmart, "books_load_error"
+132 -2
View File
@@ -63,7 +63,7 @@ func NewCollectionService(db *database.Queries, search ...*SearchService) *Colle
// CreateCollection creates a new collection. filterTree (item 3.2), when // CreateCollection creates a new collection. filterTree (item 3.2), when
// present, makes it smart — the qf engine is the only membership source // present, makes it smart — the qf engine is the only membership source
// (the legacy rules-array storage retired 2026-10-31). // (the legacy rules-array storage retired 2026-10-31).
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) { func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, showOnDashboard bool, filterTree []byte) (database.Collections, error) {
// Convert view settings to JSONB ([]byte) // Convert view settings to JSONB ([]byte)
var settingsJSON []byte var settingsJSON []byte
if len(viewSettings) > 0 { if len(viewSettings) > 0 {
@@ -82,6 +82,7 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
Icon: pgtype.Text{String: icon, Valid: icon != ""}, Icon: pgtype.Text{String: icon, Valid: icon != ""},
ViewSettings: settingsJSON, ViewSettings: settingsJSON,
FilterTree: filterTree, FilterTree: filterTree,
ShowOnDashboard: pgtype.Bool{Bool: showOnDashboard, Valid: true},
}) })
if err != nil { if err != nil {
@@ -270,6 +271,18 @@ func IsCollectionSmart(coll database.Collections) bool {
return len(coll.FilterTree) > 0 return len(coll.FilterTree) > 0
} }
// Search exposes the composed search engine the service was built with
// (nil when the constructor omitted it) — callers composing secondary
// services that evaluate trees (the dashboard service) pass it through.
func (s *CollectionService) Search() *SearchService { return s.search }
// IsCollectionSmartColl is IsCollectionSmart on the service handle —
// the dashboard service composes this service and calls the detector
// through it (no import cycle, one detection definition).
func (s *CollectionService) IsCollectionSmartColl(coll database.Collections) bool {
return IsCollectionSmart(coll)
}
// EvaluateSmartMembership is THE single smart evaluator (item 3.2): // EvaluateSmartMembership is THE single smart evaluator (item 3.2):
// tree-bearing collections evaluate through the unified filter engine. // tree-bearing collections evaluate through the unified filter engine.
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) { func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
@@ -285,7 +298,9 @@ func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll dat
return nil, errors.New("tree collections require the search service (constructor wiring)") return nil, errors.New("tree collections require the search service (constructor wiring)")
} }
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree) return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
} // mediaItemMatchesAnyRule is the legacy OR path, kept for callers that// episodeFinished is THE canonical finished definition (same predicate }
// episodeFinished is THE canonical finished definition (same predicate
// as hide_finished): viewer progress percentage >= 0.999. // as hide_finished): viewer progress percentage >= 0.999.
func episodeFinished(viewerPercentage float64) bool { func episodeFinished(viewerPercentage float64) bool {
return viewerPercentage >= 0.999 return viewerPercentage >= 0.999
@@ -487,6 +502,121 @@ func compareNumbers(a, b, op string) bool {
} }
// CreateDeviceShelfMapping creates a shelf mapping for a device // CreateDeviceShelfMapping creates a shelf mapping for a device
// ── Custom-section-builder adapter (2026-10-31) ──────────────────
// The builder posts the legacy rules vocabulary; these convert it to a
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
// (The 2026-10-31 retirement deleted these as READ-time conversion;
// the builder's tree migration revived them as the single write path.)
// The builder's match_type select: "any" (OR) | "all" (AND).
const (
RuleMatchAny = "any"
RuleMatchAll = "all"
)
// The builder posts the legacy rules vocabulary; these convert it to a
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
// (The 2026-10-31 retirement deleted these as READ-time conversion;
// the builder's tree migration revived them as the single write path.)
// RulesToFilterTree converts a legacy rules array + its match mode
// into a qf tree so a collection upgrades to the unified engine at its
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
// upgrade at write. Episode rules map mechanically (feed→in-list,
// played/downloaded→booleans, published_within→published_date
// within_days); legacy operators without an engine equivalent
// (starts_with/ends_with) fall back to contains — documented, and the
// operator labels stay in the rules docs.
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
children := make([]FilterChild, 0, len(rules))
scope := ""
for _, r := range rules {
child, err := ruleToFilterChild(r)
if err != nil {
return nil, err
}
if episodeOnlyFields[r.Field] {
scope = ScopeEpisodes
}
children = append(children, child)
}
if len(children) == 0 {
return nil, fmt.Errorf("no rules to convert")
}
mode := RuleMatchAny
if matchMode == RuleMatchAll {
mode = RuleMatchAll
}
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
}
func ruleToFilterChild(r Rule) (FilterChild, error) {
child := FilterChild{Field: r.Field}
switch r.Field {
case "feed":
// The rules format stores comma-separated feed IDs in one value.
child.Op = OpIn
child.Values = splitRuleValues(r.Value)
return child, nil
case "downloaded":
child.Op = isTrueFalse(r.Value == "true")
return child, nil
case "played":
child.Op = isTrueFalse(r.Value == "true")
return child, nil
case "published_within":
child.Field = "published_date"
child.Op = OpWithinDays
child.Values = []string{r.Value}
return child, nil
}
// Registry fields: legacy operators → engine operators.
switch r.Operator {
case "equals":
child.Op = OpIs
case "not_equals":
child.Op = OpIsNot
case "contains":
child.Op = OpContains
case "not_contains":
child.Op = OpNotContains
case "greater_than":
child.Op = OpAtLeast
case "less_than":
child.Op = OpAtMost
case "in":
child.Op = OpIn
child.Values = splitRuleValues(r.Value)
case "starts_with", "ends_with":
// No engine equivalent — contains is the honest nearest.
child.Op = OpContains
default:
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
}
child.Values = []string{r.Value}
return child, nil
}
func isTrueFalse(b bool) string {
if b {
return OpIsTrue
}
return OpIsFalse
}
func splitRuleValues(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) { func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{ mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true}, CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
+35 -4
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -292,13 +293,43 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
} }
// The query filters excluded rows in SQL and embeds the media row, // The query filters excluded rows in SQL and embeds the media row,
// so the items are MediaItems as-is. (The legacy auto-assign branch // so the items are MediaItems as-is.
// retired 2026-10-31 with the rules-array storage: smart user
// collections evaluate on their detail page; sections render their
// stored membership.)
items := make([]database.MediaItems, 0, len(manualItems)) items := make([]database.MediaItems, 0, len(manualItems))
seen := make(map[uuid.UUID]bool, len(manualItems))
for _, row := range manualItems { for _, row := range manualItems {
id := uuid.UUID(row.MediaItems.ID.Bytes)
if !seen[id] {
items = append(items, row.MediaItems) items = append(items, row.MediaItems)
seen[id] = true
}
}
// Smart sections (custom sections are rules + manual picks): the
// tree's LIVE membership rides along, scoped to the dashboard's
// active library and the viewing user (played rules are per-user).
// Evaluated items dedupe against the stored ones and the section
// caps at its limit. (Custom-section builder tree migration,
// 2026-10-31 — the rules-array branch this replaces evaluated the
// same way, minus the library scoping.)
if s.collectionService.IsCollectionSmartColl(coll) {
matches, evalErr := s.collectionService.EvaluateSmartMembership(ctx, coll, SmartEvalOptions{
LibraryID: libraryID,
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
})
if evalErr != nil {
log.Printf("dashboard: smart section %s evaluation failed: %v", coll.Name, evalErr)
}
for _, m := range matches {
id := uuid.UUID(m.ID.Bytes)
if !seen[id] {
items = append(items, m)
seen[id] = true
}
}
}
if len(items) > limit {
items = items[:limit]
} }
return items, nil return items, nil
} }