feat(176): visual rule builder for the SmartCollection editor #433
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 |
|
||||
|
||||
@@ -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.
|
||||
Vendored
+7
@@ -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>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '' }]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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")');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user