diff --git a/docs/superpowers/plans/2026-07-17-smartcollection-rule-builder.md b/docs/superpowers/plans/2026-07-17-smartcollection-rule-builder.md index 7a6e52cf3..2404a8511 100644 --- a/docs/superpowers/plans/2026-07-17-smartcollection-rule-builder.md +++ b/docs/superpowers/plans/2026-07-17-smartcollection-rule-builder.md @@ -324,7 +324,7 @@ git -c core.hooksPath=/dev/null commit -m "feat(176): GET /api/v1/search/fields - Produces: `FieldType = 'text'|'fulltext'|'number'|'date'|'enum'`; `Operator`; `Rule`; `Group`; `Match`; `isGroup(node)`; `compile(group: Group): string`. - The canonical compiled forms (parser in Task 4 is the exact inverse): - text `is` → `field:"v"` · `isNot` → `NOT field:"v"` · `contains` → `field:*v*` · `startsWith` → `field:v*` - - fulltext `contains` → `field:"v"` · `notContains` → `NOT field:"v"` + - fulltext `matches` → `field:"v"` · `notMatches` → `NOT field:"v"` (distinct operator names from text's `contains`, so `compileRule` maps each operator to exactly one canonical form; parse disambiguates the shared `field:"v"` shape by field type: text/enum→`is`, fulltext→`matches`) - enum `is`/`isNot` → `field:"v"` / `NOT field:"v"` - number `eq` → `field:v` · `gt` → `field:{v TO *}` · `lt` → `field:{* TO v}` · `between` → `field:[v TO v2]` - date `before` → `field:{* TO v}` · `after` → `field:{v TO *}` · `between` → `field:[v TO v2]` @@ -337,9 +337,10 @@ export type FieldType = 'text' | 'fulltext' | 'number' | 'date' | 'enum'; export type Match = 'all' | 'any'; export type Operator = - | 'is' | 'isNot' | 'contains' | 'startsWith' | 'notContains' - | 'eq' | 'gt' | 'lt' | 'between' - | 'before' | 'after'; + | 'is' | 'isNot' | 'contains' | 'startsWith' // text + | 'matches' | 'notMatches' // fulltext + | 'eq' | 'gt' | 'lt' | 'between' // number + | 'before' | 'after'; // date export interface Rule { field: string; @@ -360,7 +361,7 @@ export function isGroup(node: Rule | Group): node is Group { // Which fields each catalog type exposes as operators (used by the UI and tests). export const OPERATORS_BY_TYPE: Record = { text: ['is', 'isNot', 'contains', 'startsWith'], - fulltext: ['contains', 'notContains'], + fulltext: ['matches', 'notMatches'], enum: ['is', 'isNot'], number: ['eq', 'gt', 'lt', 'between'], date: ['before', 'after', 'between'] @@ -387,11 +388,16 @@ describe('compile', () => { expect(compile(g)).toBe('NOT genre:"Horror"'); }); - it('wildcards contains and startsWith', () => { + it('wildcards text contains and startsWith', () => { expect(compile({ match: 'all', children: [{ field: 'title', operator: 'contains', value: 'night' }] })).toBe('title:*night*'); expect(compile({ match: 'all', children: [{ field: 'title', operator: 'startsWith', value: 'The' }] })).toBe('title:The*'); }); + it('quotes fulltext matches / notMatches (distinct from text contains)', () => { + expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car chase' }] })).toBe('plot:"car chase"'); + expect(compile({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] })).toBe('NOT plot:"car"'); + }); + it('emits numeric ranges', () => { expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'gt', value: '30' }] })).toBe('minutes:{30 TO *}'); expect(compile({ match: 'all', children: [{ field: 'minutes', operator: 'lt', value: '90' }] })).toBe('minutes:{* TO 90}'); @@ -445,15 +451,15 @@ function compileRule(rule: Rule): string { const v = rule.value; switch (rule.operator) { case 'is': + case 'matches': // fulltext: same quoted form as text `is`, disambiguated by field type on parse return `${f}:${quote(v)}`; case 'isNot': + case 'notMatches': return `NOT ${f}:${quote(v)}`; case 'contains': return `${f}:*${escapeWild(v)}*`; case 'startsWith': return `${f}:${escapeWild(v)}*`; - case 'notContains': - return `NOT ${f}:${quote(v)}`; case 'eq': return `${f}:${v}`; case 'gt': @@ -524,9 +530,13 @@ describe('parse', () => { expect(parse('genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'is', value: 'Horror' }] }); }); - it('parses NOT as isNot / notContains by field type', () => { + it('parses NOT as isNot / notMatches by field type', () => { expect(parse('NOT genre:"Horror"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'genre', operator: 'isNot', value: 'Horror' }] }); - expect(parse('NOT plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'notContains', value: 'car' }] }); + expect(parse('NOT plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'notMatches', value: 'car' }] }); + }); + + it('parses a quoted fulltext atom as matches', () => { + expect(parse('plot:"car"', FIELDS)).toEqual({ match: 'all', children: [{ field: 'plot', operator: 'matches', value: 'car' }] }); }); it('parses wildcard forms', () => { @@ -634,10 +644,10 @@ function parseAtom(atom: string, fieldTypes: Record): Rule | return null; } - // Quoted phrase → is / isNot / contains / notContains by type + // Quoted phrase → is/isNot (text, enum) or matches/notMatches (fulltext) by field type if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { const v = unquote(raw.slice(1, -1)); - if (type === 'fulltext') return mk(field, negate ? 'notContains' : 'contains', v); + if (type === 'fulltext') return mk(field, negate ? 'notMatches' : 'matches', v); if (type === 'text' || type === 'enum') return mk(field, negate ? 'isNot' : 'is', v); return null; } @@ -736,7 +746,7 @@ const BY_TYPE: Record = { }; const OPS: Record = { text: ['is', 'isNot', 'contains', 'startsWith'], - fulltext: ['contains', 'notContains'], + fulltext: ['matches', 'notMatches'], enum: ['is', 'isNot'], number: ['eq', 'gt', 'lt', 'between'], date: ['before', 'after', 'between'] @@ -922,7 +932,8 @@ import type { SearchField } from '../../api/search'; import { isGroup, OPERATORS_BY_TYPE, type FieldType, type Group, type Operator, type Rule } from './types'; const OP_LABEL: Record = { - is: 'is', isNot: 'is not', contains: 'contains', startsWith: 'starts with', notContains: 'does not contain', + is: 'is', isNot: 'is not', contains: 'contains', startsWith: 'starts with', + matches: 'contains', notMatches: 'does not contain', eq: '=', gt: '>', lt: '<', between: 'between', before: 'before', after: 'after' };