feat(176): visual rule builder for the SmartCollection editor #433

Merged
timothy merged 22 commits from feat/176-smartcollection-rule-builder into main 2026-07-18 03:30:24 +02:00
28 changed files with 2827 additions and 10 deletions
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public record GetSearchFieldCatalog : IRequest<List<SearchFieldResponseModel>>;
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldCatalogHandler : IRequestHandler<GetSearchFieldCatalog, List<SearchFieldResponseModel>>
{
public Task<List<SearchFieldResponseModel>> Handle(
GetSearchFieldCatalog request,
CancellationToken cancellationToken) =>
Task.FromResult(SearchFieldCatalog.Fields);
}
@@ -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<SearchFieldResponseModel> 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)
];
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Search;
/// <summary>
/// One filterable field in the visual rule builder's catalog.
/// <c>Type</c> is one of: text, fulltext, number, date, enum.
/// <c>Values</c> is populated only for <c>enum</c> fields (allowed dropdown values); empty otherwise.
/// </summary>
public record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values);
@@ -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<SearchFieldResponseModel> 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<SearchFieldResponseModel> 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<SearchFieldResponseModel> result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None);
foreach (SearchFieldResponseModel field in result)
{
if (field.Type == "enum")
{
field.Values.ShouldNotBeEmpty();
}
else
{
field.Values.ShouldBeEmpty();
}
}
}
}
@@ -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<SearchFieldResponseModel>), StatusCodes.Status200OK)]
public Task<List<SearchFieldResponseModel>> GetSearchFields(CancellationToken cancellationToken) =>
mediator.Send(new GetSearchFieldCatalog(), cancellationToken);
private static SearchResultAllItemsResponseModel Project(SearchResultAllItemsViewModel vm) =>
new(
vm.MovieIds,
+99
View File
@@ -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",
+11
View File
@@ -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<SearchFieldResponseModel>`. 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
+37
View File
@@ -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).
+2 -1
View File
@@ -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 |
+32
View File
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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<Rule | Group> }
```
- **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.
+7
View File
@@ -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<string>;
};
"SearchResultAllItemsResponseModel": {
"movieIds": Array<number>;
+28 -1
View File
@@ -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(
+10
View File
@@ -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<SearchField[]> {
return request<SearchField[]>('/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.
@@ -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(<RuleBuilder value={initial} onChange={onChange} fields={FIELDS} />);
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(
<RuleBuilder
value={{ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '10', value2: '90' }] }}
onChange={onChange}
fields={fieldsWithNumber}
/>
);
fireEvent.change(screen.getByLabelText('Field'), { target: { value: 'genre' } });
expect(onChange).toHaveBeenCalledWith({
match: 'all',
children: [{ field: 'genre', operator: 'is', value: '' }]
});
});
});
+212
View File
@@ -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<Operator, string> = {
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 <select>/<input> 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 (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<select
aria-label="Field"
value={rule.field}
onChange={(e) => {
const nextType = typeOf(fields, e.target.value);
onChange({ field: e.target.value, operator: OPERATORS_BY_TYPE[nextType][0], value: '' });
}}
>
{fields.map((f) => (
<option key={f.name} value={f.name}>
{f.label}
</option>
))}
</select>
<select
aria-label="Operator"
value={rule.operator}
onChange={(e) => onChange({ ...rule, operator: e.target.value as Operator })}
>
{ops.map((op) => (
<option key={op} value={op}>
{OP_LABEL[op]}
</option>
))}
</select>
{enumField ? (
<select aria-label="Value" value={rule.value} onChange={(e) => onChange({ ...rule, value: e.target.value })}>
<option value=""></option>
{enumField.values.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
) : (
<input
aria-label="Value"
className="ctv-input"
value={rule.value}
onChange={(e) => onChange({ ...rule, value: e.target.value })}
type={type === 'number' ? 'number' : type === 'date' ? 'date' : 'text'}
/>
)}
{rule.operator === 'between' && (
<input
aria-label="Upper bound"
className="ctv-input"
value={rule.value2 ?? ''}
onChange={(e) => onChange({ ...rule, value2: e.target.value })}
type={type === 'number' ? 'number' : 'date'}
/>
)}
<IconButton title="Remove rule" onClick={onRemove}>
<Trash2 size={14} />
</IconButton>
</div>
);
}
function MatchToggle({ match, onChange }: { match: 'all' | 'any'; onChange: (m: 'all' | 'any') => void }) {
return (
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 }}>
<span>Match</span>
<select aria-label="Match" value={match} onChange={(e) => onChange(e.target.value as 'all' | 'any')}>
<option value="all">All</option>
<option value="any">Any</option>
</select>
<span>of the following:</span>
</div>
);
}
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 (
<div
style={{
border: depth > 0 ? '1px solid var(--ctv-border, #333)' : 'none',
borderRadius: 8,
padding: depth > 0 ? 12 : 0,
marginBottom: 8
}}
>
<MatchToggle match={group.match} onChange={(m) => onChange({ ...group, match: m })} />
{group.children.map((child, i) =>
isGroup(child) ? (
<GroupEditor
key={i}
group={child}
fields={fields}
depth={depth + 1}
onChange={(g) => setChild(i, g)}
onRemove={() => removeChild(i)}
/>
) : (
<RuleRow key={i} rule={child} fields={fields} onChange={(r) => setChild(i, r)} onRemove={() => removeChild(i)} />
)
)}
<div style={{ display: 'flex', gap: 8 }}>
<Button
size="sm"
variant="secondary"
startIcon={<Plus size={14} />}
onClick={() => onChange({ ...group, children: [...group.children, defaultRule(fields)] })}
>
Add rule
</Button>
{depth === 0 && (
<Button
size="sm"
variant="secondary"
startIcon={<Plus size={14} />}
onClick={() =>
onChange({ ...group, children: [...group.children, { match: 'any', children: [defaultRule(fields)] }] })
}
>
Add group
</Button>
)}
{onRemove && (
<IconButton title="Remove group" onClick={onRemove}>
<Trash2 size={14} />
</IconButton>
)}
</div>
</div>
);
}
export function RuleBuilder({
value,
onChange,
fields
}: {
value: Group;
onChange: (g: Group) => void;
fields: RuleField[];
}) {
return <GroupEditor group={value} fields={fields} depth={0} onChange={onChange} />;
}
+56
View File
@@ -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")');
});
});
+62
View File
@@ -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);
}
@@ -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([]);
});
});
+62
View File
@@ -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 <Nullable>disable</Nullable>).
export interface RuleField {
name: string;
label: string;
type: FieldType;
group: string;
values: string[];
}
export interface FieldCatalog {
fields: RuleField[];
fieldTypes: Record<string, FieldType>;
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<SearchField[]>([]);
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<string, FieldType> = {};
for (const f of fields) fieldTypes[f.name] = f.type;
const groups = new Map<string, RuleField[]>();
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 };
}
+83
View File
@@ -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<string, FieldType> = {
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);
}
});
});
+137
View File
@@ -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<string, FieldType>): 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<string, FieldType>, allowNested: boolean): Group | null {
const split = splitTopLevel(input.trim());
if (!split) return null;
const children: Array<Rule | Group> = [];
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<string, FieldType>): Group | null {
if (!input.trim()) return null;
return parseGroup(input, fieldTypes, true);
}
+75
View File
@@ -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<string, FieldType> = {
genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date'
};
const BY_TYPE: Record<FieldType, string[]> = {
text: ['genre', 'title'], fulltext: ['plot'], enum: ['type'], number: ['minutes'], date: ['release_date']
};
const OPS: Record<FieldType, Operator[]> = {
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 = <T,>(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<Rule | Group> = [];
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);
}
});
});
+33
View File
@@ -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<Rule | Group>;
}
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<FieldType, Operator[]> = {
text: ['is', 'isNot', 'contains', 'startsWith'],
fulltext: ['matches', 'notMatches'],
enum: ['is', 'isNot'],
number: ['eq', 'gt', 'lt', 'between'],
date: ['before', 'after', 'between']
};
@@ -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(<CollectionsScreen />);
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(<CollectionsScreen />);
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) => {
+93 -8
View File
@@ -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<string | null>(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<Group>(
() => 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 (
<Dialog
@@ -269,15 +311,58 @@ function SmartDialog({
width={560}
>
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Smart collection name" value={name} />
<div style={{ marginTop: 12 }}>
<Input
label="Search query"
onChange={(event) => setQuery(event.target.value)}
placeholder='e.g. genre:"action" AND released:2000-2010'
style={{ fontFamily: 'var(--font-mono)' }}
value={query}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 12, marginBottom: 8 }}>
<Button
disabled={builderUnavailable}
onClick={() => {
const parsed = parse(query, fieldTypes);
if (parsed) {
setGroup(parsed);
setQuery(compile(parsed));
setMode('builder');
}
}}
size="sm"
variant={mode === 'builder' ? 'primary' : 'secondary'}
>
Builder
</Button>
<Button
onClick={() => {
if (mode !== 'advanced') {
setQuery(compile(group));
setMode('advanced');
}
}}
size="sm"
variant={mode === 'advanced' ? 'primary' : 'secondary'}
>
Advanced
</Button>
</div>
{mode === 'builder' ? (
fieldsStatus === 'success' ? (
<RuleBuilder fields={fields} onChange={handleGroupChange} value={group} />
) : (
<Spinner />
)
) : (
<div>
<Input
label="Search query"
onChange={(event) => 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 && (
<span className="ctv-field-hint">This query is too advanced to show in the builder.</span>
)}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button
disabled={previewing || trimmedQuery.length === 0}