diff --git a/cmd/server/main.go b/cmd/server/main.go index abd96908..05f58cf2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -176,7 +176,7 @@ func main() { collectionSearchService := services.NewSearchService(queries, scopedDB) collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService) dashboardService := services.NewDashboardService(queries, collectionSearchService) - dashboardHandler := handlers.NewDashboardHandler(queries) + dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService) seriesHandler := handlers.NewSeriesHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService) diff --git a/cmd/server/tests/custom_section_builder_test.go b/cmd/server/tests/custom_section_builder_test.go new file mode 100644 index 00000000..eb2a2f2a --- /dev/null +++ b/cmd/server/tests/custom_section_builder_test.go @@ -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") +} diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 31fda710..68c6fe4e 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -623,7 +623,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService) filtersHandler := handlers.NewFiltersHandler(queries) dashboardService := services.NewDashboardService(queries, collectionSearchService) - dashboardHandler := handlers.NewDashboardHandler(queries) + dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService) statisticsService := services.NewStatisticsService(queries) statsHandler := handlers.NewStatsHandler(statisticsService) seriesHandler := handlers.NewSeriesHandler(queries) diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 011d87b9..efd3d195 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -577,3 +577,29 @@ KEPT (live consumers): `POST /api/collections/preview` still accepts `rules` and evaluates them in memory (the custom-section builder's preview button); `include_auto` on the list stays (the Android app 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. diff --git a/docs/developer/api/collections/create_collection.md b/docs/developer/api/collections/create_collection.md index a5ea0e08..ffba4c54 100644 --- a/docs/developer/api/collections/create_collection.md +++ b/docs/developer/api/collections/create_collection.md @@ -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` | | view_settings | object | no | per-device display preferences | | 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 diff --git a/docs/developer/api/custom-section-builder.md b/docs/developer/api/custom-section-builder.md index b71bea36..cf7194ea 100644 --- a/docs/developer/api/custom-section-builder.md +++ b/docs/developer/api/custom-section-builder.md @@ -68,10 +68,14 @@ Creates a new custom collection with filter rules and/or manual book selection. **Request Body:** -**Status (2026-10-31):** the rules-array storage this payload targets -was retired β€” collections carry filter trees now. The server rejects -this save with a 400 naming the retired fields; the builder needs its -flagged tree migration (Β§12) before saving works again. +**Save contract (2026-10-31 tree migration):** the rules array travels +as a JSON-encoded string and the server converts it to a filter tree at +write time β€” `match_type` seeds the tree's root mode (`any` default), +`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 { diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 9380fb55..ebfffcaa 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -40,12 +40,16 @@ type CreateCollectionRequest struct { Color string `form:"color" json:"color"` Icon string `form:"icon" json:"icon"` CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" - // Retired custom-section-builder payload (2026-10-31): the rules - // ARRAY storage is gone β€” the filter tree is the only smart - // membership. The field exists ONLY so the builder's legacy save - // fails LOUDLY (400) instead of silently creating a hollow static - // section. Remove it at the builder's tree migration. + // Custom-section builder payload (adapted 2026-10-31): the page + // posts the legacy rules vocabulary β€” rules as a JSON-encoded array + // (a string), a match_type select, hand-picked book ids, and the + // dashboard pin. The server converts rules β†’ filter tree at write + // time and attaches the manual picks; storage stays tree-only. 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"` // The unified filter tree (item 3.2): when present the collection's // smart membership runs through the qf engine. INVALID trees are a @@ -63,6 +67,28 @@ type CreateCollectionRequest struct { 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 // bytes (nil when none): object payloads marshal; strings (a legacy // 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()}) } - // The custom-section builder still posts the RETIRED rules payload - // (its save was already failing to bind β€” verified 2026-10-31). - // Reject loudly rather than silently creating a hollow section. - if len(req.AutoAssignRules) > 0 { - return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "auto_assign_rules was retired β€” collections carry filter trees now (the custom-section builder needs its flagged tree migration)", - }) + // Custom-section builder adapter: convert the posted rules to a + // filter tree at WRITE time (the save-fix β€” the page's payload had + // never once bound successfully before this). + builderRules, rulesErr := parseBuilderRules(req.AutoAssignRules) + if rulesErr != nil { + 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)) if treeErr != nil { 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( c.Request().Context(), userUUID, @@ -226,6 +271,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { req.Color, req.Icon, req.ViewSettings, + req.ShowOnDashboard, treeJSON, ) 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()}) } + // 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) isSmart := req.CollectionType == "smart" || treeJSON != nil 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" { user := c.Get("user").(database.Users) 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{}) if secErr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()}) diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 19f09f58..7aa958bd 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -18,10 +18,10 @@ type DashboardHandler struct { dashboardService *services.DashboardService } -func NewDashboardHandler(db *database.Queries) *DashboardHandler { +func NewDashboardHandler(db *database.Queries, search ...*services.SearchService) *DashboardHandler { return &DashboardHandler{ db: db, - dashboardService: services.NewDashboardService(db), + dashboardService: services.NewDashboardService(db, search...), } } diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 920304e7..301c13a8 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -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" { - 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{}) if secErr != nil { return nil, isSmart, "books_load_error" diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index 8f462da1..c6aa9c01 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -63,7 +63,7 @@ func NewCollectionService(db *database.Queries, search ...*SearchService) *Colle // CreateCollection creates a new collection. filterTree (item 3.2), when // present, makes it smart β€” the qf engine is the only membership source // (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) var settingsJSON []byte if len(viewSettings) > 0 { @@ -75,13 +75,14 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU } collection, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - Name: name, - Description: pgtype.Text{String: description, Valid: description != ""}, - Color: pgtype.Text{String: color, Valid: color != ""}, - Icon: pgtype.Text{String: icon, Valid: icon != ""}, - ViewSettings: settingsJSON, - FilterTree: filterTree, + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + Name: name, + Description: pgtype.Text{String: description, Valid: description != ""}, + Color: pgtype.Text{String: color, Valid: color != ""}, + Icon: pgtype.Text{String: icon, Valid: icon != ""}, + ViewSettings: settingsJSON, + FilterTree: filterTree, + ShowOnDashboard: pgtype.Bool{Bool: showOnDashboard, Valid: true}, }) if err != nil { @@ -270,6 +271,18 @@ func IsCollectionSmart(coll database.Collections) bool { 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): // tree-bearing collections evaluate through the unified filter engine. 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 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. func episodeFinished(viewerPercentage float64) bool { return viewerPercentage >= 0.999 @@ -487,6 +502,121 @@ func compareNumbers(a, b, op string) bool { } // 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) { mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{ CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true}, diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index bd0606d2..d5492aed 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "github.com/google/uuid" "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, - // so the items are MediaItems as-is. (The legacy auto-assign branch - // retired 2026-10-31 with the rules-array storage: smart user - // collections evaluate on their detail page; sections render their - // stored membership.) + // so the items are MediaItems as-is. items := make([]database.MediaItems, 0, len(manualItems)) + seen := make(map[uuid.UUID]bool, len(manualItems)) for _, row := range manualItems { - items = append(items, row.MediaItems) + id := uuid.UUID(row.MediaItems.ID.Bytes) + if !seen[id] { + 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 }