feat(438,435,434): RuleBuilder validation, relative-date operators, DB-sourced facet typeahead #577

Merged
timothy merged 19 commits from feat/rulebuilder-bundle into main 2026-07-23 21:16:55 +02:00
28 changed files with 1950 additions and 73 deletions
@@ -0,0 +1,6 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public record GetSearchFieldValues(string Name, string Query, int Limit)
: IRequest<Option<SearchFieldValuesResponseModel>>;
@@ -0,0 +1,120 @@
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSearchFieldValues, Option<SearchFieldValuesResponseModel>>
{
private const int DefaultLimit = 50;
private const int MaxLimit = 50;
public async Task<Option<SearchFieldValuesResponseModel>> Handle(
GetSearchFieldValues request,
CancellationToken cancellationToken)
{
SearchFieldResponseModel field = SearchFieldCatalog.Fields
.FirstOrDefault(f => f.Name == request.Name);
if (field is null || field.Type != "text")
{
return Option<SearchFieldValuesResponseModel>.None;
}
int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit);
string qLower = (request.Query ?? string.Empty).ToLower();
// in-memory special cases (no DB query needed)
switch (request.Name)
{
case "state":
return new SearchFieldValuesResponseModel(
FilterSortTake(Enum.GetNames<MediaItemState>(), qLower, limit));
case "video_dynamic_range":
return new SearchFieldValuesResponseModel(
FilterSortTake(["hdr", "sdr"], qLower, limit));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
if (request.Name == "content_rating")
{
return new SearchFieldValuesResponseModel(
await GetContentRatingValues(dbContext, qLower, limit, cancellationToken));
}
IQueryable<string> source = GetSource(dbContext, request.Name);
if (source is null)
{
return Option<SearchFieldValuesResponseModel>.None;
}
List<string> values = await source
.Where(v => v != null && v.ToLower().StartsWith(qLower))
.Distinct()
.OrderBy(v => v)
.Take(limit)
.ToListAsync(cancellationToken);
return new SearchFieldValuesResponseModel(values);
}
private static IQueryable<string> GetSource(TvContext dbContext, string name) => name switch
{
"genre" or "show_genre" => dbContext.Set<Genre>().Select(g => g.Name),
"studio" => dbContext.Set<Studio>().Select(s => s.Name),
"director" => dbContext.Set<Director>().Select(d => d.Name),
"writer" => dbContext.Set<Writer>().Select(w => w.Name),
"actor" => dbContext.Actors.Select(a => a.Name),
// entity artists only; free-text music-video/song artist credits are not included (known limitation)
"artist" => dbContext.ArtistMetadata.Select(m => m.Title),
"tag" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId != Tag.NfoCountryTypeId && t.ExternalTypeId != Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"network" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"collection" => dbContext.Collections.Select(c => c.Name),
"video_codec" => dbContext.MediaStreams
.Where(s => s.MediaStreamKind == MediaStreamKind.Video && s.Codec != null)
.Select(s => s.Codec),
"album" => dbContext.MusicVideoMetadata
.Where(m => m.Album != null)
.Select(m => m.Album)
.Concat(dbContext.SongMetadata.Where(m => m.Album != null).Select(m => m.Album)),
_ => null
};
private static async Task<List<string>> GetContentRatingValues(
TvContext dbContext,
string qLower,
int limit,
CancellationToken cancellationToken)
{
List<string> raw = await dbContext.MovieMetadata
.Where(m => m.ContentRating != null)
.Select(m => m.ContentRating)
.Concat(dbContext.ShowMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.OtherVideoMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.RemoteStreamMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Distinct()
.ToListAsync(cancellationToken);
IEnumerable<string> split = raw
.SelectMany(cr => cr.Split('/'))
.Select(cr => cr.Trim())
.Where(cr => !string.IsNullOrEmpty(cr))
.Distinct();
return FilterSortTake(split, qLower, limit);
}
private static List<string> FilterSortTake(IEnumerable<string> values, string qLower, int limit) =>
values
.Where(v => v.ToLower().StartsWith(qLower))
.OrderBy(v => v)
.Take(limit)
.ToList();
}
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
/// <summary>
/// Distinct term values for a single text field in the search index, used to power the visual rule
/// builder's facet-value typeahead.
/// </summary>
public record SearchFieldValuesResponseModel(List<string> Values);
@@ -0,0 +1,199 @@
using ErsatzTV.Application.Search.Queries;
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Search;
[TestFixture]
public class GetSearchFieldValuesHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Returns_Distinct_Whole_Values_For_Genre_With_Prefix_And_Limit()
{
await using (TvContext context = _db.CreateContext())
{
context.Set<Genre>().AddRange(
new Genre { Name = "Action" },
new Genre { Name = "Adventure" },
new Genre { Name = "Animation" },
new Genre { Name = "Comedy" });
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("genre", "A", 50),
CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action", "Adventure", "Animation" }));
Option<SearchFieldValuesResponseModel> limited = await handler.Handle(
new GetSearchFieldValues("genre", "A", 2),
CancellationToken.None);
limited.IsSome.ShouldBeTrue();
limited.IfSome(r => r.Values.ShouldBe(new List<string> { "Action", "Adventure" }));
}
[Test]
public async Task Splits_Content_Rating_On_Slash()
{
await using (TvContext context = _db.CreateContext())
{
context.MovieMetadata.Add(new MovieMetadata
{
MetadataKind = MetadataKind.External,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ContentRating = "PG-13/TV-14"
});
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("content_rating", string.Empty, 50),
CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfSome(r => r.Values.ShouldBe(new List<string> { "PG-13", "TV-14" }));
}
[Test]
public async Task Returns_MediaItemState_Enum_Names_Without_Seeding()
{
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("state", string.Empty, 50),
CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfSome(r => r.Values.ShouldBe(
new List<string> { "FileNotFound", "Normal", "RemoteOnly", "Unavailable" }));
}
[Test]
public async Task Returns_NotFound_For_Excluded_Text_Field_Title()
{
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("title", string.Empty, 50),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
[Test]
public async Task Returns_NotFound_For_NonText_Field()
{
var handler = new GetSearchFieldValuesHandler(_db.Factory);
// "minutes" is a "number" field in SearchFieldCatalog, not "text"
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("minutes", string.Empty, 50),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
[Test]
public async Task Returns_NotFound_For_Unknown_Field()
{
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("nope", string.Empty, 50),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
}
[Test]
public async Task Matches_Case_Insensitive_Prefix()
{
await using (TvContext context = _db.CreateContext())
{
context.Set<Genre>().AddRange(
new Genre { Name = "Action" },
new Genre { Name = "Comedy" });
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("genre", "a", 50),
CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action" }));
}
[Test]
public async Task Excludes_Network_And_Country_Tags_From_Tag_Field_And_Routes_Network_Tags_To_Network_Field()
{
await using (TvContext context = _db.CreateContext())
{
context.Set<Tag>().AddRange(
new Tag { Name = "PlainTag" },
new Tag { Name = "HBO", ExternalTypeId = Tag.PlexNetworkTypeId },
new Tag { Name = "USA", ExternalTypeId = Tag.NfoCountryTypeId });
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> tagResult = await handler.Handle(
new GetSearchFieldValues("tag", string.Empty, 50),
CancellationToken.None);
tagResult.IsSome.ShouldBeTrue();
tagResult.IfSome(r => r.Values.ShouldBe(new List<string> { "PlainTag" }));
Option<SearchFieldValuesResponseModel> networkResult = await handler.Handle(
new GetSearchFieldValues("network", string.Empty, 50),
CancellationToken.None);
networkResult.IsSome.ShouldBeTrue();
networkResult.IfSome(r => r.Values.ShouldBe(new List<string> { "HBO" }));
}
[Test]
public async Task Dedupes_Repeated_Values()
{
await using (TvContext context = _db.CreateContext())
{
context.Set<Genre>().AddRange(
new Genre { Name = "Action" },
new Genre { Name = "Action" });
await context.SaveChangesAsync();
}
var handler = new GetSearchFieldValuesHandler(_db.Factory);
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
new GetSearchFieldValues("genre", string.Empty, 50),
CancellationToken.None);
result.IsSome.ShouldBeTrue();
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action" }));
}
}
@@ -189,6 +189,29 @@ public class SearchController(IMediator mediator) : ControllerBase
public Task<List<SearchFieldResponseModel>> GetSearchFields(CancellationToken cancellationToken) =>
mediator.Send(new GetSearchFieldCatalog(), cancellationToken);
[HttpGet("/api/v1/search/fields/{name}/values", Name = "GetSearchFieldValues")]
[Tags("Search")]
[EndpointSummary("List distinct database values for a text search field")]
[EndpointDescription(
"Returns distinct whole values from the database for the given text field, filtered by an " +
"optional case-insensitive prefix. Powers the visual rule builder's facet-value typeahead. " +
"404 when the field is unknown, is not a text field, or is a text field with no distinct-value " +
"source.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(SearchFieldValuesResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSearchFieldValues(
[FromRoute] string name,
[FromQuery] string q = "",
[FromQuery] int limit = 50,
CancellationToken cancellationToken = default)
{
Option<SearchFieldValuesResponseModel> result = await mediator.Send(
new GetSearchFieldValues(name, q ?? string.Empty, limit),
cancellationToken);
return result.ToGetResult();
}
private static SearchResultAllItemsResponseModel Project(SearchResultAllItemsViewModel vm) =>
new(
vm.MovieIds,
+116
View File
@@ -18035,6 +18035,108 @@
]
}
},
"/api/v1/search/fields/{name}/values": {
"get": {
"tags": [
"Search"
],
"summary": "List distinct database values for a text search field",
"description": "Returns distinct whole values from the database for the given text field, filtered by an optional case-insensitive prefix. Powers the visual rule builder's facet-value typeahead. 404 when the field is unknown, is not a text field, or is a text field with no distinct-value source.",
"operationId": "GetSearchFieldValues",
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "q",
"in": "query",
"schema": {
"type": "string",
"default": ""
}
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 50
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/SearchFieldValuesResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchFieldValuesResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SearchFieldValuesResponseModel"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "API key missing or invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"400": {
"description": "Request validation failed (model binding or FluentValidation).",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
{ }
]
}
},
"/api/v1/seasons/{id}": {
"get": {
"tags": [
@@ -30612,6 +30714,20 @@
}
}
},
"SearchFieldValuesResponseModel": {
"required": [
"values"
],
"type": "object",
"properties": {
"values": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"SearchResultAllItemsResponseModel": {
"required": [
"movieIds",
+18
View File
@@ -412,6 +412,24 @@ Returns the curated `SearchFieldCatalog` (name, friendly label, type, UI group,
enum fields) as `List<SearchFieldResponseModel>`. Drives the SmartCollection rule builder and is
introspectable by MCP; no query parameters.
**Endpoint inventory addition (#434, facet-value typeahead)**: one read-only `SearchController` GET,
standard credential (catalog-read tier — no `[RequiresAuthentication]`):
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/v1/search/fields/{name}/values` | `GetSearchFieldValues` | List distinct term values for a text search field |
Query params: `q` (optional prefix filter, case-insensitive, default empty) and `limit` (optional,
clamped `1..50`, default 50). `{name}` is allow-listed to `SearchFieldCatalog` fields with
`type: "text"` AND a distinct-value source in the database — an unknown field, a non-text field (e.g.
an enum), or a text field without a source (`title`, `show_title`, `album_artist`) 404s rather than
returning an empty list, since enum fields already ship their values inline on
`GET /api/v1/search/fields` and never need this endpoint. Returns `SearchFieldValuesResponseModel`
(`{ values: string[] }`), sourced from a per-field distinct-values DB query (`IDbContextFactory<TvContext>`),
not the Lucene term dictionary — analyzed text fields store lowercased word tokens, not whole values.
No server-side caching. Powers the visual rule builder's value-input combobox for text fields; see
`docs/decisions.md` 2026-07-23 (#434) and `spa-conventions.md` §12.
**Param + DTO expansion (#293, cap `search/all-items`)**: no new endpoint — `GET /api/v1/search/all-items`
gained two **optional** query params (`pageSize` default 500, clamped 11000 via the §1 Logs `Math.Clamp`
precedent; `pageNum` 0-based, clamped `0..2_000_000` so `pageNum * pageSize` can't overflow `int` to a 500)
+67
View File
@@ -3599,3 +3599,70 @@ This is #72 scope item (a), deferred in `api.channel-health-signal` because "no
**Immutable provenance, not a mutable "still managed" flag.** `Origin` records how the row was *born* and a later user edit never changes it, so "auto-generated then user-edited" stays `AutoTuned`. This deliberately avoids reviving the fragile "detect when it's been edited away" heuristic the issue rejected. A future "has diverged from its auto-tune template" signal, if wanted, is a *separate* concern owned by the #383/#384 auto-tune arc (which knows the template), not this column — mirroring the `api.channel-health-signal` reasoning that kept health a raw fact rather than freezing a policy enum.
**`Unknown = 0` is the honest legacy default.** A new non-null int column defaults existing rows to `0`; making that `Unknown` (rather than `UserCreated`) means pre-migration rows say "we never recorded this" instead of asserting a provenance we cannot know. The SPA badges only `AutoTuned`, so `Unknown` and `UserCreated` both render unbadged. Enum (not `bool IsAutoTuned`) so a future origin (e.g. `Imported`) is additive without a wire-contract break. Stamped in `CreateChannelFromLineupHandler.BuildChannel`, which is the single channel-construction primitive `CreateAutoTunedChannelsHandler` delegates to, so both the lineup endpoint and bulk auto-tune are covered by one stamp site. Empty-schedule and broken-source fault detection remain deferred to #415.
## 2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434)
`key: api.search-field-values` · `status: active` · `since: 2026-07-23` · `supersedes: none` · `superseded-by: none`
**Rule:** `GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50).
**Signals:** facet-value typeahead, rule builder value combobox, distinct field values, GetSearchFieldValues, text field allow-list, DB-sourced distinct values, content_rating split · paths: `ErsatzTV/Controllers/Api/SearchController.cs`, `ErsatzTV.Application/Search/Queries/GetSearchFieldValues.cs`, `ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs`, `web/src/api/search.ts` · issues: #434, #176
**Mechanics:** `SearchController.GetSearchFieldValues`; `GetSearchFieldValuesHandler`; api-conventions.md; spa-conventions.md §12
Enum fields (e.g. `type`, `content_rating` group) already ship their allowed values inline on
`SearchFieldResponseModel` from the existing `GET /api/v1/search/fields` catalog (`spa.smartcollection-rule-builder`,
#176), so they need no endpoint — a client already has the full value set. **Text** fields (title, studio,
genre-as-free-text, etc.) don't: their values are whatever strings the library actually contains, so the
rule builder's value input for a text field needs a live lookup rather than a fixed list.
The handler allow-lists on `field.Type != "text"` (matching the same `SearchFieldCatalog.Fields` the
`/fields` endpoint serves) and returns `Option.None` → 404 for anything else, rather than silently returning
an empty list for a field that will never have values — a 404 tells a caller "wrong field kind," an empty
200 would look like "no matches yet."
**DB-sourced, not the search index.** The handler injects `IDbContextFactory<TvContext>` and resolves an
explicit per-field-name `IQueryable<string>` (or, for a few special cases, an in-memory list) rather than
querying `ISearchIndex`: `genre`/`show_genre``Set<Genre>()`, `studio``Set<Studio>()`, `director`
`Set<Director>()`, `writer``Set<Writer>()`, `actor``Actors`, `artist``ArtistMetadata.Title` (entity
artists only — free-text music-video/song artist credits are a known, intentionally-uncovered gap), `tag`
`Set<Tag>()` excluding `Tag.NfoCountryTypeId`/`Tag.PlexNetworkTypeId` (reapplying the indexer's own
exclusions so country/network strings don't leak in as tags), `network``Set<Tag>()` filtered to
`Tag.PlexNetworkTypeId`, `collection``Collections`, `video_codec``MediaStreams` filtered to
`MediaStreamKind.Video`, `album``MusicVideoMetadata.Album` concatenated with `SongMetadata.Album`. Every
DB-sourced field runs the same pipeline: `.Where(v => v.ToLower().StartsWith(qLower)).Distinct().OrderBy(v =>
v).Take(limit)`, translated to SQL by EF for both SQLite and MySQL. Two fields are computed in memory instead
of queried: `state` (the fixed 4-value `MediaItemState` enum) and `video_dynamic_range` (the literal
`["hdr", "sdr"]`). `content_rating` is special-cased: the DB stores an unsplit `"PG-13/TV-14"` string across
`MovieMetadata`/`ShowMetadata`/`OtherVideoMetadata`/`RemoteStreamMetadata`, so the handler pulls the distinct
raw strings then `Split('/')`s, trims, and dedupes in memory before the same prefix-filter/sort/take — this
matches what search actually matches on, rather than surfacing the compound string as one facet value.
**`title`, `show_title`, `album_artist` are explicitly NOT supported** (404, free-text fallback): `title`/
`show_title` are near-unique free-text fields spanning ~9 metadata tables where a distinct list of every
title isn't a useful facet; `album_artist` backs onto `SongMetadata.AlbumArtists`, a value-converted
`IList<string>` column EF can't translate into a server-side distinct query.
**Why a thin query, not a cache.** No result cache, no debounce on the server side (the SPA combobox
debounces the keystroke) — each per-field query is a bounded, indexed `Distinct`/`Take`; adding a cache
before there's a measured cost would be premature.
## 2026-07-23 — Relative-date rule builder operators are a frontend-only mapping onto existing Lucene macros (#435)
`key: rulebuilder.relative-date-macros` · `status: active` · `since: 2026-07-23` · `supersedes: none` · `superseded-by: none`
**Rule:** The visual rule builder's `inLast`/`notInLast` date operators compile to/parse from the pre-existing `CustomMultiFieldQueryParser` macros `released_inthelast`/`released_notinthelast` and `added_inthelast`/`added_notinthelast`, value form `"<n> day|week|month|year"`; there is no backend change.
**Signals:** relative date operator, inLast, notInLast, inthelast macro, released_inthelast, added_inthelast, date unit picker, rule builder relative dates · paths: `web/src/builder/rules/dateMacro.ts`, `web/src/builder/rules/compile.ts`, `web/src/builder/rules/parse.ts`, `web/src/builder/rules/types.ts`, `web/src/builder/rules/validation.ts` · issues: #435, #176, #438
**Mechanics:** `dateMacro.ts` (`compileRelative`/`parseRelative`, `OP_TO_SUFFIX`/`SUFFIX_TO_OP`, `RELATIVE_SYNTHETIC`); spa-conventions.md §12
`released_inthelast`/`added_inthelast` (+ their `notinthelast` negations) already existed in
`CustomMultiFieldQueryParser` as free-text macro fields before the rule builder could reach them — they
were only usable by typing raw Lucene. #435 exposes them as first-class builder operators without touching
the parser: `release_date`/`added_date` gain `inLast`/`notInLast` alongside the existing `before`/`after`/
`between`, backed by a numeric-value input plus a `day|week|month|year` unit picker (`types.ts`'s `unit?:
DateUnit`). `dateMacro.ts` is the single seam — a small `field↔macro-prefix` table (`release_date↔released`,
`added_date↔added`) plus the `inLast/notInLast ↔ inthelast/notinthelast` suffix maps — that `compile.ts`
delegates to for these two fields and `parse.ts` recognizes via `RELATIVE_SYNTHETIC` before falling into the
generic field:value grammar. Validation (`ruleError` in `validation.ts`) requires the value to parse as a
positive integer; a non-numeric or non-positive value is a builder-side error, never sent to the server.
**Frontend-only because the macros are the query-string wire format, not a new query kind.** The compiled
query for `release_date inLast "7 day"` is literally `released_inthelast:"7 day"` — the same string a user
could type by hand — so nothing downstream (search index, SmartCollection storage, Auto-Tune) needs to know
the rule builder exists. This keeps `rulebuilder.relative-date-macros` symmetric with
`spa.smartcollection-rule-builder`'s "compile-only, no new stored AST" stance (#176): a relative-date rule is
just another point in the same closed grammar subset, proven by the same compile→parse round-trip discipline
(`dateMacro.test.ts`, and the property test in `roundtrip.test.ts`, #438) rather than a special case.
+2
View File
@@ -27,6 +27,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `api.schedule-item-flat-dto` | Schedule-item GET/POST/PUT use a flat, non-polymorphic `ScheduleItemResponseModel` (every subtype field promoted to a nullable top-level member) instead of the polymorphic Application VM hierarchy, with mutation field names matching `ScheduleItemRequest` 1:1 for a lossless round-trip. | 2026-07-10 | [link](../decisions.md#2026-07-10--schedule-item-get-returns-a-flat-non-polymorphic-dto-scheduleitemresponsemodel) |
| `api.scheduling-hardening` | Create/Replace handlers guard against null/whitespace `name` (`IsNullOrWhiteSpace`, not just `Length`) to prevent NRE-500s, template-item overlap validation compares by index (not record value-equality) to catch exact-duplicate items, and unreachable 404 `ProducesResponseType` attributes on create-only actions are trimmed. | 2026-07-13 | [link](../decisions.md#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172) |
| `api.search-allitems-paging` | `GET /api/v1/search/all-items` is paginated (capped page size, `Totals` field) to bound DoS exposure; the SPA add-all flow pages to completeness instead of relying on an unbounded response. | 2026-07-18 | [link](../decisions.md#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293) |
| `api.search-field-values` | `GET /api/v1/search/fields/{name}/values?q=&limit=` returns distinct WHOLE values from the database for one of a narrow allow-list of catalog fields (not the Lucene term dictionary — analyzed `TextField`s store lowercased word tokens, e.g. "Science Fiction" → `science`/`fiction`, useless as a typeahead suggestion), 404 for an unknown field, a non-`text` field, or a `text` field with no distinct-value source; case-insensitive prefix-filtered on `q`, `limit` clamped to `[1, 50]` (default 50). | 2026-07-23 | [link](../decisions.md#2026-07-23--facet-value-typeahead-is-a-new-endpoint-allow-listed-to-text-fields-no-caching-434) |
| `api.search-paging-cap` | Search stays capped at 100 items per media kind; an overflowing kind's "See all" reuses library-browse paging instead of adding new API surface. | 2026-07-11 | [link](../decisions.md#2026-07-11--trash-see-all-reuses-library-browse-paging-search-stays-capped-per-kind-213) |
| `api.versioning-v1` | The entire `/api` surface is versioned to `/api/v1` uniformly (no unversioned corner); legacy unversioned callers are rewritten in-pipeline (not redirected) with Deprecation/Link/Sunset headers, and post-freeze `/api/v1` is additive-only — a breaking change requires `/api/v2`. | 2026-07-13 | [link](../decisions.md#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286) |
| `blazor.rollback-tag` | The commit immediately preceding the Blazor-removal merge is tagged `blazor-final` (not a `v*` tag, so it doesn't trigger a prod release build) as the documented rollback/restore path. | 2026-07-11 | [link](../decisions.md#2026-07-11--pre-removal-blazor-rollback-tag-blazor-final-205) |
@@ -105,6 +106,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `release.prepush-clean-worktree-guard` | A fail-open pre-push hook blocks a push when any file in the branch's diff vs `origin/main` also has uncommitted working-tree or index changes, since a stale-index commit (e.g. `git reset --soft` + `git add` over an edited-but-unstaged fix) can silently push, CI-test, and get reviewed a different tree than the one on disk. Scope is precise to pushed-diff files; escape hatch `ETV_ALLOW_DIRTY_PUSH=1`. | 2026-07-17 | [link](../decisions.md#2026-07-17--pre-push-guard-dont-push-a-file-whose-working-tree-copy-is-uncommitted-h13-416-session) |
| `release.promotion-floating-prod` | Prod tracks the floating `:prod` image reference; a tag build's immutable `:<version>` image is scanned first, then promotion happens via a separate manual `DeployStack`, with daily auto-update only as a fallback — tag with enough runway before 03:00 to avoid an unscanned promotion. | 2026-07-13 | [link](release-ci-governance.md#2026-07-13--release-promotion-floating-prod-exact-image-scan-before-manual-deploy-335) |
| `release.review-verdict-gate` | A PR may not merge until a `Review-verdict: <MERGEABLE\|APPROVED\|BLOCKED\|NOT-MERGEABLE> @ <head-sha>` comment references the PR's current head sha (short-sha prefix match, line-start marker only, negative wins over positive on the same head); folds into the H6 merge-consent hook as condition (c). | 2026-07-12 | [link](release-ci-governance.md#2026-07-12--review-verdict-merge-gate-latest-commit-must-be-reviewed-303-h10) |
| `rulebuilder.relative-date-macros` | The visual rule builder's `inLast`/`notInLast` date operators compile to/parse from the pre-existing `CustomMultiFieldQueryParser` macros `released_inthelast`/`released_notinthelast` and `added_inthelast`/`added_notinthelast`, value form `"<n> day\|week\|month\|year"`; there is no backend change. | 2026-07-23 | [link](../decisions.md#2026-07-23--relative-date-rule-builder-operators-are-a-frontend-only-mapping-onto-existing-lucene-macros-435) |
| `scan.collections-scan-status` | `GET /api/v1/media-sources/collections-scan-status` reports a family-global (not per-source), boolean-only active-scan set read from `IEntityLocker`; the SPA reconciles authoritatively against it (with a grace-tick helper) instead of a fixed client-side timeout. | 2026-07-12 | [link](../decisions.md#2026-07-12--external-collections-scans-get-an-authoritative-status-surface-271-the-spa-timeout-is-retired) |
| `scan.getoraddfolder-db-lookup` | `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. | 2026-07-20 | [link](../decisions.md#2026-07-20--ilibraryrepositorygetoraddfolder-resolves-the-folder-from-the-db-not-the-callers-librarypathlibraryfolders-navigation-488) |
| `scan.jellyfin-mixed-content-library` | A Jellyfin library whose collection type is `mixed` (or absent) maps to one ErsatzTV library of `LibraryMediaKind.Mixed`, scanned by running the movie/television/music-video scanners in sequence against that single library — a library is a place, not a media kind. | 2026-07-20 | [link](../decisions.md#2026-07-20-489--jellyfin-mixed-content-libraries-map-to-one-library-holding-many-kinds) |
+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`.*
166 endpoints, 249 operations.
167 endpoints, 250 operations.
## Artists
@@ -338,6 +338,7 @@
| 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/fields/{name}/values` | GetSearchFieldValues | List distinct database values for a text search field |
| 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 |
+21
View File
@@ -508,6 +508,27 @@ This module is intentionally reusable beyond SmartCollections — ChannelBuilder
inline query editing (#69) are candidate future consumers, tracked as separate follow-up issues
rather than wired in #176.
- **Relative-date operators** (#435, `rulebuilder.relative-date-macros`) — `release_date`/`added_date`
gain `inLast`/`notInLast` alongside `before`/`after`/`between`, each with a numeric value plus a
`day|week|month|year` `unit` picker (`types.ts`'s `unit?: DateUnit`). `dateMacro.ts` is the single
seam: a `field↔macro-prefix` table (`release_date↔released`, `added_date↔added`) plus the
`inLast/notInLast ↔ inthelast/notinthelast` suffix maps, delegated to from `compile.ts`/`parse.ts`
for these two fields — the compiled query is the existing `released_inthelast:"7 day"`-style
`CustomMultiFieldQueryParser` macro, so nothing downstream changes. `validation.ts`'s `ruleError`
requires the value to parse as a positive integer before it's compiled.
- **Facet-value typeahead** (#434, `api.search-field-values`) — the value input for a `text` field
(not enum) is a combobox backed by `getSearchFieldValues` (`web/src/api/search.ts` →
`GET /api/v1/search/fields/{name}/values?q=&limit=`), debounced on keystroke, prefix-matching the
in-progress value against distinct terms already in the index. It always allows free-text entry as a
fallback — a 404 (non-text field) or an empty result list (e.g. ElasticSearch backend) degrades to a
plain text input rather than blocking the rule.
- **Single-child-group normalization** (#438) — `normalizeGroup` (`validation.ts`) coerces a group's
`match` to `all` whenever it has fewer than two children, recursively. A one-child `any` group is
semantically identical to `all` but doesn't round-trip through `compile`→`parse` (the compiled Lucene
for a lone child carries no `AND`/`OR`), so the builder normalizes on every change rather than let the
UI and the compiled query silently diverge; `roundtrip.test.ts`'s property test asserts against
`normalizeGroup(tree)`, not the raw generated tree.
## 13. Collapsible sidebar + nav-group accordions (#396)
The shell sidebar (`web/src/app/AppShell.tsx`) supports two independent, persisted collapse states.
@@ -0,0 +1,655 @@
# RuleBuilder Bundle Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship #438 (validation & polish), #435 (relative-date operators), and #434 (facet-value typeahead) for the visual rule builder on one feature branch.
**Architecture:** Pure-logic changes land in the small `web/src/builder/rules/` modules (types/compile/parse + two new helper modules `validation.ts`, `dateMacro.ts`) under TDD. UI changes to `RuleBuilder.tsx` consume those helpers. #434 adds a disjoint C# read endpoint (Lucene term enumeration) built in a parallel worktree; its combobox is wired last, after the generated API types exist.
**Tech Stack:** React 18 + TypeScript + Vite (vitest for web tests); .NET 10 / C# / MediatR / Lucene.NET (`ISearchIndex`); NUnit + Shouldly + NSubstitute for C# tests.
## Global Constraints
- Work in worktree `feat/rulebuilder-bundle` off `origin/main`. Never commit in `/Users/timothy/ersatztv`. → `process.shared-tree-readonly`
- Backend #434 slice runs in its OWN worktree branched off `feat/rulebuilder-bundle`, merged back by fast-forward/plumbing. All frontend work is one committing agent, sequential (shared `RuleBuilder.tsx`). → `process.one-worktree-one-committing-agent`, `process.foreign-worktree-plumbing-merge`
- The compile↔parse **lossless round-trip contract** (spa-conventions §12) must hold: `parse(compile(g)) ≡ normalizeGroup(g)` for every valid `g`. Extend `roundtrip.test.ts` for every new operator.
- New REST response DTOs live in `ErsatzTV.Core/Api/Search/*ResponseModel.cs` with file-scoped `#nullable enable`. → `api.response-dtos`
- A PR touching `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` MUST ship regenerated `v1.json` / `v1.d.ts` / `endpoint-index.md` in the same diff. → `release.api-contract-ci-gate`
- Before any push touching `.cs`: BOM-check the touched set (`xxd -p | grep -c ^efbbbf`) and run the format gate under `bash -c`. → `process.bom-format-detection-recipe`
- Independent adversarial review over the whole diff before push (new API endpoint + >~150 lines). → `process.independent-review-rubric`
- Commit trailer on every commit: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Worktree hooks: commit with `--no-verify` and run gates manually (worktree husky friction).
**Test commands** (run from the worktree):
- Web unit: `cd web && npx vitest run src/builder/rules/<file>.test.ts`
- Web all rules: `cd web && npx vitest run src/builder/rules`
- C#: `dotnet test ErsatzTV.Application.Tests --filter <Name>` (or the relevant test project)
---
## Task 1: `#438`/`#435` — extend types, then rule/group validation module (`validation.ts`)
**Files:**
- Modify: `web/src/builder/rules/types.ts` (extend first, so validation typechecks)
- Create: `web/src/builder/rules/validation.ts`
- Test: `web/src/builder/rules/validation.test.ts`
- [ ] **Step 0: Extend types.ts** (the new operators/unit are consumed first by validation's tests):
```ts
export type Operator =
| 'is' | 'isNot' | 'contains' | 'startsWith' // text
| 'matches' | 'notMatches' // fulltext
| 'eq' | 'gt' | 'lt' | 'between' // number
| 'before' | 'after' // date (absolute)
| 'inLast' | 'notInLast'; // date (relative, #435)
export type DateUnit = 'day' | 'week' | 'month' | 'year';
export interface Rule {
field: string;
operator: Operator;
value: string;
value2?: string; // upper bound for `between`
unit?: DateUnit; // unit for `inLast`/`notInLast` (#435)
}
```
and extend the date row of `OPERATORS_BY_TYPE`:
```ts
date: ['before', 'after', 'between', 'inLast', 'notInLast']
```
**Interfaces:**
- Produces:
- `ruleError(rule: Rule): string | null` — a human string when the rule is incomplete, else null.
- `groupHasErrors(group: Group): boolean` — true if any descendant rule has an error.
- `topGroupAllNegative(group: Group): boolean` — true when every direct child of the *top* group is a negative rule (`isNot`/`notMatches`) (warning predicate, non-blocking).
- `normalizeGroup(group: Group): Group` — returns a copy with `match` coerced to `'all'` for every group (recursively) that has fewer than 2 children (single-child connective is meaningless and cannot round-trip).
- [ ] **Step 1: Write the failing test**
```ts
// web/src/builder/rules/validation.test.ts
import { describe, expect, it } from 'vitest';
import { groupHasErrors, normalizeGroup, ruleError, topGroupAllNegative } from './validation';
import type { Group, Rule } from './types';
const r = (o: Partial<Rule>): Rule => ({ field: 'title', operator: 'is', value: 'x', ...o });
describe('ruleError', () => {
it('flags between with a missing upper bound', () => {
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '' }))).toBeTruthy();
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '', value2: '60' }))).toBeTruthy();
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '60' }))).toBeNull();
});
it('flags empty text values', () => {
for (const operator of ['is', 'isNot', 'contains', 'startsWith'] as const) {
expect(ruleError(r({ operator, value: '' }))).toBeTruthy();
expect(ruleError(r({ operator, value: 'x' }))).toBeNull();
}
});
it('flags relative-date rule with a non-positive or non-integer N', () => {
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '', unit: 'day' }))).toBeTruthy();
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '0', unit: 'day' }))).toBeTruthy();
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }))).toBeNull();
});
});
describe('groupHasErrors', () => {
it('walks nested groups', () => {
const g: Group = { match: 'all', children: [{ match: 'any', children: [r({ value: '' })] }] };
expect(groupHasErrors(g)).toBe(true);
expect(groupHasErrors({ match: 'all', children: [r({ value: 'ok' })] })).toBe(false);
});
});
describe('topGroupAllNegative', () => {
it('is true only when every top-level child is a negative rule', () => {
expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'notMatches', field: 'plot' })] })).toBe(true);
expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'is' })] })).toBe(false);
expect(topGroupAllNegative({ match: 'all', children: [] })).toBe(false);
});
});
describe('normalizeGroup', () => {
it("coerces a single-child group's match to 'all'", () => {
const g: Group = { match: 'any', children: [r({})] };
expect(normalizeGroup(g).match).toBe('all');
});
it('leaves a 2+ child group match unchanged and recurses', () => {
const g: Group = { match: 'any', children: [r({}), { match: 'any', children: [r({})] }] };
const n = normalizeGroup(g);
expect(n.match).toBe('any');
expect((n.children[1] as Group).match).toBe('all');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd web && npx vitest run src/builder/rules/validation.test.ts`
Expected: FAIL — cannot find module `./validation`.
- [ ] **Step 3: Write minimal implementation**
```ts
// web/src/builder/rules/validation.ts
import { isGroup, type Group, type Rule } from './types';
const NEGATIVE = new Set(['isNot', 'notMatches']);
const RELATIVE_DATE = new Set(['inLast', 'notInLast']);
export function ruleError(rule: Rule): string | null {
if (rule.operator === 'between') {
if (rule.value.trim() === '' || (rule.value2 ?? '').trim() === '') return 'Both bounds are required.';
return null;
}
if (RELATIVE_DATE.has(rule.operator)) {
const n = Number(rule.value);
if (!Number.isInteger(n) || n <= 0) return 'Enter a whole number greater than zero.';
return null;
}
if (rule.value.trim() === '') return 'A value is required.';
return null;
}
export function groupHasErrors(group: Group): boolean {
return group.children.some((c) => (isGroup(c) ? groupHasErrors(c) : ruleError(c) !== null));
}
export function topGroupAllNegative(group: Group): boolean {
const rules = group.children.filter((c): c is Rule => !isGroup(c));
if (rules.length === 0 || rules.length !== group.children.length) return false;
return rules.every((r) => NEGATIVE.has(r.operator));
}
export function normalizeGroup(group: Group): Group {
const children = group.children.map((c) => (isGroup(c) ? normalizeGroup(c) : c));
return { match: children.length < 2 ? 'all' : group.match, children };
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd web && npx vitest run src/builder/rules/validation.test.ts`
Expected: PASS (all cases).
- [ ] **Step 5: Commit**
```bash
git add web/src/builder/rules/types.ts web/src/builder/rules/validation.ts web/src/builder/rules/validation.test.ts
git commit --no-verify -m "feat(438,435): extend rule types; validation + single-child normalization helpers"
```
---
## Task 2: `#435` — dateMacro mapping module
> `types.ts` was already extended in Task 1 Step 0 (operators `inLast`/`notInLast`, `DateUnit`,
> `Rule.unit`, `OPERATORS_BY_TYPE.date`). This task adds only the mapping module.
**Files:**
- Create: `web/src/builder/rules/dateMacro.ts`
- Test: `web/src/builder/rules/dateMacro.test.ts`
**Interfaces:**
- Produces:
- `dateMacro.ts`:
- `RELATIVE_FIELD_MAP: Record<'release_date' | 'added_date', 'released' | 'added'>`
- `compileRelative(rule: Rule): string | null` — e.g. `{release_date, inLast, '7', day}``released_inthelast:"7 day"`; null if not a relative-date rule or invalid.
- `parseRelative(field: string, quoted: string): Rule | null` — recognizes synthetic field + `"<n> <unit>"`, returns a Rule on `release_date`/`added_date`; null otherwise.
- `RELATIVE_SYNTHETIC = /^(released|added)_(inthelast|notinthelast)$/` (exported for parse.ts field detection).
- [ ] **Step 1: Write the failing test**
```ts
// web/src/builder/rules/dateMacro.test.ts
import { describe, expect, it } from 'vitest';
import { compileRelative, parseRelative } from './dateMacro';
import type { Rule } from './types';
const r = (o: Partial<Rule>): Rule => ({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day', ...o });
describe('compileRelative', () => {
it('maps release_date/inLast → released_inthelast', () => {
expect(compileRelative(r({}))).toBe('released_inthelast:"7 day"');
});
it('maps added_date/notInLast → added_notinthelast', () => {
expect(compileRelative(r({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' }))).toBe('added_notinthelast:"3 week"');
});
it('returns null for a non-relative rule', () => {
expect(compileRelative(r({ operator: 'before' }))).toBeNull();
});
it('returns null when N is invalid', () => {
expect(compileRelative(r({ value: '0' }))).toBeNull();
});
});
describe('parseRelative', () => {
it('round-trips released_inthelast', () => {
expect(parseRelative('released_inthelast', '7 day')).toEqual({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' });
});
it('round-trips added_notinthelast', () => {
expect(parseRelative('added_notinthelast', '3 week')).toEqual({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' });
});
it('returns null for an unrelated field', () => {
expect(parseRelative('title', '7 day')).toBeNull();
});
it('returns null for a malformed value', () => {
expect(parseRelative('released_inthelast', 'soon')).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd web && npx vitest run src/builder/rules/dateMacro.test.ts`
Expected: FAIL — cannot find module `./dateMacro`.
- [ ] **Step 3: Write dateMacro.ts**
```ts
// web/src/builder/rules/dateMacro.ts
import type { DateUnit, Rule } from './types';
export const RELATIVE_FIELD_MAP = { release_date: 'released', added_date: 'added' } as const;
type RelField = keyof typeof RELATIVE_FIELD_MAP;
const OP_TO_SUFFIX = { inLast: 'inthelast', notInLast: 'notinthelast' } as const;
const SUFFIX_TO_OP = { inthelast: 'inLast', notinthelast: 'notInLast' } as const;
const CATALOG_FROM_PREFIX = { released: 'release_date', added: 'added_date' } as const;
const UNITS: DateUnit[] = ['day', 'week', 'month', 'year'];
export const RELATIVE_SYNTHETIC = /^(released|added)_(inthelast|notinthelast)$/;
function isValidN(value: string): boolean {
const n = Number(value);
return Number.isInteger(n) && n > 0;
}
export function compileRelative(rule: Rule): string | null {
if (rule.operator !== 'inLast' && rule.operator !== 'notInLast') return null;
const prefix = RELATIVE_FIELD_MAP[rule.field as RelField];
if (!prefix) return null;
if (!isValidN(rule.value) || !rule.unit || !UNITS.includes(rule.unit)) return null;
return `${prefix}_${OP_TO_SUFFIX[rule.operator]}:"${rule.value} ${rule.unit}"`;
}
export function parseRelative(field: string, quoted: string): Rule | null {
const m = RELATIVE_SYNTHETIC.exec(field);
if (!m) return null;
const [, prefix, suffix] = m;
const vm = /^(\d+)\s+(day|week|month|year)$/.exec(quoted.trim());
if (!vm) return null;
const [, n, unit] = vm;
if (!isValidN(n)) return null;
return {
field: CATALOG_FROM_PREFIX[prefix as 'released' | 'added'],
operator: SUFFIX_TO_OP[suffix as 'inthelast' | 'notinthelast'],
value: n,
unit: unit as DateUnit
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd web && npx vitest run src/builder/rules/dateMacro.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add web/src/builder/rules/dateMacro.ts web/src/builder/rules/dateMacro.test.ts
git commit --no-verify -m "feat(435): dateMacro compile/parse mapping for relative-date operators"
```
---
## Task 3: `#438` + `#435` — compile.ts wiring
**Files:**
- Modify: `web/src/builder/rules/compile.ts`
- Modify: `web/src/builder/rules/compile.test.ts`
**Interfaces:**
- Consumes: `compileRelative` (Task 2), `ruleError` (Task 1).
- Produces: `compileRule` emits the relative-date macro for `inLast`/`notInLast`, and returns `''` for a structurally-invalid rule (incomplete `between`, empty text value) so malformed Lucene is never emitted (the empty string is already filtered by `compileGroup`).
- [ ] **Step 1: Write the failing test** — append to `compile.test.ts`:
```ts
import { compileRelative } from './dateMacro'; // ensure no import cycle; if compile imports dateMacro, this is fine
describe('compile — #438 guards + #435 relative dates', () => {
it('emits the relative-date macro', () => {
expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }] })).toBe('released_inthelast:"7 day"');
});
it('drops an incomplete between instead of emitting field:[v TO ]', () => {
const out = compile({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30' }] });
expect(out).not.toContain('TO ]');
expect(out).toBe(''); // sole invalid child filtered out
});
it('drops an empty text value instead of emitting field:* / field:**', () => {
expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: '' }] })).toBe('');
expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: '' }] })).toBe('');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd web && npx vitest run src/builder/rules/compile.test.ts`
Expected: FAIL — relative macro not emitted; incomplete-between emits `minutes:[30 TO ]`.
- [ ] **Step 3: Edit compile.ts**
Add the import and two guards at the top of `compileRule`:
```ts
import { isGroup, type Group, type Rule } from './types';
import { compileRelative } from './dateMacro';
import { ruleError } from './validation';
// ... quote/escapeWild/normalizeDate unchanged ...
function compileRule(rule: Rule): string {
// #435: relative-date operators map to synthetic query fields.
const relative = compileRelative(rule);
if (relative !== null) return relative;
// #438: never emit malformed Lucene for a structurally-invalid rule; compileGroup filters ''.
if (ruleError(rule) !== null) return '';
const f = rule.field;
const v = rule.value;
switch (rule.operator) {
// ... existing cases unchanged ...
default:
return '';
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd web && npx vitest run src/builder/rules/compile.test.ts`
Expected: PASS (new + existing cases).
- [ ] **Step 5: Commit**
```bash
git add web/src/builder/rules/compile.ts web/src/builder/rules/compile.test.ts
git commit --no-verify -m "feat(438,435): compile relative-date macros; drop invalid rules instead of malformed Lucene"
```
---
## Task 4: `#435` — parse.ts relative-date recognition + round-trip
**Files:**
- Modify: `web/src/builder/rules/parse.ts`
- Modify: `web/src/builder/rules/parse.test.ts`
- Modify: `web/src/builder/rules/roundtrip.test.ts`
**Interfaces:**
- Consumes: `parseRelative`, `RELATIVE_SYNTHETIC` (Task 2); `normalizeGroup` (Task 1).
- Produces: `parseAtom` recognizes `(released|added)_(inthelast|notinthelast):"..."` and returns the relative Rule; the round-trip test asserts `parse(compile(g)) ≡ normalizeGroup(g)`.
- [ ] **Step 1: Write the failing test** — append to `parse.test.ts`:
```ts
describe('parse — #435 relative dates', () => {
const ft = { release_date: 'date', added_date: 'date' } as const;
it('parses released_inthelast back to a relative rule', () => {
expect(parse('released_inthelast:"7 day"', ft)).toEqual({
match: 'all',
children: [{ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }]
});
});
it('parses added_notinthelast', () => {
expect(parse('added_notinthelast:"3 week"', ft)).toEqual({
match: 'all',
children: [{ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' }]
});
});
});
```
Note: the synthetic field is NOT in `fieldTypes`, so `parseAtom` must handle it BEFORE the `fieldTypes[field]` lookup (which would reject it).
- [ ] **Step 2: Run test to verify it fails**
Run: `cd web && npx vitest run src/builder/rules/parse.test.ts`
Expected: FAIL — synthetic field returns null (unknown field type).
- [ ] **Step 3: Edit parse.ts**
At the top of `parseAtom`, before the `type` lookup, add relative-date recognition (only the quoted form is valid; `NOT` prefix does not apply):
```ts
import type { FieldType, Group, Operator, Rule } from './types';
import { parseRelative, RELATIVE_SYNTHETIC } from './dateMacro';
function parseAtom(atom: string, fieldTypes: Record<string, FieldType>): Rule | null {
const trimmed = atom.trim();
// #435: relative-date synthetic fields are not in the catalog; match them first.
const relColon = trimmed.indexOf(':');
if (relColon > 0 && RELATIVE_SYNTHETIC.test(trimmed.slice(0, relColon))) {
const rawRel = trimmed.slice(relColon + 1);
if (rawRel.startsWith('"') && rawRel.endsWith('"') && rawRel.length >= 2) {
return parseRelative(trimmed.slice(0, relColon), rawRel.slice(1, -1));
}
return null;
}
const negate = trimmed.startsWith('NOT ');
// ... rest unchanged ...
}
```
- [ ] **Step 4: Update the round-trip test** — in `roundtrip.test.ts`, (a) extend the generator `makeRule` to sometimes emit `date` rules with `inLast`/`notInLast` + a unit, and (b) compare against `normalizeGroup(g)` rather than `g` so single-child groups match. Concretely, import `normalizeGroup` and change the assertion:
```ts
import { normalizeGroup } from './validation';
// ...
// inside the property loop, replace `expect(parse(compile(g), fieldTypes)).toEqual(g)` with:
expect(parse(compile(g), fieldTypes)).toEqual(normalizeGroup(g));
```
For the generator, add a relative-date branch (guard so `value` is a positive integer string and `unit` is set):
```ts
// where a date-field rule is generated:
if (rng() < 0.5) {
const op = rng() < 0.5 ? 'inLast' : 'notInLast';
const unit = (['day', 'week', 'month', 'year'] as const)[Math.floor(rng() * 4)];
return { field: dateField, operator: op, value: String(1 + Math.floor(rng() * 30)), unit };
}
// else fall through to the existing before/after/between generation
```
(Read the actual `roundtrip.test.ts` generator first — match its existing rng/field-selection shape; the above is the required behavior, not a verbatim drop-in.)
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd web && npx vitest run src/builder/rules`
Expected: PASS (parse + roundtrip + all prior).
- [ ] **Step 6: Commit**
```bash
git add web/src/builder/rules/parse.ts web/src/builder/rules/parse.test.ts web/src/builder/rules/roundtrip.test.ts
git commit --no-verify -m "feat(435): parse relative-date macros; round-trip against normalizeGroup"
```
---
## Task 5: `#438` + `#435` — RuleBuilder.tsx UI (validation surface, relative-date inputs, single-child toggle)
**Files:**
- Modify: `web/src/builder/rules/RuleBuilder.tsx`
- Modify: `web/src/screens/CollectionsScreen.tsx` (consumer — disable Save on errors)
- Modify: `web/src/builder/rules/RuleBuilder.test.tsx`
**Interfaces:**
- Consumes: `ruleError`, `groupHasErrors`, `topGroupAllNegative`, `normalizeGroup` (Task 1); `DateUnit` (Task 2).
- Produces: RuleBuilder renders a per-rule error message + an aggregate warning; for `inLast`/`notInLast` it renders a number input + unit `<select>`; single-child groups hide the any/all toggle. Validity is surfaced to the consumer via a new optional prop `onValidityChange?(hasErrors: boolean): void` (or the existing `onChange` contract — read the component first and follow whichever the file already uses).
> **Read `RuleBuilder.tsx` in full before editing** — it is the only file here whose exact JSX shape isn't reproduced in this plan. Match its existing operator-`<select>`, value-`<input>`, and group-toggle patterns. The required behaviors:
- [ ] **Step 1: Relative-date inputs.** When `rule.operator` is `inLast`/`notInLast`, render (in place of the single date input) a numeric `<input>` bound to `rule.value` and a `<select>` bound to `rule.unit` with options `day/week/month/year` (default `day` when the operator is first selected). When switching a date field's operator TO `inLast`/`notInLast`, set `unit: 'day'` if unset; when switching AWAY, clear `unit`.
- [ ] **Step 2: Per-rule error.** Call `ruleError(rule)`; when non-null, render the message inline under the rule row (use the existing error/helper-text styling in the SPA — grep the file/screen for an existing validation message class; do not invent new CSS). This is the #438 block for incomplete `between` / empty text / bad relative-N.
- [ ] **Step 3: Single-child toggle.** Hide the any/all match toggle for any group whose `children.length < 2` (its connective is meaningless and is normalized to `'all'`). Apply `normalizeGroup` to the tree in the builder's `onChange` path so stored state matches what round-trips.
- [ ] **Step 4: All-negative warning.** When `topGroupAllNegative(rootGroup)` is true, render a non-blocking warning near the preview ("This query is entirely negative and will match nothing on its own"). Do NOT disable Save for this.
- [ ] **Step 5: Consumer — disable Save on errors.** In `CollectionsScreen.tsx`, compute `groupHasErrors(group)` and disable the Save/Create button (and skip compile) while true. Read the screen's existing submit-disabled logic and AND this in.
- [ ] **Step 6: Component tests.** Add to `RuleBuilder.test.tsx`: (a) selecting `inLast` shows number + unit inputs; (b) an incomplete `between` shows the error text; (c) a single-child group renders no any/all toggle; (d) an all-negative root shows the warning. Use the existing test-render harness in the file (React Testing Library).
- [ ] **Step 7: Run web tests**
Run: `cd web && npx vitest run src/builder/rules && npx vitest run src/screens/CollectionsScreen`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add web/src/builder/rules/RuleBuilder.tsx web/src/builder/rules/RuleBuilder.test.tsx web/src/screens/CollectionsScreen.tsx
git commit --no-verify -m "feat(438,435): RuleBuilder UI — validation surface, relative-date inputs, single-child toggle"
```
---
## Task 6: `#434` — backend distinct-values endpoint (PARALLEL WORKTREE)
> **Isolation:** run this task in its OWN worktree branched off `feat/rulebuilder-bundle`
> (`git worktree add -b feat/rb-434-backend <path> feat/rulebuilder-bundle`), merged back by
> fast-forward before Task 7. It touches only C# + generated OpenAPI — disjoint from Tasks 15.
> **Use csharp-lsp** for symbol lookup. Independent review required (new API endpoint).
**Files:**
- Modify: `ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs` (add method) — *confirm exact path via LSP*
- Modify: `ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs` (implement term enumeration)
- Create: `ErsatzTV.Application/Search/Queries/GetSearchFieldValues.cs` (+ `...Handler.cs`)
- Create: `ErsatzTV.Core/Api/Search/SearchFieldValuesResponseModel.cs`
- Modify: `ErsatzTV/Controllers/Api/SearchController.cs` (add endpoint)
- Test: `ErsatzTV.Application.Tests/Search/GetSearchFieldValuesHandlerTests.cs`
- Regenerate: `ErsatzTV/wwwroot/.../v1.json`, `web/src/api/v1.d.ts`, `docs/openapi/endpoint-index.md` via `./scripts/update-openapi.sh`
**Interfaces:**
- Produces (frontend Task 7 consumes the generated client for): `GET /api/v1/search/fields/{name}/values?q=&limit=``{ "values": string[] }`. 404 when `name` is absent from `SearchFieldCatalog` or is not `type == "text"`. `limit` default 50, capped 50; `q` is a case-insensitive prefix filter (empty ⇒ first N terms).
- [ ] **Step 1: Write the failing handler test**
```csharp
// GetSearchFieldValuesHandlerTests.cs (NUnit + Shouldly + NSubstitute)
// - substitute ISearchIndex returning e.g. ["Action","Adventure","Drama"] for field "genre"
// - assert: allow-listed text field "genre" with q="A" returns ["Action","Adventure"] (prefix, case-insensitive)
// - assert: unknown field "nope" → the not-found result (Option.None / Either.Left per the handler's result type)
// - assert: non-text field "minutes" (number) → not-found result
// - assert: limit is clamped to 50
```
Write these as real NUnit `[Test]` methods mirroring an existing `ErsatzTV.Application.Tests/Search/*` test (read one first for the harness/DI shape).
- [ ] **Step 2: Run to verify it fails**`dotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues` → FAIL (types missing).
- [ ] **Step 3: Implement**
- `ISearchIndex`: `Task<List<string>> GetFieldValues(string field, string query, int limit);`
- `LuceneSearchIndex`: open the index reader, enumerate `Terms` for the field (`MultiFields.GetTerms(reader, field)` / `TermsEnum`), filter by case-insensitive prefix on `query`, distinct, take `limit`. (Confirm the Lucene.NET term-enumeration API against the version in `Directory.Packages.props`.)
- Handler: look the field up in `SearchFieldCatalog` (the static list in `ErsatzTV.Application/Search/SearchFieldCatalog.cs`); if missing or `type != "text"` return the not-found result; else clamp `limit` to `[1,50]`, call `ISearchIndex.GetFieldValues`, wrap in `SearchFieldValuesResponseModel`.
- DTO: `public record SearchFieldValuesResponseModel(List<string> Values);` with file-scoped `#nullable enable`.
- Controller: `[HttpGet("fields/{name}/values")]``mediator.Send(new GetSearchFieldValues(name, q, limit))`, mapping the not-found result to `NotFound()` (follow the existing `SearchController` result-mapping style).
- [ ] **Step 4: Run to verify pass**`dotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues` → PASS.
- [ ] **Step 5: Regenerate OpenAPI** — build the app project FIRST, then `./scripts/update-openapi.sh`, then confirm the endpoint appears in `endpoint-index.md` and `v1.d.ts` has `getSearchFieldValues` (or the generated name).
- [ ] **Step 6: Format + BOM gate** (touched `.cs`):
```bash
bash -c 'git diff --name-only feat/rulebuilder-bundle...HEAD -- "*.cs" | while read f; do xxd -p "$f" | grep -q "^efbbbf" && echo "BOM: $f"; done'
bash -c 'dotnet format whitespace . --folder --include $(git diff --name-only feat/rulebuilder-bundle...HEAD -- "*.cs" | tr "\n" " ")'
```
- [ ] **Step 7: Commit**`git commit --no-verify -m "feat(434): distinct-values search endpoint (text fields, Lucene term enumeration)"`
- [ ] **Step 8: Merge back to feature branch** by fast-forward (per `process.foreign-worktree-plumbing-merge`, land on the branch ref without committing inside a foreign worktree):
```bash
git -C <feat/rulebuilder-bundle worktree> merge --ff-only feat/rb-434-backend
```
---
## Task 7: `#434` — facet-value combobox (frontend, after Task 6 merged)
**Files:**
- Modify: `web/src/api/search.ts` (add `getSearchFieldValues`) — or wherever the generated search client lives
- Modify: `web/src/builder/rules/RuleBuilder.tsx` (combobox for text value inputs)
- Test: `web/src/builder/rules/RuleBuilder.test.tsx`
**Interfaces:**
- Consumes: the generated `GET /api/v1/search/fields/{name}/values` client from Task 6.
- Produces: text-field value inputs (`is`/`isNot`/`contains`/`startsWith`) become an autocomplete combobox; free-text is preserved (any typed value still compiles).
- [ ] **Step 1: API client.** Add `getSearchFieldValues(name: string, q: string, limit = 50): Promise<string[]>` in `web/src/api/search.ts`, mirroring the existing `getSearchFields()` there and reading `.values` from the response.
- [ ] **Step 2: Combobox.** In `RuleBuilder.tsx`, for a `text`-typed field with a text operator, render an autocomplete input: on input change (debounced ~200ms), call `getSearchFieldValues(field, q)` and show a datalist/suggestion dropdown. Selecting a suggestion sets `rule.value`. Typing a value not in the list is still accepted (free-text fallback → preview count remains the safety net). Reuse an existing combobox/datalist pattern in the SPA if one exists (grep for `<datalist` or an autocomplete component); otherwise a native `<datalist>` is the minimal, dependency-free choice.
- [ ] **Step 3: Test.** Add a `RuleBuilder.test.tsx` case: mocking `getSearchFieldValues` to return `["Action","Adventure"]`, typing in a text value input surfaces the suggestions; selecting one updates the rule; a free-typed value not in the list is retained.
- [ ] **Step 4: Run web tests**`cd web && npx vitest run src/builder/rules` → PASS.
- [ ] **Step 5: Commit**`git commit --no-verify -m "feat(434): facet-value typeahead combobox for text fields"`
---
## Task 8: Docs — decisions records, spa-conventions §12, api-conventions
**Files:**
- Modify: `docs/decisions.md` (2 new records) + regenerate `docs/decisions/README.md` catalog via `scripts/build_decisions_catalog.py`
- Modify: `docs/spa-conventions.md` §12
- Modify: `docs/api-conventions.md` (endpoint checklist)
- [ ] **Step 1: decisions.md** — append two records (follow the existing 5-field metadata schema `key/status/since/supersedes/superseded-by`; `docs.decision-lifecycle`):
- `key: api.search-field-values` — "Distinct field-value typeahead is a new `GET /api/v1/search/fields/{name}/values?q=` endpoint enumerating Lucene terms, allow-listed to `text` catalog fields (404 otherwise), capped at 50; enum ships values inline so it needs no endpoint."
- `key: rulebuilder.relative-date-macros` — "Relative-date operators (`inLast`/`notInLast`) are a frontend-only compile/parse mapping onto the existing `released_inthelast`/`added_inthelast` (+`notinthelast`) `CustomMultiFieldQueryParser` macros (value form `\"<n> day|week|month|year\"`); the `release_date↔released`/`added_date↔added` table is the single seam. No backend change."
- [ ] **Step 2: Regenerate the catalog**`python3 scripts/build_decisions_catalog.py` (or the documented invocation); verify `decisions_validate.py` passes: `python3 scripts/decisions_validate.py`.
- [ ] **Step 3: spa-conventions.md §12** — add the relative-date operators (#435) and the typeahead-combobox behavior (#434) to the RuleBuilder section; note the single-child-group normalization (#438).
- [ ] **Step 4: api-conventions.md** — add the `search/fields/{name}/values` endpoint to the endpoint checklist/table.
- [ ] **Step 5: Commit**`git commit --no-verify -m "docs(434,435,438): decisions records, spa-conventions §12, api-conventions"`
---
## Task 9: Whole-diff verification, review, live-E2E, push
- [ ] **Step 1: Full local gate.**
- `cd web && npx vitest run && npm run build` (or the repo's web test/build scripts)
- `dotnet build ErsatzTV.sln`
- `dotnet test ErsatzTV.Application.Tests --filter GetSearchFieldValues`
- BOM + format gate over all touched `.cs` (recipe in Task 6 Step 6).
- [ ] **Step 2: Independent adversarial review** — cold-context review-only agent (different model family if available) over the whole diff. Loop until a clean verdict; re-review the fix commit, not just the initial diff (`release.review-verdict-gate`). Focus: the compile/parse round-trip invariant, the Lucene term-enumeration correctness + the 404 allow-list, and the combobox free-text fallback.
- [ ] **Step 3: Live-E2E**`scripts/e2e-local.sh` with a fresh config dir; drive the SmartCollection RuleBuilder: build a relative-date rule, save, reload, confirm it round-trips; curl the new `search/fields/genre/values?q=` endpoint and confirm filtered values. (`testing.live-e2e-prepush-timing`.)
- [ ] **Step 4: Push once** (batch all commits), open the PR `fixes #438 #435 #434`, arm the CI monitor on the PR head sha at open (`ci.monitor-armed-at-pr-open`). Post the `Review-verdict:` comment referencing head (`release.review-verdict-gate` / H10).
- [ ] **Step 5:** Tick each issue's `## Done-when` boxes as evidence lands; the merge-consent gate derives consent from state.
---
## Self-Review
**Spec coverage:** #438 (all four cases → Tasks 1,3,5), #435 (operators/mapping/compile/parse/UI → Tasks 2,3,4,5), #434 (endpoint + combobox + OpenAPI → Tasks 6,7), docs (Task 8), review/E2E/push (Task 9). ✔ All spec sections mapped.
**Placeholder scan:** logic-module code is complete and verbatim-runnable; `RuleBuilder.tsx`/C# steps state required behavior + exact signatures with an explicit "read the file first" instruction (their exact JSX/DI shape is not reproduced by design, since it wasn't read into the plan). No TBD/TODO.
**Type consistency:** `Operator` gains `inLast`/`notInLast` (Task 2) used identically in validation (Task 1 test), dateMacro (Task 2), compile (Task 3), parse (Task 4), UI (Task 5). `Rule.unit?: DateUnit` consistent throughout. `normalizeGroup`/`groupHasErrors`/`topGroupAllNegative`/`ruleError` names stable across Tasks 1→5. `GetSearchFieldValues`/`SearchFieldValuesResponseModel`/`getSearchFieldValues` consistent across Tasks 6→7. `RELATIVE_FIELD_MAP`/`RELATIVE_SYNTHETIC` consistent Task 2→4.
**Ordering fix applied:** the `types.ts` extension (operators `inLast`/`notInLast`, `DateUnit`, `Rule.unit`) is Task 1 Step 0, so Task 1's `validation.test.ts` (the first consumer of those types) typechecks and gates green on its own. Task 2 then adds only `dateMacro.ts`. Each task is independently green.
@@ -0,0 +1,125 @@
# RuleBuilder bundle — design (#438 + #435 + #434)
**Date:** 2026-07-23
**Issues:** #438 (validation & polish), #435 (relative-date operators), #434 (facet-value typeahead)
**Branch:** `feat/rulebuilder-bundle`
**Left open:** #436 (deeper nesting — self-labeled YAGNI), #437 (inline RuleBuilder adoption)
The visual rule builder (`web/src/builder/rules/`, landed #176 / PR #433) compiles a visual rule tree
to a Lucene query string and parses it back, with a lossless-round-trip contract. It has one consumer
today: `CollectionsScreen.tsx` (SmartCollection create/edit, mounted at line ~347). Convention doc:
`docs/spa-conventions.md` **§12** (not §11 — #437's citation is wrong and will be corrected when #437
is worked).
## Slicing & workflow
- **One feature branch / one PR** closing all three (`fixes #438 #435 #434`). They share
`RuleBuilder.tsx` (and #435/#438 share `types.ts`/`compile.ts`/`parse.ts`) too heavily to split
without rebase churn, and read as one cohesive polish sweep.
- **#434's backend endpoint is the only disjoint slice** — delegated to a parallel worktree agent
(branched off the feature branch, merged back early) so its C# + OpenAPI regen runs while the
frontend is built. **No parallel agents over the shared frontend module** (the one-file fan-out
trap).
- Everything else is **sequential, single committing agent** in the main worktree:
**#438#435#434-combobox** (combobox last, after the endpoint's generated API types exist).
- Independent adversarial review over the whole diff before push (new API endpoint + >~150 lines).
Live-E2E on the SmartCollection screen for the round-trip.
## #438 — validation & polish (frontend only)
Confirmed current behavior (compile.ts):
- `between` with empty `value2` emits `field:[v TO ]` (malformed). — compile.ts:45
- empty `contains``field:**`; empty `startsWith``field:*`. — compile.ts:3336
- single-child group drops its connective (`join` over one element). — compile.ts:5257
- `isNot`/`notMatches``NOT field:"v"`, which matches nothing alone (no MatchAllDocs). — compile.ts:31
Fixes:
1. **`between` requires both bounds.** A `between` rule with empty `value` or `value2` is *invalid*
surface a per-rule validation error and block compile/save. Never emit `field:[v TO ]`.
2. **Empty text values are invalid.** `contains`/`startsWith`/`is`/`isNot` with an empty `value` are
incomplete (not a wildcard) — same per-rule validation error; block compile/save. Never emit
`field:*` or `field:**`.
3. **Single-child group round-trips its connective.** Fix on the **parser** side: a compiled
single-child group parses back to the builder's default `match` so a 1-child `{match:'any'}` no
longer reopens as `'all'`. Output (compile) is unchanged; this is a parse-side alignment only.
4. **Pure-negative top group → non-blocking warning.** When every child of the *top* group is negative
(`isNot`/`notMatches`), show a warning (a `NOT`-only query matches nothing) and rely on the live
preview count. **Do not block** — a positive branch nested deeper can legitimately rescue it, so a
hard block would create false negatives. (Decision confirmed with user 2026-07-23.)
Validation surface: a per-rule "incomplete" flag rendered inline in `RuleBuilder.tsx`, and an
aggregate "has-errors" boolean the consumer (`CollectionsScreen`) uses to disable Save. Compile of an
invalid tree returns a sentinel (empty / null) rather than a malformed string.
Tests: extend `compile.test.ts` / `parse.test.ts` / `roundtrip.test.ts` for each case (cases 13 are
hard assertions; case 4 asserts the warning predicate, not a block).
## #435 — relative-date operators (frontend only — backend macros already exist)
The Lucene macros exist in `ErsatzTV.Infrastructure/Search/CustomMultiFieldQueryParser.cs`:
`released_inthelast` / `released_notinthelast` / `added_inthelast` / `added_notinthelast`, value form
`"<n> day|week|month|year"` (unit substring-matched; negated from `DateTime.Today`). **No C# change**
they are absent from `SearchFieldCatalog` only because they are query-time synthetic fields. (There is
no `inthenext` macro, so "in the next N" is out of scope — matches the issue.)
- **types.ts:** add operators `inLast` and `notInLast`, offered only on `date` fields (extend
`OPERATORS_BY_TYPE.date`). Add optional `unit?: 'day' | 'week' | 'month' | 'year'` to `Rule` (cleaner
than overloading `value2`, which is the `between` upper bound). `value` holds the integer N.
- **RuleBuilder.tsx:** for `inLast`/`notInLast`, render a number input + a unit `<select>`.
- **compile.ts:** map `{field, op, value:N, unit:U}``<synthetic>:"N U"` via a small mapping table
`release_date → released`, `added_date → added`, and `inLast → inthelast` / `notInLast → notinthelast`.
e.g. `{release_date, inLast, 7, day}``released_inthelast:"7 day"`.
- **parse.ts:** recognize the four synthetic-field spellings `(released|added)_(inthelast|notinthelast):"<n> <unit>"`
and map back to `{field: release_date|added_date, op: inLast|notInLast, value, unit}`.
- The `release_date↔released` / `added_date↔added` + `inLast↔inthelast` mapping table is the single
seam, shared by compile and parse.
- Tests: extend `roundtrip.test.ts` + explicit compile/parse cases for each synthetic field and unit.
## #434 — facet-value typeahead
### Backend (parallel worktree agent)
- **New endpoint** `GET /api/v1/search/fields/{name}/values?q=&limit=` in
`ErsatzTV/Controllers/Api/SearchController.cs`, thin `mediator.Send(...)` per the
`SearchCollections`/`SearchArtists` pattern (SearchController.cs:8599).
- **New MediatR query + handler** under `ErsatzTV.Application/Search/Queries/`
(`GetSearchFieldValues` / `GetSearchFieldValuesHandler`), following `GetSearchFieldCatalog*`.
- **New `ISearchIndex` method** to enumerate distinct terms for a field with a case-insensitive prefix
filter — implemented in `LuceneSearchIndex` (read the index reader's terms for the field). Cap at
`limit` (default/max 50).
- **Allow-list: `text`-type catalog fields only.** A field that is absent from `SearchFieldCatalog` or
is not typed `text` returns **404** (bounds the surface; enum ships values inline, number/date/fulltext
aren't typeahead targets). (Decision confirmed with user 2026-07-23.)
- **Response DTO:** `SearchFieldValuesResponseModel { string[] Values }` in
`ErsatzTV.Core/Api/Search/`, file-scoped `#nullable enable` (per `api.response-dtos`).
- **OpenAPI:** regenerate `v1.json` / `v1.d.ts` / `endpoint-index.md` via `./scripts/update-openapi.sh`
in the same diff (per `release.api-contract-ci-gate`).
- **Tests:** handler test (allow-listed field returns filtered values; non-text/unknown field → the
404 path); term-enumeration unit coverage.
### Frontend (sequential, after the endpoint's generated types exist)
- **`fieldCatalog.ts`:** add a `getSearchFieldValues(name, q)` client over the generated API.
- **`RuleBuilder.tsx`:** for a `text` field's value input with a `contains`/`startsWith`/`is`/`isNot`
operator, swap the `<input>` for an autocomplete combobox (debounced query to the endpoint). Purely
additive — the emitted value and compile/parse are unchanged. Falls back to free-text when the
endpoint returns nothing (so a typo still compiles, keeping the preview-count safety net).
## Docs updated in-PR
| Doc | Change |
|---|---|
| `api-conventions.md` | checklist entry for the new `search/fields/{name}/values` endpoint |
| `v1.json` / `v1.d.ts` / `endpoint-index.md` | regenerated (#434 endpoint) |
| `spa-conventions.md` §12 | relative-date operators (#435) + typeahead combobox (#434) behavior |
| `docs/decisions.md` | record for the #434 distinct-values endpoint; record for the #435 relative-date field-mapping convention |
| `docs/blazor-route-parity.md` | not required (no route change) |
## Out of scope / deferred
- **#436** (deeper group nesting): `compile.ts` already recurses arbitrarily deep; the cap is solely
`parse.ts:121` passing `false`. Left open — self-labeled YAGNI until a real query needs it.
- **#437** (inline RuleBuilder adoption in ChannelBuilder + Auto-Tune): larger integration + live-E2E,
its own session. Note for then: cite `spa-conventions.md` **§12**, not §11.
- **`does not contain` / date `is`**: these operators do not exist in the set and are not being added
(only #435's `inLast`/`notInLast`).
+3
View File
@@ -1461,6 +1461,9 @@ export interface components {
"type": null | string;
"group": null | string;
"values": null | Array<string>;
};
"SearchFieldValuesResponseModel": {
"values": Array<string>;
};
"SearchResultAllItemsResponseModel": {
"movieIds": Array<number>;
+15
View File
@@ -15,6 +15,21 @@ export function getSearchFields(): Promise<SearchField[]> {
return request<SearchField[]>('/api/v1/search/fields');
}
export type SearchFieldValues = components['schemas']['SearchFieldValuesResponseModel'];
// Backs the rule builder's facet-value typeahead (#434): distinct values a text field already holds
// in the library, matching the in-progress query prefix. `.values` is non-nullable on the DTO, but we
// still coerce defensively at the boundary (Core-DTO-nullable convention) in case a future model
// change reintroduces nullability.
export function getSearchFieldValues(name: string, q: string, limit = 50): Promise<string[]> {
const searchParams = new URLSearchParams();
searchParams.set('q', q);
searchParams.set('limit', String(limit));
return request<SearchFieldValues>(`/api/v1/search/fields/${encodeURIComponent(name)}/values?${searchParams.toString()}`).then(
(result) => result.values ?? []
);
}
// 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.
+82 -3
View File
@@ -1,16 +1,27 @@
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
import { useState } from 'react';
import { cleanup, render, screen, fireEvent, act } 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';
const getSearchFieldValues = vi.fn().mockResolvedValue([]);
vi.mock('../../api/search', () => ({
getSearchFieldValues: (...args: unknown[]) => getSearchFieldValues(...args)
}));
afterEach(() => {
cleanup();
vi.useRealTimers();
getSearchFieldValues.mockReset();
getSearchFieldValues.mockResolvedValue([]);
});
const FIELDS: RuleField[] = [
{ name: 'genre', label: 'Genre', type: 'text', group: 'General', values: [] },
{ name: 'type', label: 'Item type', type: 'enum', group: 'General', values: ['movie', 'episode'] }
{ name: 'type', label: 'Item type', type: 'enum', group: 'General', values: ['movie', 'episode'] },
{ name: 'added', label: 'Added', type: 'date', group: 'General', values: [] },
{ name: 'minutes', label: 'Duration', type: 'number', group: 'Technical', values: [] }
];
function setup(initial: Group) {
@@ -19,6 +30,17 @@ function setup(initial: Group) {
return { onChange, ...utils };
}
// RuleBuilder is fully controlled, so exercising a real onChange -> re-render round trip (e.g.
// an operator switch that changes which inputs are shown) needs a stateful harness rather than a
// static value + spy.
function setupControlled(initial: Group) {
function Harness() {
const [group, setGroup] = useState(initial);
return <RuleBuilder value={group} onChange={setGroup} fields={FIELDS} />;
}
return render(<Harness />);
}
describe('RuleBuilder', () => {
it('adds a rule', () => {
const { onChange } = setup({ match: 'all', children: [] });
@@ -40,8 +62,10 @@ describe('RuleBuilder', () => {
it('adds a nested group only at top level', () => {
const { onChange } = setup({ match: 'all', children: [] });
fireEvent.click(screen.getByText('Add group'));
// The new nested group has a single child, so the builder's onChange path normalizes its
// `match` to 'all' (normalizeGroup) — a 1-child group's connective is moot either way.
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ children: expect.arrayContaining([expect.objectContaining({ match: 'any' })]) })
expect.objectContaining({ children: expect.arrayContaining([expect.objectContaining({ match: 'all' })]) })
);
});
@@ -69,4 +93,59 @@ describe('RuleBuilder', () => {
children: [{ field: 'genre', operator: 'is', value: '' }]
});
});
it('shows a number and unit input when a relative-date operator is selected', () => {
setupControlled({ match: 'all', children: [{ field: 'added', operator: 'before', value: '' }] });
fireEvent.change(screen.getByLabelText('Operator'), { target: { value: 'inLast' } });
expect(screen.getByLabelText('Value')).toHaveAttribute('type', 'number');
expect(screen.getByLabelText('Unit')).toBeInTheDocument();
});
it('shows the error message for an incomplete between rule', () => {
setup({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '10', value2: '' }] });
expect(screen.getByText('Both bounds are required.')).toBeInTheDocument();
});
it('hides the any/all toggle for a single-child group', () => {
setup({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] });
expect(screen.queryByLabelText('Match')).not.toBeInTheDocument();
});
it('offers facet-value suggestions for a text field and still accepts free text (#434)', async () => {
vi.useFakeTimers();
getSearchFieldValues.mockResolvedValue(['Action', 'Adventure']);
const { container } = setupControlled({ match: 'all', children: [{ field: 'genre', operator: 'is', value: '' }] });
const input = screen.getByLabelText('Value') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'Ac' } });
expect(input.value).toBe('Ac');
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
expect(getSearchFieldValues).toHaveBeenCalledWith('genre', 'Ac');
const options = Array.from(container.querySelectorAll('datalist option')).map((o) => (o as HTMLOptionElement).value);
expect(options).toEqual(['Action', 'Adventure']);
// Selecting a suggestion (a native datalist selection fires the same input/change event a keystroke would).
fireEvent.change(input, { target: { value: 'Adventure' } });
expect(input.value).toBe('Adventure');
// A free-typed value that isn't in the suggestion list is still retained (free-text fallback).
fireEvent.change(input, { target: { value: 'Something else entirely' } });
expect(input.value).toBe('Something else entirely');
});
it('shows an all-negative warning for the root group', () => {
setup({
match: 'all',
children: [
{ field: 'genre', operator: 'isNot', value: 'Horror' },
{ field: 'genre', operator: 'isNot', value: 'Comedy' }
]
});
expect(screen.getByText(/entirely negative/)).toBeInTheDocument();
});
});
+168 -60
View File
@@ -1,7 +1,10 @@
import { useEffect, useId, useRef, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button, IconButton } from '../../components';
import { Badge, Button, IconButton } from '../../components';
import { getSearchFieldValues } from '../../api/search';
import type { RuleField } from './fieldCatalog';
import { isGroup, OPERATORS_BY_TYPE, type FieldType, type Group, type Operator, type Rule } from './types';
import { isGroup, OPERATORS_BY_TYPE, type DateUnit, type FieldType, type Group, type Operator, type Rule } from './types';
import { normalizeGroup, ruleError, topGroupAllNegative } from './validation';
const OP_LABEL: Record<Operator, string> = {
is: 'is',
@@ -15,13 +18,71 @@ const OP_LABEL: Record<Operator, string> = {
lt: '<',
between: 'between',
before: 'before',
after: 'after'
after: 'after',
inLast: 'in the last',
notInLast: 'not in the last'
};
const RELATIVE_DATE_OPERATORS = new Set<Operator>(['inLast', 'notInLast']);
const UNIT_LABEL: Record<DateUnit, string> = {
day: 'days',
week: 'weeks',
month: 'months',
year: 'years'
};
function typeOf(fields: RuleField[], name: string): FieldType {
return fields.find((f) => f.name === name)?.type ?? 'text';
}
// Facet-value typeahead for text-field rules (#434): backed by a `<datalist>` so free-text is always
// accepted alongside the server-suggested values (the query preview count is the safety net for a
// value that doesn't match anything). Debounces the lookup ~200ms after each keystroke and drops any
// response that arrives after a newer request was issued or the field changed underneath it.
function TextValueInput({ field, value, onChange }: { field: string; value: string; onChange: (v: string) => void }) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const seqRef = useRef(0);
const listId = useId();
useEffect(() => {
const seq = ++seqRef.current;
const handle = window.setTimeout(() => {
getSearchFieldValues(field, value.trim())
.then((values) => {
if (seqRef.current === seq) {
setSuggestions(values);
}
})
.catch(() => {
if (seqRef.current === seq) {
setSuggestions([]);
}
});
}, 200);
return () => window.clearTimeout(handle);
}, [field, value]);
return (
<>
<input
aria-label="Value"
className="ctv-input"
value={value}
onChange={(e) => onChange(e.target.value)}
type="text"
list={listId}
autoComplete="off"
/>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</>
);
}
function defaultRule(fields: RuleField[]): Rule {
const field = fields[0]?.name ?? 'title';
const type = typeOf(fields, field);
@@ -45,68 +106,104 @@ function RuleRow({
const type = typeOf(fields, rule.field);
const ops = OPERATORS_BY_TYPE[type];
const enumField = fields.find((f) => f.name === rule.field && f.type === 'enum');
const isRelativeDate = RELATIVE_DATE_OPERATORS.has(rule.operator);
const error = ruleError(rule);
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}
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<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>
) : (
<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'}
/>
)}
<select
aria-label="Operator"
value={rule.operator}
onChange={(e) => {
const operator = e.target.value as Operator;
const relative = RELATIVE_DATE_OPERATORS.has(operator);
onChange({ ...rule, operator, unit: relative ? (rule.unit ?? 'day') : undefined });
}}
>
{ops.map((op) => (
<option key={op} value={op}>
{OP_LABEL[op]}
</option>
))}
</select>
<IconButton title="Remove rule" onClick={onRemove}>
<Trash2 size={14} />
</IconButton>
{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>
) : isRelativeDate ? (
<>
<input
aria-label="Value"
className="ctv-input"
value={rule.value}
onChange={(e) => onChange({ ...rule, value: e.target.value })}
type="number"
/>
<select
aria-label="Unit"
value={rule.unit ?? 'day'}
onChange={(e) => onChange({ ...rule, unit: e.target.value as DateUnit })}
>
{(Object.keys(UNIT_LABEL) as DateUnit[]).map((u) => (
<option key={u} value={u}>
{UNIT_LABEL[u]}
</option>
))}
</select>
</>
) : type === 'text' ? (
<TextValueInput field={rule.field} value={rule.value} onChange={(v) => onChange({ ...rule, value: v })} />
) : (
<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>
{error && (
<span className="ctv-field-error" role="alert">
{error}
</span>
)}
</div>
);
}
@@ -153,7 +250,9 @@ function GroupEditor({
marginBottom: 8
}}
>
<MatchToggle match={group.match} onChange={(m) => onChange({ ...group, match: m })} />
{group.children.length >= 2 && (
<MatchToggle match={group.match} onChange={(m) => onChange({ ...group, match: m })} />
)}
{group.children.map((child, i) =>
isGroup(child) ? (
<GroupEditor
@@ -208,5 +307,14 @@ export function RuleBuilder({
onChange: (g: Group) => void;
fields: RuleField[];
}) {
return <GroupEditor group={value} fields={fields} depth={0} onChange={onChange} />;
return (
<div>
<GroupEditor group={value} fields={fields} depth={0} onChange={(g) => onChange(normalizeGroup(g))} />
{topGroupAllNegative(value) && (
<Badge tone="warn" dot>
This query is entirely negative and will match nothing on its own.
</Badge>
)}
</div>
);
}
+26
View File
@@ -54,3 +54,29 @@ describe('compile', () => {
expect(compile(g)).toBe('type:"movie" AND (genre:"Horror" OR genre:"Thriller")');
});
});
describe('compile — #438 guards + #435 relative dates', () => {
it('emits the relative-date macro', () => {
expect(compile({ match: 'all', children: [{ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }] })).toBe('released_inthelast:"7 day"');
});
it('drops an incomplete between instead of emitting field:[v TO ]', () => {
const out = compile({ match: 'all', children: [{ field: 'minutes', operator: 'between', value: '30' }] });
expect(out).not.toContain('TO ]');
expect(out).toBe(''); // sole invalid child filtered out
});
it('drops an empty text value instead of emitting field:* / field:**', () => {
expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: '' }] })).toBe('');
expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: '' }] })).toBe('');
});
it('drops a nested all-invalid subgroup instead of emitting ()', () => {
const out = compile({
match: 'all',
children: [
{ field: 'type', operator: 'is', value: 'movie' },
{ match: 'any', children: [{ field: 'minutes', operator: 'between', value: '30' }] }
]
});
expect(out).toBe('type:"movie"');
expect(out).not.toContain('()');
});
});
+16 -1
View File
@@ -1,4 +1,6 @@
import { isGroup, type Group, type Rule } from './types';
import { compileRelative } from './dateMacro';
import { ruleError } from './validation';
// The compiler is the sole author of quoting. Quoted phrases escape only " and \.
function quote(value: string): string {
@@ -21,6 +23,13 @@ function normalizeDate(value: string): string {
}
function compileRule(rule: Rule): string {
// #435: relative-date operators map to synthetic query fields.
const relative = compileRelative(rule);
if (relative !== null) return relative;
// #438: never emit malformed Lucene for a structurally-invalid rule; compileGroup filters ''.
if (ruleError(rule) !== null) return '';
const f = rule.field;
const v = rule.value;
switch (rule.operator) {
@@ -52,7 +61,13 @@ function compileRule(rule: Rule): string {
function compileGroup(group: Group): string {
const conn = group.match === 'all' ? ' AND ' : ' OR ';
return group.children
.map((child) => (isGroup(child) ? `(${compileGroup(child)})` : compileRule(child)))
.map((child) => {
if (isGroup(child)) {
const inner = compileGroup(child);
return inner.length > 0 ? `(${inner})` : '';
}
return compileRule(child);
})
.filter((s) => s.length > 0)
.join(conn);
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { compileRelative, parseRelative } from './dateMacro';
import type { Rule } from './types';
const r = (o: Partial<Rule>): Rule => ({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day', ...o });
describe('compileRelative', () => {
it('maps release_date/inLast → released_inthelast', () => {
expect(compileRelative(r({}))).toBe('released_inthelast:"7 day"');
});
it('maps release_date/notInLast → released_notinthelast', () => {
expect(compileRelative(r({ operator: 'notInLast', value: '14', unit: 'month' }))).toBe('released_notinthelast:"14 month"');
});
it('maps added_date/inLast → added_inthelast', () => {
expect(compileRelative(r({ field: 'added_date', value: '2', unit: 'year' }))).toBe('added_inthelast:"2 year"');
});
it('maps added_date/notInLast → added_notinthelast', () => {
expect(compileRelative(r({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' }))).toBe('added_notinthelast:"3 week"');
});
it('returns null for a non-relative rule', () => {
expect(compileRelative(r({ operator: 'before' }))).toBeNull();
});
it('returns null when N is invalid', () => {
expect(compileRelative(r({ value: '0' }))).toBeNull();
});
it('returns null when N is not an integer', () => {
expect(compileRelative(r({ value: 'abc' }))).toBeNull();
});
it('returns null when N is empty', () => {
expect(compileRelative(r({ value: '' }))).toBeNull();
});
it('returns null when N is decimal notation', () => {
expect(compileRelative(r({ value: '7.0' }))).toBeNull();
});
it('returns null when N is scientific notation', () => {
expect(compileRelative(r({ value: '1e2' }))).toBeNull();
});
it('returns null when unit is undefined', () => {
expect(compileRelative(r({ unit: undefined }))).toBeNull();
});
});
describe('parseRelative', () => {
it('round-trips released_inthelast', () => {
expect(parseRelative('released_inthelast', '7 day')).toEqual({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' });
});
it('round-trips released_notinthelast', () => {
expect(parseRelative('released_notinthelast', '14 month')).toEqual({ field: 'release_date', operator: 'notInLast', value: '14', unit: 'month' });
});
it('round-trips added_inthelast', () => {
expect(parseRelative('added_inthelast', '2 year')).toEqual({ field: 'added_date', operator: 'inLast', value: '2', unit: 'year' });
});
it('round-trips added_notinthelast', () => {
expect(parseRelative('added_notinthelast', '3 week')).toEqual({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' });
});
it('returns null for an unrelated field', () => {
expect(parseRelative('title', '7 day')).toBeNull();
});
it('returns null for a malformed value', () => {
expect(parseRelative('released_inthelast', 'soon')).toBeNull();
});
});
describe('composed round-trip', () => {
it('compiles and parses back release_date/inLast', () => {
const rule = r({});
const out = compileRelative(rule)!;
const [, field, quoted] = /^(\S+):"(.+)"$/.exec(out)!;
expect(parseRelative(field, quoted)).toEqual(rule);
});
it('compiles and parses back release_date/notInLast', () => {
const rule = r({ operator: 'notInLast', value: '14', unit: 'month' });
const out = compileRelative(rule)!;
const [, field, quoted] = /^(\S+):"(.+)"$/.exec(out)!;
expect(parseRelative(field, quoted)).toEqual(rule);
});
it('compiles and parses back added_date/inLast', () => {
const rule = r({ field: 'added_date', value: '2', unit: 'year' });
const out = compileRelative(rule)!;
const [, field, quoted] = /^(\S+):"(.+)"$/.exec(out)!;
expect(parseRelative(field, quoted)).toEqual(rule);
});
it('compiles and parses back added_date/notInLast', () => {
const rule = r({ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' });
const out = compileRelative(rule)!;
const [, field, quoted] = /^(\S+):"(.+)"$/.exec(out)!;
expect(parseRelative(field, quoted)).toEqual(rule);
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { DateUnit, Rule } from './types';
export const RELATIVE_FIELD_MAP = { release_date: 'released', added_date: 'added' } as const;
type RelField = keyof typeof RELATIVE_FIELD_MAP;
const OP_TO_SUFFIX = { inLast: 'inthelast', notInLast: 'notinthelast' } as const;
const SUFFIX_TO_OP = { inthelast: 'inLast', notinthelast: 'notInLast' } as const;
const CATALOG_FROM_PREFIX = { released: 'release_date', added: 'added_date' } as const;
const UNITS: DateUnit[] = ['day', 'week', 'month', 'year'];
export const RELATIVE_SYNTHETIC = /^(released|added)_(inthelast|notinthelast)$/;
function isValidN(value: string): boolean {
const t = value.trim();
return /^\d+$/.test(t) && Number(t) > 0;
}
export function compileRelative(rule: Rule): string | null {
if (rule.operator !== 'inLast' && rule.operator !== 'notInLast') return null;
const prefix = RELATIVE_FIELD_MAP[rule.field as RelField];
if (!prefix) return null;
if (!isValidN(rule.value) || !rule.unit || !UNITS.includes(rule.unit)) return null;
return `${prefix}_${OP_TO_SUFFIX[rule.operator]}:"${rule.value} ${rule.unit}"`;
}
export function parseRelative(field: string, quoted: string): Rule | null {
const m = RELATIVE_SYNTHETIC.exec(field);
if (!m) return null;
const [, prefix, suffix] = m;
const vm = /^(\d+)\s+(day|week|month|year)$/.exec(quoted.trim());
if (!vm) return null;
const [, n, unit] = vm;
if (!isValidN(n)) return null;
return {
field: CATALOG_FROM_PREFIX[prefix as 'released' | 'added'],
operator: SUFFIX_TO_OP[suffix as 'inthelast' | 'notinthelast'],
value: n,
unit: unit as DateUnit
};
}
+16
View File
@@ -81,3 +81,19 @@ describe('parse: special-char round-trips', () => {
}
});
});
describe('parse — #435 relative dates', () => {
const ft = { release_date: 'date', added_date: 'date' } as const;
it('parses released_inthelast back to a relative rule', () => {
expect(parse('released_inthelast:"7 day"', ft)).toEqual({
match: 'all',
children: [{ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }]
});
});
it('parses added_notinthelast', () => {
expect(parse('added_notinthelast:"3 week"', ft)).toEqual({
match: 'all',
children: [{ field: 'added_date', operator: 'notInLast', value: '3', unit: 'week' }]
});
});
});
+12
View File
@@ -1,4 +1,5 @@
import type { FieldType, Group, Operator, Rule } from './types';
import { parseRelative, RELATIVE_SYNTHETIC } from './dateMacro';
// 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
@@ -51,6 +52,17 @@ function endsWithOperatorStar(s: string): boolean {
function parseAtom(atom: string, fieldTypes: Record<string, FieldType>): Rule | null {
const trimmed = atom.trim();
// #435: relative-date synthetic fields are not in the catalog; match them first.
const relColon = trimmed.indexOf(':');
if (relColon > 0 && RELATIVE_SYNTHETIC.test(trimmed.slice(0, relColon))) {
const rawRel = trimmed.slice(relColon + 1);
if (rawRel.startsWith('"') && rawRel.endsWith('"') && rawRel.length >= 2) {
return parseRelative(trimmed.slice(0, relColon), rawRel.slice(1, -1));
}
return null;
}
const negate = trimmed.startsWith('NOT ');
const body = negate ? trimmed.slice(4).trim() : trimmed;
+15 -4
View File
@@ -1,13 +1,16 @@
import { describe, expect, it } from 'vitest';
import { compile } from './compile';
import { parse } from './parse';
import type { FieldType, Group, Operator, Rule } from './types';
import { normalizeGroup } from './validation';
import type { DateUnit, FieldType, Group, Operator, Rule } from './types';
const FIELDS: Record<string, FieldType> = {
genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number', release_date: 'date'
genre: 'text', title: 'text', plot: 'fulltext', type: 'enum', minutes: 'number',
release_date: 'date', added_date: 'date'
};
const BY_TYPE: Record<FieldType, string[]> = {
text: ['genre', 'title'], fulltext: ['plot'], enum: ['type'], number: ['minutes'], date: ['release_date']
text: ['genre', 'title'], fulltext: ['plot'], enum: ['type'], number: ['minutes'],
date: ['release_date', 'added_date']
};
const OPS: Record<FieldType, Operator[]> = {
text: ['is', 'isNot', 'contains', 'startsWith'],
@@ -40,6 +43,14 @@ const token = (rng: () => number): string => {
function makeRule(rng: () => number): Rule {
const type = pick(rng, Object.keys(BY_TYPE) as FieldType[]);
const field = pick(rng, BY_TYPE[type]);
// #435: sometimes emit a relative-date rule for date fields, alongside before/after/between.
if (type === 'date' && rng() < 0.5) {
const operator = rng() < 0.5 ? 'inLast' : 'notInLast';
const unit = pick(rng, ['day', 'week', 'month', 'year'] as DateUnit[]);
return { field, operator, value: String(1 + Math.floor(rng() * 30)), unit };
}
const operator = pick(rng, OPS[type]);
if (operator === 'between') {
return type === 'date'
@@ -69,7 +80,7 @@ describe('round-trip: parse(compile(tree)) === tree', () => {
const tree = makeGroup(rng, true);
const text = compile(tree);
const back = parse(text, FIELDS);
expect(back, `seed-iter ${i} failed for: ${text}`).toEqual(tree);
expect(back, `seed-iter ${i} failed for: ${text}`).toEqual(normalizeGroup(tree));
}
});
});
+6 -2
View File
@@ -5,13 +5,17 @@ export type Operator =
| 'is' | 'isNot' | 'contains' | 'startsWith' // text
| 'matches' | 'notMatches' // fulltext
| 'eq' | 'gt' | 'lt' | 'between' // number
| 'before' | 'after'; // date
| 'before' | 'after' // date (absolute)
| 'inLast' | 'notInLast'; // date (relative, #435)
export type DateUnit = 'day' | 'week' | 'month' | 'year';
export interface Rule {
field: string;
operator: Operator;
value: string;
value2?: string; // upper bound for `between`
unit?: DateUnit; // unit for `inLast`/`notInLast` (#435)
}
export interface Group {
@@ -29,5 +33,5 @@ export const OPERATORS_BY_TYPE: Record<FieldType, Operator[]> = {
fulltext: ['matches', 'notMatches'],
enum: ['is', 'isNot'],
number: ['eq', 'gt', 'lt', 'between'],
date: ['before', 'after', 'between']
date: ['before', 'after', 'between', 'inLast', 'notInLast']
};
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { groupHasErrors, normalizeGroup, ruleError, topGroupAllNegative } from './validation';
import type { Group, Rule } from './types';
const r = (o: Partial<Rule>): Rule => ({ field: 'title', operator: 'is', value: 'x', ...o });
describe('ruleError', () => {
it('flags between with a missing upper bound', () => {
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '' }))).toBeTruthy();
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '', value2: '60' }))).toBeTruthy();
expect(ruleError(r({ field: 'minutes', operator: 'between', value: '30', value2: '60' }))).toBeNull();
});
it('flags empty text values', () => {
for (const operator of ['is', 'isNot', 'contains', 'startsWith'] as const) {
expect(ruleError(r({ operator, value: '' }))).toBeTruthy();
expect(ruleError(r({ operator, value: 'x' }))).toBeNull();
}
});
it('flags relative-date rule with a non-positive or non-integer N', () => {
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '', unit: 'day' }))).toBeTruthy();
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '0', unit: 'day' }))).toBeTruthy();
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '7', unit: 'day' }))).toBeNull();
});
it('flags relative-date rule with decimal notation N', () => {
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '7.0', unit: 'day' }))).toBeTruthy();
});
it('flags relative-date rule with scientific notation N', () => {
expect(ruleError(r({ field: 'release_date', operator: 'inLast', value: '1e2', unit: 'day' }))).toBeTruthy();
});
});
describe('groupHasErrors', () => {
it('walks nested groups', () => {
const g: Group = { match: 'all', children: [{ match: 'any', children: [r({ value: '' })] }] };
expect(groupHasErrors(g)).toBe(true);
expect(groupHasErrors({ match: 'all', children: [r({ value: 'ok' })] })).toBe(false);
});
});
describe('topGroupAllNegative', () => {
it('is true only when every top-level child is a negative rule', () => {
expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'notMatches', field: 'plot' })] })).toBe(true);
expect(topGroupAllNegative({ match: 'any', children: [r({ operator: 'isNot' }), r({ operator: 'is' })] })).toBe(false);
expect(topGroupAllNegative({ match: 'all', children: [] })).toBe(false);
});
});
describe('normalizeGroup', () => {
it("coerces a single-child group's match to 'all'", () => {
const g: Group = { match: 'any', children: [r({})] };
expect(normalizeGroup(g).match).toBe('all');
});
it('leaves a 2+ child group match unchanged and recurses', () => {
const g: Group = { match: 'any', children: [r({}), { match: 'any', children: [r({})] }] };
const n = normalizeGroup(g);
expect(n.match).toBe('any');
expect((n.children[1] as Group).match).toBe('all');
});
});
+33
View File
@@ -0,0 +1,33 @@
import { isGroup, type Group, type Rule } from './types';
const NEGATIVE = new Set(['isNot', 'notMatches']);
const RELATIVE_DATE = new Set(['inLast', 'notInLast']);
export function ruleError(rule: Rule): string | null {
if (rule.operator === 'between') {
if (rule.value.trim() === '' || (rule.value2 ?? '').trim() === '') return 'Both bounds are required.';
return null;
}
if (RELATIVE_DATE.has(rule.operator)) {
const t = rule.value.trim();
if (!/^\d+$/.test(t) || Number(t) <= 0) return 'Enter a whole number greater than zero.';
return null;
}
if (rule.value.trim() === '') return 'A value is required.';
return null;
}
export function groupHasErrors(group: Group): boolean {
return group.children.some((c) => (isGroup(c) ? groupHasErrors(c) : ruleError(c) !== null));
}
export function topGroupAllNegative(group: Group): boolean {
const rules = group.children.filter((c): c is Rule => !isGroup(c));
if (rules.length === 0 || rules.length !== group.children.length) return false;
return rules.every((r) => NEGATIVE.has(r.operator));
}
export function normalizeGroup(group: Group): Group {
const children = group.children.map((c) => (isGroup(c) ? normalizeGroup(c) : c));
return { match: children.length < 2 ? 'all' : group.match, children };
}
+8 -2
View File
@@ -53,6 +53,7 @@ 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';
import { groupHasErrors } from '../builder/rules/validation';
type Tab = 'manual' | 'smart';
@@ -261,7 +262,11 @@ function SmartDialog({
// compiled string is always what `query` — the single value submitted/previewed — holds.
const handleGroupChange = (next: Group) => {
setGroup(next);
setQuery(compile(next));
// Skip compiling an errored tree — a partial rule can still compile to a non-empty (but
// meaningless) query, which would otherwise satisfy the Save button's non-empty check.
if (!groupHasErrors(next)) {
setQuery(compile(next));
}
};
const runPreview = async () => {
@@ -287,6 +292,7 @@ function SmartDialog({
const trimmedName = name.trim();
const trimmedQuery = query.trim();
const builderUnavailable = query.trim().length > 0 && parse(query, fieldTypes) === null;
const groupInvalid = mode === 'builder' && groupHasErrors(group);
return (
<Dialog
@@ -296,7 +302,7 @@ function SmartDialog({
Cancel
</Button>
<Button
disabled={busy || trimmedName.length === 0 || trimmedQuery.length === 0}
disabled={busy || trimmedName.length === 0 || trimmedQuery.length === 0 || groupInvalid}
loading={busy}
onClick={() => onSubmit({ name: trimmedName, query: trimmedQuery })}
variant="primary"