@karmaniverous/jeeves-server 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tsbuildinfo +1 -1
- package/CHANGELOG.md +49 -27
- package/client/src/components/SearchModal.tsx +533 -222
- package/client/src/lib/api.ts +1 -0
- package/dist/client/assets/{CodeEditor-0XHVI8Nu.js → CodeEditor-DBeIVlfL.js} +1 -1
- package/dist/client/assets/{CodeViewer-CykMVsfX.js → CodeViewer-CbneqN2L.js} +1 -1
- package/dist/client/assets/index-D-RC7ZS6.css +1 -0
- package/dist/client/assets/index-fY6PleHE.js +62 -0
- package/dist/client/index.html +2 -2
- package/dist/src/routes/api/search.js +23 -1
- package/dist/src/routes/api/status.js +7 -1
- package/dist/src/routes/api/status.test.js +1 -1
- package/dist/src/services/eventLog.js +8 -0
- package/guides/api-integration.md +1 -1
- package/guides/deployment.md +1 -1
- package/guides/event-gateway.md +11 -0
- package/package.json +1 -1
- package/src/routes/api/search.ts +22 -1
- package/src/routes/api/status.test.ts +1 -1
- package/src/routes/api/status.ts +52 -41
- package/src/services/eventLog.ts +9 -0
- package/dist/client/assets/index-DbMebkkd.css +0 -1
- package/dist/client/assets/index-LjwgzZ7F.js +0 -62
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useNavigate } from 'react-router-dom';
|
|
3
|
-
import { ChevronDown, ChevronRight, FileText, RotateCcw, Search, X } from 'lucide-react';
|
|
4
|
-
|
|
5
|
-
import { fetchFacets, searchDocuments, type SearchFacet, type SearchResult
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return
|
|
3
|
+
import { ChevronDown, ChevronRight, FileText, Plus, RotateCcw, Search, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import { fetchFacets, searchDocuments, type SearchFacet, type SearchResult } from '@/lib/api';
|
|
6
|
+
|
|
7
|
+
/** Enumerated facets with ≤ this many values render as chips; above → searchable dropdown */
|
|
8
|
+
const CHIP_THRESHOLD = 8;
|
|
9
|
+
|
|
10
|
+
/** Filter out empty, garbage, and unresolved template values */
|
|
11
|
+
function cleanFacetValues(values: string[]): string[] {
|
|
12
|
+
return values.filter((v) => {
|
|
13
|
+
const s = String(v ?? '');
|
|
14
|
+
if (!s || !s.trim()) return false;
|
|
15
|
+
if (s.includes('[object Object]')) return false;
|
|
16
|
+
if (/^\$\{.*\}$/.test(s)) return false;
|
|
17
|
+
return true;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatFieldLabel(field: string): string {
|
|
22
|
+
return field.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface GarbageEntry {
|
|
26
|
+
field: string;
|
|
27
|
+
removed: string[];
|
|
28
|
+
reason: string;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
interface SearchModalProps {
|
|
@@ -27,18 +33,21 @@ interface SearchModalProps {
|
|
|
27
33
|
onClose: () => void;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
// ─── Sub-components ───────────────────────────────────────────────────────
|
|
37
|
+
|
|
30
38
|
function FilterChips({
|
|
31
39
|
label,
|
|
32
40
|
values,
|
|
33
41
|
selected,
|
|
34
42
|
onToggle,
|
|
43
|
+
onRemove,
|
|
35
44
|
}: {
|
|
36
45
|
label: string;
|
|
37
46
|
values: string[];
|
|
38
47
|
selected: Set<string>;
|
|
39
48
|
onToggle: (value: string) => void;
|
|
49
|
+
onRemove: () => void;
|
|
40
50
|
}) {
|
|
41
|
-
if (values.length === 0) return null;
|
|
42
51
|
return (
|
|
43
52
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
44
53
|
<span className="text-xs text-muted-foreground font-medium">{label}:</span>
|
|
@@ -55,21 +64,234 @@ function FilterChips({
|
|
|
55
64
|
{v}
|
|
56
65
|
</button>
|
|
57
66
|
))}
|
|
67
|
+
<button onClick={onRemove} className="text-muted-foreground hover:text-foreground ml-0.5" title={`Remove ${label} filter`}>
|
|
68
|
+
<X className="h-3 w-3" />
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function SearchableSelect({
|
|
75
|
+
label,
|
|
76
|
+
values,
|
|
77
|
+
selected,
|
|
78
|
+
onToggle,
|
|
79
|
+
multi,
|
|
80
|
+
onRemove,
|
|
81
|
+
}: {
|
|
82
|
+
label: string;
|
|
83
|
+
values: string[];
|
|
84
|
+
selected: Set<string>;
|
|
85
|
+
onToggle: (value: string) => void;
|
|
86
|
+
multi: boolean;
|
|
87
|
+
onRemove: () => void;
|
|
88
|
+
}) {
|
|
89
|
+
const [open, setOpen] = useState(false);
|
|
90
|
+
const [filter, setFilter] = useState('');
|
|
91
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!open) return;
|
|
95
|
+
const handler = (e: MouseEvent) => {
|
|
96
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
97
|
+
setOpen(false);
|
|
98
|
+
setFilter('');
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
document.addEventListener('mousedown', handler, true);
|
|
102
|
+
return () => document.removeEventListener('mousedown', handler, true);
|
|
103
|
+
}, [open]);
|
|
104
|
+
|
|
105
|
+
const filtered = filter
|
|
106
|
+
? values.filter((v) => v.toLowerCase().includes(filter.toLowerCase()))
|
|
107
|
+
: values;
|
|
108
|
+
|
|
109
|
+
const selectedLabel =
|
|
110
|
+
selected.size === 0 ? 'All'
|
|
111
|
+
: selected.size <= 2 ? [...selected].join(', ')
|
|
112
|
+
: `${selected.size} selected`;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex items-center gap-1.5" ref={containerRef}>
|
|
116
|
+
<span className="text-xs text-muted-foreground font-medium">{label}:</span>
|
|
117
|
+
<div className="relative">
|
|
118
|
+
<button
|
|
119
|
+
onClick={() => setOpen(!open)}
|
|
120
|
+
className={`text-xs px-2 py-0.5 rounded border transition-colors flex items-center gap-1 min-w-[100px] ${
|
|
121
|
+
selected.size > 0
|
|
122
|
+
? 'bg-primary/10 text-primary border-primary'
|
|
123
|
+
: 'bg-muted text-muted-foreground border-border hover:bg-accent'
|
|
124
|
+
}`}
|
|
125
|
+
>
|
|
126
|
+
<span className="truncate max-w-[200px]">{selectedLabel}</span>
|
|
127
|
+
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
128
|
+
</button>
|
|
129
|
+
{open && (
|
|
130
|
+
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded shadow-lg w-64 max-h-48 flex flex-col">
|
|
131
|
+
<div className="p-1.5 border-b border-border">
|
|
132
|
+
<input
|
|
133
|
+
type="text"
|
|
134
|
+
value={filter}
|
|
135
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
136
|
+
placeholder="Search..."
|
|
137
|
+
className="text-xs w-full px-2 py-1 rounded border border-border bg-muted text-foreground placeholder:text-muted-foreground outline-none focus:border-primary"
|
|
138
|
+
autoFocus
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="overflow-y-auto flex-1">
|
|
142
|
+
{filtered.length === 0 && (
|
|
143
|
+
<div className="px-3 py-2 text-xs text-muted-foreground">No matches</div>
|
|
144
|
+
)}
|
|
145
|
+
{filtered.map((v) => (
|
|
146
|
+
<button
|
|
147
|
+
key={v}
|
|
148
|
+
onClick={() => {
|
|
149
|
+
onToggle(v);
|
|
150
|
+
if (!multi) { setOpen(false); setFilter(''); }
|
|
151
|
+
}}
|
|
152
|
+
className={`w-full text-left text-xs px-3 py-1.5 hover:bg-accent transition-colors flex items-center gap-2 ${
|
|
153
|
+
selected.has(v) ? 'bg-primary/10 text-primary' : 'text-foreground'
|
|
154
|
+
}`}
|
|
155
|
+
>
|
|
156
|
+
{multi && (
|
|
157
|
+
<span className={`w-3 h-3 rounded-sm border flex items-center justify-center shrink-0 ${
|
|
158
|
+
selected.has(v) ? 'bg-primary border-primary' : 'border-border'
|
|
159
|
+
}`}>
|
|
160
|
+
{selected.has(v) && <span className="text-[8px] text-primary-foreground">✓</span>}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
<span className="truncate">{v}</span>
|
|
164
|
+
</button>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
{selected.size > 0 && selected.size <= 5 && [...selected].map((v) => (
|
|
171
|
+
<button
|
|
172
|
+
key={v}
|
|
173
|
+
onClick={() => onToggle(v)}
|
|
174
|
+
className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/30 hover:bg-primary/20 flex items-center gap-0.5"
|
|
175
|
+
title={`Remove ${v}`}
|
|
176
|
+
>
|
|
177
|
+
<span className="truncate max-w-[120px]">{v}</span>
|
|
178
|
+
<X className="h-2.5 w-2.5 shrink-0" />
|
|
179
|
+
</button>
|
|
180
|
+
))}
|
|
181
|
+
<button onClick={onRemove} className="text-muted-foreground hover:text-foreground ml-0.5" title={`Remove ${label} filter`}>
|
|
182
|
+
<X className="h-3 w-3" />
|
|
183
|
+
</button>
|
|
58
184
|
</div>
|
|
59
185
|
);
|
|
60
186
|
}
|
|
61
187
|
|
|
62
|
-
function
|
|
188
|
+
function FacetTextInput({
|
|
189
|
+
label,
|
|
190
|
+
value,
|
|
191
|
+
onChange,
|
|
192
|
+
inputType = 'text',
|
|
193
|
+
onRemove,
|
|
194
|
+
}: {
|
|
195
|
+
label: string;
|
|
196
|
+
value: string;
|
|
197
|
+
onChange: (value: string) => void;
|
|
198
|
+
inputType?: 'text' | 'number';
|
|
199
|
+
onRemove: () => void;
|
|
200
|
+
}) {
|
|
201
|
+
return (
|
|
202
|
+
<div className="flex items-center gap-1.5">
|
|
203
|
+
<span className="text-xs text-muted-foreground font-medium">{label}:</span>
|
|
204
|
+
<input
|
|
205
|
+
type={inputType}
|
|
206
|
+
value={value}
|
|
207
|
+
onChange={(e) => onChange(e.target.value)}
|
|
208
|
+
placeholder={`Filter by ${label.toLowerCase()}...`}
|
|
209
|
+
className="text-xs px-2 py-0.5 rounded border border-border bg-muted text-foreground placeholder:text-muted-foreground w-64 outline-none focus:border-primary"
|
|
210
|
+
/>
|
|
211
|
+
<button onClick={onRemove} className="text-muted-foreground hover:text-foreground" title={`Remove ${label} filter`}>
|
|
212
|
+
<X className="h-3 w-3" />
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Max chips shown in collapsed view */
|
|
219
|
+
const COLLAPSED_CHIP_COUNT = 4;
|
|
220
|
+
|
|
221
|
+
/** Internal fields to exclude from metadata chips */
|
|
222
|
+
const META_INTERNAL_KEYS = new Set([
|
|
223
|
+
'file_path', 'chunk_text', 'chunk_index', 'total_chunks', 'content_hash',
|
|
224
|
+
'embedded_at',
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
interface ResultRowProps {
|
|
228
|
+
result: SearchResult;
|
|
229
|
+
facets: SearchFacet[];
|
|
230
|
+
onNavigate: (path: string) => void;
|
|
231
|
+
onChipClick: (field: string, value: string) => void;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Build sorted chip entries from result metadata × facets. Lowest cardinality first; text/number last. */
|
|
235
|
+
function buildChips(
|
|
236
|
+
result: SearchResult,
|
|
237
|
+
facets: SearchFacet[],
|
|
238
|
+
): Array<{ field: string; label: string; value: string; cardinality: number }> {
|
|
239
|
+
const meta = result.metadata ?? {};
|
|
240
|
+
const chips: Array<{ field: string; label: string; value: string; cardinality: number }> = [];
|
|
241
|
+
const facetMap = new Map(facets.map((f) => [f.field, f]));
|
|
242
|
+
|
|
243
|
+
for (const [key, raw] of Object.entries(meta)) {
|
|
244
|
+
if (META_INTERNAL_KEYS.has(key)) continue;
|
|
245
|
+
const facet = facetMap.get(key);
|
|
246
|
+
if (!facet) continue; // only show facet-connected props
|
|
247
|
+
|
|
248
|
+
const values = Array.isArray(raw) ? raw.map(String) : [String(raw)];
|
|
249
|
+
const isEnum = facet.uiHint !== 'text' && facet.uiHint !== 'number';
|
|
250
|
+
const cardinality = isEnum ? facet.values.length : Infinity;
|
|
251
|
+
|
|
252
|
+
for (const v of values) {
|
|
253
|
+
if (!v || v === 'undefined' || v === 'null') continue;
|
|
254
|
+
chips.push({
|
|
255
|
+
field: key,
|
|
256
|
+
label: formatFieldLabel(key),
|
|
257
|
+
value: v,
|
|
258
|
+
cardinality,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
chips.sort((a, b) => a.cardinality - b.cardinality);
|
|
264
|
+
return chips;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function ResultRow({ result, facets, onNavigate, onChipClick }: ResultRowProps) {
|
|
63
268
|
const [expanded, setExpanded] = useState(false);
|
|
64
269
|
const preview = result.chunks[0]?.text ?? '';
|
|
65
270
|
const truncatedPreview = preview.length > 150 ? preview.slice(0, 150) + '…' : preview;
|
|
66
271
|
|
|
272
|
+
const allChips = buildChips(result, facets);
|
|
273
|
+
const collapsedChips = allChips.slice(0, COLLAPSED_CHIP_COUNT);
|
|
274
|
+
const hasMore = allChips.length > COLLAPSED_CHIP_COUNT;
|
|
275
|
+
|
|
276
|
+
const renderChip = (chip: { field: string; label: string; value: string }, i: number) => {
|
|
277
|
+
const display = chip.value.length > 30 ? chip.value.slice(0, 28) + '…' : chip.value;
|
|
278
|
+
return (
|
|
279
|
+
<button
|
|
280
|
+
key={`${chip.field}-${i}`}
|
|
281
|
+
onClick={(e) => { e.stopPropagation(); onChipClick(chip.field, chip.value); }}
|
|
282
|
+
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-foreground border border-border hover:bg-primary/10 hover:border-primary/30 transition-colors cursor-pointer truncate max-w-[200px]"
|
|
283
|
+
title={`${chip.label}: ${chip.value} — click to filter`}
|
|
284
|
+
>
|
|
285
|
+
<span className="text-muted-foreground">{chip.label}:</span> {display}
|
|
286
|
+
</button>
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
67
290
|
return (
|
|
68
291
|
<div className="border-b border-border last:border-0 px-4 py-2">
|
|
69
292
|
<div className="flex items-start gap-2">
|
|
70
293
|
<FileText className="h-4 w-4 text-zinc-400 shrink-0 mt-1" />
|
|
71
294
|
<div className="min-w-0 flex-1">
|
|
72
|
-
{/* Header row: file name, domain, score, expand toggle */}
|
|
73
295
|
<div className="flex items-center gap-2">
|
|
74
296
|
<button
|
|
75
297
|
onClick={() => onNavigate(`/browse/${result.browsePath}`)}
|
|
@@ -77,11 +299,6 @@ function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (
|
|
|
77
299
|
>
|
|
78
300
|
{result.fileName}
|
|
79
301
|
</button>
|
|
80
|
-
{result.domains && result.domains.length > 0 && (
|
|
81
|
-
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border">
|
|
82
|
-
{result.domains.join(', ')}
|
|
83
|
-
</span>
|
|
84
|
-
)}
|
|
85
302
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
86
303
|
{(result.bestScore * 100).toFixed(0)}%
|
|
87
304
|
</span>
|
|
@@ -104,22 +321,38 @@ function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (
|
|
|
104
321
|
<div className="text-xs text-muted-foreground mt-0.5 break-words leading-relaxed">
|
|
105
322
|
{result.browsePath}
|
|
106
323
|
</div>
|
|
107
|
-
{/* Collapsed
|
|
108
|
-
{!expanded && (
|
|
109
|
-
<div className="
|
|
110
|
-
{
|
|
324
|
+
{/* Collapsed chips */}
|
|
325
|
+
{!expanded && collapsedChips.length > 0 && (
|
|
326
|
+
<div className="flex flex-wrap items-center gap-1 mt-1">
|
|
327
|
+
{collapsedChips.map((c, i) => renderChip(c, i))}
|
|
328
|
+
{hasMore && (
|
|
329
|
+
<button
|
|
330
|
+
onClick={() => setExpanded(true)}
|
|
331
|
+
className="text-[10px] text-blue-500 hover:underline cursor-pointer"
|
|
332
|
+
>+{allChips.length - COLLAPSED_CHIP_COUNT} more</button>
|
|
333
|
+
)}
|
|
111
334
|
</div>
|
|
112
335
|
)}
|
|
113
|
-
{
|
|
336
|
+
{!expanded && collapsedChips.length === 0 && (
|
|
337
|
+
<div className="text-sm text-foreground/70 mt-1 truncate">{truncatedPreview}</div>
|
|
338
|
+
)}
|
|
339
|
+
{/* Expanded: all chips + chunks */}
|
|
114
340
|
{expanded && (
|
|
115
|
-
|
|
116
|
-
{
|
|
117
|
-
<div
|
|
118
|
-
|
|
119
|
-
{chunk.text}
|
|
341
|
+
<>
|
|
342
|
+
{allChips.length > 0 && (
|
|
343
|
+
<div className="flex flex-wrap items-center gap-1 mt-1.5 mb-2">
|
|
344
|
+
{allChips.map((c, i) => renderChip(c, i))}
|
|
120
345
|
</div>
|
|
121
|
-
)
|
|
122
|
-
|
|
346
|
+
)}
|
|
347
|
+
<div className="max-h-48 overflow-y-auto border border-border rounded bg-muted/30 divide-y divide-border">
|
|
348
|
+
{result.chunks.map((chunk, i) => (
|
|
349
|
+
<div key={i} className="px-3 py-2 text-sm text-foreground/80 leading-relaxed">
|
|
350
|
+
<span className="text-[10px] text-muted-foreground mr-2">#{chunk.index}</span>
|
|
351
|
+
{chunk.text}
|
|
352
|
+
</div>
|
|
353
|
+
))}
|
|
354
|
+
</div>
|
|
355
|
+
</>
|
|
123
356
|
)}
|
|
124
357
|
</div>
|
|
125
358
|
</div>
|
|
@@ -127,159 +360,232 @@ function ResultRow({ result, onNavigate }: { result: SearchResult; onNavigate: (
|
|
|
127
360
|
);
|
|
128
361
|
}
|
|
129
362
|
|
|
363
|
+
// ─── Main component ───────────────────────────────────────────────────────
|
|
364
|
+
|
|
130
365
|
export function SearchModal({ open, onClose }: SearchModalProps) {
|
|
131
366
|
const [query, setQuery] = useState('');
|
|
132
367
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
133
|
-
const [metadata, setMetadata] = useState<SearchMetadata>({ domains: [], authors: [], participants: [] });
|
|
134
368
|
const [loading, setLoading] = useState(false);
|
|
135
369
|
const [error, setError] = useState<string | null>(null);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const [extFilter, setExtFilter] = useState<Set<string>>(new Set());
|
|
139
|
-
const [datePreset, setDatePreset] = useState<DatePreset>(null);
|
|
140
|
-
const [dateFrom, setDateFrom] = useState('');
|
|
141
|
-
const [dateTo, setDateTo] = useState('');
|
|
370
|
+
|
|
371
|
+
// Schema-driven facets (lazy-loaded)
|
|
142
372
|
const [facets, setFacets] = useState<SearchFacet[]>([]);
|
|
373
|
+
const [facetsLoading, setFacetsLoading] = useState(false);
|
|
374
|
+
const facetsLoadedRef = useRef(false);
|
|
143
375
|
const [facetSelections, setFacetSelections] = useState<Record<string, Set<string>>>({});
|
|
376
|
+
const [facetTextInputs, setFacetTextInputs] = useState<Record<string, string>>({});
|
|
377
|
+
const [activeFacetFields, setActiveFacetFields] = useState<Set<string>>(new Set());
|
|
378
|
+
const [garbageEntries, setGarbageEntries] = useState<GarbageEntry[]>([]);
|
|
379
|
+
const [showGarbage, setShowGarbage] = useState(false);
|
|
380
|
+
const [addFilterOpen, setAddFilterOpen] = useState(false);
|
|
381
|
+
const [addFilterSearch, setAddFilterSearch] = useState('');
|
|
382
|
+
const addFilterRef = useRef<HTMLDivElement>(null);
|
|
383
|
+
|
|
144
384
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
145
385
|
const navigate = useNavigate();
|
|
146
386
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
147
387
|
|
|
388
|
+
// Close "Add filter" on outside click
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
if (!addFilterOpen) return;
|
|
391
|
+
const handler = (e: MouseEvent) => {
|
|
392
|
+
if (addFilterRef.current && !addFilterRef.current.contains(e.target as Node)) {
|
|
393
|
+
setAddFilterOpen(false);
|
|
394
|
+
setAddFilterSearch('');
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
document.addEventListener('mousedown', handler);
|
|
398
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
399
|
+
}, [addFilterOpen]);
|
|
400
|
+
|
|
401
|
+
const loadFacets = useCallback(async () => {
|
|
402
|
+
if (facetsLoadedRef.current) return;
|
|
403
|
+
facetsLoadedRef.current = true;
|
|
404
|
+
setFacetsLoading(true);
|
|
405
|
+
try {
|
|
406
|
+
const res = await fetchFacets();
|
|
407
|
+
const cleaned: SearchFacet[] = [];
|
|
408
|
+
const garbage: GarbageEntry[] = [];
|
|
409
|
+
for (const f of res.facets) {
|
|
410
|
+
if (f.uiHint === 'hidden') continue;
|
|
411
|
+
// Text/number facets use free-text input — values array is irrelevant
|
|
412
|
+
if (f.uiHint === 'text' || f.uiHint === 'number') {
|
|
413
|
+
cleaned.push({ ...f, values: [] });
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const goodValues = cleanFacetValues(f.values);
|
|
417
|
+
const badValues = f.values.filter((v) => !goodValues.includes(v));
|
|
418
|
+
if (badValues.length > 0) {
|
|
419
|
+
const reasons = badValues.map((v) => {
|
|
420
|
+
if (!v || !String(v).trim()) return 'empty';
|
|
421
|
+
if (String(v).includes('[object Object]')) return 'object-to-string';
|
|
422
|
+
if (/^\$\{.*\}$/.test(String(v))) return 'unresolved-template';
|
|
423
|
+
return 'unknown';
|
|
424
|
+
});
|
|
425
|
+
garbage.push({
|
|
426
|
+
field: f.field,
|
|
427
|
+
removed: badValues.map((v, i) => `${JSON.stringify(v)} (${reasons[i]})`),
|
|
428
|
+
reason: [...new Set(reasons)].join(', '),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (goodValues.length > 0) {
|
|
432
|
+
cleaned.push({ ...f, values: goodValues });
|
|
433
|
+
} else {
|
|
434
|
+
// All values were garbage — include facet with empty values but log it
|
|
435
|
+
garbage.push({
|
|
436
|
+
field: f.field,
|
|
437
|
+
removed: badValues.length > 0 ? ['(all values filtered)'] : ['(no values)'],
|
|
438
|
+
reason: badValues.length > 0 ? 'no valid values remain' : 'empty values array',
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
console.log('[SearchModal] Loaded facets:', cleaned.length, 'clean,', garbage.length, 'garbage entries');
|
|
443
|
+
setFacets(cleaned);
|
|
444
|
+
setGarbageEntries(garbage);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
console.error("Failed to load facets:", err);
|
|
447
|
+
setFacets([]);
|
|
448
|
+
} finally {
|
|
449
|
+
setFacetsLoading(false);
|
|
450
|
+
}
|
|
451
|
+
}, []);
|
|
452
|
+
|
|
148
453
|
useEffect(() => {
|
|
149
454
|
if (open) {
|
|
150
455
|
setTimeout(() => inputRef.current?.focus(), 50);
|
|
151
|
-
|
|
152
|
-
void fetchFacets()
|
|
153
|
-
.then((res) => setFacets(res.facets))
|
|
154
|
-
.catch(() => setFacets([]));
|
|
456
|
+
void loadFacets();
|
|
155
457
|
}
|
|
156
|
-
}, [open]);
|
|
458
|
+
}, [open, loadFacets]);
|
|
157
459
|
|
|
158
460
|
const resetSearch = useCallback(() => {
|
|
159
461
|
setQuery('');
|
|
160
462
|
setResults([]);
|
|
161
|
-
setMetadata({ domains: [], authors: [], participants: [] });
|
|
162
463
|
setError(null);
|
|
163
|
-
setDomainFilter(new Set());
|
|
164
|
-
setAuthorFilter(new Set());
|
|
165
|
-
setExtFilter(new Set());
|
|
166
|
-
setDatePreset(null);
|
|
167
|
-
setDateFrom('');
|
|
168
|
-
setDateTo('');
|
|
169
464
|
setFacetSelections({});
|
|
465
|
+
setFacetTextInputs({});
|
|
466
|
+
setActiveFacetFields(new Set());
|
|
467
|
+
setShowGarbage(false);
|
|
170
468
|
inputRef.current?.focus();
|
|
171
469
|
}, []);
|
|
172
470
|
|
|
173
471
|
const doSearch = useCallback(async (q: string) => {
|
|
174
472
|
if (!q.trim()) {
|
|
175
473
|
setResults([]);
|
|
176
|
-
setMetadata({ domains: [], authors: [], participants: [] });
|
|
177
474
|
return;
|
|
178
475
|
}
|
|
179
476
|
setLoading(true);
|
|
180
477
|
setError(null);
|
|
181
478
|
try {
|
|
182
|
-
// Build Qdrant filter from facet selections
|
|
183
479
|
const mustClauses: Record<string, unknown>[] = [];
|
|
184
480
|
for (const [field, selected] of Object.entries(facetSelections)) {
|
|
185
481
|
if (selected.size > 0) {
|
|
186
|
-
mustClauses.push({
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
482
|
+
mustClauses.push({ key: field, match: { any: [...selected] } });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (const [field, text] of Object.entries(facetTextInputs)) {
|
|
486
|
+
if (text.trim()) {
|
|
487
|
+
mustClauses.push({ key: field, match: { text: text.trim() } });
|
|
190
488
|
}
|
|
191
489
|
}
|
|
192
|
-
const filter = mustClauses.length > 0
|
|
193
|
-
? { must: mustClauses }
|
|
194
|
-
: undefined;
|
|
490
|
+
const filter = mustClauses.length > 0 ? { must: mustClauses } : undefined;
|
|
195
491
|
const res = await searchDocuments(q, 30, filter);
|
|
196
492
|
setResults(res.results);
|
|
197
|
-
setMetadata(res.metadata);
|
|
198
493
|
} catch (err) {
|
|
199
494
|
setError(String(err));
|
|
200
495
|
} finally {
|
|
201
496
|
setLoading(false);
|
|
202
497
|
}
|
|
203
|
-
}, [facetSelections]);
|
|
204
|
-
|
|
205
|
-
const handleInputChange = useCallback(
|
|
206
|
-
(value: string) => {
|
|
207
|
-
setQuery(value);
|
|
208
|
-
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
209
|
-
debounceRef.current = setTimeout(() => void doSearch(value), 400);
|
|
210
|
-
},
|
|
211
|
-
[doSearch],
|
|
212
|
-
);
|
|
498
|
+
}, [facetSelections, facetTextInputs]);
|
|
213
499
|
|
|
214
|
-
const
|
|
215
|
-
(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
[navigate, onClose],
|
|
220
|
-
);
|
|
500
|
+
const handleInputChange = useCallback((value: string) => {
|
|
501
|
+
setQuery(value);
|
|
502
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
503
|
+
debounceRef.current = setTimeout(() => void doSearch(value), 400);
|
|
504
|
+
}, [doSearch]);
|
|
221
505
|
|
|
222
|
-
const
|
|
223
|
-
(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
else next.add(value);
|
|
227
|
-
setFn(next);
|
|
228
|
-
},
|
|
229
|
-
[],
|
|
230
|
-
);
|
|
506
|
+
const handleNavigate = useCallback((path: string) => {
|
|
507
|
+
onClose();
|
|
508
|
+
navigate(path);
|
|
509
|
+
}, [navigate, onClose]);
|
|
231
510
|
|
|
232
|
-
const
|
|
233
|
-
|
|
511
|
+
const handleChipClick = useCallback((field: string, value: string) => {
|
|
512
|
+
// Ensure the facet field is active
|
|
513
|
+
setActiveFacetFields((prev) => new Set([...prev, field]));
|
|
514
|
+
// Find the facet to determine uiHint
|
|
515
|
+
const facet = facets.find((f) => f.field === field);
|
|
516
|
+
if (facet && (facet.uiHint === 'text' || facet.uiHint === 'number')) {
|
|
517
|
+
setFacetTextInputs((prev) => ({ ...prev, [field]: value }));
|
|
518
|
+
} else {
|
|
234
519
|
setFacetSelections((prev) => {
|
|
235
520
|
const current = prev[field] ?? new Set<string>();
|
|
236
521
|
const next = new Set(current);
|
|
237
|
-
|
|
238
|
-
else next.add(value);
|
|
522
|
+
next.add(value);
|
|
239
523
|
return { ...prev, [field]: next };
|
|
240
524
|
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
);
|
|
525
|
+
}
|
|
526
|
+
}, [facets]);
|
|
244
527
|
|
|
245
|
-
|
|
528
|
+
const toggleFacet = useCallback((field: string, value: string) => {
|
|
529
|
+
setFacetSelections((prev) => {
|
|
530
|
+
const current = prev[field] ?? new Set<string>();
|
|
531
|
+
const next = new Set(current);
|
|
532
|
+
if (next.has(value)) next.delete(value); else next.add(value);
|
|
533
|
+
return { ...prev, [field]: next };
|
|
534
|
+
});
|
|
535
|
+
}, []);
|
|
536
|
+
|
|
537
|
+
const addFacetField = useCallback((field: string) => {
|
|
538
|
+
setActiveFacetFields((prev) => new Set([...prev, field]));
|
|
539
|
+
}, []);
|
|
540
|
+
|
|
541
|
+
const removeFacetField = useCallback((field: string) => {
|
|
542
|
+
setActiveFacetFields((prev) => { const n = new Set(prev); n.delete(field); return n; });
|
|
543
|
+
setFacetSelections((prev) => { const n = { ...prev }; delete n[field]; return n; });
|
|
544
|
+
setFacetTextInputs((prev) => { const n = { ...prev }; delete n[field]; return n; });
|
|
545
|
+
}, []);
|
|
546
|
+
|
|
547
|
+
// Re-search when selections change
|
|
246
548
|
useEffect(() => {
|
|
247
|
-
if (query.trim())
|
|
248
|
-
void doSearch(query);
|
|
249
|
-
}
|
|
549
|
+
if (query.trim()) void doSearch(query);
|
|
250
550
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
251
|
-
}, [facetSelections]);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
if (effectiveDateFrom && r.mtime) {
|
|
276
|
-
if (new Date(r.mtime) < effectiveDateFrom) return false;
|
|
551
|
+
}, [facetSelections, facetTextInputs]);
|
|
552
|
+
|
|
553
|
+
const activeFacets = facets.filter((f) => activeFacetFields.has(f.field));
|
|
554
|
+
const inactiveFacets = facets.filter((f) => !activeFacetFields.has(f.field));
|
|
555
|
+
const filteredInactive = addFilterSearch
|
|
556
|
+
? inactiveFacets.filter((f) => formatFieldLabel(f.field).toLowerCase().includes(addFilterSearch.toLowerCase()))
|
|
557
|
+
: inactiveFacets;
|
|
558
|
+
|
|
559
|
+
function renderFacet(f: SearchFacet) {
|
|
560
|
+
const label = formatFieldLabel(f.field);
|
|
561
|
+
const remove = () => removeFacetField(f.field);
|
|
562
|
+
|
|
563
|
+
if (f.uiHint === 'text' || f.uiHint === 'number') {
|
|
564
|
+
return (
|
|
565
|
+
<FacetTextInput
|
|
566
|
+
key={f.field}
|
|
567
|
+
label={label}
|
|
568
|
+
value={facetTextInputs[f.field] ?? ''}
|
|
569
|
+
onChange={(v) => setFacetTextInputs((prev) => ({ ...prev, [f.field]: v }))}
|
|
570
|
+
inputType={f.uiHint === 'number' ? 'number' : 'text'}
|
|
571
|
+
onRemove={remove}
|
|
572
|
+
/>
|
|
573
|
+
);
|
|
277
574
|
}
|
|
278
|
-
|
|
279
|
-
|
|
575
|
+
|
|
576
|
+
const sel = facetSelections[f.field] ?? new Set<string>();
|
|
577
|
+
if (f.values.length <= CHIP_THRESHOLD) {
|
|
578
|
+
return (
|
|
579
|
+
<FilterChips key={f.field} label={label} values={f.values} selected={sel}
|
|
580
|
+
onToggle={(v) => toggleFacet(f.field, v)} onRemove={remove} />
|
|
581
|
+
);
|
|
280
582
|
}
|
|
281
|
-
|
|
282
|
-
|
|
583
|
+
|
|
584
|
+
return (
|
|
585
|
+
<SearchableSelect key={f.field} label={label} values={f.values} selected={sel}
|
|
586
|
+
onToggle={(v) => toggleFacet(f.field, v)} multi={f.uiHint === 'multiselect'} onRemove={remove} />
|
|
587
|
+
);
|
|
588
|
+
}
|
|
283
589
|
|
|
284
590
|
if (!open) return null;
|
|
285
591
|
|
|
@@ -313,112 +619,117 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
|
|
313
619
|
</button>
|
|
314
620
|
</div>
|
|
315
621
|
|
|
316
|
-
{/*
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
/>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
type="month"
|
|
374
|
-
value={dateFrom}
|
|
375
|
-
onChange={(e) => setDateFrom(e.target.value ? e.target.value + '-01' : '')}
|
|
376
|
-
className="text-xs px-1.5 py-0.5 rounded border border-border bg-muted text-foreground w-28"
|
|
377
|
-
placeholder="From"
|
|
378
|
-
/>
|
|
379
|
-
<span className="text-xs text-muted-foreground">to</span>
|
|
380
|
-
<input
|
|
381
|
-
type="month"
|
|
382
|
-
value={dateTo}
|
|
383
|
-
onChange={(e) => setDateTo(e.target.value ? e.target.value + '-28' : '')}
|
|
384
|
-
className="text-xs px-1.5 py-0.5 rounded border border-border bg-muted text-foreground w-28"
|
|
385
|
-
placeholder="To"
|
|
386
|
-
/>
|
|
387
|
-
</>
|
|
622
|
+
{/* Active facet filters + Add filter button */}
|
|
623
|
+
<div className="px-4 py-2 border-b border-border flex flex-col gap-1.5">
|
|
624
|
+
{activeFacets.map(renderFacet)}
|
|
625
|
+
<div className="flex items-center gap-2">
|
|
626
|
+
<div className="relative" ref={addFilterRef}>
|
|
627
|
+
<button
|
|
628
|
+
onClick={() => {
|
|
629
|
+
if (!addFilterOpen) void loadFacets();
|
|
630
|
+
setAddFilterOpen(!addFilterOpen);
|
|
631
|
+
}}
|
|
632
|
+
className="text-xs px-2 py-0.5 rounded border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground transition-colors flex items-center gap-1"
|
|
633
|
+
>
|
|
634
|
+
<Plus className="h-3 w-3" />
|
|
635
|
+
{facetsLoading ? 'Loading filters...' : 'Add filter'}
|
|
636
|
+
</button>
|
|
637
|
+
{addFilterOpen && (
|
|
638
|
+
<div className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded shadow-lg w-56 max-h-48 flex flex-col">
|
|
639
|
+
<div className="p-1.5 border-b border-border">
|
|
640
|
+
<input
|
|
641
|
+
type="text"
|
|
642
|
+
value={addFilterSearch}
|
|
643
|
+
onChange={(e) => setAddFilterSearch(e.target.value)}
|
|
644
|
+
placeholder="Search filters..."
|
|
645
|
+
className="text-xs w-full px-2 py-1 rounded border border-border bg-muted text-foreground placeholder:text-muted-foreground outline-none focus:border-primary"
|
|
646
|
+
autoFocus
|
|
647
|
+
/>
|
|
648
|
+
</div>
|
|
649
|
+
<div className="overflow-y-auto flex-1">
|
|
650
|
+
{facetsLoading && (
|
|
651
|
+
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
|
|
652
|
+
<div className="h-3 w-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
653
|
+
Loading...
|
|
654
|
+
</div>
|
|
655
|
+
)}
|
|
656
|
+
{!facetsLoading && filteredInactive.length === 0 && (
|
|
657
|
+
<div className="px-3 py-2 text-xs text-muted-foreground">
|
|
658
|
+
{facets.length === 0 ? 'No filters available' : 'All filters active'}
|
|
659
|
+
</div>
|
|
660
|
+
)}
|
|
661
|
+
{filteredInactive.map((f) => (
|
|
662
|
+
<button
|
|
663
|
+
key={f.field}
|
|
664
|
+
onClick={() => {
|
|
665
|
+
addFacetField(f.field);
|
|
666
|
+
setAddFilterOpen(false);
|
|
667
|
+
setAddFilterSearch('');
|
|
668
|
+
}}
|
|
669
|
+
className="w-full text-left text-xs px-3 py-1.5 hover:bg-accent transition-colors flex items-center justify-between"
|
|
670
|
+
>
|
|
671
|
+
<span className="text-foreground">{formatFieldLabel(f.field)}</span>
|
|
672
|
+
<span className="text-[10px] text-muted-foreground">
|
|
673
|
+
{f.uiHint === 'text' || f.uiHint === 'number' ? f.uiHint : `${f.values.length}`}
|
|
674
|
+
</span>
|
|
675
|
+
</button>
|
|
676
|
+
))}
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
388
679
|
)}
|
|
389
680
|
</div>
|
|
681
|
+
{garbageEntries.length > 0 && (
|
|
682
|
+
<button
|
|
683
|
+
onClick={() => setShowGarbage(!showGarbage)}
|
|
684
|
+
className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 border border-amber-500/30 hover:bg-amber-500/20 transition-colors"
|
|
685
|
+
title="Show inference rule issues"
|
|
686
|
+
>
|
|
687
|
+
{garbageEntries.length} issue{garbageEntries.length !== 1 ? 's' : ''}
|
|
688
|
+
</button>
|
|
689
|
+
)}
|
|
390
690
|
</div>
|
|
391
|
-
|
|
691
|
+
{showGarbage && garbageEntries.length > 0 && (
|
|
692
|
+
<div className="mt-1 p-2 rounded border border-amber-500/30 bg-amber-500/5 text-xs max-h-32 overflow-y-auto">
|
|
693
|
+
<div className="text-amber-600 font-medium mb-1">Filtered facet values (inference rule issues):</div>
|
|
694
|
+
{garbageEntries.map((g) => (
|
|
695
|
+
<div key={g.field} className="mb-1">
|
|
696
|
+
<span className="text-foreground font-medium">{formatFieldLabel(g.field)}</span>
|
|
697
|
+
<span className="text-muted-foreground">: </span>
|
|
698
|
+
{g.removed.map((r, i) => (
|
|
699
|
+
<span key={i} className="text-amber-700">
|
|
700
|
+
{i > 0 && ', '}
|
|
701
|
+
<code className="bg-amber-500/10 px-0.5 rounded">{r}</code>
|
|
702
|
+
</span>
|
|
703
|
+
))}
|
|
704
|
+
</div>
|
|
705
|
+
))}
|
|
706
|
+
</div>
|
|
707
|
+
)}
|
|
708
|
+
</div>
|
|
392
709
|
|
|
393
710
|
{/* Results */}
|
|
394
711
|
<div className="overflow-y-auto flex-1">
|
|
395
|
-
{error &&
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
{!error && filtered.length === 0 && query.trim() && !loading && (
|
|
399
|
-
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
|
|
400
|
-
No results found
|
|
401
|
-
</div>
|
|
712
|
+
{error && <div className="px-4 py-3 text-sm text-red-500">{error}</div>}
|
|
713
|
+
{!error && results.length === 0 && query.trim() && !loading && (
|
|
714
|
+
<div className="px-4 py-8 text-center text-muted-foreground text-sm">No results found</div>
|
|
402
715
|
)}
|
|
403
716
|
{!error && !query.trim() && !loading && (
|
|
404
717
|
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
|
|
405
718
|
Type a query to search across all documents
|
|
406
719
|
</div>
|
|
407
720
|
)}
|
|
408
|
-
{
|
|
409
|
-
<ResultRow key={r.browsePath} result={r} onNavigate={handleNavigate} />
|
|
721
|
+
{results.map((r) => (
|
|
722
|
+
<ResultRow key={r.browsePath} result={r} facets={facets} onNavigate={handleNavigate} onChipClick={handleChipClick} />
|
|
410
723
|
))}
|
|
411
724
|
</div>
|
|
412
725
|
|
|
413
726
|
{/* Footer */}
|
|
414
|
-
{
|
|
727
|
+
{results.length > 0 && (
|
|
415
728
|
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground">
|
|
416
|
-
{
|
|
417
|
-
{filtered.length < results.length && ` (${results.length} total, ${results.length - filtered.length} filtered)`}
|
|
729
|
+
{results.length} result{results.length !== 1 ? 's' : ''}
|
|
418
730
|
</div>
|
|
419
731
|
)}
|
|
420
732
|
</div>
|
|
421
733
|
</div>
|
|
422
734
|
);
|
|
423
735
|
}
|
|
424
|
-
|