gbs-add-block 2.0.4 → 2.3.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/.gbs/skills/gbs-components/SKILL.md +190 -134
- package/.gbs/skills/gbs-components/references/install.md +25 -3
- package/.gbs/skills/gbs-components/references/styling.md +246 -201
- package/CHANGELOG.md +74 -0
- package/README.md +157 -11
- package/index.cjs +212 -3
- package/package.json +41 -10
- package/schema/passport-v1.schema.json +204 -0
- package/source/beta-components/accordion/passport.json +259 -0
- package/source/beta-components/accordion/styles.css +207 -208
- package/source/beta-components/alert/passport.json +250 -0
- package/source/beta-components/alert/styles.css +154 -155
- package/source/beta-components/avatar/passport.json +294 -0
- package/source/beta-components/avatar/styles.css +201 -203
- package/source/beta-components/badge/passport.json +332 -0
- package/source/beta-components/badge/styles.css +203 -204
- package/source/beta-components/breadcrumb/passport.json +243 -0
- package/source/beta-components/breadcrumb/styles.css +138 -139
- package/source/beta-components/button/passport.json +402 -0
- package/source/beta-components/button/passport.manual.json +31 -0
- package/source/beta-components/button/styles.css +232 -233
- package/source/beta-components/card/passport.json +337 -0
- package/source/beta-components/card/styles.css +230 -231
- package/source/beta-components/checkbox/passport.json +456 -0
- package/source/beta-components/checkbox/styles.css +211 -212
- package/source/beta-components/combobox/passport.json +456 -0
- package/source/beta-components/combobox/styles.css +419 -417
- package/source/beta-components/data-grid/agent/coerce.ts +368 -0
- package/source/beta-components/data-grid/agent/contract.ts +410 -0
- package/source/beta-components/data-grid/agent/dataset.ts +92 -0
- package/source/beta-components/data-grid/agent/engine.ts +470 -0
- package/source/beta-components/data-grid/agent/executors.ts +155 -0
- package/source/beta-components/data-grid/agent/index.ts +79 -0
- package/source/beta-components/data-grid/agent/intent.ts +324 -0
- package/source/beta-components/data-grid/agent/operations.ts +335 -0
- package/source/beta-components/data-grid/agent/validate.ts +630 -0
- package/source/beta-components/data-grid/agent/webmcp.ts +107 -0
- package/source/beta-components/data-grid/index.ts +14 -7
- package/source/beta-components/data-grid/passport.json +1051 -0
- package/source/beta-components/data-grid/passport.manual.json +255 -0
- package/source/beta-components/data-grid/react/AskGrid.tsx +164 -0
- package/source/beta-components/data-grid/react/DataGrid.tsx +39 -0
- package/source/beta-components/data-grid/styles.css +874 -717
- package/source/beta-components/date-picker/passport.json +407 -0
- package/source/beta-components/date-picker/styles.css +445 -446
- package/source/beta-components/dialog/passport.json +344 -0
- package/source/beta-components/dialog/styles.css +280 -278
- package/source/beta-components/file-uploader/passport.json +518 -0
- package/source/beta-components/file-uploader/styles.css +394 -395
- package/source/beta-components/input/passport.json +536 -0
- package/source/beta-components/input/styles.css +295 -296
- package/source/beta-components/menu/passport.json +322 -0
- package/source/beta-components/menu/styles.css +224 -222
- package/source/beta-components/modal/passport.json +289 -0
- package/source/beta-components/modal/styles.css +241 -239
- package/source/beta-components/number-input/passport.json +541 -0
- package/source/beta-components/number-input/styles.css +230 -231
- package/source/beta-components/popover/passport.json +238 -0
- package/source/beta-components/popover/styles.css +148 -146
- package/source/beta-components/progress/passport.json +270 -0
- package/source/beta-components/progress/styles.css +200 -202
- package/source/beta-components/radio-group/passport.json +477 -0
- package/source/beta-components/radio-group/styles.css +241 -242
- package/source/beta-components/shared/core/agent/adapter.ts +65 -0
- package/source/beta-components/shared/core/agent/history.ts +120 -0
- package/source/beta-components/shared/core/agent/index.ts +46 -0
- package/source/beta-components/shared/core/agent/numbers.ts +217 -0
- package/source/beta-components/shared/core/agent/schema.ts +180 -0
- package/source/beta-components/shared/core/agent/types.ts +169 -0
- package/source/beta-components/shared/core/agent/webmcp.ts +328 -0
- package/source/beta-components/shared/index.ts +9 -0
- package/source/beta-components/shared/react/GramproAIProvider.tsx +50 -0
- package/source/beta-components/shared/react/useAskAgent.ts +217 -0
- package/source/beta-components/shared/styles.css +79 -0
- package/source/beta-components/shared/version.json +4 -4
- package/source/beta-components/shared/version.ts +6 -6
- package/source/beta-components/skeleton/passport.json +251 -0
- package/source/beta-components/skeleton/styles.css +185 -186
- package/source/beta-components/spinner/passport.json +245 -0
- package/source/beta-components/spinner/styles.css +145 -146
- package/source/beta-components/switch/passport.json +421 -0
- package/source/beta-components/switch/styles.css +194 -196
- package/source/beta-components/tabs/passport.json +315 -0
- package/source/beta-components/tabs/styles.css +234 -235
- package/source/beta-components/textarea/passport.json +382 -0
- package/source/beta-components/textarea/styles.css +158 -159
- package/source/beta-components/toaster/passport.json +221 -0
- package/source/beta-components/toaster/styles.css +282 -283
- package/source/beta-components/tooltip/passport.json +170 -0
- package/source/beta-components/tooltip/styles.css +71 -72
- package/tools/env.cjs +61 -0
- package/tools/passport/cli.cjs +79 -0
- package/tools/passport/extract.cjs +493 -0
- package/tools/passport/index.cjs +185 -0
- package/tools/passport/merge.cjs +131 -0
- package/tools/passport/policy.cjs +65 -0
- package/tools/passport/validate.cjs +277 -0
- package/tools/ts-require.cjs +79 -0
- package/source/beta-components/accordion/__tests__/core.test.ts +0 -58
- package/source/beta-components/alert/__tests__/core.test.ts +0 -17
- package/source/beta-components/avatar/__tests__/core.test.ts +0 -88
- package/source/beta-components/badge/__tests__/core.test.ts +0 -46
- package/source/beta-components/breadcrumb/__tests__/core.test.ts +0 -58
- package/source/beta-components/button/__tests__/core.test.ts +0 -31
- package/source/beta-components/card/__tests__/core.test.ts +0 -57
- package/source/beta-components/checkbox/__tests__/core.test.ts +0 -40
- package/source/beta-components/combobox/__tests__/core.test.ts +0 -134
- package/source/beta-components/data-grid/__tests__/core.test.ts +0 -356
- package/source/beta-components/data-grid/__tests__/export.test.ts +0 -70
- package/source/beta-components/data-grid/__tests__/pdf.test.ts +0 -209
- package/source/beta-components/date-picker/__tests__/core.test.ts +0 -273
- package/source/beta-components/dialog/__tests__/core.test.ts +0 -86
- package/source/beta-components/file-uploader/__tests__/core.test.ts +0 -395
- package/source/beta-components/input/__tests__/core.test.ts +0 -75
- package/source/beta-components/menu/__tests__/core.test.ts +0 -120
- package/source/beta-components/modal/__tests__/core.test.ts +0 -55
- package/source/beta-components/number-input/__tests__/core.test.ts +0 -151
- package/source/beta-components/progress/__tests__/core.test.ts +0 -56
- package/source/beta-components/radio-group/__tests__/core.test.ts +0 -64
- package/source/beta-components/shared/__tests__/boundaries.test.ts +0 -95
- package/source/beta-components/shared/__tests__/core.test.ts +0 -55
- package/source/beta-components/shared/__tests__/position.test.ts +0 -143
- package/source/beta-components/skeleton/__tests__/core.test.ts +0 -41
- package/source/beta-components/spinner/__tests__/core.test.ts +0 -48
- package/source/beta-components/switch/__tests__/core.test.ts +0 -64
- package/source/beta-components/tabs/__tests__/core.test.ts +0 -51
- package/source/beta-components/textarea/__tests__/core.test.ts +0 -38
- package/source/beta-components/toaster/__tests__/core.test.ts +0 -256
- package/source/beta-components/tooltip/__tests__/core.test.ts +0 -42
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Turning what a person wrote into what the column stores.
|
|
3
|
+
*
|
|
4
|
+
* This is the layer that exists so a model never has to do arithmetic. "1
|
|
5
|
+
* lakh" is 100000 every single time here; ask a model and it is 100000 almost
|
|
6
|
+
* every time, which is worse, because the failures are silent and look like
|
|
7
|
+
* data. The same goes for "last quarter", for "20%" on a column that stores
|
|
8
|
+
* 0.2, and for "kerala" where the data says "Kerala".
|
|
9
|
+
*
|
|
10
|
+
* Every conversion that changes the caller's input leaves a note, so the
|
|
11
|
+
* confirmation UI can show its working:
|
|
12
|
+
*
|
|
13
|
+
* you asked revenue above 1 lakh
|
|
14
|
+
* reading as revenue > ₹1,00,000
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { parseQuantity } from "../../shared/core/agent";
|
|
18
|
+
import type { FilterOperator, FilterValue } from "../core/types";
|
|
19
|
+
import { toTime } from "../core/values";
|
|
20
|
+
import type { GridContractColumn } from "./contract";
|
|
21
|
+
|
|
22
|
+
export interface CoercionNote {
|
|
23
|
+
code: string;
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type CoercionOutcome =
|
|
28
|
+
| { ok: true; value: FilterValue | undefined; notes: CoercionNote[] }
|
|
29
|
+
| { ok: false; code: string; message: string; suggestion?: string };
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------- near matching
|
|
32
|
+
|
|
33
|
+
const distance = (a: string, b: string): number => {
|
|
34
|
+
const previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
35
|
+
for (let i = 1; i <= a.length; i++) {
|
|
36
|
+
let diagonal = previous[0];
|
|
37
|
+
previous[0] = i;
|
|
38
|
+
for (let j = 1; j <= b.length; j++) {
|
|
39
|
+
const next = Math.min(
|
|
40
|
+
previous[j] + 1,
|
|
41
|
+
previous[j - 1] + 1,
|
|
42
|
+
diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
43
|
+
);
|
|
44
|
+
diagonal = previous[j];
|
|
45
|
+
previous[j] = next;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return previous[b.length];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The closest of `candidates` to `text`, or null when nothing is close. */
|
|
52
|
+
export function nearest(text: string, candidates: readonly string[]): string | null {
|
|
53
|
+
const needle = text.trim().toLowerCase();
|
|
54
|
+
if (needle === "") return null;
|
|
55
|
+
|
|
56
|
+
let best: { value: string; score: number } | null = null;
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
const hay = candidate.toLowerCase();
|
|
59
|
+
const score = hay === needle ? 0 : hay.includes(needle) || needle.includes(hay) ? 1 : distance(needle, hay) + 1;
|
|
60
|
+
if (!best || score < best.score) best = { value: candidate, score };
|
|
61
|
+
}
|
|
62
|
+
if (!best) return null;
|
|
63
|
+
const tolerance = Math.max(2, Math.floor(needle.length * 0.4)) + 1;
|
|
64
|
+
return best.score <= tolerance ? best.value : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --------------------------------------------------------------- date phrases
|
|
68
|
+
|
|
69
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
70
|
+
const iso = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
71
|
+
const shift = (date: Date, days: number) => {
|
|
72
|
+
const next = new Date(date);
|
|
73
|
+
next.setDate(next.getDate() + days);
|
|
74
|
+
return next;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface DateRange {
|
|
78
|
+
from: string;
|
|
79
|
+
to: string;
|
|
80
|
+
/** True when the phrase names a single day. */
|
|
81
|
+
single: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolves the relative date phrases the coercion layer understands. Anything
|
|
86
|
+
* not on this list is left alone and parsed as a date, so an unknown phrase
|
|
87
|
+
* fails loudly rather than resolving to something plausible but wrong.
|
|
88
|
+
*
|
|
89
|
+
* Weeks start on Monday.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveRelativeDate(text: string, now: Date = new Date()): DateRange | null {
|
|
92
|
+
const phrase = text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
93
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
94
|
+
|
|
95
|
+
const day = (date: Date): DateRange => ({ from: iso(date), to: iso(date), single: true });
|
|
96
|
+
const span = (from: Date, to: Date): DateRange => ({ from: iso(from), to: iso(to), single: false });
|
|
97
|
+
|
|
98
|
+
switch (phrase) {
|
|
99
|
+
case "today":
|
|
100
|
+
return day(today);
|
|
101
|
+
case "yesterday":
|
|
102
|
+
return day(shift(today, -1));
|
|
103
|
+
case "tomorrow":
|
|
104
|
+
return day(shift(today, 1));
|
|
105
|
+
default:
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const startOfWeek = (date: Date) => shift(date, -((date.getDay() + 6) % 7));
|
|
110
|
+
const quarter = (date: Date) => Math.floor(date.getMonth() / 3);
|
|
111
|
+
|
|
112
|
+
switch (phrase) {
|
|
113
|
+
case "this week":
|
|
114
|
+
return span(startOfWeek(today), today);
|
|
115
|
+
case "last week": {
|
|
116
|
+
const start = shift(startOfWeek(today), -7);
|
|
117
|
+
return span(start, shift(start, 6));
|
|
118
|
+
}
|
|
119
|
+
case "this month":
|
|
120
|
+
return span(new Date(today.getFullYear(), today.getMonth(), 1), today);
|
|
121
|
+
case "last month": {
|
|
122
|
+
const start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
|
123
|
+
return span(start, new Date(today.getFullYear(), today.getMonth(), 0));
|
|
124
|
+
}
|
|
125
|
+
case "this quarter":
|
|
126
|
+
return span(new Date(today.getFullYear(), quarter(today) * 3, 1), today);
|
|
127
|
+
case "last quarter": {
|
|
128
|
+
const start = new Date(today.getFullYear(), quarter(today) * 3 - 3, 1);
|
|
129
|
+
return span(start, new Date(start.getFullYear(), start.getMonth() + 3, 0));
|
|
130
|
+
}
|
|
131
|
+
case "this year":
|
|
132
|
+
return span(new Date(today.getFullYear(), 0, 1), today);
|
|
133
|
+
case "last year":
|
|
134
|
+
return span(new Date(today.getFullYear() - 1, 0, 1), new Date(today.getFullYear() - 1, 11, 31));
|
|
135
|
+
default:
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const rolling = /^(?:in\s+the\s+)?(?:last|past)\s+(\d{1,4})\s+(day|week|month|year)s?$/.exec(phrase);
|
|
140
|
+
if (rolling) {
|
|
141
|
+
const amount = Number(rolling[1]);
|
|
142
|
+
const unit = rolling[2];
|
|
143
|
+
const start = new Date(today);
|
|
144
|
+
if (unit === "day") start.setDate(start.getDate() - amount);
|
|
145
|
+
if (unit === "week") start.setDate(start.getDate() - amount * 7);
|
|
146
|
+
if (unit === "month") start.setMonth(start.getMonth() - amount);
|
|
147
|
+
if (unit === "year") start.setFullYear(start.getFullYear() - amount);
|
|
148
|
+
return span(start, today);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ------------------------------------------------------------------- booleans
|
|
155
|
+
|
|
156
|
+
const TRUE_WORDS = new Set(["true", "yes", "y", "1", "on", "enabled"]);
|
|
157
|
+
const FALSE_WORDS = new Set(["false", "no", "n", "0", "off", "disabled"]);
|
|
158
|
+
|
|
159
|
+
// --------------------------------------------------------------------- values
|
|
160
|
+
|
|
161
|
+
export interface CoerceOptions {
|
|
162
|
+
/** Reference date for relative phrases. Injected so tests are stable. */
|
|
163
|
+
now?: Date;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Canonical form of one option value written by a caller, or null. */
|
|
167
|
+
function matchOption(
|
|
168
|
+
column: GridContractColumn,
|
|
169
|
+
raw: unknown,
|
|
170
|
+
): { value: string | number; exact: boolean } | null {
|
|
171
|
+
const options = column.options;
|
|
172
|
+
if (!options) return null;
|
|
173
|
+
for (const option of options) if (option.value === raw) return { value: option.value, exact: true };
|
|
174
|
+
|
|
175
|
+
const text = String(raw).trim().toLowerCase();
|
|
176
|
+
for (const option of options) {
|
|
177
|
+
if (String(option.value).toLowerCase() === text || option.label.toLowerCase() === text) {
|
|
178
|
+
return { value: option.value, exact: false };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function coerceScalar(
|
|
185
|
+
column: GridContractColumn,
|
|
186
|
+
raw: unknown,
|
|
187
|
+
options: CoerceOptions,
|
|
188
|
+
notes: CoercionNote[],
|
|
189
|
+
): CoercionOutcome {
|
|
190
|
+
const note = (code: string, message: string) => notes.push({ code, message });
|
|
191
|
+
|
|
192
|
+
if (raw === null) return { ok: true, value: null, notes };
|
|
193
|
+
|
|
194
|
+
// A closed option set wins over the column's nominal type.
|
|
195
|
+
if (column.options) {
|
|
196
|
+
const matched = matchOption(column, raw);
|
|
197
|
+
if (!matched) {
|
|
198
|
+
const labels = column.options.map((option) => option.label);
|
|
199
|
+
const guess = nearest(String(raw), [...labels, ...column.options.map((o) => String(o.value))]);
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
code: "unknown-option",
|
|
203
|
+
message: `${column.label} has no option "${String(raw)}"`,
|
|
204
|
+
...(guess ? { suggestion: `Did you mean "${guess}"?` } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (!matched.exact) note("option-normalised", `Read "${String(raw)}" as "${matched.value}".`);
|
|
208
|
+
return { ok: true, value: matched.value, notes };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
switch (column.type) {
|
|
212
|
+
case "number": {
|
|
213
|
+
const quantity = parseQuantity(raw as string | number);
|
|
214
|
+
if (!quantity) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
code: "not-a-number",
|
|
218
|
+
message: `${column.label} is a number, and "${String(raw)}" is not one`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
let value = quantity.value;
|
|
222
|
+
|
|
223
|
+
if (quantity.scale) note("scale-applied", `Read "${String(raw)}" as ${value}.`);
|
|
224
|
+
if (quantity.currency && column.unit && quantity.currency !== column.unit) {
|
|
225
|
+
note(
|
|
226
|
+
"currency-mismatch",
|
|
227
|
+
`Written in ${quantity.currency}; ${column.label} is in ${column.unit}. No conversion was applied.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const basis = column.percentBasis ?? (column.unit === "%" ? "whole" : undefined);
|
|
232
|
+
if (basis === "fraction") {
|
|
233
|
+
if (quantity.percent) {
|
|
234
|
+
value = value / 100;
|
|
235
|
+
note("percent-converted", `${column.label} stores fractions, so ${quantity.value}% is ${value}.`);
|
|
236
|
+
} else if (Math.abs(value) > 1) {
|
|
237
|
+
value = value / 100;
|
|
238
|
+
note(
|
|
239
|
+
"percent-basis-assumed",
|
|
240
|
+
`${column.label} stores fractions, so ${quantity.value} was read as ${value}.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
} else if (quantity.percent && basis === undefined) {
|
|
244
|
+
note("percent-ignored", `${column.label} is not a percentage; the % sign was ignored.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { ok: true, value, notes };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case "date": {
|
|
251
|
+
if (typeof raw === "string") {
|
|
252
|
+
const range = resolveRelativeDate(raw, options.now);
|
|
253
|
+
if (range) {
|
|
254
|
+
note("relative-date", `Read "${raw}" as ${range.from}${range.single ? "" : ` to ${range.to}`}.`);
|
|
255
|
+
return { ok: true, value: range.from, notes };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const time = toTime(raw);
|
|
259
|
+
if (time === null) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
code: "not-a-date",
|
|
263
|
+
message: `${column.label} is a date, and "${String(raw)}" is not one`,
|
|
264
|
+
suggestion: "Use YYYY-MM-DD, or a phrase such as \"last month\".",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const value = iso(new Date(time));
|
|
268
|
+
if (value !== raw) note("date-normalised", `Read "${String(raw)}" as ${value}.`);
|
|
269
|
+
return { ok: true, value, notes };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "boolean": {
|
|
273
|
+
if (typeof raw === "boolean") return { ok: true, value: raw, notes };
|
|
274
|
+
const text = String(raw).trim().toLowerCase();
|
|
275
|
+
if (TRUE_WORDS.has(text)) return { ok: true, value: true, notes };
|
|
276
|
+
if (FALSE_WORDS.has(text)) return { ok: true, value: false, notes };
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
code: "not-a-boolean",
|
|
280
|
+
message: `${column.label} is yes/no, and "${String(raw)}" is neither`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
default: {
|
|
285
|
+
const text = String(raw);
|
|
286
|
+
const known = column.stats?.kind === "string" ? column.stats.values : undefined;
|
|
287
|
+
// Only exact-match operators care whether the value exists in the data.
|
|
288
|
+
if (known && known.length > 0) {
|
|
289
|
+
const exact = known.find((candidate) => candidate === text);
|
|
290
|
+
if (!exact) {
|
|
291
|
+
const insensitive = known.find((candidate) => candidate.toLowerCase() === text.toLowerCase());
|
|
292
|
+
if (insensitive) {
|
|
293
|
+
note("case-normalised", `Read "${text}" as "${insensitive}".`);
|
|
294
|
+
return { ok: true, value: insensitive, notes };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, value: text, notes };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Reads a filter's value, and its upper bound where the operator takes one.
|
|
305
|
+
*
|
|
306
|
+
* A relative phrase that names a span is the one case where coercion changes
|
|
307
|
+
* the operator: "signed up last month" is a range, so `equals` becomes
|
|
308
|
+
* `between`. The rewrite is reported, never silent.
|
|
309
|
+
*/
|
|
310
|
+
export function coerceFilter(
|
|
311
|
+
column: GridContractColumn,
|
|
312
|
+
operator: FilterOperator,
|
|
313
|
+
value: unknown,
|
|
314
|
+
value2: unknown,
|
|
315
|
+
options: CoerceOptions = {},
|
|
316
|
+
): | {
|
|
317
|
+
ok: true;
|
|
318
|
+
operator: FilterOperator;
|
|
319
|
+
value: FilterValue | undefined;
|
|
320
|
+
value2: FilterValue | undefined;
|
|
321
|
+
notes: CoercionNote[];
|
|
322
|
+
}
|
|
323
|
+
| { ok: false; code: string; message: string; suggestion?: string } {
|
|
324
|
+
const notes: CoercionNote[] = [];
|
|
325
|
+
|
|
326
|
+
if (operator === "isEmpty" || operator === "isNotEmpty") {
|
|
327
|
+
return { ok: true, operator, value: undefined, value2: undefined, notes };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (operator === "in") {
|
|
331
|
+
const list = Array.isArray(value) ? value : [value];
|
|
332
|
+
const out: (string | number)[] = [];
|
|
333
|
+
for (const item of list) {
|
|
334
|
+
const result = coerceScalar(column, item, options, notes);
|
|
335
|
+
if (!result.ok) return result;
|
|
336
|
+
out.push(result.value as string | number);
|
|
337
|
+
}
|
|
338
|
+
return { ok: true, operator, value: out, value2: undefined, notes };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// A date phrase covering a span widens the operator before anything else.
|
|
342
|
+
if (column.type === "date" && typeof value === "string" && value2 === undefined) {
|
|
343
|
+
const range = resolveRelativeDate(value, options.now);
|
|
344
|
+
if (range && !range.single && (operator === "equals" || operator === "between")) {
|
|
345
|
+
notes.push({
|
|
346
|
+
code: "relative-range",
|
|
347
|
+
message: `Read "${value}" as ${range.from} to ${range.to}${
|
|
348
|
+
operator === "equals" ? ", which is a range rather than a single day" : ""
|
|
349
|
+
}.`,
|
|
350
|
+
});
|
|
351
|
+
return { ok: true, operator: "between", value: range.from, value2: range.to, notes };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const first = coerceScalar(column, value, options, notes);
|
|
356
|
+
if (!first.ok) return first;
|
|
357
|
+
|
|
358
|
+
if (operator !== "between") {
|
|
359
|
+
return { ok: true, operator, value: first.value, value2: undefined, notes };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (value2 === undefined) {
|
|
363
|
+
return { ok: true, operator, value: first.value, value2: undefined, notes };
|
|
364
|
+
}
|
|
365
|
+
const second = coerceScalar(column, value2, options, notes);
|
|
366
|
+
if (!second.ok) return second;
|
|
367
|
+
return { ok: true, operator, value: first.value, value2: second.value, notes };
|
|
368
|
+
}
|