diff --git a/ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs b/ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs new file mode 100644 index 000000000..93ef57ac3 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Search; + +namespace ErsatzTV.Application.Search.Queries; + +public record GetSearchFieldCatalog : IRequest>; diff --git a/ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs b/ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs new file mode 100644 index 000000000..e01b8cfa0 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Api.Search; + +namespace ErsatzTV.Application.Search.Queries; + +public class GetSearchFieldCatalogHandler : IRequestHandler> +{ + public Task> Handle( + GetSearchFieldCatalog request, + CancellationToken cancellationToken) => + Task.FromResult(SearchFieldCatalog.Fields); +} diff --git a/ErsatzTV.Application/Search/SearchFieldCatalog.cs b/ErsatzTV.Application/Search/SearchFieldCatalog.cs new file mode 100644 index 000000000..4dacf8259 --- /dev/null +++ b/ErsatzTV.Application/Search/SearchFieldCatalog.cs @@ -0,0 +1,58 @@ +using ErsatzTV.Core.Api.Search; + +namespace ErsatzTV.Application.Search; + +public static class SearchFieldCatalog +{ + private static readonly string[] None = []; + + // Allowed values for the `type` enum — mirrors the lowercase tokens the Lucene index stores for + // the `type` field (see LuceneSearchIndex.TypeField and its *Type constants). + private static readonly string[] ItemTypes = + [ + "movie", "show", "season", "episode", "artist", "music_video", "other_video", "song", "image", + "remote_stream" + ]; + + public static readonly List Fields = + [ + // General + new("title", "Title", "text", "General", None), + new("genre", "Genre", "text", "General", None), + new("tag", "Tag", "text", "General", None), + new("plot", "Plot", "fulltext", "General", None), + new("content_rating", "Content rating", "text", "General", None), + new("studio", "Studio", "text", "General", None), + new("collection", "Collection", "text", "General", None), + new("state", "State", "text", "General", None), + new("type", "Item type", "enum", "General", ItemTypes), + + // TV + new("network", "Network", "text", "TV", None), + new("show_title", "Show title", "text", "TV", None), + new("show_genre", "Show genre", "text", "TV", None), + new("season_number", "Season number", "number", "TV", None), + new("episode_number", "Episode number", "number", "TV", None), + + // Movie / People + new("director", "Director", "text", "Movie", None), + new("writer", "Writer", "text", "Movie", None), + new("actor", "Actor", "text", "Movie", None), + + // Music + new("artist", "Artist", "text", "Music", None), + new("album", "Album", "text", "Music", None), + new("album_artist", "Album artist", "text", "Music", None), + + // Technical + new("minutes", "Duration (min)", "number", "Technical", None), + new("height", "Height (px)", "number", "Technical", None), + new("width", "Width (px)", "number", "Technical", None), + new("video_codec", "Video codec", "text", "Technical", None), + new("video_dynamic_range", "Dynamic range", "text", "Technical", None), + + // Dates + new("added_date", "Date added", "date", "Dates", None), + new("release_date", "Release date", "date", "Dates", None) + ]; +} diff --git a/ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs b/ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs new file mode 100644 index 000000000..4a7670a72 --- /dev/null +++ b/ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Api.Search; + +/// +/// One filterable field in the visual rule builder's catalog. +/// Type is one of: text, fulltext, number, date, enum. +/// Values is populated only for enum fields (allowed dropdown values); empty otherwise. +/// +public record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values); diff --git a/ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs b/ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs new file mode 100644 index 000000000..f9e3df51f --- /dev/null +++ b/ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Application.Search.Queries; +using ErsatzTV.Core.Api.Search; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Search; + +[TestFixture] +public class GetSearchFieldCatalogHandlerTests +{ + [Test] + public async Task Returns_Curated_Catalog_Without_Internal_Fields() + { + var handler = new GetSearchFieldCatalogHandler(); + + List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); + + result.ShouldNotBeEmpty(); + result.Select(f => f.Name).ShouldNotContain("id"); + result.Select(f => f.Name).ShouldNotContain("tag_full"); + result.Select(f => f.Name).ShouldNotContain("library_folder_id"); + } + + [Test] + public async Task Every_Field_Has_A_Known_Type_And_A_Group() + { + var handler = new GetSearchFieldCatalogHandler(); + string[] knownTypes = ["text", "fulltext", "number", "date", "enum"]; + + List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); + + foreach (SearchFieldResponseModel field in result) + { + knownTypes.ShouldContain(field.Type); + field.Group.ShouldNotBeNullOrWhiteSpace(); + field.Label.ShouldNotBeNullOrWhiteSpace(); + } + } + + [Test] + public async Task Enum_Fields_Carry_NonEmpty_Values_And_NonEnum_Do_Not() + { + var handler = new GetSearchFieldCatalogHandler(); + + List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); + + foreach (SearchFieldResponseModel field in result) + { + if (field.Type == "enum") + { + field.Values.ShouldNotBeEmpty(); + } + else + { + field.Values.ShouldBeEmpty(); + } + } + } +} diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs index 5a6c2f7ce..6a833bbfc 100644 --- a/ErsatzTV/Controllers/Api/SearchController.cs +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -1,6 +1,7 @@ using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Search; +using ErsatzTV.Application.Search.Queries; using ErsatzTV.Core; using ErsatzTV.Core.Api.Scheduling; using ErsatzTV.Core.Api.Search; @@ -161,6 +162,17 @@ public class SearchController(IMediator mediator) : ControllerBase return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList(); } + [HttpGet("/api/v1/search/fields", Name = "GetSearchFields")] + [Tags("Search")] + [EndpointSummary("List the filterable fields for the visual rule builder")] + [EndpointDescription( + "Returns the curated catalog of searchable fields (name, friendly label, type, UI group, and " + + "allowed values for enum fields). Drives the SmartCollection rule builder and is introspectable by MCP.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public Task> GetSearchFields(CancellationToken cancellationToken) => + mediator.Send(new GetSearchFieldCatalog(), cancellationToken); + private static SearchResultAllItemsResponseModel Project(SearchResultAllItemsViewModel vm) => new( vm.MovieIds, diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 6f45f9716..f72c5ddef 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -17942,6 +17942,60 @@ ] } }, + "/api/v1/search/fields": { + "get": { + "tags": [ + "Search" + ], + "summary": "List the filterable fields for the visual rule builder", + "description": "Returns the curated catalog of searchable fields (name, friendly label, type, UI group, and allowed values for enum fields). Drives the SmartCollection rule builder and is introspectable by MCP.", + "operationId": "GetSearchFields", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFieldResponseModel" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFieldResponseModel" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFieldResponseModel" + } + } + } + } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, "/api/v1/seasons/{id}": { "get": { "tags": [ @@ -30323,6 +30377,51 @@ } } }, + "SearchFieldResponseModel": { + "required": [ + "name", + "label", + "type", + "group", + "values" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "label": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "group": { + "type": [ + "null", + "string" + ] + }, + "values": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + } + } + }, "SearchResultAllItemsResponseModel": { "required": [ "movieIds", diff --git a/docs/api-conventions.md b/docs/api-conventions.md index d4e6efa70..c294fa7fe 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -348,6 +348,17 @@ a second caller of the `from-lineup` advanced-options wire contract — do **not rotation weights are **not** here (deferred to #425; they need a MultiCollection redesign — see `docs/decisions.md` 2026-07-17 #385). Regenerated the OpenAPI trio (v1.json/v1.d.ts/endpoint-index) even though only schemas changed. +**Endpoint inventory addition (#176, visual rule builder backend)**: one read-only `SearchController` +GET, standard credential (catalog-read tier — no `[RequiresAuthentication]`): + +| Method | Path | Operation | Summary | +|---|---|---|---| +| GET | `/api/v1/search/fields` | `GetSearchFields` | List the filterable fields for the visual rule builder | + +Returns the curated `SearchFieldCatalog` (name, friendly label, type, UI group, and allowed values for +enum fields) as `List`. Drives the SmartCollection rule builder and is +introspectable by MCP; no query parameters. + **Resolved wart (#287)**: `DayOfWeek` previously serialized as an integer in the OpenAPI schema while the runtime JSON payload is the enum's **name string** ("Sunday".."Saturday"). It is now added to `Startup.UseStringEnumSchemas`'s hand-list, so the "v1" schema emits it as a **string enum** matching diff --git a/docs/decisions.md b/docs/decisions.md index 08f5282dd..53dbb94bb 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -93,6 +93,7 @@ in-file entries. - [2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)](#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) - [2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)](#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164) - [2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)](#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386) +- [2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)](#2026-07-18--smartcollection-rule-builder-compile-only-closed-subset-no-stored-ast-one-level-nesting-176) --- @@ -1677,3 +1678,39 @@ never presents a control with nowhere to send its value. - **Unsaved-changes guard is screen-scoped** (spa-conventions §8/§11): per-channel edits live in `AutoTuneScreen` draft state until bulk-create, so closing the panel keeps them (an "Edited" row badge makes that visible) and only screen navigation / full-page unload with uncommitted edits confirms. + +## 2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176) + +The SmartCollection create/edit dialog gained a visual rule builder (`web/src/builder/rules/`) +alongside the existing raw-Lucene textarea. **The SmartCollection still stores a plain Lucene query +string — no new stored rule AST, no schema change.** The builder compiles its in-memory rule tree +into a **closed subset** of the Lucene grammar (`compile.ts`) and parses exactly that subset back out +(`parse.ts`, the exact inverse — returns `null`, not a best-effort guess, for anything outside the +subset); escaping is total, so any builder-authored query round-trips losslessly, proven by a 500-tree +property test (`roundtrip.test.ts`, including Lucene special characters). Opening an existing +SmartCollection tries the parse first and falls back to raw-text mode on `null` (fuzzy queries, +boosts, mixed AND/OR at one nesting level, or nesting deeper than one level). + +**Why compile-only over persisting an authoritative rule AST**: an AST would still need a +Lucene→rules parser to open every *pre-existing* free-text query — including every query the +Auto-Tune feature (#69) generates — so the AST would buy almost nothing (it still can't represent +arbitrary hand-written Lucene) while costing a dual-provider EF migration and a second source of +truth to keep in sync with the Lucene grammar. Compile-only keeps the query string as the single +source of truth and treats the builder as a structured *editor* over it, not a new storage model. + +**One-level-nesting "Kodi" model.** `types.ts` defines a top `Group` (`match: all|any`) over `Rule`s +and/or **one level** of sub-`Group`s — enough to express `type:movie AND (genre:Horror OR +genre:Thriller)`, which covers the smart-playlist patterns Kodi-style rule builders are known for. +Arbitrary/recursive nesting was scoped out as YAGNI; revisit only if a real query needs it. + +**Field vocabulary comes from a new catalog endpoint, not a hardcoded list.** `GET +/api/v1/search/fields` (read-only, MCP-introspectable; see `api-conventions.md`) returns the curated, +typed, labeled field set derived from `LuceneSearchIndex` — name/label/type/group/values — and is the +single source of truth the builder's field pickers (`fieldCatalog.ts`'s `useSearchFields` hook) and +operator/value-input choices are driven from, so the builder's vocabulary can't drift from what the +index actually supports. + +**Deferred as separate follow-up issues** (explicitly out of scope for #176): facet-value typeahead +for value inputs, relative-date operators, nesting deeper than one level, and inline adoption of +`RuleBuilder` by ChannelBuilder / Auto-Tune (it was built reusable for exactly that reuse — see +`spa-conventions.md` §12). diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index c3f809cab..124c7e57b 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -165 endpoints, 248 operations. +166 endpoints, 249 operations. ## Artists @@ -337,6 +337,7 @@ | GET | `/api/v1/search/all-items` | SearchAllItems | Search library items across all media kinds and return raw id lists | | GET | `/api/v1/search/artists` | SearchArtists | Search artists by name | | GET | `/api/v1/search/collections` | SearchCollections | Search collections by name | +| GET | `/api/v1/search/fields` | GetSearchFields | List the filterable fields for the visual rule builder | | GET | `/api/v1/search/multi-collections` | SearchMultiCollections | Search multi collections by name | | GET | `/api/v1/search/smart-collections` | SearchSmartCollections | Search smart collections by name | | GET | `/api/v1/search/television-seasons` | SearchTelevisionSeasons | Search television seasons by name | diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index b93d35f09..14fce2cb7 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -427,3 +427,35 @@ string-keyed dispatcher, or generic screen-action framework participates in norm screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode` are surfaced as dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and merged into `advanced` at create, not carried in the override map. + +## 12. Reusable rule builder (`web/src/builder/rules/`) + +A visual rule builder for Lucene-backed queries, introduced for the SmartCollection create/edit +dialog (#176) and built as a **standalone, controlled component module** — not a SmartCollection +screen concern — so it can be embedded by later screens without duplicating the rule model. + +- **`types.ts`** — the rule tree: `Rule` (field/operator/value), `Group` (`match: all|any` over + `Rule`s and/or one level of sub-`Group`s, the "Kodi" one-level-nesting model — see + `docs/decisions.md` 2026-07-18), `Operator`, `FieldType`, and an `isGroup` narrowing helper. +- **`compile.ts`** / **`parse.ts`** — `compile(group)` turns a rule tree into a query string covering + a **closed subset** of the Lucene grammar; `parse(input, fieldTypes)` is its exact inverse, returning + `null` (never a lossy best-effort tree) for any query outside that subset. Escaping is total, so a + compile→parse round-trip is lossless for any builder-authored value, including Lucene special + characters — pinned by a 500-tree property test (`roundtrip.test.ts`). +- **`fieldCatalog.ts`** — `useSearchFields()` wraps `GET /api/v1/search/fields` and exposes a clean, + non-null `RuleField[]` (plus a `fieldTypes` lookup and a `byGroup` grouping) for field pickers; this + is the only place the raw API response shape is unwrapped. +- **`RuleBuilder.tsx`** — the controlled component itself: `{ group, onChange }` in, add/remove + rule and sub-group UI out. It holds no query-string state — the embedding screen owns the compiled + `query` string (`compile(group)` on change) and, symmetrically, seeds `group` via `parse(query, + fieldTypes)` on load (falling back to raw-text editing on a `null` parse). + +**Usage pattern**: a screen owns two things — the raw `query` string (what actually gets saved) and +the parsed `Group | null` (what the builder edits). Toggling between "Builder" and raw-text modes is +just switching which of the two is the source of truth for that render, re-deriving the other via +`compile`/`parse` at the toggle boundary. See the SmartCollection dialog for the reference +integration. + +This module is intentionally reusable beyond SmartCollections — ChannelBuilder and Auto-Tune's +inline query editing (#69) are candidate future consumers, tracked as separate follow-up issues +rather than wired in #176. diff --git a/docs/superpowers/plans/2026-07-17-smartcollection-rule-builder.md b/docs/superpowers/plans/2026-07-17-smartcollection-rule-builder.md new file mode 100644 index 000000000..5f4965e60 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-smartcollection-rule-builder.md @@ -0,0 +1,1293 @@ +# SmartCollection Visual Rule Builder — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Kodi-style visual rule builder to the SmartCollection create/edit dialog that compiles to (and parses back from) a closed subset of the existing Lucene query grammar, with a read-only backend field catalog as the single source of truth. + +**Architecture:** A new read-only `GET /api/v1/search/fields` returns a curated, typed, labeled field catalog (MediatR query + static list in `ErsatzTV.Application`). The SPA gains pure-TS `compile`/`parse` modules over a closed Lucene subset (exact inverses, proven by a round-trip property test), a `RuleBuilder` React component, and a `Builder | Advanced` mode toggle in the existing `SmartDialog`. Nothing new is persisted — the SmartCollection still stores a plain Lucene query string, so there is **no DB migration and no write-path change**. + +**Tech Stack:** C# / .NET 10, MediatR (CQRS), ASP.NET Core controllers; React + TypeScript SPA (Vite), vitest + @testing-library/react + jsdom. NUnit + Shouldly for backend tests. + +## Global Constraints + +- **.NET 10**; functional C# with LanguageExt where the surrounding code uses it. +- **Backend tests: NUnit + Shouldly + NSubstitute only** — xUnit is not used here. +- **No new NuGet or npm packages.** Central Package Management (repo-root `Directory.Packages.props`); the round-trip generator is hand-rolled (do **not** add `fast-check`). +- **Every API action needs:** `[ApiController]`, absolute versioned route `[HttpGet("/api/v1/...", Name="...")]`, `[Tags("Search")]`, `[EndpointSummary(...)]`, **`[EndpointGroupName("general")]` (REQUIRED — omitting it drops the endpoint from the OpenAPI doc)**, and `[ProducesResponseType(typeof(X), StatusCodes.Status200OK)]`. +- **After any `/api/*` change:** build the app FIRST, then `./scripts/update-openapi.sh`, then `cd web && npm run generate:api`. Regenerated `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, and `web/src/api/generated/v1.d.ts` are committed. +- **Docs in the same PR:** tick `docs/api-conventions.md`; append to `docs/decisions.md`; update `docs/spa-conventions.md` for the new component pattern. (No `blazor-route-parity.md` / `domain-model.md` — no route/entity change.) +- **Before every push touching `.cs`:** BOM-check the touched set — `for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done` — strip any hit (`charset=utf-8` ⇒ no BOM). +- **Worktree:** all work in `.claude/worktrees/176-rule-builder` (branch `feat/176-smartcollection-rule-builder`, off `origin/main`). Copy `web/node_modules` from the shared tree read-only if needed: `cp -R /Users/timothy/ersatztv/web/node_modules web/node_modules`. +- **The compiler is the SOLE author of quoting/escaping** so the parser can rely on canonical forms. + +--- + +## File Structure + +**Backend (new):** +- `ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs` — response DTO (record). +- `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs` — MediatR query (marker record). +- `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs` — returns the curated static catalog. +- `ErsatzTV.Application/Search/SearchFieldCatalog.cs` — the curated static list (product-owned, cross-references `LuceneSearchIndex`). +- `ErsatzTV/Controllers/Api/SearchController.cs` — **modify**: add the `GET /api/v1/search/fields` action. +- `ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs` — NUnit test (new; Application handler tests live under `ErsatzTV.Tests/Application//`, namespace `ErsatzTV.Tests.Application.`, run via `dotnet test ErsatzTV.Tests`). + +**SPA (new — `web/src/builder/rules/`):** +- `types.ts` — `FieldType`, `Operator`, `Rule`, `Group`, `Match`, `isGroup`. +- `compile.ts` — `compile(group) → string`. +- `parse.ts` — `parse(str, fieldTypes) → Group | null`. +- `compile.test.ts`, `parse.test.ts`, `roundtrip.test.ts`. +- `fieldCatalog.ts` — `getSearchFields()` + `useSearchFields()` hook. +- `RuleBuilder.tsx` — the group/rule UI. +- `RuleBuilder.test.tsx`. + +**SPA (modify):** +- `web/src/api/search.ts` — add `getSearchFields` + `SearchField` type. +- `web/src/screens/CollectionsScreen.tsx` — `SmartDialog`: `Builder | Advanced` toggle wiring. + +**Docs (modify):** `docs/api-conventions.md`, `docs/decisions.md`, `docs/spa-conventions.md`. + +--- + +## Task 1: Field catalog — response model, curated list, MediatR handler + +**Files:** +- Create: `ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs` +- Create: `ErsatzTV.Application/Search/SearchFieldCatalog.cs` +- Create: `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs` +- Create: `ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs` +- Test: `ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs` + +**Interfaces:** +- Produces: `record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values)`; `record GetSearchFieldCatalog : IRequest>`; `SearchFieldCatalog.Fields` (static `List`). + +- [ ] **Step 1: (resolved) Test location** + +Application handler tests live in `ErsatzTV.Tests/Application//` with namespace `ErsatzTV.Tests.Application.`, run via `dotnet test ErsatzTV.Tests`. The new test goes in `ErsatzTV.Tests/Application/Search/`. This repo uses **explicit `using`s** (no ImplicitUsings) — include the `System.*` usings shown in Step 5. Exemplar: `ErsatzTV.Tests/Application/Channels/PreviewAutoTuneChannelsHandlerTests.cs`. + +- [ ] **Step 2: Write the response model** + +`ErsatzTV.Core/Api/Search/SearchFieldResponseModel.cs`: + +```csharp +namespace ErsatzTV.Core.Api.Search; + +/// +/// One filterable field in the visual rule builder's catalog. +/// Type is one of: text, fulltext, number, date, enum. +/// Values is populated only for enum fields (allowed dropdown values); empty otherwise. +/// +public record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values); +``` + +- [ ] **Step 3: Write the curated catalog** + +`ErsatzTV.Application/Search/SearchFieldCatalog.cs`. This is a **product-curated** subset of the Lucene index fields (see `ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs`); internal/index-plumbing fields (`id`, `tag_full`, `library_id`, `library_folder_id`, `jump_letter`, `language_tag`, `sub_language_tag`, `title_and_year_search`, `metadata_kind`) are intentionally omitted. Curation lives here by design, not mirrored mechanically, so Application does not depend on Infrastructure. + +```csharp +using ErsatzTV.Core.Api.Search; + +namespace ErsatzTV.Application.Search; + +public static class SearchFieldCatalog +{ + private static readonly string[] None = []; + + // Allowed values for the `type` enum — mirror the lowercase tokens the Lucene index stores for + // the `type` field. VERIFY against LuceneSearchIndex.cs during implementation and adjust if the + // tokens differ; the GetSearchFieldCatalogHandlerTests asserts this list is non-empty. + private static readonly string[] ItemTypes = + ["movie", "show", "season", "episode", "artist", "music_video", "other_video", "song", "image"]; + + public static readonly List Fields = + [ + // General + new("title", "Title", "text", "General", None), + new("genre", "Genre", "text", "General", None), + new("tag", "Tag", "text", "General", None), + new("plot", "Plot", "fulltext", "General", None), + new("content_rating", "Content rating", "text", "General", None), + new("studio", "Studio", "text", "General", None), + new("collection", "Collection", "text", "General", None), + new("state", "State", "text", "General", None), + new("type", "Item type", "enum", "General", ItemTypes), + + // TV + new("network", "Network", "text", "TV", None), + new("show_title", "Show title", "text", "TV", None), + new("show_genre", "Show genre", "text", "TV", None), + new("season_number", "Season number", "number", "TV", None), + new("episode_number", "Episode number", "number", "TV", None), + + // Movie / People + new("director", "Director", "text", "Movie", None), + new("writer", "Writer", "text", "Movie", None), + new("actor", "Actor", "text", "Movie", None), + + // Music + new("artist", "Artist", "text", "Music", None), + new("album", "Album", "text", "Music", None), + new("album_artist", "Album artist", "text", "Music", None), + + // Technical + new("minutes", "Duration (min)", "number", "Technical", None), + new("height", "Height (px)", "number", "Technical", None), + new("width", "Width (px)", "number", "Technical", None), + new("video_codec", "Video codec", "text", "Technical", None), + new("video_dynamic_range", "Dynamic range", "text", "Technical", None), + + // Dates + new("added_date", "Date added", "date", "Dates", None), + new("release_date", "Release date", "date", "Dates", None) + ]; +} +``` + +- [ ] **Step 4: Write the MediatR query + handler** + +`ErsatzTV.Application/Search/Queries/GetSearchFieldCatalog.cs`: + +```csharp +using ErsatzTV.Core.Api.Search; +using MediatR; + +namespace ErsatzTV.Application.Search.Queries; + +public record GetSearchFieldCatalog : IRequest>; +``` + +`ErsatzTV.Application/Search/Queries/GetSearchFieldCatalogHandler.cs`: + +```csharp +using ErsatzTV.Core.Api.Search; +using MediatR; + +namespace ErsatzTV.Application.Search.Queries; + +public class GetSearchFieldCatalogHandler : IRequestHandler> +{ + public Task> Handle( + GetSearchFieldCatalog request, + CancellationToken cancellationToken) => + Task.FromResult(SearchFieldCatalog.Fields); +} +``` + +- [ ] **Step 5: Write the failing NUnit test** + +`ErsatzTV.Tests/Application/Search/GetSearchFieldCatalogHandlerTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Application.Search.Queries; +using ErsatzTV.Core.Api.Search; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Search; + +[TestFixture] +public class GetSearchFieldCatalogHandlerTests +{ + [Test] + public async Task Returns_Curated_Catalog_Without_Internal_Fields() + { + var handler = new GetSearchFieldCatalogHandler(); + + List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); + + result.ShouldNotBeEmpty(); + result.Select(f => f.Name).ShouldNotContain("id"); + result.Select(f => f.Name).ShouldNotContain("tag_full"); + result.Select(f => f.Name).ShouldNotContain("library_folder_id"); + } + + [Test] + public async Task Every_Field_Has_A_Known_Type_And_A_Group() + { + var handler = new GetSearchFieldCatalogHandler(); + string[] knownTypes = ["text", "fulltext", "number", "date", "enum"]; + + List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); + + foreach (SearchFieldResponseModel field in result) + { + knownTypes.ShouldContain(field.Type); + field.Group.ShouldNotBeNullOrWhiteSpace(); + field.Label.ShouldNotBeNullOrWhiteSpace(); + } + } + + [Test] + public async Task Enum_Fields_Carry_NonEmpty_Values_And_NonEnum_Do_Not() + { + var handler = new GetSearchFieldCatalogHandler(); + + List result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None); + + foreach (SearchFieldResponseModel field in result) + { + if (field.Type == "enum") + { + field.Values.ShouldNotBeEmpty(); + } + else + { + field.Values.ShouldBeEmpty(); + } + } + } +} +``` + +- [ ] **Step 6: Run the test to verify it fails, then passes after building** + +Run: `cd /Users/timothy/ersatztv/.claude/worktrees/176-rule-builder && dotnet test ErsatzTV.Tests --filter GetSearchFieldCatalogHandlerTests` +Expected: fails to compile first if any type is missing, then PASS once Steps 2–4 are in. **Grep the build for `error CS` before trusting a `--no-build` result.** + +- [ ] **Step 7: Commit** + +```bash +git add ErsatzTV.Core/Api/Search ErsatzTV.Application/Search ErsatzTV.Tests/Application/Search +git -c core.hooksPath=/dev/null commit -m "feat(176): search field catalog query + curated list" +``` + +--- + +## Task 2: Wire the endpoint, regenerate OpenAPI + TS types, tick api-conventions + +**Files:** +- Modify: `ErsatzTV/Controllers/Api/SearchController.cs` +- Modify (generated): `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts` +- Modify: `docs/api-conventions.md` + +**Interfaces:** +- Produces: `GET /api/v1/search/fields` → `List`; generated TS type `components['schemas']['SearchFieldResponseModel']`. + +- [ ] **Step 1: Add the action to `SearchController`** + +Add `using ErsatzTV.Application.Search.Queries;` and `using ErsatzTV.Core.Api.Search;` at the top (some may already be present), then this action inside the class: + +```csharp + [HttpGet("/api/v1/search/fields", Name = "GetSearchFields")] + [Tags("Search")] + [EndpointSummary("List the filterable fields for the visual rule builder")] + [EndpointDescription( + "Returns the curated catalog of searchable fields (name, friendly label, type, UI group, and " + + "allowed values for enum fields). Drives the SmartCollection rule builder and is introspectable by MCP.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public Task> GetSearchFields(CancellationToken cancellationToken) => + mediator.Send(new GetSearchFieldCatalog(), cancellationToken); +``` + +- [ ] **Step 2: Build the app, regenerate the OpenAPI doc + endpoint index** + +Run: `cd /Users/timothy/ersatztv/.claude/worktrees/176-rule-builder && ./scripts/update-openapi.sh` +Expected: builds `ErsatzTV`, regenerates `v1.json` and `docs/endpoint-index.md`. Confirm `GetSearchFields` appears: `grep -n "search/fields" docs/endpoint-index.md`. + +- [ ] **Step 3: Regenerate the SPA client types** + +Run: `cd web && npm run generate:api && grep -n "SearchFieldResponseModel" src/api/generated/v1.d.ts` +Expected: the schema is present in `v1.d.ts`. + +- [ ] **Step 4: Tick the api-conventions checklist** + +In `docs/api-conventions.md`, add `GET /api/v1/search/fields` to the endpoint inventory/exemplar list in the same style as neighboring read-only GET entries (a one-line mention that it returns the rule-builder field catalog). This is the "update the doc in the same PR" requirement for a new endpoint. + +- [ ] **Step 5: Sanity-run the app and curl the endpoint (optional but recommended)** + +If a local run is convenient: `dotnet run --project ErsatzTV` then `curl -s localhost:/api/v1/search/fields | jq '.[0]'`. +Expected: a JSON object `{ "name": "title", "label": "Title", "type": "text", "group": "General", "values": [] }`. + +- [ ] **Step 6: Commit (BOM-check first)** + +```bash +for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done +git add ErsatzTV/Controllers/Api/SearchController.cs ErsatzTV/wwwroot/openapi/v1.json docs/endpoint-index.md docs/api-conventions.md web/src/api/generated/v1.d.ts +git -c core.hooksPath=/dev/null commit -m "feat(176): GET /api/v1/search/fields endpoint + regenerated api artifacts" +``` + +--- + +## Task 3: TS rule model + compiler (`types.ts`, `compile.ts`) + +**Files:** +- Create: `web/src/builder/rules/types.ts` +- Create: `web/src/builder/rules/compile.ts` +- Test: `web/src/builder/rules/compile.test.ts` + +**Interfaces:** +- Produces: `FieldType = 'text'|'fulltext'|'number'|'date'|'enum'`; `Operator`; `Rule`; `Group`; `Match`; `isGroup(node)`; `compile(group: Group): string`. +- The canonical compiled forms (parser in Task 4 is the exact inverse): + - text `is` → `field:"v"` · `isNot` → `NOT field:"v"` · `contains` → `field:*v*` · `startsWith` → `field:v*` + - fulltext `matches` → `field:"v"` · `notMatches` → `NOT field:"v"` (distinct operator names from text's `contains`, so `compileRule` maps each operator to exactly one canonical form; parse disambiguates the shared `field:"v"` shape by field type: text/enum→`is`, fulltext→`matches`) + - enum `is`/`isNot` → `field:"v"` / `NOT field:"v"` + - number `eq` → `field:v` · `gt` → `field:{v TO *}` · `lt` → `field:{* TO v}` · `between` → `field:[v TO v2]` + - date `before` → `field:{* TO v}` · `after` → `field:{v TO *}` · `between` → `field:[v TO v2]` + - group: children joined by ` AND ` (match `all`) / ` OR ` (match `any`); a **nested** group is wrapped in `( … )`. Top-level group is not wrapped. + +- [ ] **Step 1: Write `types.ts`** + +```ts +export type FieldType = 'text' | 'fulltext' | 'number' | 'date' | 'enum'; +export type Match = 'all' | 'any'; + +export type Operator = + | 'is' | 'isNot' | 'contains' | 'startsWith' // text + | 'matches' | 'notMatches' // fulltext + | 'eq' | 'gt' | 'lt' | 'between' // number + | 'before' | 'after'; // date + +export interface Rule { + field: string; + operator: Operator; + value: string; + value2?: string; // upper bound for `between` +} + +export interface Group { + match: Match; + children: Array; +} + +export function isGroup(node: Rule | Group): node is Group { + return (node as Group).children !== undefined; +} + +// Which fields each catalog type exposes as operators (used by the UI and tests). +export const OPERATORS_BY_TYPE: Record = { + text: ['is', 'isNot', 'contains', 'startsWith'], + fulltext: ['matches', 'notMatches'], + enum: ['is', 'isNot'], + number: ['eq', 'gt', 'lt', 'between'], + date: ['before', 'after', 'between'] +}; +``` + +- [ ] **Step 2: Write the failing compiler test** + +`web/src/builder/rules/compile.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { compile } from './compile'; +import type { Group } from './types'; + +describe('compile', () => { + it('quotes text is', () => { + const g: Group = { match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }; + expect(compile(g)).toBe('genre:"Horror"'); + }); + + it('emits NOT for isNot', () => { + const g: Group = { match: 'all', children: [{ field: 'genre', operator: 'isNot', value: 'Horror' }] }; + expect(compile(g)).toBe('NOT genre:"Horror"'); + }); + + it('wildcards text contains and startsWith', () => { + expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: 'night' }] })).toBe('title:*night*'); + expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: 'The' }] })).toBe('title:The*'); + }); + + it('quotes fulltext matches / notMatches (distinct from text contains)', () => { + expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car chase' }] })).toBe('plot:"car chase"'); + expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] })).toBe('NOT plot:"car"'); + }); + + it('emits numeric ranges', () => { + expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'gt', value: '30' }] })).toBe('minutes:{30 TO *}'); + expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'lt', value: '90' }] })).toBe('minutes:{* TO 90}'); + expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30', value2: '90' }] })).toBe('minutes:[30 TO 90]'); + expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'eq', value: '42' }] })).toBe('minutes:42'); + }); + + it('emits date ranges', () => { + expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'after', value: '2000-01-01' }] })).toBe('release_date:{2000-01-01 TO *}'); + expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'before', value: '2010-01-01' }] })).toBe('release_date:{* TO 2010-01-01}'); + }); + + it('joins by AND / OR and parenthesizes nested groups', () => { + const g: Group = { + match: 'all', + children: [ + { field: 'type', operator: 'is', value: 'movie' }, + { match: 'any', children: [ + { field: 'genre', operator: 'is', value: 'Horror' }, + { field: 'genre', operator: 'is', value: 'Thriller' } + ] } + ] + }; + expect(compile(g)).toBe('type:"movie" AND (genre:"Horror" OR genre:"Thriller")'); + }); +}); +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `cd web && npx vitest run src/builder/rules/compile.test.ts` +Expected: FAIL — `compile` not found. + +- [ ] **Step 4: Write `compile.ts`** + +```ts +import { isGroup, type Group, type Rule } from './types'; + +// The compiler is the sole author of quoting. Quoted phrases escape only " and \. +function quote(value: string): string { + return `"${value.replace(/(["\\])/g, '\\$1')}"`; +} + +// Wildcard/prefix values escape Lucene specials but keep the wildcard the operator adds. +function escapeWild(value: string): string { + return value.replace(/([+\-!(){}[\]^"~:\\/])/g, '\\$1'); +} + +function compileRule(rule: Rule): string { + const f = rule.field; + const v = rule.value; + switch (rule.operator) { + case 'is': + case 'matches': // fulltext: same quoted form as text `is`, disambiguated by field type on parse + return `${f}:${quote(v)}`; + case 'isNot': + case 'notMatches': + return `NOT ${f}:${quote(v)}`; + case 'contains': + return `${f}:*${escapeWild(v)}*`; + case 'startsWith': + return `${f}:${escapeWild(v)}*`; + case 'eq': + return `${f}:${v}`; + case 'gt': + case 'after': + return `${f}:{${v} TO *}`; + case 'lt': + case 'before': + return `${f}:{* TO ${v}}`; + case 'between': + return `${f}:[${v} TO ${rule.value2 ?? ''}]`; + default: + return ''; + } +} + +function compileGroup(group: Group): string { + const conn = group.match === 'all' ? ' AND ' : ' OR '; + return group.children + .map((child) => (isGroup(child) ? `(${compileGroup(child)})` : compileRule(child))) + .filter((s) => s.length > 0) + .join(conn); +} + +export function compile(group: Group): string { + return compileGroup(group); +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd web && npx vitest run src/builder/rules/compile.test.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/builder/rules/types.ts web/src/builder/rules/compile.ts web/src/builder/rules/compile.test.ts +git -c core.hooksPath=/dev/null commit -m "feat(176): rule model + Lucene-subset compiler" +``` + +--- + +## Task 4: The parser (`parse.ts`) — exact inverse, null on out-of-subset + +**Files:** +- Create: `web/src/builder/rules/parse.ts` +- Test: `web/src/builder/rules/parse.test.ts` + +**Interfaces:** +- Consumes: `types.ts`, the canonical forms from Task 3. +- Produces: `parse(input: string, fieldTypes: Record): Group | null`. Returns `null` for any string outside the closed subset (unknown field, mixed AND/OR at one level, nesting deeper than one level, unrecognized clause shape) — this drives the raw-text fallback. A single atom with no connective parses to `{ match: 'all', children: [rule] }`. + +- [ ] **Step 1: Write the failing parser test** + +`web/src/builder/rules/parse.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { parse } from './parse'; +import type { FieldType } from './types'; + +const FIELDS: Record = { + genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date' +}; + +describe('parse', () => { + it('parses a single quoted text atom as is', () => { + expect(parse('genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }); + }); + + it('parses NOT as isNot / notMatches by field type', () => { + expect(parse('NOT genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'isNot', value: 'Horror' }] }); + expect(parse('NOT plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] }); + }); + + it('parses a quoted fulltext atom as matches', () => { + expect(parse('plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car' }] }); + }); + + it('parses wildcard forms', () => { + expect(parse('title:*night*', FIELDS)).toEqual({ match: 'all', children: [{ field: 'title', operator: 'contains', value: 'night' }] }); + expect(parse('title:The*', FIELDS)).toEqual({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: 'The' }] }); + }); + + it('parses number/date ranges disambiguated by field type', () => { + expect(parse('minutes:{30 TO *}', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'gt', value: '30' }] }); + expect(parse('release_date:{* TO 2010-01-01}', FIELDS)).toEqual({ match: 'all', children: [{ field: 'release_date', operator: 'before', value: '2010-01-01' }] }); + expect(parse('minutes:[30 TO 90]', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30', value2: '90' }] }); + expect(parse('minutes:42', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'eq', value: '42' }] }); + }); + + it('parses a nested group', () => { + expect(parse('type:"movie" AND (genre:"Horror" OR genre:"Thriller")', FIELDS)).toEqual({ + match: 'all', + children: [ + { field: 'type', operator: 'is', value: 'movie' }, + { match: 'any', children: [ + { field: 'genre', operator: 'is', value: 'Horror' }, + { field: 'genre', operator: 'is', value: 'Thriller' } + ] } + ] + }); + }); + + it('returns null on out-of-subset input', () => { + expect(parse('genre:"a" AND genre:"b" OR genre:"c"', FIELDS)).toBeNull(); // mixed AND/OR at one level + expect(parse('unknown_field:"x"', FIELDS)).toBeNull(); // unknown field + expect(parse('genre:"a" AND (type:"movie" AND (genre:"b"))', FIELDS)).toBeNull(); // two levels of nesting + expect(parse('title:jo~2', FIELDS)).toBeNull(); // fuzzy — not in subset + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cd web && npx vitest run src/builder/rules/parse.test.ts` +Expected: FAIL — `parse` not found. + +- [ ] **Step 3: Write `parse.ts`** + +```ts +import type { FieldType, Group, Operator, Rule } from './types'; + +// Split a group body into top-level parts separated by a single connective, respecting quotes and +// one level of parens. Returns the parts and the connective, or null if the split is malformed or the +// connectives are mixed (mixed AND/OR at one level is out of subset). +function splitTopLevel(input: string): { parts: string[]; match: 'all' | 'any' } | null { + const parts: string[] = []; + const ops: string[] = []; + let depth = 0; + let quoted = false; + let token = ''; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (quoted) { + token += ch; + if (ch === '"' && input[i - 1] !== '\\') quoted = false; + continue; + } + if (ch === '"') { quoted = true; token += ch; continue; } + if (ch === '(') { depth++; token += ch; continue; } + if (ch === ')') { depth--; token += ch; continue; } + if (depth === 0 && input.startsWith(' AND ', i)) { parts.push(token); ops.push('AND'); token = ''; i += 4; continue; } + if (depth === 0 && input.startsWith(' OR ', i)) { parts.push(token); ops.push('OR'); token = ''; i += 3; continue; } + token += ch; + } + if (depth !== 0 || quoted) return null; + parts.push(token); + + if (ops.length === 0) return { parts, match: 'all' }; + const allAnd = ops.every((o) => o === 'AND'); + const allOr = ops.every((o) => o === 'OR'); + if (!allAnd && !allOr) return null; // mixed connectives + return { parts, match: allAnd ? 'all' : 'any' }; +} + +function unquote(value: string): string { + return value.replace(/\\(["\\])/g, '$1'); +} + +function parseAtom(atom: string, fieldTypes: Record): Rule | null { + const trimmed = atom.trim(); + const negate = trimmed.startsWith('NOT '); + const body = negate ? trimmed.slice(4).trim() : trimmed; + + const colon = body.indexOf(':'); + if (colon < 0) return null; + const field = body.slice(0, colon); + const raw = body.slice(colon + 1); + const type = fieldTypes[field]; + if (!type) return null; + + // Ranges: {a TO b} (exclusive) and [a TO b] (inclusive between) + const range = raw.match(/^([[{])(\S+) TO (\S+)([\]}])$/); + if (range) { + const [, , lo, hi, close] = range; + if (type !== 'number' && type !== 'date') return null; + if (close === ']') return mk(field, 'between', lo, hi); + if (lo === '*') return mk(field, type === 'date' ? 'before' : 'lt', hi); + if (hi === '*') return mk(field, type === 'date' ? 'after' : 'gt', lo); + return null; + } + + // Quoted phrase → is/isNot (text, enum) or matches/notMatches (fulltext) by field type + if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { + const v = unquote(raw.slice(1, -1)); + if (type === 'fulltext') return mk(field, negate ? 'notMatches' : 'matches', v); + if (type === 'text' || type === 'enum') return mk(field, negate ? 'isNot' : 'is', v); + return null; + } + + if (negate) return null; // NOT only valid with quoted forms above + + // Wildcards (text only): *v* → contains, v* → startsWith + if (raw.startsWith('*') && raw.endsWith('*') && raw.length >= 2) { + if (type !== 'text') return null; + return mk(field, 'contains', unescapeWild(raw.slice(1, -1))); + } + if (/^[^*]+\*$/.test(raw)) { + if (type !== 'text') return null; + return mk(field, 'startsWith', unescapeWild(raw.slice(0, -1))); + } + + // Bare token → numeric eq only (reject fuzzy ~, boosts ^, etc.) + if (type === 'number' && /^[0-9.]+$/.test(raw)) return mk(field, 'eq', raw); + + return null; +} + +function unescapeWild(value: string): string { + return value.replace(/\\([+\-!(){}[\]^"~:\\/])/g, '$1'); +} + +function mk(field: string, operator: Operator, value: string, value2?: string): Rule { + return value2 === undefined ? { field, operator, value } : { field, operator, value, value2 }; +} + +function parseGroup(input: string, fieldTypes: Record, allowNested: boolean): Group | null { + const split = splitTopLevel(input.trim()); + if (!split) return null; + + const children: Array = []; + for (const part of split.parts) { + const p = part.trim(); + if (p.startsWith('(') && p.endsWith(')')) { + if (!allowNested) return null; // deeper than one level + const inner = parseGroup(p.slice(1, -1), fieldTypes, false); + if (!inner) return null; + children.push(inner); + } else { + const rule = parseAtom(p, fieldTypes); + if (!rule) return null; + children.push(rule); + } + } + if (children.length === 0) return null; + return { match: split.match, children }; +} + +export function parse(input: string, fieldTypes: Record): Group | null { + if (!input.trim()) return null; + return parseGroup(input, fieldTypes, true); +} +``` + +- [ ] **Step 4: Run the parser test to verify it passes** + +Run: `cd web && npx vitest run src/builder/rules/parse.test.ts` +Expected: PASS. If the `startsWith` branch misbehaves, note the intent: a value that ends in a single trailing `*` and contains no other `*` is `startsWith`; the regex `/^[^*]+\*$/` captures exactly that. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/builder/rules/parse.ts web/src/builder/rules/parse.test.ts +git -c core.hooksPath=/dev/null commit -m "feat(176): Lucene-subset parser (exact inverse of compiler)" +``` + +--- + +## Task 5: Round-trip property test (the keystone) + +**Files:** +- Test: `web/src/builder/rules/roundtrip.test.ts` + +**Interfaces:** +- Consumes: `compile`, `parse`, `types.ts`. No new production code — this test is what makes "compile-only, no stored AST" safe. + +- [ ] **Step 1: Write the generator + property test** + +`web/src/builder/rules/roundtrip.test.ts`. A deterministic pseudo-random generator (seeded LCG — no `Math.random`, so failures reproduce) builds rule trees within the closed subset; each must satisfy `parse(compile(tree)) deep-equals tree`. Groups always have ≥2 children (a 1-child group's `match` is meaningless and would not round-trip); string values are drawn from an alphabet that INCLUDES Lucene specials, spaces, quotes and backslashes — escaping is total (see the `escapeWild`/`quote` + tokenizer fix, commit `6591c6ef`), so every value must round-trip regardless of content. A generator restricted to `[A-Za-z0-9]` would pass blind to escaping bugs — do not narrow it. + +```ts +import { describe, expect, it } from 'vitest'; +import { compile } from './compile'; +import { parse } from './parse'; +import type { FieldType, Group, Operator, Rule } from './types'; + +const FIELDS: Record = { + genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date' +}; +const BY_TYPE: Record = { + text: ['genre', 'title'], fulltext: ['plot'], enum: ['type'], number: ['minutes'], date: ['release_date'] +}; +const OPS: Record = { + text: ['is', 'isNot', 'contains', 'startsWith'], + fulltext: ['matches', 'notMatches'], + enum: ['is', 'isNot'], + number: ['eq', 'gt', 'lt', 'between'], + date: ['before', 'after', 'between'] +}; + +// Deterministic LCG so a failing case is reproducible. +function lcg(seed: number) { + let s = seed >>> 0; + return () => { + s = (1664525 * s + 1013904223) >>> 0; + return s / 0xffffffff; + }; +} +const pick = (rng: () => number, arr: T[]): T => arr[Math.floor(rng() * arr.length)]; +// Include Lucene specials, spaces, quotes and backslashes: escaping must be TOTAL, so string +// values must round-trip regardless of content (this is what catches escaping bugs the pure +// alphanumeric generator would miss). Numbers/dates are generated separately in makeRule. +const VALUE_CHARS = 'abcdefghijABCDEFGHIJ0123456789 *?"\\():-'; +const token = (rng: () => number): string => { + const n = 3 + Math.floor(rng() * 5); + let out = ''; + for (let i = 0; i < n; i++) out += VALUE_CHARS[Math.floor(rng() * VALUE_CHARS.length)]; + return out; +}; + +function makeRule(rng: () => number): Rule { + const type = pick(rng, Object.keys(BY_TYPE) as FieldType[]); + const field = pick(rng, BY_TYPE[type]); + const operator = pick(rng, OPS[type]); + if (operator === 'between') { + return type === 'date' + ? { field, operator, value: '2000-01-01', value2: '2010-01-01' } + : { field, operator, value: '10', value2: '90' }; + } + if (type === 'number') return { field, operator, value: String(1 + Math.floor(rng() * 500)) }; + if (type === 'date') return { field, operator, value: '2005-06-15' }; + return { field, operator, value: token(rng) }; +} + +function makeGroup(rng: () => number, allowNested: boolean): Group { + const match = rng() < 0.5 ? 'all' : 'any'; + const count = 2 + Math.floor(rng() * 3); // >= 2 children + const children: Array = []; + for (let i = 0; i < count; i++) { + if (allowNested && rng() < 0.25) children.push(makeGroup(rng, false)); + else children.push(makeRule(rng)); + } + return { match, children }; +} + +describe('round-trip: parse(compile(tree)) === tree', () => { + it('holds over 500 generated trees', () => { + const rng = lcg(12345); + for (let i = 0; i < 500; i++) { + const tree = makeGroup(rng, true); + const text = compile(tree); + const back = parse(text, FIELDS); + expect(back, `seed-iter ${i} failed for: ${text}`).toEqual(tree); + } + }); +}); +``` + +- [ ] **Step 2: Run it** + +Run: `cd web && npx vitest run src/builder/rules/roundtrip.test.ts` +Expected: PASS. If a case fails, the assertion message prints the exact compiled string — fix `compile`/`parse` to be exact inverses for that shape (do NOT weaken the generator to hide it, unless the shape is genuinely out-of-subset, in which case document why). + +- [ ] **Step 3: Commit** + +```bash +git add web/src/builder/rules/roundtrip.test.ts +git -c core.hooksPath=/dev/null commit -m "test(176): compile/parse round-trip property test" +``` + +--- + +## Task 6: Field catalog client (`fieldCatalog.ts` + api) + +**Files:** +- Modify: `web/src/api/search.ts` +- Create: `web/src/builder/rules/fieldCatalog.ts` +- Test: `web/src/api/search.test.ts` (add a case), `web/src/builder/rules/fieldCatalog.test.ts` + +**Interfaces:** +- Produces: `getSearchFields(): Promise` and `type SearchField`; `useSearchFields()` → `{ fields, fieldTypes, byGroup, status }`. + +- [ ] **Step 1: Add `getSearchFields` to `web/src/api/search.ts`** + +Add near the top (after the existing type exports): + +```ts +export type SearchField = components['schemas']['SearchFieldResponseModel']; + +export function getSearchFields(): Promise { + return request('/api/v1/search/fields'); +} +``` + +- [ ] **Step 2: Add an api test (`web/src/api/search.test.ts`)** + +Follow the existing tests in that file (they mock `request`/`fetch`). Add: + +```ts +it('getSearchFields calls the catalog endpoint', async () => { + // Match the mocking style already used in this file (fetch or ./client mock). + const result = await getSearchFields(); + expect(Array.isArray(result)).toBe(true); +}); +``` + +Adapt to the file's actual mock harness — read the top of `search.test.ts` first and mirror it (do not invent a new mocking approach). + +- [ ] **Step 3: Write `fieldCatalog.ts` (hook)** + +The generated `SearchField` type is **all-nullable** (`ErsatzTV.Core` compiles with `disable`, so every response-model string is `string | null` in `v1.d.ts`). To keep that null-noise out of the UI (Tasks 7/8), the hook coerces to a clean **`RuleField`** (all non-null) — drop entries with no `name`, default `type→'text'`, `group→'Other'`, `label→name`, `values→[]`. + +```ts +import { useEffect, useState } from 'react'; +import { getSearchFields, type SearchField } from '../../api/search'; +import type { FieldType } from './types'; + +// Clean, non-null field the builder UI consumes (SearchField from the generated client is all-nullable). +export interface RuleField { + name: string; + label: string; + type: FieldType; + group: string; + values: string[]; +} + +export interface FieldCatalog { + fields: RuleField[]; + fieldTypes: Record; + byGroup: Array<{ group: string; fields: RuleField[] }>; + status: 'loading' | 'success' | 'error'; +} + +function toRuleField(f: SearchField): RuleField | null { + if (!f.name) return null; + return { + name: f.name, + label: f.label ?? f.name, + type: (f.type as FieldType | null) ?? 'text', + group: f.group ?? 'Other', + values: f.values ?? [] + }; +} + +export function useSearchFields(): FieldCatalog { + const [raw, setRaw] = useState([]); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + + useEffect(() => { + let active = true; + getSearchFields() + .then((f) => { if (active) { setRaw(f); setStatus('success'); } }) + .catch(() => { if (active) setStatus('error'); }); + return () => { active = false; }; + }, []); + + const fields = raw.map(toRuleField).filter((f): f is RuleField => f !== null); + + const fieldTypes: Record = {}; + for (const f of fields) fieldTypes[f.name] = f.type; + + const groups = new Map(); + for (const f of fields) { + const list = groups.get(f.group) ?? []; + list.push(f); + groups.set(f.group, list); + } + const byGroup = [...groups.entries()].map(([group, gf]) => ({ group, fields: gf })); + + return { fields, fieldTypes, byGroup, status }; +} +``` + +- [ ] **Step 4: Run tests + typecheck** + +Run: `cd web && npx vitest run src/api/search.test.ts && npx tsc -b --pretty false` +Expected: PASS, no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/api/search.ts web/src/api/search.test.ts web/src/builder/rules/fieldCatalog.ts +git -c core.hooksPath=/dev/null commit -m "feat(176): field-catalog api + useSearchFields hook" +``` + +--- + +## Task 7: `RuleBuilder.tsx` component + +**Files:** +- Create: `web/src/builder/rules/RuleBuilder.tsx` +- Test: `web/src/builder/rules/RuleBuilder.test.tsx` + +**Interfaces:** +- Consumes: `types.ts` (`Group`, `Rule`, `Operator`, `OPERATORS_BY_TYPE`), `RuleField` (from `./fieldCatalog` — clean, non-null). +- Produces: `RuleBuilder({ value, onChange, fields }: { value: Group; onChange: (g: Group) => void; fields: RuleField[] })`. Renders the top group's `Match: All | Any` toggle, a row per child (`field ▾` → `operator ▾` → value input by type; `between` shows two inputs; `enum` shows a value dropdown), `+ Add rule` / `+ Add group` (one nested level), and remove buttons. Purely controlled — every edit calls `onChange` with the next `Group`. + +- [ ] **Step 1: Write the component** + +Use the existing SPA primitives (`../../components` exports `Button`, `IconButton`, `Input`, `Switch`; native ` { + const nextType = typeOf(fields, e.target.value); + onChange({ field: e.target.value, operator: OPERATORS_BY_TYPE[nextType][0], value: '' }); + }} + > + {fields.map((f) => )} + + + + + {enumField ? ( + + ) : ( + onChange({ ...rule, value: e.target.value })} + type={type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'} /> + )} + + {rule.operator === 'between' && ( + onChange({ ...rule, value2: e.target.value })} + type={type === 'number' ? 'number' : 'date'} /> + )} + + + + ); +} + +function MatchToggle({ match, onChange }: { match: 'all' | 'any'; onChange: (m: 'all' | 'any') => void }) { + return ( +
+ Match + + of the following: +
+ ); +} + +function GroupEditor({ group, fields, depth, onChange, onRemove }: { + group: Group; fields: RuleField[]; depth: number; onChange: (g: Group) => void; onRemove?: () => void; +}) { + const setChild = (i: number, child: Rule | Group) => { + const children = group.children.slice(); + children[i] = child; + onChange({ ...group, children }); + }; + const removeChild = (i: number) => onChange({ ...group, children: group.children.filter((_, j) => j !== i) }); + + return ( +
0 ? '1px solid var(--ctv-border, #333)' : 'none', borderRadius: 8, padding: depth > 0 ? 12 : 0, marginBottom: 8 }}> + onChange({ ...group, match: m })} /> + {group.children.map((child, i) => + isGroup(child) + ? setChild(i, g)} onRemove={() => removeChild(i)} /> + : setChild(i, r)} onRemove={() => removeChild(i)} /> + )} +
+ + {depth === 0 && ( + + )} + {onRemove && } +
+
+ ); +} + +export function RuleBuilder({ value, onChange, fields }: { value: Group; onChange: (g: Group) => void; fields: RuleField[] }) { + return ; +} +``` + +- [ ] **Step 2: Write the component test** + +`web/src/builder/rules/RuleBuilder.test.tsx`: + +```tsx +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { RuleBuilder } from './RuleBuilder'; +import type { RuleField } from './fieldCatalog'; +import type { Group } from './types'; + +const FIELDS: RuleField[] = [ + { name: 'genre', label: 'Genre', type: 'text', group: 'General', values: [] }, + { name: 'type', label: 'Item type', type: 'enum', group: 'General', values: ['movie', 'episode'] } +]; + +function setup(initial: Group) { + const onChange = vi.fn(); + const utils = render(); + return { onChange, ...utils }; +} + +describe('RuleBuilder', () => { + it('adds a rule', () => { + const { onChange } = setup({ match: 'all', children: [] }); + fireEvent.click(screen.getByText('Add rule')); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ match: 'all', children: expect.arrayContaining([expect.objectContaining({ field: 'genre' })]) })); + }); + + it('removes a rule', () => { + const { onChange } = setup({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }); + fireEvent.click(screen.getByLabelText('Remove rule')); + expect(onChange).toHaveBeenCalledWith({ match: 'all', children: [] }); + }); + + it('adds a nested group only at top level', () => { + const { onChange } = setup({ match: 'all', children: [] }); + fireEvent.click(screen.getByText('Add group')); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ children: expect.arrayContaining([expect.objectContaining({ match: 'any' })]) })); + }); + + it('shows enum values as a dropdown', () => { + setup({ match: 'all', children: [{ field: 'type', operator: 'is', value: 'movie' }] }); + expect(screen.getByRole('option', { name: 'episode' })).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Run the tests + typecheck** + +Run: `cd web && npx vitest run src/builder/rules/RuleBuilder.test.tsx && npx tsc -b --pretty false` +Expected: PASS. (If `IconButton`/`Button`/`Input` prop names differ, read `web/src/components/index.ts` and adjust — mirror how `CollectionsScreen.tsx` uses them.) + +- [ ] **Step 4: Commit** + +```bash +git add web/src/builder/rules/RuleBuilder.tsx web/src/builder/rules/RuleBuilder.test.tsx +git -c core.hooksPath=/dev/null commit -m "feat(176): RuleBuilder component" +``` + +--- + +## Task 8: Integrate into `SmartDialog` (Builder | Advanced toggle) + +**Files:** +- Modify: `web/src/screens/CollectionsScreen.tsx` (the `SmartDialog` function, ~lines 204-300) +- Test: extend `web/src/screens/CollectionsScreen.test.tsx` if it exists; otherwise add a focused `SmartDialog` test file. + +**Interfaces:** +- Consumes: `RuleBuilder`, `useSearchFields`, `compile`, `parse`. No change to `SmartDialog`'s props or `onSubmit` — `query` (the compiled/raw Lucene string) remains the single value submitted. + +- [ ] **Step 1: Add imports at the top of `CollectionsScreen.tsx`** + +```ts +import { RuleBuilder } from '../builder/rules/RuleBuilder'; +import { useSearchFields } from '../builder/rules/fieldCatalog'; +import { compile } from '../builder/rules/compile'; +import { parse } from '../builder/rules/parse'; +import type { Group } from '../builder/rules/types'; +``` + +- [ ] **Step 2: Add builder state in `SmartDialog` and derive the initial mode from the seeded query** + +Inside `SmartDialog`, after the existing `query` state (around line 221), add: + +```ts + const { fields, fieldTypes, status: fieldsStatus } = useSearchFields(); + const seededGroup = parse(initial?.query ?? '', fieldTypes); + const [mode, setMode] = useState<'builder' | 'advanced'>(() => + initial?.query && !parse(initial.query, fieldTypes) ? 'advanced' : 'builder' + ); + const [group, setGroup] = useState(seededGroup ?? { match: 'all', children: [] }); +``` + +Note: `fieldTypes` is empty on first render (catalog still loading), so `parse` returns `null` and the dialog opens in `advanced` until the catalog arrives. To seed the builder once fields load, add: + +```ts + useEffect(() => { + if (fieldsStatus !== 'success' || !initial?.query) return; + const parsed = parse(initial.query, fieldTypes); + if (parsed) { setGroup(parsed); setQuery(compile(parsed)); setMode('builder'); } else { setMode('advanced'); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fieldsStatus]); +``` + +- [ ] **Step 3: Keep `query` in sync when in builder mode** + +Add an effect so the compiled string is what gets submitted/previewed: + +```ts + useEffect(() => { + if (mode === 'builder') setQuery(compile(group)); + }, [mode, group]); +``` + +- [ ] **Step 4: Replace the raw `Input` (lines ~272-280) with the mode toggle + conditional body** + +```tsx +
+ + +
+ + {mode === 'builder' ? ( + fieldsStatus === 'success' + ? + : + ) : ( +
+ setQuery(event.target.value)} + placeholder='e.g. genre:"action" AND type:"movie"' + style={{ fontFamily: 'var(--font-mono)' }} value={query} /> + {parse(query, fieldTypes) === null && query.trim().length > 0 && ( + This query is too advanced to show in the builder. + )} +
+ )} +``` + +- [ ] **Step 5: Add a focused test** + +Read `CollectionsScreen.test.tsx` (if present) for the existing harness; otherwise create `web/src/screens/CollectionsScreen.smartDialog.test.tsx` that mocks `../builder/rules/fieldCatalog` (`useSearchFields` → success with a small catalog) and `../api` (so `getLibraryBrowseItems` is stubbed). Assert: + +```tsx +it('opens an existing subset query in builder mode', () => { + // render the CollectionsScreen with a smart collection whose query is 'genre:"Horror"' + // then: expect(screen.getByLabelText('Field')).toHaveValue('genre'); +}); + +it('falls back to advanced mode for an out-of-subset query', () => { + // query 'genre:jo~2' → expect the raw Search query input to be visible +}); +``` + +Fill these in against the real screen harness (the exact render/props mirror the existing CollectionsScreen tests). If wiring a full-screen render is heavy, extract `SmartDialog` is **not** required — test via the screen as the existing tests do. + +- [ ] **Step 6: Run web tests, typecheck, lint** + +Run: `cd web && npx vitest run && npx tsc -b --pretty false && npm run lint` +Expected: all PASS. Give any heavy-render test an explicit vitest timeout (e.g. `it('…', { timeout: 15000 }, …)`) — the CI VM is slower than local. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/screens/CollectionsScreen.tsx web/src/screens/CollectionsScreen*.test.tsx +git -c core.hooksPath=/dev/null commit -m "feat(176): Builder|Advanced toggle in the SmartCollection dialog" +``` + +--- + +## Task 9: Docs, full verification, live smoke, PR + +**Files:** +- Modify: `docs/decisions.md`, `docs/spa-conventions.md` + +- [ ] **Step 1: Append the decision to `docs/decisions.md`** + +Add a dated entry (append-only) recording: the compile-only / closed-subset / **no-stored-AST** model for the SmartCollection rule builder (why: even an authoritative AST would still need a Lucene→rules parser for pre-existing free-text queries, so persisting an AST buys almost nothing while costing a dual-provider migration); the **one-level-nesting Kodi model**; and the read-only `GET /api/v1/search/fields` catalog as the field source of truth. Note the deferred follow-ups. + +- [ ] **Step 2: Document the reusable component pattern in `docs/spa-conventions.md`** + +Add a short subsection: the `web/src/builder/rules/` module (types + compile/parse + `useSearchFields` + `RuleBuilder`) is a reusable, controlled component that compiles to the Lucene query string; screens embed it and own the `query` string. Note it is intended for later reuse by ChannelBuilder / Auto-Tune. + +- [ ] **Step 3: Full local verification** + +Run: +```bash +cd /Users/timothy/ersatztv/.claude/worktrees/176-rule-builder +dotnet build ErsatzTV.sln 2>&1 | grep -E "error|Build succeeded" +dotnet test ErsatzTV.Tests --filter GetSearchFieldCatalogHandlerTests +cd web && npx vitest run && npx tsc -b --pretty false && npm run lint && npm run check:api +``` +Expected: `Build succeeded`, all tests PASS, `check:api` clean (no uncommitted generated diff). + +- [ ] **Step 4: Live smoke (Playwright, headless)** + +Per `docs/e2e-local.md`, bring up a local instance with a seeded library, then drive: open Collections → Smart tab → New → Builder mode → add `genre is Horror` → confirm the live preview count updates → Save → reopen → confirm it opens in Builder with the rule populated. (Live-E2E is **not gated** here — read-only endpoint, unchanged write path — but this smoke is cheap insurance.) Never open download endpoints in a browser tab. + +- [ ] **Step 5: BOM-check, push, open PR** + +```bash +for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done +# rebase on origin/main if it moved: git fetch origin main && git rebase origin/main (regenerate v1.json/v1.d.ts/endpoint-index if they conflict) +git push -u origin feat/176-smartcollection-rule-builder +``` +Open a PR with `fixes #176` (or `part of #176` if you split the deferred follow-ups). Arm a CI monitor on the head sha at PR-open. + +- [ ] **Step 6: Cold-context adversarial review + follow-up issues** + +Dispatch a cold, review-only agent (cross-model if available) scoped to the diff — focus on the parser/compiler round-trip correctness and the escape/quote handling. Post a `Review-verdict: @ ` PR comment. File the deferred follow-ups as separate issues: facet typeahead, relative dates, deeper nesting, ChannelBuilder/Auto-Tune inline adoption. Run the H12 qualification audit and label anything new. + +--- + +## Self-Review (checked against the spec) + +**Spec coverage:** §3 rule model → Task 3 (`types.ts`) + Tasks 7-8; §3 closed subset compile → Task 3; parse/round-trip → Tasks 4-5; §4 catalog endpoint → Tasks 1-2; §5 UI integration + mode switching + open-existing behavior → Task 8; §6 testing (property, compile/parse, component, backend NUnit, live smoke, cold review) → Tasks 1,3,4,5,7,8,9; §7 docs → Tasks 2,9; §8 deferred follow-ups → Task 9 Step 6. No spec section is unmapped. + +**Type consistency:** `Group`/`Rule`/`Operator`/`Match`/`FieldType`/`isGroup` defined in Task 3 and consumed unchanged in Tasks 4-8; `compile(group)`/`parse(input, fieldTypes)` signatures identical everywhere; `SearchFieldResponseModel(Name,Label,Type,Group,Values)` (C#) ↔ `SearchField` (TS generated) fields align; `useSearchFields()` returns `{ fields, fieldTypes, byGroup, status }` and every consumer uses those names. + +**Placeholder scan:** the two intentionally implementer-adapted spots (the exact `*.Tests` project name in Task 1 Step 1; mirroring the existing api/screen test harness in Tasks 6 & 8) are gated by an explicit "read the sibling file first" instruction, not vague TODOs. The `type` enum token list carries an explicit VERIFY-against-`LuceneSearchIndex` step backed by a test asserting non-emptiness. diff --git a/docs/superpowers/specs/2026-07-17-smartcollection-rule-builder-design.md b/docs/superpowers/specs/2026-07-17-smartcollection-rule-builder-design.md new file mode 100644 index 000000000..2b7f8101c --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-smartcollection-rule-builder-design.md @@ -0,0 +1,119 @@ +# SmartCollection visual rule builder — design (sub-project #1 of #176) + +**Issue:** [ersatztv#176](http://192.168.1.95:3000/timothy/ersatztv/issues/176) — "Rethink channel/playout/schedule creation: PseudoTV-style channel-first flow with a Kodi-smart-playlist-like query builder." + +**Date:** 2026-07-17 · **Status:** design approved, pre-implementation. + +--- + +## 1. Context & why the scope shrank + +#176 as originally filed described three legs of a PseudoTV-style vision. Recon (2026-07-17) shows two of them already shipped since the issue was written: + +- **Autotune** (channel-first bulk create) — shipped as #69: `/app/auto-tune`, a 3-step Configure → Preview → Create wizard, each channel backed by an auto-generated `SmartCollection` query (`AutoTuneScreen.tsx`, `CreateAutoTunedChannelsHandler.cs`, `AutoTuneAxisMap.cs`). +- **Composite Create-Channel wizard** — shipped as #63: `web/src/builder/ChannelBuilder.tsx` at `/app/new-channel`, composing collection-lineup + template + playout + advanced overrides in one call (`POST /api/v1/channels/from-lineup`). + +The **one genuinely missing leg is the visual WYSIWYG rule builder.** Authoring a `SmartCollection` query today is a *single free-text box* (`CollectionsScreen.tsx:271`) over the Lucene grammar — no field/operator/AND-OR UI. This design covers **sub-project #1: a visual rule builder landing in the SmartCollection create/edit dialog.** Inline adoption by ChannelBuilder and Auto-Tune is deliberately deferred (the builder is built as a reusable component so that later adoption is a trivial plug-in). + +## 2. Approved architectural decisions + +Three pillars, settled during brainstorming: + +1. **Scope = the rule builder in the SmartCollection editor** (highest-traffic surface, standalone value, no dependency on the wizard). +2. **Compile-only, closed subset, no stored AST, no schema change.** The builder emits a *canonical closed subset* of the Lucene grammar and parses exactly that subset back. Because compile and parse are exact inverses over a closed subset, anything the builder produces round-trips losslessly with nothing new persisted — the `SmartCollection` keeps storing a plain Lucene query string, exactly as today. Queries outside the subset (hand-written fuzzy/boost/wildcard-heavy Lucene) open in a raw-text fallback. This was chosen over persisting a rule AST because *even an authoritative AST would still need a Lucene→rules parser* for the many pre-existing free-text queries (every Auto-Tune query included), so the migration cost of storing an AST buys almost nothing. +3. **Field catalog on the backend, compile/parse on the client.** A new read-only `GET /api/v1/search/fields` is the single source of truth for the field vocabulary (no client/index drift; MCP can introspect it). The compile/parse/round-trip logic lives in the SPA. + +## 3. The rule model (Kodi model, one level of nesting) + +``` +Rule = { field: string, operator: Operator, value: string | [string, string] } +Group = { match: "all" | "any", children: Array } +``` + +- **Top level is a `Group`.** Its `children` may be `Rule`s and/or `Group`s. +- **Sub-groups are one level deep only** — a sub-`Group`'s children are a flat list of `Rule`s (no `Group` inside a `Group` inside a `Group`). +- **Why one level:** pure-flat (match all/any only) can't express the most common real query, `type:movie AND (genre:Horror OR genre:Thriller)`; one level of nesting expresses it exactly (`all: [type:movie, any:[Horror, Thriller]]`) and matches what Kodi ships. Arbitrary nesting is YAGNI for channel queries and bloats the compiler/parser/property-test surface. + +### The closed Lucene subset + +- **rule → clause:** + - text `is` → `field:"value"` · `is not` → `NOT field:"value"` · `contains` → `field:*value*` · `starts with` → `field:value*` + - fulltext (`plot`) `contains` → `field:value` · `not contains` → `NOT field:value` + - number `=` → `field:N` · `>` → `field:{N TO *}` · `<` → `field:{* TO N}` · `between` → `field:[a TO b]` + - date `before` → `field:{* TO d}` · `after` → `field:{d TO *}` · `between` → `field:[a TO b]` + - enum `is`/`is not` → `field:"value"` / `NOT field:"value"` (value from a fixed allowed set) +- **group → clauses joined by `AND` (match=all) or `OR` (match=any)**, wrapped in parentheses when the group is nested. +- Quoting: values containing whitespace or reserved characters are quoted; the compiler is the sole author of quoting so the parser can rely on it. + +Any string not matching this shape is **unparseable** → the editor stays in raw-text mode. + +## 4. The field catalog endpoint + +**`GET /api/v1/search/fields`** — read-only, returns a curated, typed, labeled catalog derived from `LuceneSearchIndex`: + +```json +[{ "name": "genre", "label": "Genre", "type": "text", "group": "General" }, + { "name": "minutes", "label": "Duration (min)", "type": "number", "group": "Technical" }, + { "name": "release_date", "label": "Release date", "type": "date", "group": "Dates" }, + { "name": "type", "label": "Item type", "type": "enum", "group": "General", + "values": ["movie", "episode", "music_video", "other_video", "song", "image"] }] +``` + +- **Curation happens server-side.** Internal/index-plumbing fields are excluded: `id`, `library_id`, `library_folder_id`, `tag_full`, `jump_letter`, `title_and_year_search`, `language_tag`, `sub_language_tag`, `metadata_kind` (kept only if it proves user-meaningful). The rest get friendly labels + a `group` (`General` / `TV` / `Movie` / `Music` / `Technical` / `Dates`). +- **Type taxonomy:** `text` (keyword-exact metadata), `fulltext` (tokenized — `plot`), `number` (`minutes`, `seconds`, `height`, `width`, `season_number`, `episode_number`, `video_bit_depth`, `chapters`), `date` (`added_date`, `release_date`), `enum` (small closed sets — `type`, `video_dynamic_range`, `state`; each carries a `values` array). +- The endpoint is the single source of truth: the SPA renders exactly what it returns, and MCP can introspect the same list. + +**Follow the api-conventions checklist**, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` and `npm run generate:api`. + +## 5. UI integration in the SmartCollection dialog + +The create/edit dialog (`CollectionsScreen.tsx:200-283`) gains a **`Builder | Advanced` mode toggle**: + +- **Builder mode** — the rule-group UI: a `Match: All | Any` toggle, a list of rule rows (`field ▾` → `operator ▾` → value input), `+ Add rule` / `+ Add group` (one nested level), remove buttons. The value input is chosen by field type: text box (text/fulltext), number input(s) (number, single or a-to-b pair), date picker(s) (date), dropdown (enum). Every edit **debounced-compiles → live preview** (item count + sample) via the existing `getLibraryBrowseItems({ query })`. +- **Advanced mode** — today's raw text box (also live-previewed). +- **Mode switching:** Builder→Advanced fills the text with the compiled query. Advanced→Builder best-effort parses; if the text is outside the closed subset, it **stays in Advanced** with an inline note ("This query is too advanced to show in the builder"). +- **Opening an existing SmartCollection:** parse the stored query — fits the subset → open in Builder with rules populated; otherwise → open in Advanced. (Every Auto-Tune query like `type:episode AND genre:"Horror"` fits, so those open cleanly in the builder.) +- **Save is unchanged** — posts the compiled (or raw) Lucene string to the existing SmartCollection write path. **No backend write-path change**; the only new backend surface is the read-only `GET /api/v1/search/fields`. + +### Component structure (isolated & reusable) + +``` +web/src/builder/rules/ (co-located with ChannelBuilder; final path validated vs spa-conventions.md) + types.ts Rule | Group | Match | Operator | FieldType + fieldCatalog.ts useSearchFields() hook over GET /api/v1/search/fields + compile.ts rules → lucene subset (sole author of quoting) + parse.ts lucene subset → rules (exact inverse; returns null on out-of-subset) + RuleBuilder.tsx the group/rule UI (consumed by CollectionsScreen; later by ChannelBuilder/Auto-Tune) +``` + +Each unit has one purpose and a narrow interface: `compile(Group) → string`, `parse(string) → Group | null`, `RuleBuilder({ value: Group, onChange })`. `RuleBuilder` can be understood and tested without reading `compile`/`parse` internals, and those can change without touching the UI. + +## 6. Testing + +- **Round-trip property test (keystone):** a deterministic generator produces rule trees within the closed subset → `compile → parse →` assert deep-equal. This is what makes "compile-only, no stored AST" safe. Hand-rolled generator over the finite field/operator space (deterministic; introduce `fast-check` only if it is already a `web/` dependency). +- **Compile/parse unit tests:** each operator → expected Lucene; each Lucene shape → expected rules; representative **out-of-subset strings → `parse` returns null** (drives the raw-text fallback). +- **RuleBuilder component tests** (vitest + testing-library): add/remove rule & group, match toggle, mode-switch fills text, editing an existing query populates the builder. Give heavy-render tests explicit vitest timeouts (CI-VM guidance). +- **Backend** (NUnit + Shouldly): `GET /api/v1/search/fields` returns the curated list, excludes internal fields, and reports correct types. +- **Live-E2E:** *not gated* by the write-path rule — the only new backend surface is a read-only GET and the SmartCollection write path is unchanged. Still run a light Playwright smoke (build a rule → preview count → save → reopen in builder) and a **cold-context adversarial review over the diff** (the parser is subtle enough to warrant it even though the skip rubric would permit skipping). + +## 7. Docs to update in the same PR + +- **New endpoint** → `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` (`./scripts/update-openapi.sh` + `npm run generate:api`). +- **`docs/decisions.md`** (append-only) — record the compile-only / closed-subset / no-stored-AST decision and the one-level-nesting choice. +- **`docs/spa-conventions.md`** — document the reusable rule-builder component pattern if it establishes one. +- **Not touched:** `blazor-route-parity.md` / `domain-model.md` — no route change, no new entity. + +## 8. Scope boundaries — deferred as tracked follow-up issues + +These are explicitly **out** of sub-project #1 and will be filed as separate issues: + +1. **Facet-value typeahead (v1b)** — a `GET /api/v1/search/fields/{name}/values?q=` distinct-values endpoint turning text values into autocomplete comboboxes. Purely additive: swap the text input for a combobox, nothing else changes. (Keyword-exact matching means typos silently return zero; live-preview count is the v1 safety net.) +2. **Relative dates** ("in the last N days") — can't be stored as *relative* in a compile-only Lucene string; needs a query-time macro. +3. **Nested groups beyond one level.** +4. **Inline adoption in ChannelBuilder & Auto-Tune** — reuse `RuleBuilder` for inline query authoring in the channel wizard and to generalize Auto-Tune's fixed 3-axis picker. + +## 9. Risks + +- **Round-trip correctness** is the central risk; the property test is the mitigation, and the compiler being the *sole* author of quoting keeps the parser's job bounded. +- **Field/label curation drift** — mitigated by deriving the catalog from `LuceneSearchIndex` server-side (single source of truth). Adding an index field simply won't appear until curated — additive, low harm. +- **Live-preview chatter** — debounce compile→preview; reuse the existing preview call so no new load pattern is introduced. diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 2bb390a5a..5a9315128 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -1429,6 +1429,13 @@ export interface components { "SchedulingPickerOptionResponseModel": { "id": number; "name": string; + }; + "SearchFieldResponseModel": { + "name": null | string; + "label": null | string; + "type": null | string; + "group": null | string; + "values": null | Array; }; "SearchResultAllItemsResponseModel": { "movieIds": Array; diff --git a/web/src/api/search.test.ts b/web/src/api/search.test.ts index ed2b5ec22..5a1d28c9c 100644 --- a/web/src/api/search.test.ts +++ b/web/src/api/search.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getSearchAllItems, getSearchResults, toAddItemsRequestFromSearch } from './search'; +import { getSearchAllItems, getSearchFields, getSearchResults, toAddItemsRequestFromSearch } from './search'; const emptyGroup = { totalCount: 0, items: [] }; const sampleResults = { @@ -84,6 +84,33 @@ describe('getSearchAllItems', () => { }); }); +describe('getSearchFields', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('GETs /api/v1/search/fields and returns the parsed array', async () => { + const sampleFields = [ + { name: 'title', label: 'Title', type: 'text', group: 'General', values: null }, + { name: 'year', label: 'Year', type: 'number', group: 'General', values: null } + ]; + const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue( + new Response(JSON.stringify(sampleFields), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }) + ); + + const result = await getSearchFields(); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('/api/v1/search/fields'); + expect((init?.method ?? 'GET').toUpperCase()).toBe('GET'); + expect(result).toEqual(sampleFields); + }); +}); + describe('toAddItemsRequestFromSearch', () => { it('fills every bucket, defaulting null arrays to []', () => { expect( diff --git a/web/src/api/search.ts b/web/src/api/search.ts index f09be2766..1c1d2c607 100644 --- a/web/src/api/search.ts +++ b/web/src/api/search.ts @@ -5,6 +5,16 @@ import type { AddItemsToCollectionRequest } from './collections'; export type SearchResults = components['schemas']['SearchResultsResponseModel']; export type SearchResultGroup = components['schemas']['SearchResultGroupResponseModel']; +// Generated fields are all nullable (open-api-typescript reflects the C# model's nullable strings); +// callers (fieldCatalog.ts) filter out entries missing a name before indexing by it. +export type SearchField = components['schemas']['SearchFieldResponseModel']; + +// Backs the rule builder's field picker (#176): the catalog of fields a search/smart-collection rule +// can filter on, with their type + UI grouping. +export function getSearchFields(): Promise { + return request('/api/v1/search/fields'); +} + // The ten media-item id arrays a search query resolves to, one bucket per addable kind. Wire keys // match AddItemsToCollectionRequest exactly, so a result pipes straight into addItemsToCollection / // addItemsToPlaylist via toAddItemsRequestFromSearch below. diff --git a/web/src/builder/rules/RuleBuilder.test.tsx b/web/src/builder/rules/RuleBuilder.test.tsx new file mode 100644 index 000000000..a35afb952 --- /dev/null +++ b/web/src/builder/rules/RuleBuilder.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, render, screen, fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RuleBuilder } from './RuleBuilder'; +import type { RuleField } from './fieldCatalog'; +import type { Group } from './types'; + +afterEach(() => { + cleanup(); +}); + +const FIELDS: RuleField[] = [ + { name: 'genre', label: 'Genre', type: 'text', group: 'General', values: [] }, + { name: 'type', label: 'Item type', type: 'enum', group: 'General', values: ['movie', 'episode'] } +]; + +function setup(initial: Group) { + const onChange = vi.fn(); + const utils = render(); + return { onChange, ...utils }; +} + +describe('RuleBuilder', () => { + it('adds a rule', () => { + const { onChange } = setup({ match: 'all', children: [] }); + fireEvent.click(screen.getByText('Add rule')); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + match: 'all', + children: expect.arrayContaining([expect.objectContaining({ field: 'genre' })]) + }) + ); + }); + + it('removes a rule', () => { + const { onChange } = setup({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }); + fireEvent.click(screen.getByLabelText('Remove rule')); + expect(onChange).toHaveBeenCalledWith({ match: 'all', children: [] }); + }); + + it('adds a nested group only at top level', () => { + const { onChange } = setup({ match: 'all', children: [] }); + fireEvent.click(screen.getByText('Add group')); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ children: expect.arrayContaining([expect.objectContaining({ match: 'any' })]) }) + ); + }); + + it('shows enum values as a dropdown', () => { + setup({ match: 'all', children: [{ field: 'type', operator: 'is', value: 'movie' }] }); + expect(screen.getByRole('option', { name: 'episode' })).toBeInTheDocument(); + }); + + it('resets operator and drops value/value2 when the field type changes', () => { + const onChange = vi.fn(); + const fieldsWithNumber = [ + { name: 'genre', label: 'Genre', type: 'text' as const, group: 'General', values: [] }, + { name: 'minutes', label: 'Duration', type: 'number' as const, group: 'Technical', values: [] } + ]; + render( + + ); + fireEvent.change(screen.getByLabelText('Field'), { target: { value: 'genre' } }); + expect(onChange).toHaveBeenCalledWith({ + match: 'all', + children: [{ field: 'genre', operator: 'is', value: '' }] + }); + }); +}); diff --git a/web/src/builder/rules/RuleBuilder.tsx b/web/src/builder/rules/RuleBuilder.tsx new file mode 100644 index 000000000..8ed70d404 --- /dev/null +++ b/web/src/builder/rules/RuleBuilder.tsx @@ -0,0 +1,212 @@ +import { Plus, Trash2 } from 'lucide-react'; +import { Button, IconButton } from '../../components'; +import type { RuleField } from './fieldCatalog'; +import { isGroup, OPERATORS_BY_TYPE, type FieldType, type Group, type Operator, type Rule } from './types'; + +const OP_LABEL: Record = { + is: 'is', + isNot: 'is not', + contains: 'contains', + startsWith: 'starts with', + matches: 'contains', + notMatches: 'does not contain', + eq: '=', + gt: '>', + lt: '<', + between: 'between', + before: 'before', + after: 'after' +}; + +function typeOf(fields: RuleField[], name: string): FieldType { + return fields.find((f) => f.name === name)?.type ?? 'text'; +} + +function defaultRule(fields: RuleField[]): Rule { + const field = fields[0]?.name ?? 'title'; + const type = typeOf(fields, field); + return { field, operator: OPERATORS_BY_TYPE[type][0], value: '' }; +} + +// Note: the shared `Input`/`Select` components (web/src/components/forms.tsx) don't accept an +// aria-label prop (only a visible `label`), so rows use native elements here to +// keep the field/operator/value controls addressable by aria-label in tests and assistive tech. +function RuleRow({ + rule, + fields, + onChange, + onRemove +}: { + rule: Rule; + fields: RuleField[]; + onChange: (r: Rule) => void; + onRemove: () => void; +}) { + const type = typeOf(fields, rule.field); + const ops = OPERATORS_BY_TYPE[type]; + const enumField = fields.find((f) => f.name === rule.field && f.type === 'enum'); + + return ( +
+ + + + + {enumField ? ( + + ) : ( + onChange({ ...rule, value: e.target.value })} + type={type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'} + /> + )} + + {rule.operator === 'between' && ( + onChange({ ...rule, value2: e.target.value })} + type={type === 'number' ? 'number' : 'date'} + /> + )} + + + + +
+ ); +} + +function MatchToggle({ match, onChange }: { match: 'all' | 'any'; onChange: (m: 'all' | 'any') => void }) { + return ( +
+ Match + + of the following: +
+ ); +} + +function GroupEditor({ + group, + fields, + depth, + onChange, + onRemove +}: { + group: Group; + fields: RuleField[]; + depth: number; + onChange: (g: Group) => void; + onRemove?: () => void; +}) { + const setChild = (i: number, child: Rule | Group) => { + const children = group.children.slice(); + children[i] = child; + onChange({ ...group, children }); + }; + const removeChild = (i: number) => onChange({ ...group, children: group.children.filter((_, j) => j !== i) }); + + return ( +
0 ? '1px solid var(--ctv-border, #333)' : 'none', + borderRadius: 8, + padding: depth > 0 ? 12 : 0, + marginBottom: 8 + }} + > + onChange({ ...group, match: m })} /> + {group.children.map((child, i) => + isGroup(child) ? ( + setChild(i, g)} + onRemove={() => removeChild(i)} + /> + ) : ( + setChild(i, r)} onRemove={() => removeChild(i)} /> + ) + )} +
+ + {depth === 0 && ( + + )} + {onRemove && ( + + + + )} +
+
+ ); +} + +export function RuleBuilder({ + value, + onChange, + fields +}: { + value: Group; + onChange: (g: Group) => void; + fields: RuleField[]; +}) { + return ; +} diff --git a/web/src/builder/rules/compile.test.ts b/web/src/builder/rules/compile.test.ts new file mode 100644 index 000000000..ba27dc370 --- /dev/null +++ b/web/src/builder/rules/compile.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { compile } from './compile'; +import type { Group } from './types'; + +describe('compile', () => { + it('quotes text is', () => { + const g: Group = { match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }; + expect(compile(g)).toBe('genre:"Horror"'); + }); + + it('emits NOT for isNot', () => { + const g: Group = { match: 'all', children: [{ field: 'genre', operator: 'isNot', value: 'Horror' }] }; + expect(compile(g)).toBe('NOT genre:"Horror"'); + }); + + it('wildcards contains and startsWith', () => { + expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: 'night' }] })).toBe('title:*night*'); + expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: 'The' }] })).toBe('title:The*'); + }); + + it('quotes fulltext matches / notMatches (distinct from text contains)', () => { + expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car chase' }] })).toBe('plot:"car chase"'); + expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] })).toBe('NOT plot:"car"'); + }); + + it('emits numeric ranges', () => { + expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'gt', value: '30' }] })).toBe('minutes:{30 TO *}'); + expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'lt', value: '90' }] })).toBe('minutes:{* TO 90}'); + expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30', value2: '90' }] })).toBe('minutes:[30 TO 90]'); + expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'eq', value: '42' }] })).toBe('minutes:42'); + }); + + it('emits date ranges', () => { + expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'after', value: '2000-01-01' }] })).toBe('release_date:{20000101 TO *}'); + expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'before', value: '2010-01-01' }] })).toBe('release_date:{* TO 20100101}'); + }); + + it('compiles date values to the index yyyyMMdd format', () => { + expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'after', value: '2000-01-01' }] })).toBe('release_date:{20000101 TO *}'); + expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'between', value: '2000-01-01', value2: '2010-12-31' }] })).toBe('release_date:[20000101 TO 20101231]'); + }); + + it('joins by AND / OR and parenthesizes nested groups', () => { + const g: Group = { + match: 'all', + children: [ + { field: 'type', operator: 'is', value: 'movie' }, + { match: 'any', children: [ + { field: 'genre', operator: 'is', value: 'Horror' }, + { field: 'genre', operator: 'is', value: 'Thriller' } + ] } + ] + }; + expect(compile(g)).toBe('type:"movie" AND (genre:"Horror" OR genre:"Thriller")'); + }); +}); diff --git a/web/src/builder/rules/compile.ts b/web/src/builder/rules/compile.ts new file mode 100644 index 000000000..9285b517e --- /dev/null +++ b/web/src/builder/rules/compile.ts @@ -0,0 +1,62 @@ +import { isGroup, type Group, type Rule } from './types'; + +// The compiler is the sole author of quoting. Quoted phrases escape only " and \. +function quote(value: string): string { + return `"${value.replace(/(["\\])/g, '\\$1')}"`; +} + +const WILD_SPECIAL = /([\s+\-!(){}[\]^"~*?:\\/])/g; + +// Wildcard/prefix values escape whitespace and every Lucene special (incl. * and ?) so the +// operator's boundary * are the only unescaped stars — the parser keys off exactly that. +function escapeWild(value: string): string { + return value.replace(WILD_SPECIAL, '\\$1'); +} + +// Date inputs arrive as yyyy-MM-dd, but the index stores dates as yyyyMMdd with lexicographic range +// matching — a dashed bound sorts before same-year terms. Normalize date-shaped values to yyyyMMdd. +// Numbers never match this shape, so this is a no-op for numeric ranges. +function normalizeDate(value: string): string { + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.replace(/-/g, '') : value; +} + +function compileRule(rule: Rule): string { + const f = rule.field; + const v = rule.value; + switch (rule.operator) { + case 'is': + case 'matches': // fulltext: same quoted form as text `is`, disambiguated by field type on parse + return `${f}:${quote(v)}`; + case 'isNot': + case 'notMatches': + return `NOT ${f}:${quote(v)}`; + case 'contains': + return `${f}:*${escapeWild(v)}*`; + case 'startsWith': + return `${f}:${escapeWild(v)}*`; + case 'eq': + return `${f}:${normalizeDate(v)}`; + case 'gt': + case 'after': + return `${f}:{${normalizeDate(v)} TO *}`; + case 'lt': + case 'before': + return `${f}:{* TO ${normalizeDate(v)}}`; + case 'between': + return `${f}:[${normalizeDate(v)} TO ${normalizeDate(rule.value2 ?? '')}]`; + default: + return ''; + } +} + +function compileGroup(group: Group): string { + const conn = group.match === 'all' ? ' AND ' : ' OR '; + return group.children + .map((child) => (isGroup(child) ? `(${compileGroup(child)})` : compileRule(child))) + .filter((s) => s.length > 0) + .join(conn); +} + +export function compile(group: Group): string { + return compileGroup(group); +} diff --git a/web/src/builder/rules/fieldCatalog.test.ts b/web/src/builder/rules/fieldCatalog.test.ts new file mode 100644 index 000000000..6f78d05d3 --- /dev/null +++ b/web/src/builder/rules/fieldCatalog.test.ts @@ -0,0 +1,78 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSearchFields } from './fieldCatalog'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + status + }); +} + +describe('useSearchFields', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('starts loading, then exposes fields/fieldTypes/byGroup on success', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue( + jsonResponse([ + { name: 'title', label: 'Title', type: 'text', group: 'General', values: null }, + { name: 'year', label: 'Year', type: 'number', group: 'General', values: null }, + { name: 'genre', label: 'Genre', type: 'enum', group: 'Metadata', values: ['Action', 'Drama'] } + ]) + ); + + const { result } = renderHook(() => useSearchFields()); + + expect(result.current.status).toBe('loading'); + + await waitFor(() => expect(result.current.status).toBe('success')); + + expect(result.current.fields).toHaveLength(3); + expect(result.current.fieldTypes).toEqual({ title: 'text', year: 'number', genre: 'enum' }); + expect(result.current.byGroup).toEqual([ + { + group: 'General', + fields: [ + { name: 'title', label: 'Title', type: 'text', group: 'General', values: [] }, + { name: 'year', label: 'Year', type: 'number', group: 'General', values: [] } + ] + }, + { + group: 'Metadata', + fields: [{ name: 'genre', label: 'Genre', type: 'enum', group: 'Metadata', values: ['Action', 'Drama'] }] + } + ]); + }); + + it('drops fields with a null name and falls back group/type defaults', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue( + jsonResponse([ + { name: null, label: 'Broken', type: 'text', group: 'General', values: null }, + { name: 'untyped', label: 'Untyped', type: null, group: null, values: null } + ]) + ); + + const { result } = renderHook(() => useSearchFields()); + + await waitFor(() => expect(result.current.status).toBe('success')); + + expect(result.current.fields).toHaveLength(1); + expect(result.current.fieldTypes).toEqual({ untyped: 'text' }); + expect(result.current.byGroup).toEqual([ + { group: 'Other', fields: [{ name: 'untyped', label: 'Untyped', type: 'text', group: 'Other', values: [] }] } + ]); + }); + + it('sets status to error when the fetch fails', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ status: 500, title: 'Server error' }, 500)); + + const { result } = renderHook(() => useSearchFields()); + + await waitFor(() => expect(result.current.status).toBe('error')); + + expect(result.current.fields).toEqual([]); + }); +}); diff --git a/web/src/builder/rules/fieldCatalog.ts b/web/src/builder/rules/fieldCatalog.ts new file mode 100644 index 000000000..1eb194b6b --- /dev/null +++ b/web/src/builder/rules/fieldCatalog.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { getSearchFields, type SearchField } from '../../api/search'; +import type { FieldType } from './types'; + +// Clean, non-null field the builder UI consumes (SearchField from the generated client is all-nullable +// because ErsatzTV.Core compiles with disable). +export interface RuleField { + name: string; + label: string; + type: FieldType; + group: string; + values: string[]; +} + +export interface FieldCatalog { + fields: RuleField[]; + fieldTypes: Record; + byGroup: Array<{ group: string; fields: RuleField[] }>; + status: 'loading' | 'success' | 'error'; +} + +// Coerce a nullable generated SearchField into a clean RuleField; drop entries with no name. +function toRuleField(f: SearchField): RuleField | null { + if (!f.name) return null; + return { + name: f.name, + label: f.label ?? f.name, + type: (f.type as FieldType | null) ?? 'text', + group: f.group ?? 'Other', + values: f.values ?? [] + }; +} + +// Field-catalog client for the rule builder (#176): fetches the server field list once and reshapes it +// into the two views the builder UI needs — a name -> FieldType lookup and a group -> fields listing. +export function useSearchFields(): FieldCatalog { + const [raw, setRaw] = useState([]); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + + useEffect(() => { + let active = true; + getSearchFields() + .then((f) => { if (active) { setRaw(f); setStatus('success'); } }) + .catch(() => { if (active) setStatus('error'); }); + return () => { active = false; }; + }, []); + + const fields = raw.map(toRuleField).filter((f): f is RuleField => f !== null); + + const fieldTypes: Record = {}; + for (const f of fields) fieldTypes[f.name] = f.type; + + const groups = new Map(); + for (const f of fields) { + const list = groups.get(f.group) ?? []; + list.push(f); + groups.set(f.group, list); + } + const byGroup = [...groups.entries()].map(([group, gf]) => ({ group, fields: gf })); + + return { fields, fieldTypes, byGroup, status }; +} diff --git a/web/src/builder/rules/parse.test.ts b/web/src/builder/rules/parse.test.ts new file mode 100644 index 000000000..c91b1ba27 --- /dev/null +++ b/web/src/builder/rules/parse.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from './parse'; +import { compile } from './compile'; +import type { FieldType } from './types'; + +const FIELDS: Record = { + genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date' +}; + +describe('parse', () => { + it('parses a single quoted text atom as is', () => { + expect(parse('genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }); + }); + + it('parses NOT as isNot / notMatches by field type', () => { + expect(parse('NOT genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'isNot', value: 'Horror' }] }); + expect(parse('NOT plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] }); + }); + + it('parses a quoted fulltext atom as matches', () => { + expect(parse('plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car' }] }); + }); + + it('parses wildcard forms', () => { + expect(parse('title:*night*', FIELDS)).toEqual({ match: 'all', children: [{ field: 'title', operator: 'contains', value: 'night' }] }); + expect(parse('title:The*', FIELDS)).toEqual({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: 'The' }] }); + }); + + it('parses number/date ranges disambiguated by field type', () => { + expect(parse('minutes:{30 TO *}', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'gt', value: '30' }] }); + expect(parse('release_date:{* TO 2010-01-01}', FIELDS)).toEqual({ match: 'all', children: [{ field: 'release_date', operator: 'before', value: '2010-01-01' }] }); + expect(parse('minutes:[30 TO 90]', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30', value2: '90' }] }); + expect(parse('minutes:42', FIELDS)).toEqual({ match: 'all', children: [{ field: 'minutes', operator: 'eq', value: '42' }] }); + }); + + it('round-trips date ranges (yyyy-MM-dd <-> yyyyMMdd)', () => { + for (const g of [ + { match: 'all' as const, children: [{ field: 'release_date', operator: 'after' as const, value: '2005-06-15' }] }, + { match: 'all' as const, children: [{ field: 'release_date', operator: 'between' as const, value: '2000-01-01', value2: '2010-12-31' }] } + ]) { + expect(parse(compile(g), FIELDS)).toEqual(g); + } + }); + + it('parses a nested group', () => { + expect(parse('type:"movie" AND (genre:"Horror" OR genre:"Thriller")', FIELDS)).toEqual({ + match: 'all', + children: [ + { field: 'type', operator: 'is', value: 'movie' }, + { match: 'any', children: [ + { field: 'genre', operator: 'is', value: 'Horror' }, + { field: 'genre', operator: 'is', value: 'Thriller' } + ] } + ] + }); + }); + + it('returns null on out-of-subset input', () => { + expect(parse('genre:"a" AND genre:"b" OR genre:"c"', FIELDS)).toBeNull(); // mixed AND/OR at one level + expect(parse('unknown_field:"x"', FIELDS)).toBeNull(); // unknown field + expect(parse('genre:"a" AND (type:"movie" AND (genre:"b"))', FIELDS)).toBeNull(); // two levels of nesting + expect(parse('title:jo~2', FIELDS)).toBeNull(); // fuzzy — not in subset + }); +}); + +describe('parse: special-char round-trips', () => { + it('round-trips quoted values with lucene specials (compile∘parse)', () => { + for (const v of ['a*b', 'x y', 'a\\', 'has"quote', '(paren)', 'a AND b', 'a:b']) { + const g = { match: 'all' as const, children: [{ field: 'genre', operator: 'is' as const, value: v }] }; + expect(parse(compile(g), FIELDS)).toEqual(g); + } + }); + it('round-trips text contains/startsWith values with specials', () => { + for (const v of ['a*b', 'x y', 'plain']) { + const c = { match: 'all' as const, children: [{ field: 'title', operator: 'contains' as const, value: v }] }; + expect(parse(compile(c), FIELDS)).toEqual(c); + } + for (const v of ['star*', 'x y', 'ab']) { + const s = { match: 'all' as const, children: [{ field: 'title', operator: 'startsWith' as const, value: v }] }; + expect(parse(compile(s), FIELDS)).toEqual(s); + } + }); +}); diff --git a/web/src/builder/rules/parse.ts b/web/src/builder/rules/parse.ts new file mode 100644 index 000000000..a823bcca7 --- /dev/null +++ b/web/src/builder/rules/parse.ts @@ -0,0 +1,137 @@ +import type { FieldType, Group, Operator, Rule } from './types'; + +// Split a group body into top-level parts separated by a single connective, respecting quotes and +// one level of parens. Returns the parts and the connective, or null if the split is malformed or the +// connectives are mixed (mixed AND/OR at one level is out of subset). +function splitTopLevel(input: string): { parts: string[]; match: 'all' | 'any' } | null { + const parts: string[] = []; + const ops: string[] = []; + let depth = 0; + let quoted = false; + let token = ''; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (ch === '\\') { token += ch + (input[i + 1] ?? ''); i++; continue; } // escape pair, anywhere + if (ch === '"') { quoted = !quoted; token += ch; continue; } + if (quoted) { token += ch; continue; } + if (ch === '(') { depth++; token += ch; continue; } + if (ch === ')') { depth--; token += ch; continue; } + if (depth === 0 && input.startsWith(' AND ', i)) { parts.push(token); ops.push('AND'); token = ''; i += 4; continue; } + if (depth === 0 && input.startsWith(' OR ', i)) { parts.push(token); ops.push('OR'); token = ''; i += 3; continue; } + token += ch; + } + if (depth !== 0 || quoted) return null; + parts.push(token); + + if (ops.length === 0) return { parts, match: 'all' }; + const allAnd = ops.every((o) => o === 'AND'); + const allOr = ops.every((o) => o === 'OR'); + if (!allAnd && !allOr) return null; // mixed connectives + return { parts, match: allAnd ? 'all' : 'any' }; +} + +// Reverse of compile's date normalization: the index format yyyyMMdd back to the input's yyyy-MM-dd. +function fromIndexDate(value: string): string { + return /^\d{8}$/.test(value) ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` : value; +} + +function unquote(value: string): string { + return value.replace(/\\(["\\])/g, '$1'); +} + +// A trailing '*' is an operator wildcard iff it is not escaped — i.e. preceded by an even run of backslashes. +function endsWithOperatorStar(s: string): boolean { + if (!s.endsWith('*')) return false; + let backslashes = 0; + let i = s.length - 2; + while (i >= 0 && s[i] === '\\') { backslashes++; i--; } + return backslashes % 2 === 0; +} + +function parseAtom(atom: string, fieldTypes: Record): Rule | null { + const trimmed = atom.trim(); + const negate = trimmed.startsWith('NOT '); + const body = negate ? trimmed.slice(4).trim() : trimmed; + + const colon = body.indexOf(':'); + if (colon < 0) return null; + const field = body.slice(0, colon); + const raw = body.slice(colon + 1); + const type = fieldTypes[field]; + if (!type) return null; + + // Ranges: {a TO b} (exclusive) and [a TO b] (inclusive between) + const range = raw.match(/^([[{])(\S+) TO (\S+)([\]}])$/); + if (range) { + const [, , lo, hi, close] = range; + if (type !== 'number' && type !== 'date') return null; + const fmt = (x: string) => (type === 'date' ? fromIndexDate(x) : x); + if (close === ']') return mk(field, 'between', fmt(lo), fmt(hi)); + if (lo === '*') return mk(field, type === 'date' ? 'before' : 'lt', fmt(hi)); + if (hi === '*') return mk(field, type === 'date' ? 'after' : 'gt', fmt(lo)); + return null; + } + + // Quoted phrase → is/isNot (text, enum) or matches/notMatches (fulltext) by field type + if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { + const v = unquote(raw.slice(1, -1)); + if (type === 'fulltext') return mk(field, negate ? 'notMatches' : 'matches', v); + if (type === 'text' || type === 'enum') return mk(field, negate ? 'isNot' : 'is', v); + return null; + } + + if (negate) return null; // NOT only valid with quoted forms above + + // Wildcards (text only): *v* → contains, v* → startsWith. The operator stars are the + // unescaped boundary stars; any literal * in the value was escaped to \* by the compiler. + if (type === 'text' && endsWithOperatorStar(raw)) { + if (raw.startsWith('*') && raw.length >= 2) { + return mk(field, 'contains', unescapeWild(raw.slice(1, -1))); + } + if (raw[0] !== '*') { + return mk(field, 'startsWith', unescapeWild(raw.slice(0, -1))); + } + return null; + } + + // Bare token → numeric eq only (reject fuzzy ~, boosts ^, etc.) + if (type === 'number' && /^[0-9.]+$/.test(raw)) return mk(field, 'eq', raw); + + return null; +} + +function unescapeWild(value: string): string { + return value.replace(/\\([\s\S])/g, '$1'); +} + +function mk(field: string, operator: Operator, value: string, value2?: string): Rule { + return value2 === undefined ? { field, operator, value } : { field, operator, value, value2 }; +} + +function parseGroup(input: string, fieldTypes: Record, allowNested: boolean): Group | null { + const split = splitTopLevel(input.trim()); + if (!split) return null; + + const children: Array = []; + for (const part of split.parts) { + const p = part.trim(); + if (p.startsWith('(') && p.endsWith(')')) { + if (!allowNested) return null; // deeper than one level + const inner = parseGroup(p.slice(1, -1), fieldTypes, false); + if (!inner) return null; + children.push(inner); + } else { + const rule = parseAtom(p, fieldTypes); + if (!rule) return null; + children.push(rule); + } + } + if (children.length === 0) return null; + return { match: split.match, children }; +} + +export function parse(input: string, fieldTypes: Record): Group | null { + if (!input.trim()) return null; + return parseGroup(input, fieldTypes, true); +} diff --git a/web/src/builder/rules/roundtrip.test.ts b/web/src/builder/rules/roundtrip.test.ts new file mode 100644 index 000000000..6cdcc5597 --- /dev/null +++ b/web/src/builder/rules/roundtrip.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { compile } from './compile'; +import { parse } from './parse'; +import type { FieldType, Group, Operator, Rule } from './types'; + +const FIELDS: Record = { + genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date' +}; +const BY_TYPE: Record = { + text: ['genre', 'title'], fulltext: ['plot'], enum: ['type'], number: ['minutes'], date: ['release_date'] +}; +const OPS: Record = { + text: ['is', 'isNot', 'contains', 'startsWith'], + fulltext: ['matches', 'notMatches'], + enum: ['is', 'isNot'], + number: ['eq', 'gt', 'lt', 'between'], + date: ['before', 'after', 'between'] +}; + +// Deterministic LCG so a failing case is reproducible. +function lcg(seed: number) { + let s = seed >>> 0; + return () => { + s = (1664525 * s + 1013904223) >>> 0; + return s / 0xffffffff; + }; +} +const pick = (rng: () => number, arr: T[]): T => arr[Math.floor(rng() * arr.length)]; +// Include Lucene specials, spaces, quotes and backslashes: escaping must be TOTAL, so string +// values must round-trip regardless of content (this is what catches escaping bugs the pure +// alphanumeric generator would miss). Numbers/dates are generated separately in makeRule. +const VALUE_CHARS = 'abcdefghijABCDEFGHIJ0123456789 *?"\\():-'; +const token = (rng: () => number): string => { + const n = 3 + Math.floor(rng() * 5); + let out = ''; + for (let i = 0; i < n; i++) out += VALUE_CHARS[Math.floor(rng() * VALUE_CHARS.length)]; + return out; +}; + +function makeRule(rng: () => number): Rule { + const type = pick(rng, Object.keys(BY_TYPE) as FieldType[]); + const field = pick(rng, BY_TYPE[type]); + const operator = pick(rng, OPS[type]); + if (operator === 'between') { + return type === 'date' + ? { field, operator, value: '2000-01-01', value2: '2010-01-01' } + : { field, operator, value: '10', value2: '90' }; + } + if (type === 'number') return { field, operator, value: String(1 + Math.floor(rng() * 500)) }; + if (type === 'date') return { field, operator, value: '2005-06-15' }; + return { field, operator, value: token(rng) }; +} + +function makeGroup(rng: () => number, allowNested: boolean): Group { + const match = rng() < 0.5 ? 'all' : 'any'; + const count = 2 + Math.floor(rng() * 3); // >= 2 children + const children: Array = []; + for (let i = 0; i < count; i++) { + if (allowNested && rng() < 0.25) children.push(makeGroup(rng, false)); + else children.push(makeRule(rng)); + } + return { match, children }; +} + +describe('round-trip: parse(compile(tree)) === tree', () => { + it('holds over 500 generated trees', () => { + const rng = lcg(12345); + for (let i = 0; i < 500; i++) { + const tree = makeGroup(rng, true); + const text = compile(tree); + const back = parse(text, FIELDS); + expect(back, `seed-iter ${i} failed for: ${text}`).toEqual(tree); + } + }); +}); diff --git a/web/src/builder/rules/types.ts b/web/src/builder/rules/types.ts new file mode 100644 index 000000000..3c5927b49 --- /dev/null +++ b/web/src/builder/rules/types.ts @@ -0,0 +1,33 @@ +export type FieldType = 'text' | 'fulltext' | 'number' | 'date' | 'enum'; +export type Match = 'all' | 'any'; + +export type Operator = + | 'is' | 'isNot' | 'contains' | 'startsWith' // text + | 'matches' | 'notMatches' // fulltext + | 'eq' | 'gt' | 'lt' | 'between' // number + | 'before' | 'after'; // date + +export interface Rule { + field: string; + operator: Operator; + value: string; + value2?: string; // upper bound for `between` +} + +export interface Group { + match: Match; + children: Array; +} + +export function isGroup(node: Rule | Group): node is Group { + return (node as Group).children !== undefined; +} + +// Which fields each catalog type exposes as operators (used by the UI and tests). +export const OPERATORS_BY_TYPE: Record = { + text: ['is', 'isNot', 'contains', 'startsWith'], + fulltext: ['matches', 'notMatches'], + enum: ['is', 'isNot'], + number: ['eq', 'gt', 'lt', 'between'], + date: ['before', 'after', 'between'] +}; diff --git a/web/src/screens/CollectionsScreen.test.tsx b/web/src/screens/CollectionsScreen.test.tsx index 527229397..b238a82f8 100644 --- a/web/src/screens/CollectionsScreen.test.tsx +++ b/web/src/screens/CollectionsScreen.test.tsx @@ -2,6 +2,29 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-li import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CollectionsScreen } from './CollectionsScreen'; +// The rule builder's field catalog normally comes from a live /api/v1/search/fields fetch; mock it +// with a small success catalog so SmartDialog's Builder mode has fields to render without wiring a +// second API mock into every collections test. +vi.mock('../builder/rules/fieldCatalog', () => ({ + useSearchFields: () => ({ + fields: [ + { name: 'genre', label: 'Genre', type: 'enum', group: 'Metadata', values: ['Horror', 'Comedy', 'Sci-Fi'] }, + { name: 'title', label: 'Title', type: 'text', group: 'Metadata', values: [] } + ], + fieldTypes: { genre: 'enum', title: 'text' }, + byGroup: [ + { + group: 'Metadata', + fields: [ + { name: 'genre', label: 'Genre', type: 'enum', group: 'Metadata', values: ['Horror', 'Comedy', 'Sci-Fi'] }, + { name: 'title', label: 'Title', type: 'text', group: 'Metadata', values: [] } + ] + } + ], + status: 'success' + }) +})); + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, @@ -220,6 +243,10 @@ describe('CollectionsScreen', () => { const dialog = screen.getByRole('dialog'); fireEvent.change(within(dialog).getByPlaceholderText('Smart collection name'), { target: { value: 'Sci-Fi' } }); + + // A brand-new dialog defaults to Builder mode; switch to Advanced to drive the raw query text + // box the way this test always has. + fireEvent.click(within(dialog).getByRole('button', { name: 'Advanced' })); fireEvent.change(within(dialog).getByPlaceholderText(/genre/), { target: { value: 'genre:scifi' } }); fireEvent.click(within(dialog).getByRole('button', { name: 'Preview results' })); @@ -241,6 +268,48 @@ describe('CollectionsScreen', () => { }); }); + it( + 'opens an existing smart collection with an in-subset query in builder mode', + { timeout: 15000 }, + async () => { + mockApi({ smart: [{ id: 10, name: 'Scary', query: 'genre:"Horror"' }] }); + + render(); + await screen.findByText('Favorites'); + + fireEvent.click(screen.getByRole('button', { name: /Smart/ })); + await screen.findByText('Scary'); + + fireEvent.click(screen.getByTitle('Edit')); + + const dialog = await screen.findByRole('dialog'); + // Builder mode is active by default and the parsed rule seeds the Field select. + expect(within(dialog).getByLabelText('Field')).toHaveValue('genre'); + expect(within(dialog).getByRole('button', { name: 'Builder' })).toHaveClass('ctv-button-primary'); + } + ); + + it( + 'falls back to advanced mode for an out-of-subset query', + { timeout: 15000 }, + async () => { + mockApi({ smart: [{ id: 10, name: 'Fuzzy', query: 'genre:foo~2' }] }); + + render(); + await screen.findByText('Favorites'); + + fireEvent.click(screen.getByRole('button', { name: /Smart/ })); + await screen.findByText('Fuzzy'); + + fireEvent.click(screen.getByTitle('Edit')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByPlaceholderText(/genre/)).toHaveValue('genre:foo~2'); + expect(within(dialog).getByText('This query is too advanced to show in the builder.')).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Builder' })).toBeDisabled(); + } + ); + it('opens a manual collection and lists its items via the collection-items endpoint', async () => { const fetchMock = mockApi({ onRequest: (url, method) => { diff --git a/web/src/screens/CollectionsScreen.tsx b/web/src/screens/CollectionsScreen.tsx index b4400d36d..4943a0ae6 100644 --- a/web/src/screens/CollectionsScreen.tsx +++ b/web/src/screens/CollectionsScreen.tsx @@ -48,6 +48,11 @@ import { type SmartCollection } from '../api'; import { TYPE_LABEL } from '../media/mediaKinds'; +import { compile } from '../builder/rules/compile'; +import { useSearchFields } from '../builder/rules/fieldCatalog'; +import { parse } from '../builder/rules/parse'; +import { RuleBuilder } from '../builder/rules/RuleBuilder'; +import type { Group } from '../builder/rules/types'; type Tab = 'manual' | 'smart'; @@ -223,6 +228,42 @@ function SmartDialog({ const [previewing, setPreviewing] = useState(false); const [previewError, setPreviewError] = useState(null); + const { fields, fieldTypes, status: fieldsStatus } = useSearchFields(); + const [mode, setMode] = useState<'builder' | 'advanced'>(() => + initial?.query && !parse(initial.query, fieldTypes) ? 'advanced' : 'builder' + ); + const [group, setGroup] = useState( + () => parse(initial?.query ?? '', fieldTypes) ?? { match: 'all', children: [] } + ); + + // `fieldTypes` is empty on first render (catalog still loading), so the useState initializers + // above see it empty and `parse` returns null — the dialog opens in advanced mode until the + // catalog arrives. Re-seed once `fieldsStatus` flips to 'success', using React's documented + // "adjust state during render" pattern (comparing against a previous-render value held in state) + // instead of a `useEffect` — a synchronous `setState` in an effect body trips + // react-hooks/set-state-in-effect, and reading a ref during render trips react-hooks/refs. + const [seededFieldsStatus, setSeededFieldsStatus] = useState(fieldsStatus); + if (fieldsStatus !== seededFieldsStatus) { + setSeededFieldsStatus(fieldsStatus); + if (fieldsStatus === 'success' && initial?.query) { + const parsed = parse(initial.query, fieldTypes); + if (parsed) { + setGroup(parsed); + setQuery(compile(parsed)); + setMode('builder'); + } else { + setMode('advanced'); + } + } + } + + // Builder-mode edits flow through this handler (a React event handler, not an effect) so the + // compiled string is always what `query` — the single value submitted/previewed — holds. + const handleGroupChange = (next: Group) => { + setGroup(next); + setQuery(compile(next)); + }; + const runPreview = async () => { const trimmed = query.trim(); @@ -245,6 +286,7 @@ function SmartDialog({ const trimmedName = name.trim(); const trimmedQuery = query.trim(); + const builderUnavailable = query.trim().length > 0 && parse(query, fieldTypes) === null; return ( setName(event.target.value)} placeholder="Smart collection name" value={name} /> -
- setQuery(event.target.value)} - placeholder='e.g. genre:"action" AND released:2000-2010' - style={{ fontFamily: 'var(--font-mono)' }} - value={query} - /> + +
+ +
+ + {mode === 'builder' ? ( + fieldsStatus === 'success' ? ( + + ) : ( + + ) + ) : ( +
+ setQuery(event.target.value)} + placeholder='e.g. genre:"action" AND released:2000-2010' + style={{ fontFamily: 'var(--font-mono)' }} + value={query} + /> + {parse(query, fieldTypes) === null && query.trim().length > 0 && ( + This query is too advanced to show in the builder. + )} +
+ )} +