From 4f71eda6be13a15504ed35fc053d00d3f4864e03 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 19:22:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(podcasts):=20the=20rules=20page=20retires?= =?UTF-8?q?=20=E2=80=94=20the=20collection=20builder=20IS=20the=20filter?= =?UTF-8?q?=20builder=20(item=203.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/collections_smart_test.go | 233 ++++------ cmd/server/tests/podcast_test.go | 30 +- docs/contract_pins.md | 25 ++ internal/docs/schema.go | 16 - internal/handlers/collections.go | 297 ------------- internal/router/collections.go | 4 - internal/router/frontend.go | 27 +- templates/bookshelf.templ | 95 +--- templates/bookshelf_templ.go | 52 ++- templates/collection_rules.templ | 233 ++-------- templates/collection_rules_templ.go | 91 ++-- templates/filter_builder.templ | 103 +++++ templates/filter_builder_templ.go | 59 +++ web/src/collection-rules.ts | 478 ++------------------- web/src/collections.ts | 141 ------ web/src/filter_builder.ts | 56 ++- 16 files changed, 518 insertions(+), 1422 deletions(-) create mode 100644 templates/filter_builder.templ create mode 100644 templates/filter_builder_templ.go 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. -->
- - -
- - - - -
+ @FilterBuilderRows("books")
diff --git a/templates/bookshelf_templ.go b/templates/bookshelf_templ.go index 32e67a4b..f253c302 100644 --- a/templates/bookshelf_templ.go +++ b/templates/bookshelf_templ.go @@ -412,15 +412,23 @@ func BookShelf( return templ_7745c5c3_Err } var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templates.JSONArg(services.FilterFieldsPayload("books"))) + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(services.FilterFieldsPayload("books"))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 180, Col: 86} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 180, Col: 76} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" x-effect=\"filtersOpen === true && ensureReadyRule()\">
0\" x-cloak>
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" x-effect=\"filtersOpen === true && ensureReadyRule()\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = FilterBuilderRows("books").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
0\" x-cloak>
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -429,53 +437,53 @@ func BookShelf( return templ_7745c5c3_Err } if errorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

") + 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, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if count > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " Page ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "Prev Page ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 330, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 239, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "

Save Filter

Save Filter

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/collection_rules.templ b/templates/collection_rules.templ index f58d14af..021e655e 100644 --- a/templates/collection_rules.templ +++ b/templates/collection_rules.templ @@ -1,16 +1,27 @@ package templates -templ CollectionRules(user User, collection CollectionData) { +// CollectionRules is the SMART-collection editor (plan item 3.4): the +// unified filter builder in the collection's scope. One vocabulary +// (services.FilterFieldsPayload — the same wire the All-Books builder +// renders), one engine: Apply writes the tree to the collection and +// the qf engine drives membership everywhere. Legacy rules arrays +// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade +// happens at the first Apply, never silently at load. +templ CollectionRules(user User, collection CollectionData, fieldsJSON string, treeJSON string, scope string) { - Collection Rules - { collection.Name } - Bookhoard + Smart Collection - { collection.Name } - Bookhoard - + @Header(user, "/collections")
@@ -24,204 +35,34 @@ templ CollectionRules(user User, collection CollectionData) {

{ collection.Name }

⚡ Smart - Rules evaluated live — the collection is always current, no refresh needed + if scope == "episodes" { + Episodes scope — membership recomputes live from the tree below. + } else { + Books scope — membership recomputes live from the tree below. + }

-
-
-

Rules

- -
- Match - - -
+ +
+
+

Filter tree

-

- An item is in the collection while it matches ANY of the enabled rules. Saving here takes effect immediately, everywhere the collection renders. -

-
-
- - @Icon("filter", "h-6 w-6") - -

No Rules Yet

-

Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.

-
-
-
-
-

Create New Rule

-
- -
-
- - -
-
- - -
-
-
- - -
-
- -

Uncheck to disable without deleting

-
-
- -
- - - -
-
-
- - - -
-
- -
-
-

Rule Examples

-
-
- 1 -
-

Add all Science Fiction books

- - Field: genre -
- Operator: equals -
- Value: Science Fiction -
-
-
-
- 2 -
-

Add books from a specific series

- - Field: series -
- Operator: starts with -
- Value: Harry Potter -
-
-
-
- 3 -
-

Add books published in a year range

- - Field: copyright_year -
- Operator: greater than -
- Value: 2020 -
-
-
+ @FilterBuilderRows("items") +
+ +
diff --git a/templates/collection_rules_templ.go b/templates/collection_rules_templ.go index fb180de4..8d78193c 100644 --- a/templates/collection_rules_templ.go +++ b/templates/collection_rules_templ.go @@ -8,7 +8,14 @@ package templates import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -func CollectionRules(user User, collection CollectionData) templ.Component { +// CollectionRules is the SMART-collection editor (plan item 3.4): the +// unified filter builder in the collection's scope. One vocabulary +// (services.FilterFieldsPayload — the same wire the All-Books builder +// renders), one engine: Apply writes the tree to the collection and +// the qf engine drives membership everywhere. Legacy rules arrays +// preload as their CONVERTED tree (RulesToFilterTree) — the upgrade +// happens at the first Apply, never silently at load. +func CollectionRules(user User, collection CollectionData, fieldsJSON string, treeJSON string, scope string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -29,14 +36,14 @@ func CollectionRules(user User, collection CollectionData) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Collection Rules - ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Smart Collection - ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 16, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -69,9 +76,9 @@ func CollectionRules(user User, collection CollectionData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + collection.MatchMode + "')") + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 13, Col: 152} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -100,7 +107,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 159} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 33, Col: 159} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -113,7 +120,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 22, Col: 179} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 33, Col: 179} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -126,89 +133,75 @@ func CollectionRules(user User, collection CollectionData) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 35, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> Rules evaluated live — the collection is always current, no refresh needed</p></div></div></div><div class=\"mb-8\"><div class=\"flex flex-wrap items-center justify-between gap-3 mb-4\"><h2 class=\"text-2xl font-bold\" style=\"color: var(--text-primary)\">Rules</h2><!-- Match semantics (plan item 2.4): ANY = OR (legacy),\n\t\t\t\t\t\t ALL = AND (the daily briefing). Own channel — writes\n\t\t\t\t\t\t only rule_match_mode. --><div class=\"flex items-center gap-2\"><span class=\"text-sm\" style=\"color: var(--text-secondary)\">Match</span> <button type=\"button\" @click=\"setMatchMode('any')\" :class=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</h1><p class=\"flex items-center gap-2\" style=\"color: var(--text-secondary)\"><span class=\"badge text-xs\" style=\"background-color: var(--accent-muted); color: var(--accent);\">⚡ Smart</span> ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if scope == "episodes" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Episodes scope — membership recomputes live from the tree below.") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Books scope — membership recomputes live from the tree below.") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div></div></div><!-- The unified builder island: SAME component + vocabulary as\n\t\t\t\t the All-Books drawer (item 3.3's payload). Apply is\n\t\t\t\t collection-specific (writes the tree; the engine does the\n\t\t\t\t rest). The group-mode chips express ANY/ALL — the tree's\n\t\t\t\t root mode IS the collection's match semantics. --><div x-data=\"filterBuilderExtra\" data-filter-fields=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'any' ? 'btn btn-secondary' : 'btn btn-ghost'") + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(fieldsJSON)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 42, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 54, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"btn\">ANY (or)</button> <button type=\"button\" @click=\"setMatchMode('all')\" :class=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-scope=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'all' ? 'btn btn-secondary' : 'btn btn-ghost'") + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(scope) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 46, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 55, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"btn\">ALL (and)</button></div></div><p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">An item is in the collection while it matches <span class=\"font-semibold\" x-text=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-tree=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchMode === 'all' ? 'ALL of' : 'ANY of'") + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(treeJSON)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 51, Col: 132} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 56, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">ANY of</span> the enabled rules. Saving here takes effect immediately, everywhere the collection renders.</p><div id=\"rules-container\" class=\"space-y-4\"><div id=\"no-rules\" class=\"card p-8 text-center\"><span class=\"inline-grid place-items-center h-12 w-12 rounded-xl mb-3\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"card p-6\"><div class=\"flex items-center justify-between mb-3 px-1\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Filter tree</h2></div>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = Icon("filter", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = FilterBuilderRows("items").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span><h3 class=\"text-lg font-semibold mb-2\" style=\"color: var(--text-primary)\">No Rules Yet</h3><p style=\"color: var(--text-secondary)\">Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.</p></div></div></div><div class=\"mb-8\"><h2 class=\"text-2xl font-bold mb-4\" style=\"color: var(--text-primary)\">Create New Rule</h2><form id=\"rule-form\" @submit=\"handleCreateRule($event)\" class=\"card p-6 space-y-6\"><input type=\"hidden\" id=\"collection-id\" value=\"") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 66, Col: 67} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div class=\"grid grid-cols-1 md:grid-cols-2 gap-6\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Field</label> <select id=\"rule-field\" required class=\"input\"><option value=\"\">Select field...</option> <option value=\"genre\">Genre</option> <option value=\"series\">Series</option> <option value=\"author\">Author</option> <option value=\"language\">Language</option> <option value=\"publisher\">Publisher</option> <option value=\"copyright_year\">Copyright Year</option> <option value=\"tags\">Tags</option> <option value=\"title\">Title</option> <option value=\"description\">Description</option> <optgroup label=\"Podcast episodes\"><option value=\"feed\">Feed (IDs, comma-separated)</option> <option value=\"played\">Played (true/false)</option> <option value=\"downloaded\">Downloaded (true/false)</option> <option value=\"published_within\">Published within (days)</option></optgroup></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Operator</label> <select id=\"rule-operator\" required class=\"input\"><option value=\"\">Select operator...</option> <option value=\"equals\">equals</option> <option value=\"not_equals\">does not equal</option> <option value=\"contains\">contains</option> <option value=\"not_contains\">does not contain</option> <option value=\"starts_with\">starts with</option> <option value=\"ends_with\">ends with</option> <option value=\"greater_than\">greater than</option> <option value=\"less_than\">less than</option> <option value=\"in\">is one of (comma-separated)</option></select></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Value</label> <input type=\"text\" id=\"rule-value\" required class=\"input\" placeholder=\"e.g., Science Fiction\"></div><div><label class=\"flex items-center space-x-3 cursor-pointer\"><input type=\"checkbox\" id=\"rule-enabled\" checked class=\"w-5 h-5 rounded\"> <span class=\"text-sm font-medium\" style=\"color: var(--text-primary)\">Enable Rule</span></label><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Uncheck to disable without deleting</p></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Priority</label><div class=\"flex gap-4\"><label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"1\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">High</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"2\" checked class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Medium</span></label> <label class=\"flex items-center space-x-2 cursor-pointer\"><input type=\"radio\" name=\"priority\" value=\"3\" class=\"w-4 h-4\"> <span class=\"text-sm\" style=\"color: var(--text-primary)\">Low</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"testRule()\" class=\"btn btn-secondary\">") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Test Rule</button> <button type=\"button\" @click=\"clearForm()\" class=\"btn btn-ghost\">Clear</button> <button type=\"submit\" class=\"btn btn-primary\">") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = Icon("plus", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Add Rule</button></div></form><div id=\"test-results\" class=\"hidden card p-6 mt-4 mb-6\"><h3 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Rule Test Results</h3><p class=\"text-sm mb-2\" style=\"color: var(--text-secondary)\">Books that would be added by this rule:</p><div id=\"test-results-list\" class=\"max-h-64 overflow-y-auto space-y-2\"></div></div></div><div class=\"card p-6\"><h2 class=\"text-xl font-bold mb-4\" style=\"color: var(--text-primary)\">Rule Examples</h2><div class=\"space-y-4\"><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">1</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add all Science Fiction books</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: genre<br>Operator: equals<br>Value: Science Fiction</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">2</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books from a specific series</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: series<br>Operator: starts with<br>Value: Harry Potter</code></div></div><div class=\"flex items-start gap-4\"><span class=\"grid place-items-center flex-shrink-0 w-8 h-8 rounded-full text-sm font-bold\" style=\"background-color: var(--accent-muted); color: var(--accent);\">3</span><div><p class=\"font-medium\" style=\"color: var(--text-primary)\">Add books published in a year range</p><code class=\"block mt-1 text-sm rounded-lg\" style=\"color: var(--text-secondary); background-color: var(--bg-primary); padding: 8px 12px;\">Field: copyright_year<br>Operator: greater than<br>Value: 2020</code></div></div></div></div></div></body></html>") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"mt-4 pt-4 border-t flex justify-end gap-2\" style=\"border-color: var(--border);\"><button type=\"button\" @click=\"backToCollection()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"applyCollectionTree()\" class=\"btn btn-primary\">Apply to Collection</button></div></div></div></body></html>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/filter_builder.templ b/templates/filter_builder.templ new file mode 100644 index 00000000..498dd714 --- /dev/null +++ b/templates/filter_builder.templ @@ -0,0 +1,103 @@ +package templates + +// filterBuilderRows is THE shared builder rows markup (item 3.3/3.4): +// one presentation for every builder mount — All-Books and collection +// smart editors. Purely presentational: every method/property lives on +// the shared filterBuilderExtra Alpine component (web/src/ +// filter_builder.ts), whose vocabulary arrives from +// services.FilterFieldsPayload(scope). +templ FilterBuilderRows(countNoun string) { +<template x-for="row in builderRows" :key="row.key"> + <div> + <template x-if="row.type === 'groupOpen'"> + <div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="`margin-left:${row.depth * 14}px`"> + <span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span> + <span class="flex-1"></span> + <button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button> + <button type="button" class="fb-mini-btn" @click="addGroup(row.path)">+ group</button> + <button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove group">×</button> + </div> + </template> + <template x-if="row.type === 'rule'"> + <div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="`margin-left:${(row.depth + 1) * 14}px`"> + <button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="if (row.chipClickable) toggleRuleConnective(row.path)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button> + <!-- field picker: grouped menu --> + <div class="relative"> + <button type="button" class="fb-picker" :class="{'fb-picker-active': row.rule.field !== ''}" @click="toggleMenu(row.key + ':field')" x-text="fieldLabelOf(row.rule.field)"></button> + <div class="fb-menu fb-menu-fields" x-show="openMenu === row.key + ':field'" @click.outside="openMenu = ''"> + <div class="fb-menu-filter-wrap"> + <input type="text" class="fb-menu-filter" placeholder="Filter fields…" x-model="fieldFilter" @keydown.enter.prevent @click.stop/> + </div> + <div class="fb-menu-scroll"> + <template x-for="grp in visibleFieldGroups" :key="grp.group"> + <div> + <div class="fb-menu-group" x-text="grp.group"></div> + <template x-for="f in grp.fields" :key="f.key"> + <button type="button" class="fb-menu-item" :class="row.rule.field === f.key ? 'fb-menu-item-active' : ''" @click="setRuleField(row.path, f.key)" x-text="f.label"></button> + </template> + </div> + </template> + <div class="fb-menu-empty" x-show="visibleFieldGroups.length === 0">No matching fields</div> + </div> + </div> + </div> + <!-- operator picker (negation lives here) --> + <div class="relative"> + <button type="button" class="fb-picker fb-picker-op" :class="{'fb-negated-text': isNegated(row.rule)}" @click="toggleMenu(row.key + ':op')" x-text="opLabelOf(row.rule)"></button> + <div class="fb-menu" x-show="openMenu === row.key + ':op'" @click.outside="openMenu = ''"> + <template x-for="o in opsFor(row.rule.field)" :key="o.key"> + <button type="button" class="fb-menu-item" :class="[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']" @click="setRuleOp(row.path, o.key)" x-text="o.label"></button> + </template> + </div> + </div> + <!-- value cell: shape follows the operator + field kind --> + <template x-if="hasValueCell(row.rule) && valueArity(row.rule) === 'two'"> + <span class="flex items-center gap-1"> + <input type="text" class="input fb-value" :value="row.rule.values[0] || ''" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @click.outside="suggestionsOpen = ''" placeholder="From"/> + <span class="text-xs" style="color: var(--text-secondary)">and</span> + <input type="text" class="input fb-value" :value="row.rule.values[1] || ''" @input.debounce.250ms="setRuleValue(row.path, 1, $el.value)" placeholder="To"/> + </span> + </template> + <template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')"> + <span class="fb-value-cell relative flex items-center gap-1"> + <input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'"/> + <template x-if="fieldMeta(row.rule.field)?.valueKind === 'days'"> + <span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span> + </template> + <!-- field-aware value menu: server JSON (the other + rules bound), or the field's fixed options. + Clicks anywhere inside the value cell never close + it — the opening focus-click must not race the + outside handler. --> + <div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = ''"> + <template x-for="s in (suggestions[row.key] || [])" :key="s.value"> + <button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)"> + <span x-text="s.display || s.value"></span> + <span class="text-xs" style="color: var(--text-secondary)" x-show="s.count > 0" x-text="s.count + ' books'"></span> + </button> + </template> + <div class="fb-menu-empty" x-show="suggestionsLoading === row.key">Loading…</div> + <div class="fb-menu-empty" x-show="suggestionsLoading !== row.key && !(suggestions[row.key] || []).length">No matching values</div> + </div> + </span> + </template> + <!-- delete rule --> + <button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove rule">×</button> + </div> + </template> + <template x-if="row.type === 'groupClose' && row.depth > 0"> + <div class="fb-group-end" :style="`margin-left:${row.depth * 14}px`"></div> + </template> + </div> + </template> + <!-- footer: add rule/group + the debounced live count + (the composed engine's true COUNT — what the surface will + show once applied) --> + <div class="flex items-center justify-between px-1 py-2"> + <div class="flex items-center gap-1.5"> + <button type="button" class="fb-mini-btn" @click="addRule([])">+ Rule</button> + <button type="button" class="fb-mini-btn" @click="addGroup([])">+ Group</button> + </div> + <span class="text-xs" style="color: var(--text-secondary)" x-show="matchCount !== null" x-text={ "matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 " + countNoun + " matches' : matchCount + ' " + countNoun + "s match')" }></span> + </div> +} diff --git a/templates/filter_builder_templ.go b/templates/filter_builder_templ.go new file mode 100644 index 00000000..5ababd60 --- /dev/null +++ b/templates/filter_builder_templ.go @@ -0,0 +1,59 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// filterBuilderRows is THE shared builder rows markup (item 3.3/3.4): +// one presentation for every builder mount — All-Books and collection +// smart editors. Purely presentational: every method/property lives on +// the shared filterBuilderExtra Alpine component (web/src/ +// filter_builder.ts), whose vocabulary arrives from +// services.FilterFieldsPayload(scope). +func FilterBuilderRows(countNoun string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside handler. --><div class=\"fb-menu fb-menu-value\" x-show=\"suggestionShown(row.key)\" @click.outside=\"if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = ''\"><template x-for=\"s in (suggestions[row.key] || [])\" :key=\"s.value\"><button type=\"button\" class=\"fb-menu-item\" @click=\"pickSuggestion(row.key, row.path, s)\"><span x-text=\"s.display || s.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary)\" x-show=\"s.count > 0\" x-text=\"s.count + ' books'\"></span></button></template><div class=\"fb-menu-empty\" x-show=\"suggestionsLoading === row.key\">Loading…</div><div class=\"fb-menu-empty\" x-show=\"suggestionsLoading !== row.key && !(suggestions[row.key] || []).length\">No matching values</div></div></span></template><!-- delete rule --><button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove rule\">×</button></div></template><template x-if=\"row.type === 'groupClose' && row.depth > 0\"><div class=\"fb-group-end\" :style=\"`margin-left:${row.depth * 14}px`\"></div></template></div></template><!-- footer: add rule/group + the debounced live count\n\t\t\t\t\t\t\t\t\t(the composed engine's true COUNT — what the surface will\n\t\t\t\t\t\t\t\t\tshow once applied) --><div class=\"flex items-center justify-between px-1 py-2\"><div class=\"flex items-center gap-1.5\"><button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule([])\">+ Rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup([])\">+ Group</button></div><span class=\"text-xs\" style=\"color: var(--text-secondary)\" x-show=\"matchCount !== null\" x-text=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 " + countNoun + " matches' : matchCount + ' " + countNoun + "s match')") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_builder.templ`, Line: 101, Col: 238} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></span></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/web/src/collection-rules.ts b/web/src/collection-rules.ts index 8baa3881..ec29a13c 100644 --- a/web/src/collection-rules.ts +++ b/web/src/collection-rules.ts @@ -1,451 +1,59 @@ +// Collection smart editor (item 3.4): the page hosts the SHARED +// filterBuilderExtra island (one vocabulary, one engine — the Go +// registry's payload; the builder's Apply delegates here through the +// collectionTreeApply store hook). This module owns only the +// collection-specific write: PUT the tree; the qf engine drives +// membership everywhere. The legacy per-rule page (rule CRUD, +// test-rules preview, match-mode toggle) is retired — legacy rules +// arrays preload as their CONVERTED tree (RulesToFilterTree, rendered +// server-side) and upgrade at the first Apply. + import { Alpine } from "./alpine"; -import { getSelectedLibrary } from "./storage"; import { showToast } from "./toast"; -// ============================================================ -// Collection Rules Page - Auto-Assign Rule Management -// ============================================================ - let collectionId = ""; - -let matchMode: "any" | "all" = "any"; - -function initCollectionRules(id: string, mode: string): void { +function initCollectionRules(id: string, _scope: string, _treeJSON: string): void { collectionId = id; - if (mode === "all") matchMode = "all"; - loadRules(); - setupEventDelegation(); -} -// Match semantics (plan item 2.4): flips the collection's -// rule_match_mode on its own endpoint — writes exactly that column. -async function setMatchMode(mode: "any" | "all"): Promise<void> { - if (mode === matchMode) return; - const token = localStorage.getItem("token"); - if (!token) { - showToast("Not authenticated", "error"); - return; - } - try { - const response = await fetch(`/api/collections/${collectionId}/match-mode`, { - method: "PUT", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, - body: JSON.stringify({ mode }), - }); - if (!response.ok) { - showToast("Failed to change match mode", "error"); - return; - } - matchMode = mode; - // The reactive :class and the ANY/ALL copy update via the store-free - // Alpine properties below; reload not needed. - document.querySelectorAll<HTMLElement>(".match-mode-btn").forEach((btn) => { - const isAny = btn.textContent?.trim().startsWith("ANY"); - btn.classList.toggle( - "btn-secondary", - (isAny && mode === "any") || (!isAny && mode === "all"), - ); - btn.classList.toggle("btn-ghost", !((isAny && mode === "any") || (!isAny && mode === "all"))); - }); - showToast( - mode === "all" ? "Rules now must ALL match (and)" : "Rules match when ANY passes (or)", - "success", - ); - } catch { - showToast("Failed to change match mode", "error"); - } -} - -function setupEventDelegation(): void { - const container = document.getElementById("rules-container"); - if (!container) return; - - container.addEventListener("click", (e) => { - const target = e.target as HTMLElement; - const button = target.closest("button") as HTMLButtonElement; - - if (!button) return; - - const action = button.dataset.action; - const ruleId = button.dataset.ruleId; - - if (action === "toggle" && ruleId) { - const enabled = button.dataset.enabled === "true"; - toggleRule(ruleId, enabled); - } else if (action === "delete" && ruleId) { - deleteRule(ruleId); - } + // The builder island emits its APPLY wire through this hook — the + // page (not the shared component) owns the collection write. + window.Alpine?.store("collectionTreeApply", { + hook: async (wire: string) => { + const token = localStorage.getItem("token"); + if (!token) { + showToast("Not authenticated", "error"); + return; + } + try { + const response = await fetch(`/api/collections/${collectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name: pageCollectionName(), filter_tree: JSON.parse(wire) }), + }); + if (response.ok) { + showToast("Smart collection updated", "success"); + window.location.reload(); + } else { + const body = await response.json().catch(() => ({})); + showToast(body.error || "Failed to update the collection", "error"); + } + } catch { + showToast("Failed to update the collection", "error"); + } + }, }); } +function pageCollectionName(): string { + return document.title.split(" - ")[0] || "Collection"; +} + function backToCollection(): void { - if (!collectionId) return; - const libraryId = getSelectedLibrary(); - const url = libraryId - ? `/collections/${collectionId}?library_id=${libraryId}` - : `/collections/${collectionId}`; - window.location.href = url; + const path = window.location.pathname; // /collections/:id/rules + window.location.href = path.replace(/\/rules$/, ""); } -function getFieldLabel(field: string): string { - const labels: Record<string, string> = { - genre: "Genre", - series: "Series", - author: "Author", - language: "Language", - publisher: "Publisher", - copyright_year: "Copyright Year", - tags: "Tags", - }; - return labels[field] || field; -} - -function getOperatorLabel(operator: string): string { - const labels: Record<string, string> = { - equals: "equals", - not_equals: "does not equal", - contains: "contains", - not_contains: "does not contain", - starts_with: "starts with", - ends_with: "ends with", - greater_than: "greater than", - less_than: "less than", - }; - return labels[operator] || operator; -} - -async function loadRules(): Promise<void> { - const token = localStorage.getItem("token"); - if (!token || !collectionId) return; - - try { - const response = await fetch(`/api/collections/${collectionId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - const data = await response.json(); - renderRules(data.auto_assign_rules || []); - } - } catch (error) { - console.error("Failed to load rules", error); - } -} - -function renderRules(rules: CollectionRule[]): void { - const container = document.getElementById("rules-container"); - const noRulesDiv = document.getElementById("no-rules"); - if (!container || !noRulesDiv) return; - - if (rules && rules.length > 0) { - noRulesDiv.classList.add("hidden"); - - container.innerHTML = rules - .map( - (rule) => ` - <div class="card p-4 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);"> - <div class="flex justify-between items-start"> - <div class="flex-1"> - <div class="flex items-center gap-3 mb-2"> - <span class="font-semibold" style="color: var(--text-primary)"> - ${getFieldLabel(rule.field)} - </span> - <span class="px-2 py-1 text-xs rounded" style="background-color: ${rule.enabled ? "var(--accent)" : "var(--text-secondary)"}; color: var(--bg-primary);"> - ${rule.enabled ? "Enabled" : "Disabled"} - </span> - <span class="px-2 py-1 text-xs rounded" style="background-color: var(--bg-secondary); color: var(--text-primary);"> - Priority ${rule.priority} - </span> - </div> - <code class="block text-sm" style="color: var(--text-secondary);"> - ${getOperatorLabel(rule.operator)} "${rule.value}" - </code> - </div> - <div class="flex space-x-2"> - <button data-action="toggle" data-rule-id="${rule.id}" data-enabled="${!rule.enabled}" - class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);"> - ${rule.enabled ? "⏸️" : "▶️"} - </button> - <button data-action="delete" data-rule-id="${rule.id}" - class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);"> - 🗑️ - </button> - </div> - </div> - </div> - `, - ) - .join(""); - } else { - noRulesDiv.classList.remove("hidden"); - } -} - -async function handleCreateRule(event: Event): Promise<void> { - event.preventDefault(); - - const token = localStorage.getItem("token"); - if (!token || !collectionId) return; - - const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; - const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement; - const valueInput = document.getElementById("rule-value") as HTMLInputElement; - const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement; - const priorityInput = document.querySelector( - 'input[name="priority"]:checked', - ) as HTMLInputElement; - - if (!fieldInput || !operatorInput || !valueInput || !enabledInput || !priorityInput) { - showToast("Missing form fields", "error"); - return; - } - - const data = { - field: fieldInput.value, - operator: operatorInput.value, - value: valueInput.value, - enabled: enabledInput.checked, - priority: parseInt(priorityInput.value, 10), - }; - - try { - const response = await fetch(`/api/collections/${collectionId}/rules`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(data), - }); - - if (response.ok) { - showToast("Rule created successfully", "success"); - clearForm(); - loadRules(); - } else { - showToast("Failed to create rule", "error"); - } - } catch (error) { - console.error("Failed to create rule", error); - showToast("Failed to create rule", "error"); - } -} - -async function testRule(): Promise<void> { - const token = localStorage.getItem("token"); - if (!token) return; - - const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; - const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement; - const valueInput = document.getElementById("rule-value") as HTMLInputElement; - - if (!fieldInput || !operatorInput || !valueInput) { - showToast("Missing form fields", "error"); - return; - } - - const field = fieldInput.value; - const operator = operatorInput.value; - const value = valueInput.value; - - if (!field || !operator || !value) { - showToast("Please fill in all rule fields", "error"); - return; - } - - const testResultsDiv = document.getElementById("test-results"); - const resultsList = document.getElementById("test-results-list"); - if (!testResultsDiv || !resultsList) return; - - testResultsDiv.classList.remove("hidden"); - resultsList.innerHTML = - '<p class="text-sm" style="color: var(--text-secondary)">Testing rule...</p>'; - - const rules = [ - { - field: field, - operator: operator, - value: value, - }, - ]; - - try { - const response = await fetch("/api/collections/test-rules", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ rules: rules }), - }); - - if (response.ok) { - const result = await response.json(); - if (result.matches && result.matches.length > 0) { - let html = - '<p style="color: var(--text-secondary); font-size: 0.875rem;">Found ' + - result.matches.length + - " matching books:</p>"; - html += '<div style="display: flex; flex-direction: column; gap: 0.5rem;">'; - - result.matches.slice(0, 20).forEach((book: TestRuleMatch) => { - html += - '<div style="display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; border-radius: 0.25rem; background-color: var(--bg-secondary);">'; - html += - '<img src="' + - (book.cover_image_path || "/static/placeholder-book.svg") + - '" alt="Cover" style="width: 2rem; height: 3rem; object-fit: cover; border-radius: 0.25rem;">'; - html += '<div style="flex: 1;">'; - html += - '<div style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary);">' + - book.title + - "</div>"; - if (book.author) { - html += - '<div style="font-size: 0.75rem; color: var(--text-secondary);">' + - book.author + - "</div>"; - } - if (book.match_reason) { - html += - '<div style="font-size: 0.75rem; color: var(--accent);">' + - book.match_reason + - "</div>"; - } - html += "</div>"; - html += "</div>"; - }); - - if (result.matches.length > 20) { - html += - '<p style="color: var(--text-secondary); font-size: 0.875rem;">...and ' + - (result.matches.length - 20) + - " more</p>"; - } - - html += "</div>"; - resultsList.innerHTML = html; - } else { - resultsList.innerHTML = - '<p style="color: var(--text-secondary); font-size: 0.875rem;">No books match this rule</p>'; - } - } else { - resultsList.innerHTML = - '<p class="text-sm" style="color: var(--error)">Failed to test rule</p>'; - } - } catch (error) { - console.error("Failed to test rule", error); - resultsList.innerHTML = - '<p class="text-sm" style="color: var(--error)">Failed to test rule</p>'; - } -} - -async function toggleRule(ruleId: string, enabled: boolean): Promise<void> { - const token = localStorage.getItem("token"); - if (!token || !collectionId) return; - - try { - const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ enabled }), - }); - - if (response.ok) { - showToast("Rule updated", "success"); - loadRules(); - } else { - showToast("Failed to update rule", "error"); - } - } catch (error) { - console.error("Failed to update rule", error); - showToast("Failed to update rule", "error"); - } -} - -async function deleteRule(ruleId: string): Promise<void> { - if (!confirm("Are you sure you want to delete this rule?")) return; - - const token = localStorage.getItem("token"); - if (!token || !collectionId) return; - - try { - const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - showToast("Rule deleted", "success"); - loadRules(); - } else { - showToast("Failed to delete rule", "error"); - } - } catch (error) { - console.error("Failed to delete rule", error); - showToast("Failed to delete rule", "error"); - } -} - -function clearForm(): void { - const fieldInput = document.getElementById("rule-field") as HTMLSelectElement; - const operatorInput = document.getElementById("rule-operator") as HTMLSelectElement; - const valueInput = document.getElementById("rule-value") as HTMLInputElement; - const enabledInput = document.getElementById("rule-enabled") as HTMLInputElement; - const priorityInput = document.querySelector( - 'input[name="priority"][value="2"]', - ) as HTMLInputElement; - const testResultsDiv = document.getElementById("test-results"); - - if (fieldInput) fieldInput.value = ""; - if (operatorInput) operatorInput.value = ""; - if (valueInput) valueInput.value = ""; - if (enabledInput) enabledInput.checked = true; - if (priorityInput) priorityInput.checked = true; - if (testResultsDiv) testResultsDiv.classList.add("hidden"); -} - -// Rules-page sign-out: same contract as the header's — the API clears -// the HttpOnly session cookie and revokes the refresh token; local -// cleanup and redirect are best-effort. -async function logout(): Promise<void> { - try { - await fetch("/api/auth/logout", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: localStorage.getItem("refresh_token") ?? "" }), - }); - } catch { - // redirect regardless - } - localStorage.removeItem("token"); - localStorage.removeItem("refresh_token"); - window.location.href = "/login"; -} - -// ============================================================ -// Exports -// ============================================================ - Alpine.data("collectionRules", () => ({ backToCollection, - clearForm, - deleteRule, - getFieldLabel, - getOperatorLabel, - handleCreateRule, initCollectionRules, - loadRules, - logout, - setMatchMode, - get matchMode() { - return matchMode; - }, - renderRules, - setupEventDelegation, - testRule, - toggleRule, })); diff --git a/web/src/collections.ts b/web/src/collections.ts index 7375a64d..5f013243 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -47,139 +47,6 @@ function initializeCollectionWebSocket(): void { }); } -async function loadCollectionRules(collectionId: string): Promise<void> { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch(`/api/collections/${collectionId}/rules`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - const rules: CollectionRule[] = await response.json(); - renderRules(rules); - } - } catch (error) { - console.error("Failed to load rules:", error); - } -} - -function renderRules(rules: CollectionRule[]): void { - const container = document.getElementById("rules-list"); - if (!container) return; - - if (rules.length === 0) { - container.innerHTML = - '<p class="text-center p-4" style="color: var(--text-secondary)">No rules defined</p>'; - return; - } - - container.innerHTML = rules - .map( - (rule) => ` - <div class="p-3 rounded-lg border mb-2 flex justify-between items-center" - style="background-color: var(--bg-secondary); border-color: var(--border)"> - <div> - <p class="font-medium" style="color: var(--text-primary)">${rule.field} ${rule.operator} "${rule.value}"</p> - <p class="text-sm" style="color: var(--text-secondary)">Priority: ${rule.priority} | ${rule.enabled ? "Enabled" : "Disabled"}</p> - </div> - <div class="flex space-x-2"> - <button onclick="window.editRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Edit</button> - <button onclick="window.deleteRule('${rule.id}')" class="btn-secondary px-2 py-1 rounded text-sm">Delete</button> - </div> - </div> - `, - ) - .join(""); -} - -async function createRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch(`/api/collections/${collectionId}/rules`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(rule), - }); - - if (response.ok) { - showToast("Rule created", "success"); - loadCollectionRules(collectionId); - } else { - const error = await response.json(); - showToast(error.error || "Failed to create rule", "error"); - } - } catch (error) { - console.error("Failed to create rule:", error); - showToast("Failed to create rule", "error"); - } -} - -async function deleteRule(collectionId: string, ruleId: string): Promise<void> { - const token = localStorage.getItem("token"); - if (!token) return; - - if (!confirm("Are you sure you want to delete this rule?")) return; - - try { - const response = await fetch(`/api/collections/${collectionId}/rules/${ruleId}`, { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - showToast("Rule deleted", "success"); - loadCollectionRules(collectionId); - } - } catch (error) { - console.error("Failed to delete rule:", error); - showToast("Failed to delete rule", "error"); - } -} - -async function testRule(collectionId: string, rule: Partial<CollectionRule>): Promise<void> { - const token = localStorage.getItem("token"); - if (!token) return; - - try { - const response = await fetch(`/api/collections/${collectionId}/rules/test`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(rule), - }); - - if (response.ok) { - const results = await response.json(); - renderTestResults(results); - } - } catch (error) { - console.error("Failed to test rule:", error); - showToast("Failed to test rule", "error"); - } -} - -function renderTestResults(results: unknown[]): void { - const container = document.getElementById("test-results"); - if (!container) return; - - if (!results || (Array.isArray(results) && results.length === 0)) { - container.innerHTML = - '<p class="p-2 text-sm" style="color: var(--text-secondary)">No matching books found</p>'; - return; - } - - container.innerHTML = `<p class="p-2 text-sm" style="color: var(--text-secondary)">${Array.isArray(results) ? results.length : 0} matching books</p>`; -} - function setupHTMXAuth(): void { document.body.addEventListener("htmx:configRequest", function (evt: Event) { const token = localStorage.getItem("token"); @@ -476,14 +343,11 @@ function filterCollectionBooks(): void { export { closeCollectionModal, - createRule, - deleteRule, filterCollectionBooks, filterIcons, initColorSelection, initializeCollectionWebSocket, initCollectionsPage, - loadCollectionRules, navigateToCollection, populateIconGrid, selectColor, @@ -491,21 +355,17 @@ export { setupHTMXAuth, setupHTMXModalInit, showAllIcons, - testRule, toggleSelection, moveCollectionBook, }; Alpine.data("collections", () => ({ closeCollectionModal, - createRule, - deleteRule, filterCollectionBooks, filterIcons, initColorSelection, initializeCollectionWebSocket, initCollectionsPage, - loadCollectionRules, moveCollectionBook, navigateToCollection, populateIconGrid, @@ -514,7 +374,6 @@ Alpine.data("collections", () => ({ selectedBooks: [] as string[], setupHTMXModalInit, showAllIcons, - testRule, toggleSelection, showConfirm: false, diff --git a/web/src/filter_builder.ts b/web/src/filter_builder.ts index b31f6fa5..88d5081a 100644 --- a/web/src/filter_builder.ts +++ b/web/src/filter_builder.ts @@ -351,8 +351,10 @@ function filterBuilderData() { // Lifecycle: the Go registry renders this page's vocabulary (the // data-filter-fields payload); ingest once so the menus are born // scope-filtered and server-owned (item 3.3). + scopeKey: "books" as string, init() { - const raw = (this.$el as HTMLElement | null)?.dataset.filterFields; + const el = this.$el as HTMLElement | null; + const raw = el?.dataset.filterFields; if (raw && !FIELD_BY_KEY.size) { try { ingestFilterFields(JSON.parse(raw)); @@ -361,6 +363,40 @@ function filterBuilderData() { // source-of-truth; never invent rows client-side. } } + // Collection mounts (item 3.4): the page carries the collection's + // scope (row universe for counts/apply) and its PREFILL tree (the + // stored tree, or the legacy rules' conversion). + if (el?.dataset.scope) { + this.scopeKey = el.dataset.scope; + } + const treeRaw = el?.dataset.tree; + if (treeRaw) { + try { + const t = JSON.parse(treeRaw) as FilterGroupState; + if (t && Array.isArray(t.children)) { + this.filterTree = { mode: t.mode === "any" ? "any" : "all", children: t.children }; + } + } catch { + // no prefill — start empty + } + } + }, + // treeJSON is the APPLY wire for collection mounts: the page's + // scope rides the tree's top-level key (the engine's scope head). + treeJSON(): string { + return JSON.stringify({ + scope: this.scopeKey, + mode: this.filterTree.mode, + children: this.filterTree.children, + }); + }, + // applyCollectionTree delegates to the page-registered hook (the + // collection editor PUTs the tree; All-Books never sets one). + applyCollectionTree(): void { + const hook = ( + window.Alpine?.store("collectionTreeApply") as { hook?: (wire: string) => void } | undefined + )?.hook; + if (hook) hook(this.treeJSON()); }, // Rule tree (source of truth; hidden wire inputs are synced after // every mutation). @@ -422,7 +458,13 @@ function filterBuilderData() { }, ensureReadyRule(): void { if (this.filterTree.children.length === 0) { - this.filterTree.children.push({ field: "author", op: "contains", values: [] }); + const first = wireFieldGroups[0]?.fields[0]?.key ?? "author"; + const fm = FIELD_BY_KEY.get(first); + this.filterTree.children.push({ + field: first, + op: fm?.defaultOp ?? "contains", + values: [], + }); this._touch(); } }, @@ -712,7 +754,8 @@ function filterBuilderData() { return; } const params = new URLSearchParams(); - params.set("qf", JSON.stringify(completed)); + const scoped = this.scopedTree(); + if (scoped) params.set("qf", JSON.stringify(scoped)); const currentLibraryId = (document.getElementById("library-select") as HTMLSelectElement) ?.value; if (currentLibraryId) params.set("library_id", currentLibraryId); @@ -776,6 +819,13 @@ function filterBuilderData() { completedTree(excludePath: number[] | null): FilterGroupState | null { return pruneIncomplete(this.filterTree as FilterGroupState, excludePath); }, + // scopedTree is the WIRE tree: the mount's scope rides the top + // level (the engine's scope head — counts and applies carry it). + scopedTree(): Record<string, unknown> | null { + const completed = this.completedTree(null); + if (!completed) return null; + return { scope: this.scopeKey, mode: completed.mode, children: completed.children }; + }, syncFormInputs(): void { const form = document.getElementById("filter-form") as HTMLFormElement | null; if (!form) return;