|
1 | 1 | import { CmpNode, FILTER_FIELDS, FilterNode, FilterOp, FilterableField } from 'schema/map_filter'; |
2 | 2 |
|
3 | 3 | /** |
4 | | - * Simple and advanced filtering differ only in the UI: both edit the same {@link FilterNode}. Simple |
5 | | - * mode is a constrained editor over a flat `and` of cmps with pre-selected fields, each |
6 | | - * {@link SimpleField} fixes a `(field, op)` pair so the user only supplies a value. The helpers here |
7 | | - * read and write those fields on the shared AST. |
| 4 | + * Simple and advanced filtering differ in their edit model. Advanced mode edits the shared |
| 5 | + * {@link FilterNode} AST directly. Simple mode is a constrained editor: each {@link SimpleField} |
| 6 | + * fixes a `(field, op)` pair and owns a raw string the user types, compiled to the AST only on |
| 7 | + * search ({@link compileSimpleFilter}). A `multi` field treats its value as a comma-separated list |
| 8 | + * of independent terms OR'd together (e.g. mapper `Foo,Bar`, difficulties `1,2`); comma support is a |
| 9 | + * property of the field, not the operator (so `description`, also `contains`, stays single-valued). |
8 | 10 | */ |
9 | | -export type SimpleField = { field: FilterableField; op: FilterOp }; |
| 11 | +export type SimpleField = { field: FilterableField; op: FilterOp; multi?: boolean }; |
10 | 12 |
|
11 | 13 | export const SIMPLE_FIELDS: SimpleField[] = [ |
12 | | - { field: 'artist', op: 'contains' }, |
13 | | - { field: 'author', op: 'contains' }, |
| 14 | + { field: 'artist', op: 'contains', multi: true }, |
| 15 | + { field: 'author', op: 'contains', multi: true }, |
14 | 16 | { field: 'description', op: 'contains' }, |
15 | | - { field: 'difficulties', op: 'count' }, |
| 17 | + { field: 'difficulties', op: 'count', multi: true }, |
16 | 18 | { field: 'submissionDate', op: 'after' }, |
17 | 19 | { field: 'submissionDate', op: 'before' }, |
18 | 20 | ]; |
19 | 21 |
|
20 | | -const matches = (node: CmpNode, simpleField: SimpleField) => |
21 | | - node.field === simpleField.field && node.op === simpleField.op; |
| 22 | +export const simpleFieldKey = (sf: SimpleField) => `${sf.field}:${sf.op}`; |
| 23 | + |
| 24 | +const matches = (node: CmpNode, sf: SimpleField) => node.field === sf.field && node.op === sf.op; |
22 | 25 |
|
23 | 26 | const simpleFieldOf = (node: CmpNode) => SIMPLE_FIELDS.find((sf) => matches(node, sf)); |
24 | 27 |
|
25 | | -// The cmp children of a simple-shaped filter (a flat `and`, a bare cmp, or empty/undefined). |
26 | | -function simpleCmps(filter: FilterNode | undefined): CmpNode[] { |
27 | | - if (filter == null) { |
28 | | - return []; |
| 28 | +// A simple "slot" is the node holding one field's value: a bare cmp, or - for a `multi` field whose |
| 29 | +// value lists several comma terms - an `or` of cmps that all share the slot's (field, op). |
| 30 | +function slotCmps(node: FilterNode): CmpNode[] | undefined { |
| 31 | + if (node.type === 'cmp') { |
| 32 | + return [node]; |
| 33 | + } |
| 34 | + if ( |
| 35 | + node.type === 'or' && |
| 36 | + node.children.length > 0 && |
| 37 | + node.children.every((c) => c.type === 'cmp') |
| 38 | + ) { |
| 39 | + return node.children as CmpNode[]; |
| 40 | + } |
| 41 | + return undefined; |
| 42 | +} |
| 43 | + |
| 44 | +// The simple field a slot represents, iff all its cmps share one simple (field, op). An `or` slot is |
| 45 | +// only valid for a `multi` field, since single-valued fields never compile to an OR. |
| 46 | +function slotSimpleField(node: FilterNode): SimpleField | undefined { |
| 47 | + const cmps = slotCmps(node); |
| 48 | + if (cmps == null) { |
| 49 | + return undefined; |
29 | 50 | } |
30 | | - if (filter.type === 'cmp') { |
31 | | - return [filter]; |
| 51 | + const sf = simpleFieldOf(cmps[0]); |
| 52 | + if (sf == null || !cmps.every((c) => matches(c, sf))) { |
| 53 | + return undefined; |
32 | 54 | } |
33 | | - if (filter.type === 'and') { |
34 | | - return filter.children.filter((c): c is CmpNode => c.type === 'cmp'); |
| 55 | + if (node.type === 'or' && !sf.multi) { |
| 56 | + return undefined; |
35 | 57 | } |
36 | | - return []; |
| 58 | + return sf; |
| 59 | +} |
| 60 | + |
| 61 | +// The top-level slots of a simple-shaped filter (a flat `and`, a bare slot, or empty/undefined). |
| 62 | +function simpleSlots(filter: FilterNode | undefined): FilterNode[] { |
| 63 | + if (filter == null) { |
| 64 | + return []; |
| 65 | + } |
| 66 | + return filter.type === 'and' ? filter.children : [filter]; |
37 | 67 | } |
38 | 68 |
|
39 | 69 | /** |
40 | | - * True if the node fits simple mode: empty, a bare cmp, or a flat `and` whose children are all |
41 | | - * distinct simple fields. Anything else (OR/NOT/nesting, other fields/operators, duplicates) needs |
42 | | - * advanced mode. |
| 70 | + * True if the node fits simple mode: empty, or a flat `and` (or a bare slot) whose children are all |
| 71 | + * distinct simple slots - a cmp, or an `or` of comma terms for a `multi` field. Anything else (NOT, |
| 72 | + * nesting, other fields/operators, duplicate fields) needs advanced mode. |
43 | 73 | */ |
44 | 74 | export function isSimpleFilter(node: FilterNode | undefined): boolean { |
45 | 75 | if (node == null) { |
46 | 76 | return true; |
47 | 77 | } |
48 | | - if (node.type !== 'cmp' && node.type !== 'and') { |
49 | | - return false; |
50 | | - } |
51 | | - const children: FilterNode[] = node.type === 'cmp' ? [node] : node.children; |
52 | 78 | const seen = new Set<SimpleField>(); |
53 | | - for (const child of children) { |
54 | | - if (child.type !== 'cmp') { |
55 | | - return false; |
56 | | - } |
57 | | - const simpleField = simpleFieldOf(child); |
58 | | - if (simpleField == null || seen.has(simpleField)) { |
| 79 | + for (const slot of simpleSlots(node)) { |
| 80 | + const sf = slotSimpleField(slot); |
| 81 | + if (sf == null || seen.has(sf)) { |
59 | 82 | return false; |
60 | 83 | } |
61 | | - seen.add(simpleField); |
| 84 | + seen.add(sf); |
62 | 85 | } |
63 | 86 | return true; |
64 | 87 | } |
65 | 88 |
|
66 | | -export function getFieldValue(filter: FilterNode | undefined, simpleField: SimpleField): string { |
67 | | - const cmp = simpleCmps(filter).find((c) => matches(c, simpleField)); |
68 | | - return cmp == null ? '' : String(cmp.value); |
69 | | -} |
70 | | - |
71 | 89 | /** |
72 | | - * Immutably sets (or, for a blank value, clears) a simple field on the filter, returning the rebuilt |
73 | | - * flat `and`, or undefined when no fields remain. Other fields keep their position. |
| 90 | + * Compiles the raw simple-field strings into the {@link FilterNode} AST sent to the backend, API and |
| 91 | + * URL. Run only on search: each field splits on commas when `multi`, trims terms, drops empties, |
| 92 | + * coerces numeric/countable terms (dropping non-numbers), and is omitted entirely when nothing valid |
| 93 | + * remains. Several terms become an `or`, a single term a bare cmp. Returns undefined when empty, so |
| 94 | + * an untouched or all-blank builder behaves like no filter. |
74 | 95 | */ |
75 | | -export function setFieldValue( |
76 | | - filter: FilterNode | undefined, |
77 | | - simpleField: SimpleField, |
78 | | - value: string |
79 | | -): FilterNode | undefined { |
80 | | - const children = [...simpleCmps(filter)]; |
81 | | - const idx = children.findIndex((c) => matches(c, simpleField)); |
82 | | - if (value.trim() === '') { |
83 | | - if (idx >= 0) { |
84 | | - children.splice(idx, 1); |
85 | | - } |
86 | | - } else { |
87 | | - // Numeric kinds carry a `number` in the AST; the simple-mode widget hands us its raw string, so |
88 | | - // coerce here (the field's blank state was already handled above). |
89 | | - const kind = FILTER_FIELDS[simpleField.field].kind; |
90 | | - const coerced = kind === 'number' || kind === 'countable' ? Number(value) : value; |
91 | | - const cmp: CmpNode = { |
92 | | - type: 'cmp', |
93 | | - field: simpleField.field, |
94 | | - op: simpleField.op, |
95 | | - value: coerced, |
96 | | - }; |
97 | | - if (idx >= 0) { |
98 | | - children[idx] = cmp; |
99 | | - } else { |
100 | | - children.push(cmp); |
| 96 | +export function compileSimpleFilter(values: ReadonlyMap<string, string>): FilterNode | undefined { |
| 97 | + const children: FilterNode[] = []; |
| 98 | + for (const sf of SIMPLE_FIELDS) { |
| 99 | + const node = compileSlot(sf, values.get(simpleFieldKey(sf)) ?? ''); |
| 100 | + if (node != null) { |
| 101 | + children.push(node); |
101 | 102 | } |
102 | 103 | } |
103 | 104 | return children.length === 0 ? undefined : { type: 'and', children }; |
104 | 105 | } |
105 | 106 |
|
| 107 | +function compileSlot(sf: SimpleField, raw: string): FilterNode | undefined { |
| 108 | + const kind = FILTER_FIELDS[sf.field].kind; |
| 109 | + const numeric = kind === 'number' || kind === 'countable'; |
| 110 | + const terms = sf.multi ? raw.split(',') : [raw]; |
| 111 | + const cmps: CmpNode[] = []; |
| 112 | + for (const term of terms) { |
| 113 | + const trimmed = term.trim(); |
| 114 | + if (trimmed === '') { |
| 115 | + continue; |
| 116 | + } |
| 117 | + let value: string | number = trimmed; |
| 118 | + if (numeric) { |
| 119 | + const n = Number(trimmed); |
| 120 | + // The difficulties widget is a text input, so it can hold non-numeric junk; drop those terms. |
| 121 | + if (!Number.isFinite(n)) { |
| 122 | + continue; |
| 123 | + } |
| 124 | + value = n; |
| 125 | + } |
| 126 | + cmps.push({ type: 'cmp', field: sf.field, op: sf.op, value }); |
| 127 | + } |
| 128 | + if (cmps.length === 0) { |
| 129 | + return undefined; |
| 130 | + } |
| 131 | + return cmps.length === 1 ? cmps[0] : { type: 'or', children: cmps }; |
| 132 | +} |
| 133 | + |
106 | 134 | /** |
107 | | - * Best-effort reduction of an arbitrary tree to its simple-representable parts, used when the user |
108 | | - * switches advanced → simple and accepts discarding the incompatible parts. Keeps the first cmp per |
109 | | - * simple field; `not` subtrees are dropped since a negated clause has no simple form. |
| 135 | + * Decompiles a filter back into raw simple-field strings, for URL rehydration and the |
| 136 | + * advanced -> simple switch. Recognized slots become their field's value (joining a `multi` field's |
| 137 | + * OR terms with commas); the first occurrence per field wins and anything not simple-representable is |
| 138 | + * dropped, matching the switch's discard prompt. |
110 | 139 | */ |
111 | | -export function toSimpleFilter(node: FilterNode | undefined): FilterNode | undefined { |
112 | | - const children: CmpNode[] = []; |
113 | | - const seen = new Set<SimpleField>(); |
| 140 | +export function filterToSimpleValues(node: FilterNode | undefined): Map<string, string> { |
| 141 | + const values = new Map<string, string>(); |
114 | 142 | const visit = (n: FilterNode) => { |
115 | | - if (n.type === 'cmp') { |
116 | | - const simpleField = simpleFieldOf(n); |
117 | | - if (simpleField != null && !seen.has(simpleField)) { |
118 | | - seen.add(simpleField); |
119 | | - children.push({ ...n }); |
| 143 | + const sf = slotSimpleField(n); |
| 144 | + if (sf != null) { |
| 145 | + const key = simpleFieldKey(sf); |
| 146 | + if (!values.has(key)) { |
| 147 | + values.set( |
| 148 | + key, |
| 149 | + slotCmps(n)! |
| 150 | + .map((c) => String(c.value)) |
| 151 | + .join(',') |
| 152 | + ); |
120 | 153 | } |
121 | 154 | return; |
122 | 155 | } |
123 | | - if (n.type === 'not') { |
124 | | - return; |
| 156 | + // Not a recognized slot: salvage simple cmps from inside groups; drop cmps/NOT we can't represent. |
| 157 | + if (n.type === 'and' || n.type === 'or') { |
| 158 | + n.children.forEach(visit); |
125 | 159 | } |
126 | | - n.children.forEach(visit); |
127 | 160 | }; |
128 | | - if (node) { |
| 161 | + if (node != null) { |
129 | 162 | visit(node); |
130 | 163 | } |
131 | | - return children.length === 0 ? undefined : { type: 'and', children }; |
| 164 | + return values; |
132 | 165 | } |
0 commit comments