feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)

The smart-collection editor at /collections/:id/rules now hosts the
SAME builder island as All-Books (FilterBuilderRows — the rows markup
extracted into one shared templ both mounts render; the shared
filterBuilderExtra component). One vocabulary, one engine, one row UI;
the collection differences are exactly three: the payload is the
collection's scope (episode trees get the episode fields — books
trees hide them), Apply writes the tree to the collection (PUT
filter_tree through the validated write path), and a legacy rules
array preloads as its CONVERTED tree (RulesToFilterTree rendered
server-side) so the upgrade lands at the first Apply — read-compat
never mutates data silently.

Retired with the page: the per-rule CRUD endpoints and test-rules
(their handler bodies, routes, api-docs rows, and the dead collections.ts
island functions — the file's LIVE halves, the collection modal/navigate/
grid-refresh, stay; the dead rules half went). match-mode endpoint and
preview survive (legacy channel + the dashboard custom-section-builder,
flagged for its own migration). Contract-pin changelog documents the
deletions per the asymmetry rule.

The builder component gains collection mounts: scopeKey (the row
universe for counts/apply), data-tree prefill, treeJSON (the apply
wire with scope riding the top level), and an apply hook (the page
owns the write; All-Books never sets one). ensureReadyRule defaults to
the scope's first field instead of hardcoding author.

Tests migrated onto trees: TestSmartCollections' live-membership /
reject-mutations / include-auto / system-collection subtests now drive
TREE collections; the deleted-endpoint subtests die with them.
This commit is contained in:
John O'Keefe committed 2026-10-30 19:22:17 -04:00
1 parent eb68672390
commit 4f71eda6be
16 files changed
+518 -1422

No files matched your search

-16
View File
@@ -835,22 +835,6 @@ var Schemas = map[string]Schema{
"DELETE /api/devices/:id/file-aliases/:aliasId": {
Response: json.RawMessage(`{"message": ""}`),
},
"POST /api/collections/:id/rules": {
// First rule converts a static collection to smart.
Request: handlers.AddRuleRequest{},
Response: json.RawMessage(`{
"collection_id": "", "rule": {}, "is_smart": false
}`),
},
"DELETE /api/collections/:id/rules/:ruleId": {
Response: json.RawMessage(`{
"collection_id": "", "is_smart": false
}`),
},
"POST /api/collections/test-rules": {
Request: handlers.TestRulesRequest{},
Response: json.RawMessage(`{"matches": [], "total": 0}`),
},
"POST /api/collections/:id/books": {
// Bulk add: {operations: [{collection_id, book_ids:[...]}]}.
Request: json.RawMessage(`{
-297
View File
@@ -9,7 +9,6 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -921,128 +920,8 @@ func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uu
return nil
}
type AddRuleRequest struct {
Field string `json:"field" validate:"required"`
Operator string `json:"operator" validate:"required"`
Value string `json:"value" validate:"required"`
Enabled *bool `json:"enabled"`
Priority int `json:"priority"`
}
// AddRule handles POST /api/collections/:id/rules — appends one rule to
// the ruleset. The first rule converts a static collection to smart.
func (h *CollectionHandler) AddRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
collection, err := h.GetCollectionData(c, collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
if collection.IsSystemCollection.Bool {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
}
var req AddRuleRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
Field: req.Field,
Operator: req.Operator,
Value: req.Value,
Enabled: req.Enabled,
Priority: req.Priority,
})
if err != nil {
var ruleErr *services.RuleValidationError
if errors.As(err, &ruleErr) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"rule": rule,
"is_smart": services.IsCollectionSmart(updated),
})
}
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
// the enabled toggle; the payload may only carry {"enabled": bool}.
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
ruleID := c.Param("ruleId")
if ruleID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
}
var req struct {
Enabled *bool `json:"enabled" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
if err != nil {
if strings.Contains(err.Error(), "rule not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"is_smart": services.IsCollectionSmart(updated),
})
}
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
ruleID := c.Param("ruleId")
if ruleID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
}
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
if err != nil {
if strings.Contains(err.Error(), "rule not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"is_smart": services.IsCollectionSmart(updated),
})
}
type TestRulesRequest struct {
Rules []map[string]interface{} `json:"rules" validate:"required"`
// MatchMode previews the collection's rule_match_mode: "any" (OR,
// default) or "all" (AND — the daily briefing). Empty = any.
MatchMode string `json:"match_mode"`
}
// treeEnvelope renders the stored filter tree for the envelope: nil
// (legacy/static collections) serializes as null, trees as the object.
func treeEnvelope(t []byte) any {
@@ -1056,182 +935,6 @@ func treeEnvelope(t []byte) any {
return nil
}
type BookMatch struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
MatchReason string `json:"match_reason"`
}
func (h *CollectionHandler) TestRules(c *echo.Context) error {
var req TestRulesRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Candidates mirror what smart collections actually evaluate against:
// the requester's visible libraries and live items only — the preview
// must not promise matches the live render will never produce.
visibleIDs, err := h.userVisibleLibraryIDs(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
var visibleArg []pgtype.UUID
if len(visibleIDs) > 0 {
visibleArg = visibleIDs
}
viewerID := pgtype.UUID{}
if u, ok := c.Get("user").(database.Users); ok {
viewerID = u.ID
}
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
LibraryID: pgtype.UUID{},
VisibleLibraryIds: visibleArg,
ViewerUserID: viewerID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
}
matchAll := req.MatchMode == services.RuleMatchAll
var matches []BookMatch
for _, row := range mediaItems {
matchReason := h.checkRulesAgainstBook(row.MediaItems, row.ViewerPercentage, req.Rules, matchAll)
if matchReason != "" {
item := row.MediaItems
author := ""
if item.Author.Valid {
author = item.Author.String
}
matches = append(matches, BookMatch{
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
Title: item.Title,
Author: author,
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
MatchReason: matchReason,
})
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"matches": matches,
"total": len(matches),
})
}
// checkRulesAgainstBook evaluates the candidate against the ruleset via
// the SERVICE evaluator (one vocabulary, one semantics — the handler
// keeps only the match-reason display). matchAll=true (the collection's
// "all" mode) requires every rule to hit.
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, viewerPercentage float64, rules []map[string]interface{}, matchAll bool) string {
service := h.collectionService
if matchAll {
matched := 0
for _, rule := range rules {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string)
if !service.FieldMatches(item, viewerPercentage, field, operator, value) {
return ""
}
matched++
}
if matched > 0 {
return fmt.Sprintf("Matched all %d rules", matched)
}
return ""
}
for _, rule := range rules {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string)
if service.FieldMatches(item, viewerPercentage, field, operator, value) {
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
}
}
return ""
}
func (h *CollectionHandler) evaluateRule(item database.MediaItems, field, operator, value string) bool {
var itemValue string
switch field {
case "genre":
if item.Genre.Valid {
itemValue = item.Genre.String
}
case "series":
if item.Series.Valid {
itemValue = item.Series.String
}
case "author":
if item.Author.Valid {
itemValue = item.Author.String
}
case "language":
if item.Language.Valid {
itemValue = item.Language.String
}
case "publisher":
if item.Publisher.Valid {
itemValue = item.Publisher.String
}
case "copyright_year":
if item.CopyrightYear.Valid {
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
}
case "tags":
if len(item.Tags) > 0 {
itemValue = strings.Join(item.Tags, ", ")
}
}
return h.compareValues(itemValue, operator, value)
}
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
switch operator {
case "equals":
return itemValue == ruleValue
case "not_equals":
return itemValue != ruleValue
case "contains":
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "not_contains":
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "starts_with":
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "ends_with":
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "greater_than":
itemNum, err1 := strconv.Atoi(itemValue)
ruleNum, err2 := strconv.Atoi(ruleValue)
if err1 != nil || err2 != nil {
return false
}
return itemNum > ruleNum
case "less_than":
itemNum, err1 := strconv.Atoi(itemValue)
ruleNum, err2 := strconv.Atoi(ruleValue)
if err1 != nil || err2 != nil {
return false
}
return itemNum < ruleNum
}
return false
}
// Bulk Operations
// POST /api/collections/bulk-add-books
// Bulk add books to multiple collections
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
-4
View File
@@ -21,14 +21,10 @@ func registerCollectionsRoutes(cfg *Config) {
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
// Smart collection rule CRUD (the rules builder persists through
// these; type follows rules presence — first rule = smart).
collections.POST("/:id/rules", cfg.CollectionHandler.AddRule)
collections.PUT("/:id/rules/:ruleId", cfg.CollectionHandler.UpdateRule)
collections.DELETE("/:id/rules/:ruleId", cfg.CollectionHandler.RemoveRule)
// Device shelf mapping routes
deviceCollections := protected.Group("/devices/:id/collections")
+25 -2
View File
@@ -701,6 +701,29 @@ func registerFrontendRoutes(cfg *Config) {
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
}
// The editor's vocabulary + prefill (item 3.4): the unified
// builder in the collection's scope. A stored tree renders as-is;
// a legacy rules array preloads as its CONVERTED tree
// (RulesToFilterTree) — the upgrade lands at the first Apply.
scope := "books"
treeJSON := "{}"
if len(collection.FilterTree) > 0 {
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
scope = t.FilterScope()
if b, merr := json.Marshal(t); merr == nil {
treeJSON = string(b)
}
}
} else if rules := cfg.CollectionHandler.CollectionService().ParseRules(collection.AutoAssignRules); len(rules) > 0 {
if t, cerr := services.RulesToFilterTree(rules, collection.RuleMatchMode); cerr == nil {
scope = t.FilterScope()
if b, merr := json.Marshal(t); merr == nil {
treeJSON = string(b)
}
}
}
fieldsJSON := templates.JSONArg(services.FilterFieldsPayload(scope))
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
@@ -708,11 +731,11 @@ func registerFrontendRoutes(cfg *Config) {
Color: utils.PgTextString(collection.Color),
Icon: utils.PgTextString(collection.Icon),
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
IsSmart: services.IsCollectionSmart(collection),
}
var buf bytes.Buffer
err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf)
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope).Render(c.Request().Context(), &buf)
if err != nil {
return err
}