@aglyn/plugins-data 1.0.0-beta.198 → 1.0.0-beta.200
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/package.json +9 -9
- package/src/lib/components/dataset-record-filter.d.ts +37 -41
- package/src/lib/components/dataset-record-filter.js +199 -72
- package/src/lib/components/dataset-record-filter.js.map +1 -1
- package/src/lib/components/host-datasets-card.component.js +77 -91
- package/src/lib/components/host-datasets-card.component.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-data",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.200",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
34
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
35
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.200",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.200",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.200",
|
|
31
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.200",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.200",
|
|
33
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.200",
|
|
34
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.200",
|
|
35
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.200",
|
|
36
36
|
"@swc/helpers": "0.5.23"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
@@ -16,62 +16,58 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type DatasetModel } from '@aglyn/aglyn';
|
|
18
18
|
import type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter';
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
* How many records a filter the query cannot serve on its own is matched
|
|
22
|
-
* over. Past it the card says the window was full; a single clause or a
|
|
23
|
-
* single search word reaches every record without one.
|
|
24
|
-
*/
|
|
25
|
-
export declare const RECORD_FILTER_WINDOW = 1000;
|
|
19
|
+
import type { ListFilterClause, ListFilterOption } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
|
|
20
|
+
import { type ListQueryDeclaration, type ListQueryNormalizers, type ListQueryPlan } from '@aglyn/shared-ui-jsx/const/list-query-plan';
|
|
26
21
|
/**
|
|
27
22
|
* The grid column a dataset field is shown in. Prefixed, because field ids
|
|
28
23
|
* are the dataset author's and may be any name, `actions` included.
|
|
29
24
|
*/
|
|
30
25
|
export declare const recordColumn: (fieldId: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* How the records' filter fields were normalized (`dataset-models.ts`): a
|
|
28
|
+
* typed text value becomes its `filterValues` key, and a search word its
|
|
29
|
+
* `s:` token — so the query asks for exactly what the writers stored.
|
|
30
|
+
*/
|
|
31
|
+
export declare const datasetRecordNormalizers: ListQueryNormalizers;
|
|
31
32
|
/** What the records grid's Filters panel offers for one dataset model. */
|
|
32
33
|
export interface DatasetRecordFilter {
|
|
34
|
+
/** The query declaration: every field below, the id order, the search. */
|
|
35
|
+
declaration: ListQueryDeclaration;
|
|
33
36
|
fields: ListFilterField[];
|
|
34
37
|
options: Record<string, ListFilterOption[]>;
|
|
35
38
|
headers: Record<string, string>;
|
|
36
39
|
selectFields: string[];
|
|
37
40
|
}
|
|
38
41
|
/**
|
|
39
|
-
* The model's fields as
|
|
40
|
-
* `values.<fieldId>`:
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* The model's fields as query fields, each shown in column
|
|
43
|
+
* `values.<fieldId>`:
|
|
44
|
+
*
|
|
45
|
+
* enum text picked: is, is any of `filterValues.<id>`
|
|
46
|
+
* plain text contains (one word), equals, `filterKeys`,
|
|
47
|
+
* is any of (ignoring case) `filterValues.<id>`
|
|
48
|
+
* bool picked: is `filterValues.<id>`
|
|
49
|
+
* numbers = `filterValues.<id>`
|
|
50
|
+
* list contains (a whole member) `filterKeys`
|
|
51
|
+
*
|
|
52
|
+
* Timestamps, references, maps, bytes, coordinates and nil fields offer no
|
|
53
|
+
* filter. A field whose id no query path can name (a v1 column with a `.`
|
|
54
|
+
* in it — `datasetFilterValuePath`) offers only its `contains`.
|
|
44
55
|
*/
|
|
45
56
|
export declare function datasetRecordFilter(model: DatasetModel): DatasetRecordFilter;
|
|
46
|
-
/**
|
|
47
|
-
export interface
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
/** Every quick-search token, one per word. */
|
|
57
|
-
searchTokens: string[];
|
|
58
|
-
clauses: readonly ListFilterClause[];
|
|
57
|
+
/** The records query, and what it says to the reader. */
|
|
58
|
+
export interface DatasetRecordPlan {
|
|
59
|
+
plan: ListQueryPlan;
|
|
60
|
+
/** What was not put on the query, as the reader asked it, and why. */
|
|
61
|
+
refused: Array<{
|
|
62
|
+
clause: ListFilterClause | 'search';
|
|
63
|
+
reason: string;
|
|
64
|
+
}>;
|
|
65
|
+
/** What WAS served, where it differs from what was typed. */
|
|
66
|
+
notices: string[];
|
|
59
67
|
}
|
|
60
68
|
/**
|
|
61
|
-
* The
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
export declare function planRecordFilter(model: DatasetModel, clauses: readonly ListFilterClause[], searchWords: readonly string[]): RecordFilterPlan;
|
|
65
|
-
/**
|
|
66
|
-
* The window's records that answer every condition.
|
|
67
|
-
*
|
|
68
|
-
* A token-served clause and the quick search are matched by the SAME tokens
|
|
69
|
-
* the query uses, computed from the record's values under the current model,
|
|
70
|
-
* so a record answers in memory exactly when it would answer on the server —
|
|
71
|
-
* a text `contains` is a word-prefix match either way. Everything else
|
|
72
|
-
* (ranges, dates, empty checks, negations, `isAnyOf`) is matched by the
|
|
73
|
-
* shared grammar over `values.<fieldId>`.
|
|
69
|
+
* The clauses and search words as ONE records query (`planListQuery`), with
|
|
70
|
+
* the refusals mapped back to the clauses as the reader typed them, and the
|
|
71
|
+
* notices said in the reader's words rather than as stored tokens.
|
|
74
72
|
*/
|
|
75
|
-
export declare function
|
|
76
|
-
values?: Record<string, unknown>;
|
|
77
|
-
}>(model: DatasetModel, fields: readonly ListFilterField[], rows: readonly Row[], plan: RecordFilterPlan): Row[];
|
|
73
|
+
export declare function planRecordQuery(model: DatasetModel, filter: DatasetRecordFilter, clauses: readonly ListFilterClause[], searchWords: readonly string[]): DatasetRecordPlan;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -13,19 +14,65 @@
|
|
|
13
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
15
|
* See the License for the specific language governing permissions and
|
|
15
16
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
17
|
+
*/ import { DATASET_FILTER_PREFIX_MAX, datasetFilterTextKey, datasetFilterToken, datasetFilterValuePath, datasetFilterWords, datasetSearchToken } from "@aglyn/aglyn";
|
|
18
|
+
import { LIST_QUERY_ID_PATH, planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
|
|
19
|
+
/*
|
|
20
|
+
* THE RECORDS TABLE IS ONE QUERY (AGL-3321).
|
|
21
|
+
*
|
|
22
|
+
* Every clause in the Filters panel and the quick-search word are
|
|
23
|
+
* predicates on the records query, planned by the shared `planListQuery`
|
|
24
|
+
* and answered by Firestore; nothing is matched over rows already loaded.
|
|
25
|
+
*
|
|
26
|
+
* A record's fields live under `values`, which is exempt from indexing (see
|
|
27
|
+
* `datasetFilterKeys`), so the query reads the two fields every writer
|
|
28
|
+
* stamps beside it:
|
|
29
|
+
*
|
|
30
|
+
* `filterValues.<fieldId> ==` an equality — a picked option, a
|
|
31
|
+
* boolean, a number, plain text (by its
|
|
32
|
+
* lower-cased key);
|
|
33
|
+
* `filterKeys array-contains` ONE word-level clause — a text
|
|
34
|
+
* `contains` (`f:<id>^<prefix>`), a list
|
|
35
|
+
* member (`f:<id>=<member>`), or the
|
|
36
|
+
* quick-search word (`s:<prefix>`).
|
|
37
|
+
*
|
|
38
|
+
* Firestore's automatic single-field indexes compose any number of those
|
|
39
|
+
* equalities and one `array-contains` under `orderBy(documentId())` by index
|
|
40
|
+
* merging, so the table needs NO composite index — which it could not have:
|
|
41
|
+
* every dataset's fields are its author's, and a composite per field per
|
|
42
|
+
* dataset is not something an index file can list.
|
|
43
|
+
*
|
|
44
|
+
* What one such query cannot hold is not offered, or refused by name:
|
|
45
|
+
*
|
|
46
|
+
* - no ranges. `>` on a number or a date on a timestamp is one inequality,
|
|
47
|
+
* which must then lead the order, and beside any equality it needs a
|
|
48
|
+
* composite per dataset. Numbers offer `=`, timestamps nothing.
|
|
49
|
+
* - no negations and no empty checks: Firestore has neither for a field a
|
|
50
|
+
* writer omits.
|
|
51
|
+
* - one array clause: a `contains` beside the search, or a second
|
|
52
|
+
* `contains`, is refused through `ListQueryNotices`, never half-applied.
|
|
53
|
+
* - one word of a `contains` or a search: the first word is asked, and the
|
|
54
|
+
* table says so for the rest.
|
|
55
|
+
*/ /** The array the word-level clauses and the quick search ask. */ const TOKENS_PATH = 'filterKeys';
|
|
23
56
|
/**
|
|
24
57
|
* The grid column a dataset field is shown in. Prefixed, because field ids
|
|
25
58
|
* are the dataset author's and may be any name, `actions` included.
|
|
26
59
|
*/ export const recordColumn = (fieldId)=>`values.${fieldId}`;
|
|
27
60
|
const COLUMN_PREFIX = recordColumn('');
|
|
28
61
|
const fieldIdOf = (column)=>column.startsWith(COLUMN_PREFIX) ? column.slice(COLUMN_PREFIX.length) : column;
|
|
62
|
+
/**
|
|
63
|
+
* How the records' filter fields were normalized (`dataset-models.ts`): a
|
|
64
|
+
* typed text value becomes its `filterValues` key, and a search word its
|
|
65
|
+
* `s:` token — so the query asks for exactly what the writers stored.
|
|
66
|
+
*/ export const datasetRecordNormalizers = {
|
|
67
|
+
key: datasetFilterTextKey,
|
|
68
|
+
token: (value)=>{
|
|
69
|
+
var _datasetSearchToken;
|
|
70
|
+
return (_datasetSearchToken = datasetSearchToken(value)) != null ? _datasetSearchToken : '';
|
|
71
|
+
},
|
|
72
|
+
// No field offers `endsWith`, so nothing is ever reversed.
|
|
73
|
+
reversed: (value)=>value,
|
|
74
|
+
maxPrefix: DATASET_FILTER_PREFIX_MAX
|
|
75
|
+
};
|
|
29
76
|
const BOOLEAN_OPTIONS = [
|
|
30
77
|
{
|
|
31
78
|
value: 'true',
|
|
@@ -37,11 +84,19 @@ const BOOLEAN_OPTIONS = [
|
|
|
37
84
|
}
|
|
38
85
|
];
|
|
39
86
|
/**
|
|
40
|
-
* The model's fields as
|
|
41
|
-
* `values.<fieldId>`:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
87
|
+
* The model's fields as query fields, each shown in column
|
|
88
|
+
* `values.<fieldId>`:
|
|
89
|
+
*
|
|
90
|
+
* enum text picked: is, is any of `filterValues.<id>`
|
|
91
|
+
* plain text contains (one word), equals, `filterKeys`,
|
|
92
|
+
* is any of (ignoring case) `filterValues.<id>`
|
|
93
|
+
* bool picked: is `filterValues.<id>`
|
|
94
|
+
* numbers = `filterValues.<id>`
|
|
95
|
+
* list contains (a whole member) `filterKeys`
|
|
96
|
+
*
|
|
97
|
+
* Timestamps, references, maps, bytes, coordinates and nil fields offer no
|
|
98
|
+
* filter. A field whose id no query path can name (a v1 column with a `.`
|
|
99
|
+
* in it — `datasetFilterValuePath`) offers only its `contains`.
|
|
45
100
|
*/ export function datasetRecordFilter(model) {
|
|
46
101
|
var _model_order;
|
|
47
102
|
const fields = [];
|
|
@@ -54,35 +109,75 @@ const BOOLEAN_OPTIONS = [
|
|
|
54
109
|
const field = (_model_fields = model.fields) == null ? void 0 : _model_fields[fieldId];
|
|
55
110
|
if (!field) continue;
|
|
56
111
|
const column = recordColumn(fieldId);
|
|
112
|
+
const valuePath = datasetFilterValuePath(fieldId);
|
|
57
113
|
const choices = field.type === 'text' ? (_ref = (_field_validation = field.validation) == null ? void 0 : _field_validation.options) != null ? _ref : [] : [];
|
|
58
114
|
let declared = null;
|
|
59
115
|
if (field.type === 'text' && choices.length) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
116
|
+
if (valuePath) {
|
|
117
|
+
declared = {
|
|
118
|
+
column,
|
|
119
|
+
kind: 'exact',
|
|
120
|
+
path: valuePath,
|
|
121
|
+
operators: [
|
|
122
|
+
'equals',
|
|
123
|
+
'isAnyOf'
|
|
124
|
+
]
|
|
125
|
+
};
|
|
126
|
+
options[column] = choices.map((choice)=>({
|
|
127
|
+
value: choice,
|
|
128
|
+
label: choice
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
65
131
|
} else if (field.type === 'text') {
|
|
66
|
-
|
|
132
|
+
// A `contains` asks the token the clause's value is turned into
|
|
133
|
+
// (`recordQueryClause`), matched as given.
|
|
134
|
+
declared = _extends({
|
|
135
|
+
column,
|
|
136
|
+
kind: 'text',
|
|
137
|
+
path: valuePath != null ? valuePath : column,
|
|
138
|
+
tokensPath: TOKENS_PATH,
|
|
139
|
+
verbatimTokens: true
|
|
140
|
+
}, valuePath ? {
|
|
141
|
+
lowerPath: valuePath
|
|
142
|
+
} : {}, {
|
|
143
|
+
operators: valuePath ? [
|
|
144
|
+
'contains',
|
|
145
|
+
'equals',
|
|
146
|
+
'isAnyOf'
|
|
147
|
+
] : [
|
|
148
|
+
'contains'
|
|
149
|
+
]
|
|
150
|
+
});
|
|
67
151
|
} else if (field.type === 'bool') {
|
|
68
|
-
|
|
69
|
-
|
|
152
|
+
if (valuePath) {
|
|
153
|
+
declared = {
|
|
154
|
+
column,
|
|
155
|
+
kind: 'boolean',
|
|
156
|
+
path: valuePath,
|
|
157
|
+
operators: [
|
|
158
|
+
'equals'
|
|
159
|
+
]
|
|
160
|
+
};
|
|
161
|
+
options[column] = BOOLEAN_OPTIONS;
|
|
162
|
+
}
|
|
70
163
|
} else if (field.type === 'int32' || field.type === 'int64' || field.type === 'float') {
|
|
71
|
-
declared =
|
|
72
|
-
|
|
73
|
-
|
|
164
|
+
if (valuePath) declared = {
|
|
165
|
+
column,
|
|
166
|
+
kind: 'number',
|
|
167
|
+
path: valuePath,
|
|
168
|
+
operators: [
|
|
169
|
+
'='
|
|
170
|
+
]
|
|
171
|
+
};
|
|
74
172
|
} else if (field.type === 'sorted') {
|
|
75
|
-
// A list matches by whole member, as its `=` tokens do.
|
|
76
173
|
declared = {
|
|
77
174
|
column,
|
|
78
|
-
path: column,
|
|
79
175
|
kind: 'text',
|
|
80
|
-
|
|
176
|
+
path: column,
|
|
177
|
+
tokensPath: TOKENS_PATH,
|
|
81
178
|
verbatimTokens: true,
|
|
82
179
|
operators: [
|
|
83
|
-
'contains'
|
|
84
|
-
'isEmpty',
|
|
85
|
-
'isNotEmpty'
|
|
180
|
+
'contains'
|
|
86
181
|
]
|
|
87
182
|
};
|
|
88
183
|
}
|
|
@@ -92,63 +187,95 @@ const BOOLEAN_OPTIONS = [
|
|
|
92
187
|
if (options[column]) selectFields.push(column);
|
|
93
188
|
}
|
|
94
189
|
return {
|
|
190
|
+
declaration: {
|
|
191
|
+
fields,
|
|
192
|
+
// The document name: the one order no record can be missing (see the
|
|
193
|
+
// card), and the one every automatic index already ends in.
|
|
194
|
+
sorts: [
|
|
195
|
+
{
|
|
196
|
+
path: LIST_QUERY_ID_PATH,
|
|
197
|
+
direction: 'asc'
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
search: {
|
|
201
|
+
tokensPath: TOKENS_PATH
|
|
202
|
+
}
|
|
203
|
+
},
|
|
95
204
|
fields,
|
|
96
205
|
options,
|
|
97
206
|
headers,
|
|
98
207
|
selectFields
|
|
99
208
|
};
|
|
100
209
|
}
|
|
101
|
-
|
|
102
|
-
|
|
210
|
+
/**
|
|
211
|
+
* A clause as the query asks it: a `contains` becomes the `filterKeys`
|
|
212
|
+
* token of its first word (text) or of its member (list). Everything else
|
|
213
|
+
* goes as typed — the planner applies the text key, the number, the boolean.
|
|
214
|
+
*/ function recordQueryClause(model, clause) {
|
|
215
|
+
if (clause.op !== 'contains') return {
|
|
216
|
+
field: clause.field,
|
|
103
217
|
op: clause.op,
|
|
104
218
|
value: clause.value
|
|
219
|
+
};
|
|
220
|
+
const token = datasetFilterToken(model, {
|
|
221
|
+
field: fieldIdOf(clause.field),
|
|
222
|
+
op: 'contains',
|
|
223
|
+
value: clause.value
|
|
105
224
|
});
|
|
106
|
-
/**
|
|
107
|
-
* The served condition is the first clause a token answers, else the first
|
|
108
|
-
* search word; the walk is windowed whenever anything else is in force.
|
|
109
|
-
*/ export function planRecordFilter(model, clauses, searchWords) {
|
|
110
|
-
var _searchTokens_;
|
|
111
|
-
var _clauseTokens_servedAt;
|
|
112
|
-
const clauseTokens = clauses.map((clause)=>datasetFilterTokens(model, asFieldClause(clause)));
|
|
113
|
-
const searchTokens = [
|
|
114
|
-
...new Set(searchWords.flatMap((word)=>datasetFilterWords(word)).map((word)=>datasetSearchToken(word)).filter((token)=>Boolean(token)))
|
|
115
|
-
];
|
|
116
|
-
const servedAt = clauses.findIndex((clause)=>Boolean(datasetFilterToken(model, asFieldClause(clause))));
|
|
117
|
-
const token = servedAt >= 0 ? datasetFilterToken(model, asFieldClause(clauses[servedAt])) : (_searchTokens_ = searchTokens[0]) != null ? _searchTokens_ : null;
|
|
118
|
-
const complete = servedAt >= 0 ? clauses.length === 1 && searchTokens.length === 0 && ((_clauseTokens_servedAt = clauseTokens[servedAt]) == null ? void 0 : _clauseTokens_servedAt.length) === 1 : clauses.length === 0 && searchTokens.length <= 1;
|
|
119
225
|
return {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
clauseTokens,
|
|
124
|
-
searchTokens,
|
|
125
|
-
clauses
|
|
226
|
+
field: clause.field,
|
|
227
|
+
op: clause.op,
|
|
228
|
+
value: token != null ? token : ''
|
|
126
229
|
};
|
|
127
230
|
}
|
|
231
|
+
const clipWord = (word)=>Array.from(word).slice(0, DATASET_FILTER_PREFIX_MAX).join('');
|
|
128
232
|
/**
|
|
129
|
-
* The
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const keys = new Set(datasetFilterKeys(model, row.values));
|
|
150
|
-
return needed.every((token)=>keys.has(token));
|
|
233
|
+
* The clauses and search words as ONE records query (`planListQuery`), with
|
|
234
|
+
* the refusals mapped back to the clauses as the reader typed them, and the
|
|
235
|
+
* notices said in the reader's words rather than as stored tokens.
|
|
236
|
+
*/ export function planRecordQuery(model, filter, clauses, searchWords) {
|
|
237
|
+
const asked = new Map();
|
|
238
|
+
const queryClauses = clauses.map((clause)=>{
|
|
239
|
+
const queryClause = recordQueryClause(model, clause);
|
|
240
|
+
asked.set(queryClause, clause);
|
|
241
|
+
return queryClause;
|
|
242
|
+
});
|
|
243
|
+
const plan = planListQuery(filter.declaration, {
|
|
244
|
+
clauses: queryClauses,
|
|
245
|
+
search: searchWords
|
|
246
|
+
}, datasetRecordNormalizers);
|
|
247
|
+
const refused = plan.refused.map((entry)=>{
|
|
248
|
+
var _asked_get;
|
|
249
|
+
return {
|
|
250
|
+
clause: entry.clause === 'search' ? 'search' : (_asked_get = asked.get(entry.clause)) != null ? _asked_get : entry.clause,
|
|
251
|
+
reason: entry.reason
|
|
252
|
+
};
|
|
151
253
|
});
|
|
254
|
+
const notices = [];
|
|
255
|
+
const oneWord = (label, text)=>{
|
|
256
|
+
const words = datasetFilterWords(text);
|
|
257
|
+
if (words.length > 1) {
|
|
258
|
+
notices.push(`${label} matches one word at a time: showing records with a word starting "${clipWord(words[0])}".`);
|
|
259
|
+
}
|
|
260
|
+
if (words.some((word)=>Array.from(word).length > DATASET_FILTER_PREFIX_MAX)) {
|
|
261
|
+
notices.push(`${label} reads the first ${DATASET_FILTER_PREFIX_MAX} letters of a word.`);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
if (plan.searched) oneWord('Search', searchWords.join(' '));
|
|
265
|
+
for (const served of plan.served){
|
|
266
|
+
var _filter_headers_clause_field;
|
|
267
|
+
var _model_fields_fieldIdOf, _model_fields;
|
|
268
|
+
const clause = asked.get(served);
|
|
269
|
+
// A list's `contains` is a whole member; only text is asked by word.
|
|
270
|
+
if ((clause == null ? void 0 : clause.op) !== 'contains') continue;
|
|
271
|
+
if (((_model_fields = model.fields) == null ? void 0 : (_model_fields_fieldIdOf = _model_fields[fieldIdOf(clause.field)]) == null ? void 0 : _model_fields_fieldIdOf.type) !== 'text') continue;
|
|
272
|
+
oneWord(`${(_filter_headers_clause_field = filter.headers[clause.field]) != null ? _filter_headers_clause_field : clause.field} contains`, clause.value);
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
plan,
|
|
276
|
+
refused,
|
|
277
|
+
notices
|
|
278
|
+
};
|
|
152
279
|
}
|
|
153
280
|
|
|
154
281
|
//# sourceMappingURL=dataset-record-filter.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/components/dataset-record-filter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type DatasetModel,\n datasetFilterKeys,\n datasetFilterToken,\n datasetFilterTokens,\n datasetFilterWords,\n datasetSearchToken,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport {\n filterListRows,\n inMemoryListField,\n type ListFilterClause,\n type ListFilterOption,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\n\n/**\n * How many records a filter the query cannot serve on its own is matched\n * over. Past it the card says the window was full; a single clause or a\n * single search word reaches every record without one.\n */\nexport const RECORD_FILTER_WINDOW = 1000\n\n/**\n * The grid column a dataset field is shown in. Prefixed, because field ids\n * are the dataset author's and may be any name, `actions` included.\n */\nexport const recordColumn = (fieldId: string) => `values.${fieldId}`\nconst COLUMN_PREFIX = recordColumn('')\nconst fieldIdOf = (column: string) =>\n column.startsWith(COLUMN_PREFIX) ? column.slice(COLUMN_PREFIX.length) : column\n\n/** What the records grid's Filters panel offers for one dataset model. */\nexport interface DatasetRecordFilter {\n fields: ListFilterField[]\n options: Record<string, ListFilterOption[]>\n headers: Record<string, string>\n selectFields: string[]\n}\n\nconst BOOLEAN_OPTIONS: ListFilterOption[] = [\n { value: 'true', label: 'True' },\n { value: 'false', label: 'False' },\n]\n\n/**\n * The model's fields as filter fields, each reading the record row at\n * `values.<fieldId>`: an enum is a select over its options, a boolean a\n * true/false select, numbers and timestamps number and date fields, a list a\n * member match, and plain text a text field. References, maps, bytes,\n * coordinates and nil fields offer no filter.\n */\nexport function datasetRecordFilter(model: DatasetModel): DatasetRecordFilter {\n const fields: ListFilterField[] = []\n const options: Record<string, ListFilterOption[]> = {}\n const headers: Record<string, string> = {}\n const selectFields: string[] = []\n for (const fieldId of model.order ?? []) {\n const field = model.fields?.[fieldId]\n if (!field) continue\n const column = recordColumn(fieldId)\n const choices = field.type === 'text' ? (field.validation?.options ?? []) : []\n let declared: ListFilterField | null = null\n if (field.type === 'text' && choices.length) {\n declared = inMemoryListField(column, 'select')\n options[column] = choices.map((choice) => ({ value: choice, label: choice }))\n } else if (field.type === 'text') {\n declared = inMemoryListField(column, 'text')\n } else if (field.type === 'bool') {\n declared = inMemoryListField(column, 'select')\n options[column] = BOOLEAN_OPTIONS\n } else if (field.type === 'int32' || field.type === 'int64' || field.type === 'float') {\n declared = inMemoryListField(column, 'number')\n } else if (field.type === 'timestamp') {\n declared = inMemoryListField(column, 'date')\n } else if (field.type === 'sorted') {\n // A list matches by whole member, as its `=` tokens do.\n declared = {\n column,\n path: column,\n kind: 'text',\n tokensPath: column,\n verbatimTokens: true,\n operators: ['contains', 'isEmpty', 'isNotEmpty'],\n }\n }\n if (!declared) continue\n fields.push(declared)\n headers[column] = field.name || fieldId\n if (options[column]) selectFields.push(column)\n }\n return { fields, options, headers, selectFields }\n}\n\n/** Which condition the query serves, and what is left to match in memory. */\nexport interface RecordFilterPlan {\n /** The `filterKeys` token the query narrows by, or null for the plain walk. */\n token: string | null\n /** The column of the served clause; null when a search word is served. */\n servedField: string | null\n /** Whether anything is left for the window to match. */\n windowed: boolean\n /** Every token each token-served clause needs, by clause index. */\n clauseTokens: Array<string[] | null>\n /** Every quick-search token, one per word. */\n searchTokens: string[]\n clauses: readonly ListFilterClause[]\n}\n\nconst asFieldClause = (clause: ListFilterClause) => ({\n field: fieldIdOf(clause.field),\n op: clause.op,\n value: clause.value,\n})\n\n/**\n * The served condition is the first clause a token answers, else the first\n * search word; the walk is windowed whenever anything else is in force.\n */\nexport function planRecordFilter(\n model: DatasetModel,\n clauses: readonly ListFilterClause[],\n searchWords: readonly string[],\n): RecordFilterPlan {\n const clauseTokens = clauses.map((clause) =>\n datasetFilterTokens(model, asFieldClause(clause)),\n )\n const searchTokens = [\n ...new Set(\n searchWords\n .flatMap((word) => datasetFilterWords(word))\n .map((word) => datasetSearchToken(word))\n .filter((token): token is string => Boolean(token)),\n ),\n ]\n const servedAt = clauses.findIndex((clause) =>\n Boolean(datasetFilterToken(model, asFieldClause(clause))),\n )\n const token =\n servedAt >= 0\n ? datasetFilterToken(model, asFieldClause(clauses[servedAt]))\n : (searchTokens[0] ?? null)\n const complete =\n servedAt >= 0\n ? clauses.length === 1 &&\n searchTokens.length === 0 &&\n clauseTokens[servedAt]?.length === 1\n : clauses.length === 0 && searchTokens.length <= 1\n return {\n token,\n servedField: servedAt >= 0 ? clauses[servedAt].field : null,\n windowed: !complete,\n clauseTokens,\n searchTokens,\n clauses,\n }\n}\n\n/**\n * The window's records that answer every condition.\n *\n * A token-served clause and the quick search are matched by the SAME tokens\n * the query uses, computed from the record's values under the current model,\n * so a record answers in memory exactly when it would answer on the server —\n * a text `contains` is a word-prefix match either way. Everything else\n * (ranges, dates, empty checks, negations, `isAnyOf`) is matched by the\n * shared grammar over `values.<fieldId>`.\n */\nexport function matchRecordWindow<Row extends { values?: Record<string, unknown> }>(\n model: DatasetModel,\n fields: readonly ListFilterField[],\n rows: readonly Row[],\n plan: RecordFilterPlan,\n): Row[] {\n const served = new Set(\n plan.clauses.filter((_clause, at) => plan.clauseTokens[at] !== null),\n )\n const needed = [\n ...plan.clauseTokens.flatMap((tokens) => tokens ?? []),\n ...plan.searchTokens,\n ]\n const rest = filterListRows(\n rows,\n fields,\n plan.clauses,\n { paths: [], words: [] },\n (clause) => served.has(clause),\n )\n if (!needed.length) return rest\n return rest.filter((row) => {\n const keys = new Set(datasetFilterKeys(model, row.values))\n return needed.every((token) => keys.has(token))\n })\n}\n"],"names":["datasetFilterKeys","datasetFilterToken","datasetFilterTokens","datasetFilterWords","datasetSearchToken","filterListRows","inMemoryListField","RECORD_FILTER_WINDOW","recordColumn","fieldId","COLUMN_PREFIX","fieldIdOf","column","startsWith","slice","length","BOOLEAN_OPTIONS","value","label","datasetRecordFilter","model","fields","options","headers","selectFields","order","field","choices","type","validation","declared","map","choice","path","kind","tokensPath","verbatimTokens","operators","push","name","asFieldClause","clause","op","planRecordFilter","clauses","searchWords","searchTokens","clauseTokens","Set","flatMap","word","filter","token","Boolean","servedAt","findIndex","complete","servedField","windowed","matchRecordWindow","rows","plan","served","_clause","at","needed","tokens","rest","paths","words","has","row","keys","values","every"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/components/dataset-record-filter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n DATASET_FILTER_PREFIX_MAX,\n type DatasetModel,\n datasetFilterTextKey,\n datasetFilterToken,\n datasetFilterValuePath,\n datasetFilterWords,\n datasetSearchToken,\n} from '@aglyn/aglyn'\nimport type { ListFilterField, ListFilterRequest } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type {\n ListFilterClause,\n ListFilterOption,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryNormalizers,\n type ListQueryPlan,\n planListQuery,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * THE RECORDS TABLE IS ONE QUERY (AGL-3321).\n *\n * Every clause in the Filters panel and the quick-search word are\n * predicates on the records query, planned by the shared `planListQuery`\n * and answered by Firestore; nothing is matched over rows already loaded.\n *\n * A record's fields live under `values`, which is exempt from indexing (see\n * `datasetFilterKeys`), so the query reads the two fields every writer\n * stamps beside it:\n *\n * `filterValues.<fieldId> ==` an equality — a picked option, a\n * boolean, a number, plain text (by its\n * lower-cased key);\n * `filterKeys array-contains` ONE word-level clause — a text\n * `contains` (`f:<id>^<prefix>`), a list\n * member (`f:<id>=<member>`), or the\n * quick-search word (`s:<prefix>`).\n *\n * Firestore's automatic single-field indexes compose any number of those\n * equalities and one `array-contains` under `orderBy(documentId())` by index\n * merging, so the table needs NO composite index — which it could not have:\n * every dataset's fields are its author's, and a composite per field per\n * dataset is not something an index file can list.\n *\n * What one such query cannot hold is not offered, or refused by name:\n *\n * - no ranges. `>` on a number or a date on a timestamp is one inequality,\n * which must then lead the order, and beside any equality it needs a\n * composite per dataset. Numbers offer `=`, timestamps nothing.\n * - no negations and no empty checks: Firestore has neither for a field a\n * writer omits.\n * - one array clause: a `contains` beside the search, or a second\n * `contains`, is refused through `ListQueryNotices`, never half-applied.\n * - one word of a `contains` or a search: the first word is asked, and the\n * table says so for the rest.\n */\n\n/** The array the word-level clauses and the quick search ask. */\nconst TOKENS_PATH = 'filterKeys'\n\n/**\n * The grid column a dataset field is shown in. Prefixed, because field ids\n * are the dataset author's and may be any name, `actions` included.\n */\nexport const recordColumn = (fieldId: string) => `values.${fieldId}`\nconst COLUMN_PREFIX = recordColumn('')\nconst fieldIdOf = (column: string) =>\n column.startsWith(COLUMN_PREFIX) ? column.slice(COLUMN_PREFIX.length) : column\n\n/**\n * How the records' filter fields were normalized (`dataset-models.ts`): a\n * typed text value becomes its `filterValues` key, and a search word its\n * `s:` token — so the query asks for exactly what the writers stored.\n */\nexport const datasetRecordNormalizers: ListQueryNormalizers = {\n key: datasetFilterTextKey,\n token: (value) => datasetSearchToken(value) ?? '',\n // No field offers `endsWith`, so nothing is ever reversed.\n reversed: (value) => value,\n maxPrefix: DATASET_FILTER_PREFIX_MAX,\n}\n\n/** What the records grid's Filters panel offers for one dataset model. */\nexport interface DatasetRecordFilter {\n /** The query declaration: every field below, the id order, the search. */\n declaration: ListQueryDeclaration\n fields: ListFilterField[]\n options: Record<string, ListFilterOption[]>\n headers: Record<string, string>\n selectFields: string[]\n}\n\nconst BOOLEAN_OPTIONS: ListFilterOption[] = [\n { value: 'true', label: 'True' },\n { value: 'false', label: 'False' },\n]\n\n/**\n * The model's fields as query fields, each shown in column\n * `values.<fieldId>`:\n *\n * enum text picked: is, is any of `filterValues.<id>`\n * plain text contains (one word), equals, `filterKeys`,\n * is any of (ignoring case) `filterValues.<id>`\n * bool picked: is `filterValues.<id>`\n * numbers = `filterValues.<id>`\n * list contains (a whole member) `filterKeys`\n *\n * Timestamps, references, maps, bytes, coordinates and nil fields offer no\n * filter. A field whose id no query path can name (a v1 column with a `.`\n * in it — `datasetFilterValuePath`) offers only its `contains`.\n */\nexport function datasetRecordFilter(model: DatasetModel): DatasetRecordFilter {\n const fields: ListFilterField[] = []\n const options: Record<string, ListFilterOption[]> = {}\n const headers: Record<string, string> = {}\n const selectFields: string[] = []\n for (const fieldId of model.order ?? []) {\n const field = model.fields?.[fieldId]\n if (!field) continue\n const column = recordColumn(fieldId)\n const valuePath = datasetFilterValuePath(fieldId)\n const choices = field.type === 'text' ? (field.validation?.options ?? []) : []\n let declared: ListFilterField | null = null\n if (field.type === 'text' && choices.length) {\n if (valuePath) {\n declared = { column, kind: 'exact', path: valuePath, operators: ['equals', 'isAnyOf'] }\n options[column] = choices.map((choice) => ({ value: choice, label: choice }))\n }\n } else if (field.type === 'text') {\n // A `contains` asks the token the clause's value is turned into\n // (`recordQueryClause`), matched as given.\n declared = {\n column,\n kind: 'text',\n path: valuePath ?? column,\n tokensPath: TOKENS_PATH,\n verbatimTokens: true,\n ...(valuePath ? { lowerPath: valuePath } : {}),\n operators: valuePath ? ['contains', 'equals', 'isAnyOf'] : ['contains'],\n }\n } else if (field.type === 'bool') {\n if (valuePath) {\n declared = { column, kind: 'boolean', path: valuePath, operators: ['equals'] }\n options[column] = BOOLEAN_OPTIONS\n }\n } else if (field.type === 'int32' || field.type === 'int64' || field.type === 'float') {\n if (valuePath) declared = { column, kind: 'number', path: valuePath, operators: ['='] }\n } else if (field.type === 'sorted') {\n declared = {\n column,\n kind: 'text',\n path: column,\n tokensPath: TOKENS_PATH,\n verbatimTokens: true,\n operators: ['contains'],\n }\n }\n if (!declared) continue\n fields.push(declared)\n headers[column] = field.name || fieldId\n if (options[column]) selectFields.push(column)\n }\n return {\n declaration: {\n fields,\n // The document name: the one order no record can be missing (see the\n // card), and the one every automatic index already ends in.\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }],\n search: { tokensPath: TOKENS_PATH },\n },\n fields,\n options,\n headers,\n selectFields,\n }\n}\n\n/**\n * A clause as the query asks it: a `contains` becomes the `filterKeys`\n * token of its first word (text) or of its member (list). Everything else\n * goes as typed — the planner applies the text key, the number, the boolean.\n */\nfunction recordQueryClause(model: DatasetModel, clause: ListFilterClause): ListFilterRequest {\n if (clause.op !== 'contains') return { field: clause.field, op: clause.op, value: clause.value }\n const token = datasetFilterToken(model, {\n field: fieldIdOf(clause.field),\n op: 'contains',\n value: clause.value,\n })\n return { field: clause.field, op: clause.op, value: token ?? '' }\n}\n\nconst clipWord = (word: string) =>\n Array.from(word).slice(0, DATASET_FILTER_PREFIX_MAX).join('')\n\n/** The records query, and what it says to the reader. */\nexport interface DatasetRecordPlan {\n plan: ListQueryPlan\n /** What was not put on the query, as the reader asked it, and why. */\n refused: Array<{ clause: ListFilterClause | 'search'; reason: string }>\n /** What WAS served, where it differs from what was typed. */\n notices: string[]\n}\n\n/**\n * The clauses and search words as ONE records query (`planListQuery`), with\n * the refusals mapped back to the clauses as the reader typed them, and the\n * notices said in the reader's words rather than as stored tokens.\n */\nexport function planRecordQuery(\n model: DatasetModel,\n filter: DatasetRecordFilter,\n clauses: readonly ListFilterClause[],\n searchWords: readonly string[],\n): DatasetRecordPlan {\n const asked = new Map<ListFilterRequest, ListFilterClause>()\n const queryClauses = clauses.map((clause) => {\n const queryClause = recordQueryClause(model, clause)\n asked.set(queryClause, clause)\n return queryClause\n })\n const plan = planListQuery(\n filter.declaration,\n { clauses: queryClauses, search: searchWords },\n datasetRecordNormalizers,\n )\n const refused = plan.refused.map((entry) => ({\n clause: entry.clause === 'search' ? ('search' as const) : (asked.get(entry.clause) ?? entry.clause),\n reason: entry.reason,\n }))\n\n const notices: string[] = []\n const oneWord = (label: string, text: string) => {\n const words = datasetFilterWords(text)\n if (words.length > 1) {\n notices.push(\n `${label} matches one word at a time: showing records with a word starting \"${clipWord(words[0])}\".`,\n )\n }\n if (words.some((word) => Array.from(word).length > DATASET_FILTER_PREFIX_MAX)) {\n notices.push(`${label} reads the first ${DATASET_FILTER_PREFIX_MAX} letters of a word.`)\n }\n }\n if (plan.searched) oneWord('Search', searchWords.join(' '))\n for (const served of plan.served) {\n const clause = asked.get(served)\n // A list's `contains` is a whole member; only text is asked by word.\n if (clause?.op !== 'contains') continue\n if (model.fields?.[fieldIdOf(clause.field)]?.type !== 'text') continue\n oneWord(`${filter.headers[clause.field] ?? clause.field} contains`, clause.value)\n }\n return { plan, refused, notices }\n}\n"],"names":["DATASET_FILTER_PREFIX_MAX","datasetFilterTextKey","datasetFilterToken","datasetFilterValuePath","datasetFilterWords","datasetSearchToken","LIST_QUERY_ID_PATH","planListQuery","TOKENS_PATH","recordColumn","fieldId","COLUMN_PREFIX","fieldIdOf","column","startsWith","slice","length","datasetRecordNormalizers","key","token","value","reversed","maxPrefix","BOOLEAN_OPTIONS","label","datasetRecordFilter","model","fields","options","headers","selectFields","order","field","valuePath","choices","type","validation","declared","kind","path","operators","map","choice","tokensPath","verbatimTokens","lowerPath","push","name","declaration","sorts","direction","search","recordQueryClause","clause","op","clipWord","word","Array","from","join","planRecordQuery","filter","clauses","searchWords","asked","Map","queryClauses","queryClause","set","plan","refused","entry","get","reason","notices","oneWord","text","words","some","searched","served"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,yBAAyB,EAEzBC,oBAAoB,EACpBC,kBAAkB,EAClBC,sBAAsB,EACtBC,kBAAkB,EAClBC,kBAAkB,QACb,eAAc;AAMrB,SACEC,kBAAkB,EAIlBC,aAAa,QACR,6CAA4C;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCC,GAED,+DAA+D,GAC/D,MAAMC,cAAc;AAEpB;;;CAGC,GACD,OAAO,MAAMC,eAAe,CAACC,UAAoB,CAAC,OAAO,EAAEA,SAAS,CAAA;AACpE,MAAMC,gBAAgBF,aAAa;AACnC,MAAMG,YAAY,CAACC,SACjBA,OAAOC,UAAU,CAACH,iBAAiBE,OAAOE,KAAK,CAACJ,cAAcK,MAAM,IAAIH;AAE1E;;;;CAIC,GACD,OAAO,MAAMI,2BAAiD;IAC5DC,KAAKjB;IACLkB,OAAO,CAACC;YAAUf;gBAAAA,sBAAAA,mBAAmBe,kBAAnBf,sBAA6B;;IAC/C,2DAA2D;IAC3DgB,UAAU,CAACD,QAAUA;IACrBE,WAAWtB;AACb,EAAC;AAYD,MAAMuB,kBAAsC;IAC1C;QAAEH,OAAO;QAAQI,OAAO;IAAO;IAC/B;QAAEJ,OAAO;QAASI,OAAO;IAAQ;CAClC;AAED;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,oBAAoBC,KAAmB;QAK/BA;IAJtB,MAAMC,SAA4B,EAAE;IACpC,MAAMC,UAA8C,CAAC;IACrD,MAAMC,UAAkC,CAAC;IACzC,MAAMC,eAAyB,EAAE;IACjC,KAAK,MAAMpB,YAAWgB,eAAAA,MAAMK,KAAK,YAAXL,eAAe,EAAE,CAAE;;YACzBA,eAI2BM;QAJzC,MAAMA,SAAQN,gBAAAA,MAAMC,MAAM,qBAAZD,aAAc,CAAChB,QAAQ;QACrC,IAAI,CAACsB,OAAO;QACZ,MAAMnB,SAASJ,aAAaC;QAC5B,MAAMuB,YAAY9B,uBAAuBO;QACzC,MAAMwB,UAAUF,MAAMG,IAAI,KAAK,kBAAUH,oBAAAA,MAAMI,UAAU,qBAAhBJ,kBAAkBJ,OAAO,mBAAI,EAAE,GAAI,EAAE;QAC9E,IAAIS,WAAmC;QACvC,IAAIL,MAAMG,IAAI,KAAK,UAAUD,QAAQlB,MAAM,EAAE;YAC3C,IAAIiB,WAAW;gBACbI,WAAW;oBAAExB;oBAAQyB,MAAM;oBAASC,MAAMN;oBAAWO,WAAW;wBAAC;wBAAU;qBAAU;gBAAC;gBACtFZ,OAAO,CAACf,OAAO,GAAGqB,QAAQO,GAAG,CAAC,CAACC,SAAY,CAAA;wBAAEtB,OAAOsB;wBAAQlB,OAAOkB;oBAAO,CAAA;YAC5E;QACF,OAAO,IAAIV,MAAMG,IAAI,KAAK,QAAQ;YAChC,gEAAgE;YAChE,2CAA2C;YAC3CE,WAAW;gBACTxB;gBACAyB,MAAM;gBACNC,IAAI,EAAEN,oBAAAA,YAAapB;gBACnB8B,YAAYnC;gBACZoC,gBAAgB;eACZX,YAAY;gBAAEY,WAAWZ;YAAU,IAAI,CAAC;gBAC5CO,WAAWP,YAAY;oBAAC;oBAAY;oBAAU;iBAAU,GAAG;oBAAC;iBAAW;;QAE3E,OAAO,IAAID,MAAMG,IAAI,KAAK,QAAQ;YAChC,IAAIF,WAAW;gBACbI,WAAW;oBAAExB;oBAAQyB,MAAM;oBAAWC,MAAMN;oBAAWO,WAAW;wBAAC;qBAAS;gBAAC;gBAC7EZ,OAAO,CAACf,OAAO,GAAGU;YACpB;QACF,OAAO,IAAIS,MAAMG,IAAI,KAAK,WAAWH,MAAMG,IAAI,KAAK,WAAWH,MAAMG,IAAI,KAAK,SAAS;YACrF,IAAIF,WAAWI,WAAW;gBAAExB;gBAAQyB,MAAM;gBAAUC,MAAMN;gBAAWO,WAAW;oBAAC;iBAAI;YAAC;QACxF,OAAO,IAAIR,MAAMG,IAAI,KAAK,UAAU;YAClCE,WAAW;gBACTxB;gBACAyB,MAAM;gBACNC,MAAM1B;gBACN8B,YAAYnC;gBACZoC,gBAAgB;gBAChBJ,WAAW;oBAAC;iBAAW;YACzB;QACF;QACA,IAAI,CAACH,UAAU;QACfV,OAAOmB,IAAI,CAACT;QACZR,OAAO,CAAChB,OAAO,GAAGmB,MAAMe,IAAI,IAAIrC;QAChC,IAAIkB,OAAO,CAACf,OAAO,EAAEiB,aAAagB,IAAI,CAACjC;IACzC;IACA,OAAO;QACLmC,aAAa;YACXrB;YACA,qEAAqE;YACrE,4DAA4D;YAC5DsB,OAAO;gBAAC;oBAAEV,MAAMjC;oBAAoB4C,WAAW;gBAAM;aAAE;YACvDC,QAAQ;gBAAER,YAAYnC;YAAY;QACpC;QACAmB;QACAC;QACAC;QACAC;IACF;AACF;AAEA;;;;CAIC,GACD,SAASsB,kBAAkB1B,KAAmB,EAAE2B,MAAwB;IACtE,IAAIA,OAAOC,EAAE,KAAK,YAAY,OAAO;QAAEtB,OAAOqB,OAAOrB,KAAK;QAAEsB,IAAID,OAAOC,EAAE;QAAElC,OAAOiC,OAAOjC,KAAK;IAAC;IAC/F,MAAMD,QAAQjB,mBAAmBwB,OAAO;QACtCM,OAAOpB,UAAUyC,OAAOrB,KAAK;QAC7BsB,IAAI;QACJlC,OAAOiC,OAAOjC,KAAK;IACrB;IACA,OAAO;QAAEY,OAAOqB,OAAOrB,KAAK;QAAEsB,IAAID,OAAOC,EAAE;QAAElC,KAAK,EAAED,gBAAAA,QAAS;IAAG;AAClE;AAEA,MAAMoC,WAAW,CAACC,OAChBC,MAAMC,IAAI,CAACF,MAAMzC,KAAK,CAAC,GAAGf,2BAA2B2D,IAAI,CAAC;AAW5D;;;;CAIC,GACD,OAAO,SAASC,gBACdlC,KAAmB,EACnBmC,MAA2B,EAC3BC,OAAoC,EACpCC,WAA8B;IAE9B,MAAMC,QAAQ,IAAIC;IAClB,MAAMC,eAAeJ,QAAQrB,GAAG,CAAC,CAACY;QAChC,MAAMc,cAAcf,kBAAkB1B,OAAO2B;QAC7CW,MAAMI,GAAG,CAACD,aAAad;QACvB,OAAOc;IACT;IACA,MAAME,OAAO9D,cACXsD,OAAOb,WAAW,EAClB;QAAEc,SAASI;QAAcf,QAAQY;IAAY,GAC7C9C;IAEF,MAAMqD,UAAUD,KAAKC,OAAO,CAAC7B,GAAG,CAAC,CAAC8B;YAC2BP;eADhB;YAC3CX,QAAQkB,MAAMlB,MAAM,KAAK,WAAY,YAAsBW,aAAAA,MAAMQ,GAAG,CAACD,MAAMlB,MAAM,aAAtBW,aAA2BO,MAAMlB,MAAM;YAClGoB,QAAQF,MAAME,MAAM;QACtB;;IAEA,MAAMC,UAAoB,EAAE;IAC5B,MAAMC,UAAU,CAACnD,OAAeoD;QAC9B,MAAMC,QAAQzE,mBAAmBwE;QACjC,IAAIC,MAAM7D,MAAM,GAAG,GAAG;YACpB0D,QAAQ5B,IAAI,CACV,GAAGtB,MAAM,mEAAmE,EAAE+B,SAASsB,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC;QAExG;QACA,IAAIA,MAAMC,IAAI,CAAC,CAACtB,OAASC,MAAMC,IAAI,CAACF,MAAMxC,MAAM,GAAGhB,4BAA4B;YAC7E0E,QAAQ5B,IAAI,CAAC,GAAGtB,MAAM,iBAAiB,EAAExB,0BAA0B,mBAAmB,CAAC;QACzF;IACF;IACA,IAAIqE,KAAKU,QAAQ,EAAEJ,QAAQ,UAAUZ,YAAYJ,IAAI,CAAC;IACtD,KAAK,MAAMqB,UAAUX,KAAKW,MAAM,CAAE;YAKrBnB;YADPnC,yBAAAA;QAHJ,MAAM2B,SAASW,MAAMQ,GAAG,CAACQ;QACzB,qEAAqE;QACrE,IAAI3B,CAAAA,0BAAAA,OAAQC,EAAE,MAAK,YAAY;QAC/B,IAAI5B,EAAAA,gBAAAA,MAAMC,MAAM,sBAAZD,0BAAAA,aAAc,CAACd,UAAUyC,OAAOrB,KAAK,EAAE,qBAAvCN,wBAAyCS,IAAI,MAAK,QAAQ;QAC9DwC,QAAQ,IAAGd,+BAAAA,OAAOhC,OAAO,CAACwB,OAAOrB,KAAK,CAAC,YAA5B6B,+BAAgCR,OAAOrB,KAAK,CAAC,SAAS,CAAC,EAAEqB,OAAOjC,KAAK;IAClF;IACA,OAAO;QAAEiD;QAAMC;QAASI;IAAQ;AAClC"}
|