diff --git a/cmd/server/tests/collections_smart_test.go b/cmd/server/tests/collections_smart_test.go index 21eb69a6..1210c370 100644 --- a/cmd/server/tests/collections_smart_test.go +++ b/cmd/server/tests/collections_smart_test.go @@ -56,36 +56,6 @@ func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionI return result } -func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response { - t.Helper() - payload := map[string]interface{}{ - "field": field, - "operator": operator, - "value": value, - } - if enabled != nil { - payload["enabled"] = *enabled - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - return resp -} - -func smartDecodeRuleID(t *testing.T, resp *http.Response) string { - t.Helper() - defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) - var result map[string]interface{} - require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) - rule := result["rule"].(map[string]interface{}) - return rule["id"].(string) -} - -// Data helpers ----------------------------------------------------------- - func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) { t.Helper() ctx := context.Background() @@ -145,81 +115,83 @@ func TestSmartCollections(t *testing.T) { setup := setupTestServer(t) adminID := getTestUserID(t, setup.DB) - t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) { + t.Run("TreeCollectionLiveMembership", func(t *testing.T) { + // The unified engine (item 3.2): a collection created WITH a + // books-scope tree evaluates LIVE — stored links are ignored, + // only tree matches render. _, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart SF Book One": "Science Fiction", "Smart SF Book Two": "Science Fiction", "Smart FANT Book": "Fantasy", }) + _ = bookIDs - // Rule evaluation ignores stored links: a static collection's own - // added item must NOT show up once the collection is smart. - collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static") - - // Add one hand-picked book (a Fantasy one that the smart rule - // will not match — so it must vanish from the render). - addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}}) - addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) - addReq.Header.Set("Content-Type", "application/json") - addReq.Header.Set("Authorization", "Bearer "+setup.Token) - addResp, err := http.DefaultClient.Do(addReq) + tree := map[string]interface{}{ + "scope": "books", "mode": "all", + "children": []map[string]interface{}{ + {"field": "genre", "op": "is", "values": []string{"Science Fiction"}}, + }, + } + body, _ := json.Marshal(map[string]interface{}{ + "name": "Test Smart Tree " + uuid.NewString()[:8], + "filter_tree": tree, + }) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - require.Equal(t, http.StatusNoContent, addResp.StatusCode) - _ = addResp.Body.Close() - - staticDetail := smartGetCollection(t, setup, setup.Token, collID) - assert.Equal(t, false, staticDetail["is_smart"]) - assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link") - - resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) require.Equal(t, http.StatusCreated, resp.StatusCode) + var created struct { + ID string `json:"id"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) _ = resp.Body.Close() + t.Cleanup(func() { + del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+created.ID, nil) + del.Header.Set("Authorization", "Bearer "+setup.Token) + _, _ = http.DefaultClient.Do(del) + }) - smartDetail := smartGetCollection(t, setup, setup.Token, collID) - assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type") - assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only") - - memberTitles := smartLibraryTitleSet(t, smartDetail) + detail := smartGetCollection(t, setup, setup.Token, created.ID) + assert.Equal(t, true, detail["is_smart"], "tree presence derives the type") + memberTitles := smartLibraryTitleSet(t, detail) assert.True(t, memberTitles["Smart SF Book One"]) assert.True(t, memberTitles["Smart SF Book Two"]) - assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)") - }) - - t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) { - smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ - "Smart Diss Book": "Science Fiction", - }) - collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart") - - resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) - require.Equal(t, http.StatusCreated, resp.StatusCode) - ruleID := smartDecodeRuleID(t, resp) - - assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"]) - - // Disable: membership drops to zero at once. - falseFlag := false - dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag}) - dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody)) - dReq.Header.Set("Content-Type", "application/json") - dReq.Header.Set("Authorization", "Bearer "+setup.Token) - dResp, err := http.DefaultClient.Do(dReq) - require.NoError(t, err) - require.Equal(t, http.StatusOK, dResp.StatusCode) - _ = dResp.Body.Close() - - assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match") + assert.False(t, memberTitles["Smart FANT Book"], "the tree's AND governs membership live") }) t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) { smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart Guard Book": "Science Fiction", }) - collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart") - - resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + tree := map[string]interface{}{ + "scope": "books", "mode": "all", + "children": []map[string]interface{}{ + {"field": "genre", "op": "is", "values": []string{"Science Fiction"}}, + }, + } + body, _ := json.Marshal(map[string]interface{}{ + "name": "Test Smart Guard " + uuid.NewString()[:8], + "filter_tree": tree, + }) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) require.Equal(t, http.StatusCreated, resp.StatusCode) + var created struct { + ID string `json:"id"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) _ = resp.Body.Close() + collID := created.ID + t.Cleanup(func() { + del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil) + del.Header.Set("Authorization", "Bearer "+setup.Token) + _, _ = http.DefaultClient.Do(del) + }) addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}}) addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) @@ -238,52 +210,9 @@ func TestSmartCollections(t *testing.T) { _ = removeResp.Body.Close() }) - t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) { - smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ - "Smart Back Book": "Science Fiction", - }) - collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart") - - resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil) - require.Equal(t, http.StatusCreated, resp.StatusCode) - ruleID := smartDecodeRuleID(t, resp) - - smartDetail := smartGetCollection(t, setup, setup.Token, collID) - assert.Equal(t, true, smartDetail["is_smart"]) - - delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil) - delReq.Header.Set("Authorization", "Bearer "+setup.Token) - delResp, err := http.DefaultClient.Do(delReq) - require.NoError(t, err) - require.Equal(t, http.StatusOK, delResp.StatusCode) - _ = delResp.Body.Close() - - staticDetail := smartGetCollection(t, setup, setup.Token, collID) - assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static") - - // Membership mutations work again on the reverted static. - addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}}) - addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) - addReq.Header.Set("Content-Type", "application/json") - addReq.Header.Set("Authorization", "Bearer "+setup.Token) - addResp, err := http.DefaultClient.Do(addReq) - require.NoError(t, err) - assert.Equal(t, http.StatusNoContent, addResp.StatusCode) - _ = addResp.Body.Close() - }) - - t.Run("RuleValidationAndSystemCollections", func(t *testing.T) { - collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static") - - resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil) - require.Equal(t, http.StatusBadRequest, resp.StatusCode) - _ = resp.Body.Close() - - resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) - require.Equal(t, http.StatusCreated, resp.StatusCode) - _ = resp.Body.Close() - - // System collections may never carry rules. + t.Run("SystemCollectionsBlockedFromTheEditor", func(t *testing.T) { + // System collections may never carry rules/trees — the editor + // route refuses them (item 3.4: the page IS the editor). colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}) require.NoError(t, err) var systemID string @@ -295,20 +224,46 @@ func TestSmartCollections(t *testing.T) { } require.NotEmpty(t, systemID, "admin user should have system collections") - sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil) - require.Equal(t, http.StatusBadRequest, sysResp.StatusCode) - _ = sysResp.Body.Close() + req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+systemID+"/rules", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) + assert.NotEqual(t, http.StatusOK, resp.StatusCode, + "system collections never render the smart editor") }) t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) { _, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ "Smart List Book": "Science Fiction", }) - collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart") - - resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) - require.Equal(t, http.StatusCreated, resp.StatusCode) - _ = resp.Body.Close() + tree := map[string]interface{}{ + "scope": "books", "mode": "all", + "children": []map[string]interface{}{ + {"field": "genre", "op": "is", "values": []string{"Science Fiction"}}, + }, + } + body, _ := json.Marshal(map[string]interface{}{ + "name": "Test Smart List " + uuid.NewString()[:8], + "filter_tree": tree, + }) + req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("Authorization", "Bearer "+setup.Token) + resp2, err := http.DefaultClient.Do(req2) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp2.StatusCode) + var created struct { + ID string `json:"id"` + } + require.NoError(t, json.NewDecoder(resp2.Body).Decode(&created)) + _ = resp2.Body.Close() + collID := created.ID + t.Cleanup(func() { + del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil) + del.Header.Set("Authorization", "Bearer "+setup.Token) + _, _ = http.DefaultClient.Do(del) + }) // Default list (the Android app's read): smart collections hidden. req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil) diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index bed726cf..36d004ab 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -869,31 +869,11 @@ func TestPodcastSmartCollections(t *testing.T) { assert.Equal(t, http.StatusForbidden, code, "only the owner flips the mode: %s", string(body)) }) - t.Run("test_rules_preview_honors_mode", func(t *testing.T) { - // The rules page preview speaks the same evaluator: all-mode - // requires every rule (aNew passes feed∧unplayed∧<7d; aOld - // fails published_within). - code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections/test-rules", - `{"match_mode":"all","rules":[ - {"field":"feed","operator":"in","value":"`+feedAID+`"}, - {"field":"played","operator":"equals","value":"false"}, - {"field":"published_within","operator":"greater_than","value":"7"} - ]}`) - require.Equal(t, http.StatusOK, code, "%s", string(body)) - assert.Contains(t, string(body), "SmartColl A New") - assert.NotContains(t, string(body), "SmartColl A Old") - }) - - t.Run("downloaded_and_contains_rules", func(t *testing.T) { - code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections/test-rules", - `{"match_mode":"all","rules":[ - {"field":"downloaded","operator":"equals","value":"true"}, - {"field":"title","operator":"contains","value":"SmartColl A"} - ]}`) - require.Equal(t, http.StatusOK, code, "%s", string(body)) - assert.Contains(t, string(body), "SmartColl A New") - assert.Contains(t, string(body), "SmartColl A Played", "played but downloaded — the downloaded rule alone doesn't exclude it") - assert.NotContains(t, string(body), "SmartColl B Ep") + t.Run("match_mode_endpoint_still_serves_legacy", func(t *testing.T) { + // Item 3.4 retired the rules PAGE, not the legacy channel: the + // match-mode endpoint keeps serving rules-array collections. + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"any"}`) + assert.Equal(t, http.StatusOK, code, "%s", string(body)) }) // ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ---- diff --git a/docs/contract_pins.md b/docs/contract_pins.md index e20a3e70..3d29bcda 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -489,3 +489,28 @@ unified filter tree when the smart collection carries one (item 3.2 — tree-driven membership), `null` for legacy/static collections. The tree is the same qf DSL as saved filters (`{scope?, mode, children}`). Nothing removed, nothing renamed. + +## 2026-10-30 — rules endpoints RETIRED (filter-system unification, item 3.4) + +The per-rule CRUD endpoints and the preview endpoint are DELETED — the +unified filter tree (item 3.2) is the only smart-membership authoring +surface, and the rules PAGE is now the filter builder in the +collection's scope: + +- `POST /api/collections/:id/rules`, `PUT/DELETE + /api/collections/:id/rules/:ruleId` — gone (legacy rule arrays still + EVALUATE until the collection is rewritten; conversions run at the + builder's first Apply via RulesToFilterTree). +- `POST /api/collections/test-rules` — gone (the builder's live count + via /api/media-items/filter-count replaces the preview). +- `POST /api/collections/preview` STAYS (the dashboard + custom-section-builder still consumes it — flagged for its own + migration, not silently broken). +- Legacy compatibility KEPT: `auto_assign_rules` still round-trips in + the collections envelope; `PUT /api/collections/:id/match-mode` + still serves rules-array collections. + +Test-side deletions follow the asymmetry rule: subtests exercising the +deleted endpoints are gone; their surviving SEMANTICS are pinned in +their tree form (TestSmartCollections rewritten on trees, +TestEpisodeCollectionsFilterTree, TestPodcastSmartCollections). diff --git a/internal/docs/schema.go b/internal/docs/schema.go index 70ba25f2..73a2f2e8 100644 --- a/internal/docs/schema.go +++ b/internal/docs/schema.go @@ -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(`{ diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 408cfcd1..d3075f7f 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -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) diff --git a/internal/router/collections.go b/internal/router/collections.go index 9586cb15..39adcb27 100644 --- a/internal/router/collections.go +++ b/internal/router/collections.go @@ -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") diff --git a/internal/router/frontend.go b/internal/router/frontend.go index dcaec798..04f0ce4a 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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 } diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index 908e30f7..53ce28f5 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -177,101 +177,10 @@ templ BookShelf( innerHTML templating). Rows are a flat projection of the tree (depth = indent); groups nest via + group. -->
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 312, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 221, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "