@crvouga/atlas 1.4.0 → 1.6.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/CHANGELOG.md +12 -0
- package/docs/ui.md +32 -2
- package/llms-full.txt +32 -2
- package/package.json +1 -1
- package/packages/visualizer/README.md +28 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +17 -8
- package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
- package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
- package/packages/visualizer/src/evidence/behavior.ts +268 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
- package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
- package/packages/visualizer/src/evidence/gherkin.ts +31 -1
- package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
- package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
- package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
- package/packages/visualizer/src/explorer/catalog.tsx +9 -5
- package/packages/visualizer/src/explorer/search-notes.tsx +74 -0
- package/packages/visualizer/src/explorer/search.ts +16 -6
- package/packages/visualizer/src/find/Finder.tsx +127 -81
- package/packages/visualizer/src/find/behaviors.ts +205 -0
- package/packages/visualizer/src/find/corpus.ts +21 -7
- package/packages/visualizer/src/find/doc-search.ts +66 -0
- package/packages/visualizer/src/find/query.ts +247 -0
- package/packages/visualizer/src/find/rank.ts +195 -47
- package/packages/visualizer/src/find/search.test.ts +322 -0
- package/packages/visualizer/src/find/semantic-search.ts +153 -0
- package/packages/visualizer/src/find/semantic.ts +2 -2
|
@@ -5,6 +5,8 @@ import type { AtlasView, ItemStatus } from '../data/model';
|
|
|
5
5
|
import { worstOf } from '../data/model/status';
|
|
6
6
|
import { featureModel, scenarioLabel, slotsByScenario } from '../evidence/gherkin';
|
|
7
7
|
import { kindOfFile, outlinePath, specFile } from '../spec-kind';
|
|
8
|
+
import type { TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
9
|
+
import { testFacets, testFields } from './behaviors';
|
|
8
10
|
import type { FindDoc, FindField, FindStatus, FindTarget } from './rank';
|
|
9
11
|
|
|
10
12
|
export type CorpusChart = { readonly id: string; readonly name: string };
|
|
@@ -67,6 +69,8 @@ export type CorpusSource = {
|
|
|
67
69
|
readonly texts: ReadonlyMap<string, string>;
|
|
68
70
|
/** The project home is the test explorer (no statecharts): a check without a published spec opens there. */
|
|
69
71
|
readonly explorer?: boolean;
|
|
72
|
+
/** The report's tests by key. A check that is one of them is searched by the same fields and filters as in the test list. */
|
|
73
|
+
readonly tests?: ReadonlyMap<string, TestSummary>;
|
|
70
74
|
};
|
|
71
75
|
|
|
72
76
|
function itemStatus(status: ItemStatus): FindStatus {
|
|
@@ -130,9 +134,17 @@ function actionsIn(text: string): string[] {
|
|
|
130
134
|
return [...new Set([...text.matchAll(ACTION)].map((match) => match[1]!))];
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
|
|
137
|
+
/** A check's fields: those the test list searches, else what the check itself says. */
|
|
138
|
+
function checkFields(slot: CorpusSlot, title: string, file: string, test: TestSummary | undefined): { fields: FindField[]; facets?: FindDoc['facets'] } {
|
|
139
|
+
if (!test) return { fields: fieldsOf(title, '', slot.detail, file) };
|
|
140
|
+
const fields = testFields(test);
|
|
141
|
+
return { fields: title === test.title ? fields : [{ name: 'title', text: title }, ...fields], facets: testFacets(test) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: string, explorer = false, test?: TestSummary): FindDoc {
|
|
145
|
+
const { fields, facets } = checkFields(slot, title, file, test);
|
|
134
146
|
if (explorer) {
|
|
135
|
-
return { id: `run:${slot.key}`, group: 'Runs', kind: 'run', title, subtitle: file, fields
|
|
147
|
+
return { id: `run:${slot.key}`, group: 'Runs', kind: 'run', title, subtitle: file, fields, status: slot.status, related: [], target: { to: '/', search: { test: slot.key } }, recording: null, ...(facets ? { facets } : {}) };
|
|
136
148
|
}
|
|
137
149
|
const recording = timelineTarget(kind, file, [slot]);
|
|
138
150
|
return {
|
|
@@ -141,12 +153,13 @@ function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: stri
|
|
|
141
153
|
kind: 'run',
|
|
142
154
|
title,
|
|
143
155
|
subtitle: file,
|
|
144
|
-
fields
|
|
156
|
+
fields,
|
|
145
157
|
status: slot.status,
|
|
146
158
|
related: [],
|
|
147
159
|
// Enter opens the recording when this check has a timeline.
|
|
148
160
|
target: recording ?? (kind ? specTarget(kind, file, slot.id) : { to: '/' as const, search: {} }),
|
|
149
|
-
recording
|
|
161
|
+
recording,
|
|
162
|
+
...(facets ? { facets } : {})
|
|
150
163
|
};
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -231,7 +244,7 @@ export function buildCorpus(source: CorpusSource): FindDoc[] {
|
|
|
231
244
|
const id = `run:${slot.key}`;
|
|
232
245
|
if (seenRuns.has(id)) return;
|
|
233
246
|
seenRuns.add(id);
|
|
234
|
-
docs.push(runDoc(slot, title, kind, file, explorer));
|
|
247
|
+
docs.push(runDoc(slot, title, kind, file, explorer, source.tests?.get(slot.key)));
|
|
235
248
|
};
|
|
236
249
|
|
|
237
250
|
for (const file of source.files) {
|
|
@@ -326,7 +339,7 @@ export function buildCorpus(source: CorpusSource): FindDoc[] {
|
|
|
326
339
|
}
|
|
327
340
|
|
|
328
341
|
/** The view and the evidence the page already holds, in the shape `buildCorpus` reads. */
|
|
329
|
-
export function corpusFrom(view: AtlasView | null, files: readonly string[], titleOf: (file: string) => string, evidence: EvidenceView, texts: ReadonlyMap<string, string>): FindDoc[] {
|
|
342
|
+
export function corpusFrom(view: AtlasView | null, files: readonly string[], titleOf: (file: string) => string, evidence: EvidenceView, texts: ReadonlyMap<string, string>, tests?: ReadonlyMap<string, TestSummary>): FindDoc[] {
|
|
330
343
|
const slots: CorpusSlot[] = [];
|
|
331
344
|
const take = (slot: CheckSlot) => {
|
|
332
345
|
slots.push({
|
|
@@ -370,6 +383,7 @@ export function corpusFrom(view: AtlasView | null, files: readonly string[], tit
|
|
|
370
383
|
files: files.map((path) => ({ path, title: titleOf(path) })),
|
|
371
384
|
slots,
|
|
372
385
|
texts,
|
|
373
|
-
explorer: !files.some(isStatechartFile)
|
|
386
|
+
explorer: !files.some(isStatechartFile),
|
|
387
|
+
...(tests ? { tests } : {})
|
|
374
388
|
});
|
|
375
389
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Searching a list of documents
|
|
3
|
+
*
|
|
4
|
+
* What the test list, the runs list and the results of an opened run call to narrow themselves by a typed query. It is
|
|
5
|
+
* the finder's engine (`rank.ts`) applied to a whole list: every document that is eligible for the words, phrases and
|
|
6
|
+
* identifiers of the query, best first, with the reason it matched; meaning-based additions when an embedder is
|
|
7
|
+
* available. The structured filters (`status:`, `has:video`…) are applied by the caller with `passesFilters`, so a
|
|
8
|
+
* count can leave one of them out; nothing here limits or pages the result.
|
|
9
|
+
*/
|
|
10
|
+
import { useMemo } from 'react';
|
|
11
|
+
|
|
12
|
+
import { applySemantic } from './semantic';
|
|
13
|
+
import { indexCorpus, matchText, type FindDoc, type FindIndex, type Ranked } from './rank';
|
|
14
|
+
import { parseSearch, passesFilters, type ParsedSearch } from './query';
|
|
15
|
+
import { useSemantic, type SemanticStatus } from './semantic-search';
|
|
16
|
+
|
|
17
|
+
/** The semantic part of a search to add to the words, when there is one. */
|
|
18
|
+
export type SemanticInput = {
|
|
19
|
+
readonly queryVector: Float32Array | null;
|
|
20
|
+
readonly vectors: ReadonlyMap<string, Float32Array>;
|
|
21
|
+
/** The documents a paraphrase may add (those that pass the filters). */
|
|
22
|
+
readonly docs: readonly FindDoc[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every document eligible for the text of a search (words, quoted phrases, identifiers), best first, by document id; null
|
|
27
|
+
* when the search has no text. A document missing a distinctive word is not eligible. With a semantic input, documents
|
|
28
|
+
* close in meaning are added (not when the search asks for an exact phrase or identifier).
|
|
29
|
+
*/
|
|
30
|
+
export function searchList(index: FindIndex, parsed: ParsedSearch, semantic?: SemanticInput): Map<string, Ranked> | null {
|
|
31
|
+
const lexical = matchText(index, parsed, { snippets: false });
|
|
32
|
+
if (lexical === null) return null;
|
|
33
|
+
const plain: Ranked[] = lexical.map(({ order: _order, misses: _misses, ...hit }) => hit);
|
|
34
|
+
const literal = parsed.phrases.length + parsed.identifiers.length > 0;
|
|
35
|
+
const list = semantic?.queryVector && !literal ? applySemantic(semantic.docs, plain, semantic.queryVector, semantic.vectors, Number.POSITIVE_INFINITY) : plain;
|
|
36
|
+
return new Map(list.map((hit) => [hit.doc.id, hit]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What {@link useDocSearch} reads out. */
|
|
40
|
+
export type DocSearch = {
|
|
41
|
+
readonly parsed: ParsedSearch;
|
|
42
|
+
/** Eligible documents by id, best first; null when the query has no text to match (it may still have filters). */
|
|
43
|
+
readonly hits: ReadonlyMap<string, Ranked> | null;
|
|
44
|
+
/** How the meaning-based part stands: idle when there is nothing to match by meaning. */
|
|
45
|
+
readonly status: SemanticStatus;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parses `query` and searches `docs` with it: lexical results at once, meaning-based additions when they arrive. The
|
|
50
|
+
* index is only built when the query has text.
|
|
51
|
+
*/
|
|
52
|
+
export function useDocSearch(docs: readonly FindDoc[], query: string | undefined): DocSearch {
|
|
53
|
+
const parsed = useMemo(() => parseSearch(query ?? ''), [query]);
|
|
54
|
+
const hasText = parsed.words.length > 0 || parsed.phrases.length > 0 || parsed.identifiers.length > 0;
|
|
55
|
+
const index = useMemo(() => (hasText ? indexCorpus(docs) : null), [docs, hasText]);
|
|
56
|
+
const lexical = useMemo(() => (index ? searchList(index, parsed) : null), [index, parsed]);
|
|
57
|
+
const candidates = useMemo(() => (hasText ? docs.filter((doc) => passesFilters(doc.facets, parsed.filters)) : []), [docs, parsed, hasText]);
|
|
58
|
+
const lexicalDocs = useMemo(() => (lexical ? [...lexical.values()].map((hit) => hit.doc) : []), [lexical]);
|
|
59
|
+
const wantsMeaning = hasText && parsed.words.length > 0 && parsed.phrases.length + parsed.identifiers.length === 0;
|
|
60
|
+
const semantic = useSemantic(wantsMeaning, parsed.words, candidates, lexicalDocs);
|
|
61
|
+
const hits = useMemo(
|
|
62
|
+
() => (index ? searchList(index, parsed, { queryVector: semantic.queryVector, vectors: semantic.vectors, docs: candidates }) : null),
|
|
63
|
+
[index, parsed, semantic.queryVector, semantic.vectors, candidates]
|
|
64
|
+
);
|
|
65
|
+
return { parsed, hits, status: wantsMeaning ? semantic.status : 'idle' };
|
|
66
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Search query language
|
|
3
|
+
*
|
|
4
|
+
* One reading of a search box, shared by the finder, the test list, the runs list and the results of one run, so a
|
|
5
|
+
* sentence means the same thing wherever it is typed:
|
|
6
|
+
*
|
|
7
|
+
* - plain words are matched by meaning, spelling and word form (see `rank.ts`), and every distinctive word must be found;
|
|
8
|
+
* - `"a quoted phrase"` and identifiers that contain `/`, `_` or `.` (`checkout/save.feature`) are matched literally and
|
|
9
|
+
* are reported as exact matches;
|
|
10
|
+
* - `field:value` narrows by structure instead of matching words: `status:failed`, `env:ios` (or `platform:`,
|
|
11
|
+
* `environment:`, `device:`), `producer:`, `domain:`, `spec:`, `tag:`, `branch:` and `has:video`. A value with spaces is
|
|
12
|
+
* quoted (`domain:"user account"`). Several values of one field (`status:failed,flaky`, or the field twice) match any of
|
|
13
|
+
* them; different fields must all hold.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here is ever dropped silently: a word it cannot use as a filter (`colour:red`, `status:great`, `has:smell`) is
|
|
16
|
+
* returned as an {@link SearchIssue} for the interface to show, and it filters nothing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The filters a search can carry. Each list matches any of its values; the lists combine with AND. */
|
|
20
|
+
export type SearchFilters = {
|
|
21
|
+
readonly status: readonly string[];
|
|
22
|
+
readonly env: readonly string[];
|
|
23
|
+
readonly producer: readonly string[];
|
|
24
|
+
readonly domain: readonly string[];
|
|
25
|
+
readonly spec: readonly string[];
|
|
26
|
+
readonly tag: readonly string[];
|
|
27
|
+
readonly branch: readonly string[];
|
|
28
|
+
/** `has:video`: only what has a playable video recording. */
|
|
29
|
+
readonly video: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** One of the filters a search can carry, by the name a person types before the colon. */
|
|
33
|
+
export type FilterKey = Exclude<keyof SearchFilters, 'video'> | 'video';
|
|
34
|
+
|
|
35
|
+
/** One filter as typed, for showing as a removable chip. */
|
|
36
|
+
export type FilterToken = {
|
|
37
|
+
readonly key: FilterKey;
|
|
38
|
+
/** The value (`failed`), or `video` for `has:video`. */
|
|
39
|
+
readonly value: string;
|
|
40
|
+
/** The text to remove from the query to remove this filter. */
|
|
41
|
+
readonly text: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Something in the query that could not be used, and what to type instead. */
|
|
45
|
+
export type SearchIssue = {
|
|
46
|
+
/** The text as typed. */
|
|
47
|
+
readonly token: string;
|
|
48
|
+
/** A sentence for the person, naming what is supported. */
|
|
49
|
+
readonly message: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A query read into what it asks for. */
|
|
53
|
+
export type ParsedSearch = {
|
|
54
|
+
/** The query as typed. */
|
|
55
|
+
readonly raw: string;
|
|
56
|
+
/** The loose words to match by meaning and spelling (quoted phrases, identifiers and filters removed). */
|
|
57
|
+
readonly words: string;
|
|
58
|
+
/** Quoted phrases, each of which must appear literally. */
|
|
59
|
+
readonly phrases: readonly string[];
|
|
60
|
+
/** Identifier-like words (`lists/add`, `save_answer`, `a.feature`), each of which must appear literally. */
|
|
61
|
+
readonly identifiers: readonly string[];
|
|
62
|
+
readonly filters: SearchFilters;
|
|
63
|
+
/** The filters in the order typed. */
|
|
64
|
+
readonly tokens: readonly FilterToken[];
|
|
65
|
+
readonly issues: readonly SearchIssue[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The statuses a `status:` filter accepts. */
|
|
69
|
+
export const STATUS_VALUES = ['passed', 'failed', 'flaky', 'not-run', 'running'] as const;
|
|
70
|
+
|
|
71
|
+
/** The `has:` values a search accepts. */
|
|
72
|
+
export const HAS_VALUES = ['video'] as const;
|
|
73
|
+
|
|
74
|
+
const FIELD_ALIASES: Readonly<Record<string, Exclude<FilterKey, 'video'>>> = {
|
|
75
|
+
status: 'status',
|
|
76
|
+
env: 'env',
|
|
77
|
+
environment: 'env',
|
|
78
|
+
platform: 'env',
|
|
79
|
+
device: 'env',
|
|
80
|
+
producer: 'producer',
|
|
81
|
+
domain: 'domain',
|
|
82
|
+
spec: 'spec',
|
|
83
|
+
tag: 'tag',
|
|
84
|
+
branch: 'branch'
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The operator names a search understands, for guidance. */
|
|
88
|
+
export const OPERATOR_NAMES: readonly string[] = [...Object.keys(FIELD_ALIASES), 'has'];
|
|
89
|
+
|
|
90
|
+
/** A sentence that lists what can be typed, shown with every issue. */
|
|
91
|
+
export const SYNTAX_HELP = 'Filters: status:failed, env:ios, producer:, domain:, spec:, tag:, branch: and has:video. Quote phrases: "saved answer".';
|
|
92
|
+
|
|
93
|
+
/** A search with nothing in it. */
|
|
94
|
+
export const NO_FILTERS: SearchFilters = { status: [], env: [], producer: [], domain: [], spec: [], tag: [], branch: [], video: false };
|
|
95
|
+
|
|
96
|
+
const OPERATOR = /^([A-Za-z][A-Za-z-]*):(.*)$/;
|
|
97
|
+
const IDENTIFIER = /[A-Za-z0-9][/_.][A-Za-z0-9]/;
|
|
98
|
+
|
|
99
|
+
/** The pieces of a query: quoted strings (optionally after `name:`) and runs of non-space characters. */
|
|
100
|
+
function pieces(query: string): { readonly text: string; readonly quoted: boolean; readonly name?: string }[] {
|
|
101
|
+
const out: { text: string; quoted: boolean; name?: string }[] = [];
|
|
102
|
+
const pattern = /(?:([A-Za-z][A-Za-z-]*):)?"([^"]*)"|(\S+)/g;
|
|
103
|
+
for (const match of query.matchAll(pattern)) {
|
|
104
|
+
if (match[3] !== undefined) out.push({ text: match[3], quoted: false });
|
|
105
|
+
else out.push({ text: match[2] ?? '', quoted: true, ...(match[1] ? { name: match[1] } : {}) });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Reads a query: see the module comment. Never throws; whatever it cannot use is in `issues`. */
|
|
111
|
+
export function parseSearch(query: string): ParsedSearch {
|
|
112
|
+
const words: string[] = [];
|
|
113
|
+
const phrases: string[] = [];
|
|
114
|
+
const identifiers: string[] = [];
|
|
115
|
+
const issues: SearchIssue[] = [];
|
|
116
|
+
const tokens: FilterToken[] = [];
|
|
117
|
+
const lists: Record<Exclude<FilterKey, 'video'>, string[]> = { status: [], env: [], producer: [], domain: [], spec: [], tag: [], branch: [] };
|
|
118
|
+
let video = false;
|
|
119
|
+
const issue = (token: string, message: string) => issues.push({ token, message: `${message} ${SYNTAX_HELP}` });
|
|
120
|
+
|
|
121
|
+
for (const piece of pieces(query)) {
|
|
122
|
+
const operator = piece.name !== undefined ? ([piece.name, piece.text] as const) : !piece.quoted ? OPERATOR.exec(piece.text)?.slice(1, 3) : undefined;
|
|
123
|
+
if (piece.quoted && piece.name === undefined) {
|
|
124
|
+
const phrase = piece.text.trim().toLowerCase();
|
|
125
|
+
if (phrase) phrases.push(phrase);
|
|
126
|
+
else issue('""', 'is an empty phrase, which matches nothing.');
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (operator) {
|
|
130
|
+
const name = operator[0].toLowerCase();
|
|
131
|
+
const original = piece.quoted ? `${operator[0]}:"${operator[1]}"` : piece.text;
|
|
132
|
+
const rawValues = piece.quoted ? [operator[1]!] : operator[1]!.split(',');
|
|
133
|
+
const values = rawValues.map((value) => value.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
|
134
|
+
if (name === 'has') {
|
|
135
|
+
if (!values.length) issue(original, 'needs a value after the colon (has:video).');
|
|
136
|
+
for (const value of values) {
|
|
137
|
+
if (value.toLowerCase() === 'video') {
|
|
138
|
+
video = true;
|
|
139
|
+
tokens.push({ key: 'video', value: 'video', text: original });
|
|
140
|
+
} else issue(original, 'is not supported (only has:video).');
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const key = FIELD_ALIASES[name];
|
|
145
|
+
if (!key && !piece.quoted && !values.length) {
|
|
146
|
+
// Prose such as "Done:" is not a filter.
|
|
147
|
+
words.push(piece.text.replace(/:$/, ''));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!key) {
|
|
151
|
+
issue(original, 'is not a filter.');
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!values.length) {
|
|
155
|
+
issue(original, 'needs a value after the colon.');
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const raw of values) {
|
|
159
|
+
const value = key === 'tag' ? raw.replace(/^#/, '').toLowerCase() : raw.toLowerCase();
|
|
160
|
+
if (key === 'status' && !(STATUS_VALUES as readonly string[]).includes(value)) {
|
|
161
|
+
issue(original, `is not a status (use ${STATUS_VALUES.join(', ')}).`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!lists[key].includes(value)) lists[key].push(value);
|
|
165
|
+
tokens.push({ key, value, text: original });
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (piece.text.startsWith('"')) {
|
|
170
|
+
issue(piece.text, 'has a quote that is never closed, so its words are matched as ordinary words.');
|
|
171
|
+
words.push(piece.text.replace(/"/g, ' '));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const bare = piece.text.replace(/^[("']+|[)"',;!?]+$/g, '').replace(/\.+$/, '');
|
|
175
|
+
if (IDENTIFIER.test(bare)) identifiers.push(bare.toLowerCase());
|
|
176
|
+
else words.push(piece.text);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
raw: query,
|
|
180
|
+
words: words.join(' ').trim(),
|
|
181
|
+
phrases,
|
|
182
|
+
identifiers,
|
|
183
|
+
filters: { ...lists, video },
|
|
184
|
+
tokens,
|
|
185
|
+
issues
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** True when the query narrows by structure (any filter, phrase or identifier) rather than only by loose words. */
|
|
190
|
+
export function isStructured(parsed: ParsedSearch): boolean {
|
|
191
|
+
return parsed.tokens.length > 0 || parsed.phrases.length > 0 || parsed.identifiers.length > 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** True when the query has nothing to match at all (no words, phrases, identifiers or filters). */
|
|
195
|
+
export function isBlank(parsed: ParsedSearch): boolean {
|
|
196
|
+
return parsed.words.length === 0 && !isStructured(parsed);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The query with one filter's text removed, other text left as typed. */
|
|
200
|
+
export function withoutToken(query: string, token: FilterToken): string {
|
|
201
|
+
const at = query.indexOf(token.text);
|
|
202
|
+
if (at < 0) return query;
|
|
203
|
+
return `${query.slice(0, at)}${query.slice(at + token.text.length)}`.replace(/\s{2,}/g, ' ').trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What a document says about itself, to be filtered by structure. Lowercased on use. A document without facets (a
|
|
208
|
+
* flow, a spec) can satisfy no filter.
|
|
209
|
+
*/
|
|
210
|
+
export type DocFacets = {
|
|
211
|
+
readonly status: readonly string[];
|
|
212
|
+
readonly env: readonly string[];
|
|
213
|
+
readonly producer: readonly string[];
|
|
214
|
+
readonly domain: readonly string[];
|
|
215
|
+
readonly spec: readonly string[];
|
|
216
|
+
readonly tag: readonly string[];
|
|
217
|
+
readonly branch: readonly string[];
|
|
218
|
+
readonly video: boolean;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** The filter groups a count can leave out (status and environment buttons count what the other filters leave). */
|
|
222
|
+
export type FilterOmit = ReadonlySet<FilterKey>;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Whether facets satisfy the filters: every filter that is set must match at least one of the document's values.
|
|
226
|
+
* `omit` skips some filters (to count what a button would add).
|
|
227
|
+
*/
|
|
228
|
+
export function passesFilters(facets: DocFacets | undefined, filters: SearchFilters, omit?: FilterOmit): boolean {
|
|
229
|
+
const wanted = (key: FilterKey) => !omit?.has(key);
|
|
230
|
+
const lists: (Exclude<FilterKey, 'video'>)[] = ['status', 'env', 'producer', 'domain', 'spec', 'tag', 'branch'];
|
|
231
|
+
const active = lists.filter((key) => filters[key].length > 0 && wanted(key));
|
|
232
|
+
const needsVideo = filters.video && wanted('video');
|
|
233
|
+
if (!active.length && !needsVideo) return true;
|
|
234
|
+
if (!facets) return false;
|
|
235
|
+
if (needsVideo && !facets.video) return false;
|
|
236
|
+
for (const key of active) {
|
|
237
|
+
const have = facets[key].map((value) => value.toLowerCase());
|
|
238
|
+
const ok = filters[key].some((value) => (key === 'spec' ? have.some((path) => path === value || path.endsWith(`/${value}`) || path.includes(value)) : have.includes(value)));
|
|
239
|
+
if (!ok) return false;
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** True when a search carries a filter (as opposed to words), counting only those not in `omit`. */
|
|
245
|
+
export function hasFilters(filters: SearchFilters): boolean {
|
|
246
|
+
return filters.video || filters.status.length + filters.env.length + filters.producer.length + filters.domain.length + filters.spec.length + filters.tag.length + filters.branch.length > 0;
|
|
247
|
+
}
|