1- // Value coercion for the AI validator: the parsing rules of clipboard paste (3.4.0 design, Part B),
2- // as small pure functions. Each takes the raw value (usually text) and the column and returns the
3- // typed value or a reason. Empty input is valid: null, or '' for text columns.
4- import { toDate } from '../utils/values' ;
1+ // Value coercion for the AI validator. Text goes through the same parsers as clipboard paste
2+ // (`lib/utils/parsing.ts`), so a value the grid accepts from Excel is accepted from a model and the
3+ // other way round. Model output is JSON, so typed values (a number, a boolean, a Date) are taken as
4+ // they are. Empty input is valid: null, or '' for text columns.
5+ import {
6+ parseBooleanText ,
7+ parseDateText ,
8+ parseNumberText ,
9+ parseSingleSelectText ,
10+ type GridParsedValue ,
11+ } from '../utils/parsing' ;
512
613export type CoerceResult < T = unknown > = { ok : true ; value : T } | { ok : false ; reason : 'invalidValue' } ;
714
@@ -14,10 +21,8 @@ export interface CoerceColumn {
1421const ok = < T > ( value : T ) : CoerceResult < T > => ( { ok : true , value } ) ;
1522const INVALID : CoerceResult < never > = { ok : false , reason : 'invalidValue' } ;
1623
17- const SPACE = / \s / ;
18- const CURRENCY = / \p{ Sc} / u;
19- const NUMBER_CHARS = / ^ [ 0 - 9 . e E + - ] + $ / ;
20- const DIGIT = / [ 0 - 9 ] / ;
24+ const fromParsed = < T > ( parsed : GridParsedValue ) : CoerceResult < T > =>
25+ parsed . ok ? ok ( parsed . value as T ) : INVALID ;
2126
2227function isBlank ( text : unknown ) : boolean {
2328 return text == null || ( typeof text === 'string' && text . trim ( ) === '' ) ;
@@ -31,78 +36,41 @@ export function coerceString(text: unknown): CoerceResult<string> {
3136 return INVALID ;
3237}
3338
34- /**
35- * `number` columns: `1234.5`, `1,234.5`, `-12`, `12%` (0.12). Currency symbols and spaces are
36- * stripped; a comma is a thousands separator and may only appear before the decimal point.
37- */
39+ /** `number` columns: a finite number, or text in the paste format (`1,234.5`, `12%`, `$40`). */
3840export function coerceNumber ( text : unknown ) : CoerceResult < number | null > {
3941 if ( isBlank ( text ) ) return ok ( null ) ;
4042 if ( typeof text === 'number' ) return Number . isFinite ( text ) ? ok ( text ) : INVALID ;
4143 if ( typeof text !== 'string' ) return INVALID ;
42- let source = text . trim ( ) ;
43- const percent = source . endsWith ( '%' ) ;
44- if ( percent ) source = source . slice ( 0 , - 1 ) ;
45- let digits = '' ;
46- let seenPoint = false ;
47- // A plain loop, not a regex with adjacent quantifiers: this runs on model output of any length.
48- for ( const ch of source ) {
49- if ( ch === ',' ) {
50- if ( seenPoint ) return INVALID ;
51- continue ;
52- }
53- if ( SPACE . test ( ch ) || CURRENCY . test ( ch ) ) continue ;
54- if ( ch === '.' ) seenPoint = true ;
55- digits += ch ;
56- }
57- if ( ! NUMBER_CHARS . test ( digits ) || ! DIGIT . test ( digits ) ) return INVALID ;
58- const n = Number ( digits ) ;
59- if ( ! Number . isFinite ( n ) ) return INVALID ;
60- return ok ( percent ? n / 100 : n ) ;
44+ return fromParsed ( parseNumberText ( text ) ) ;
6145}
6246
63- /**
64- * `date` columns: ISO `2026-10-07` (a local calendar day), and whatever `Date.parse` accepts.
65- * Date objects and epoch milliseconds are taken as they are.
66- */
47+ /** `date` columns: a Date, epoch milliseconds, or text in the paste format (ISO `2026-10-07` …). */
6748export function coerceDate ( text : unknown ) : CoerceResult < Date | null > {
6849 if ( isBlank ( text ) ) return ok ( null ) ;
69- if ( typeof text !== 'string' && typeof text !== 'number' && ! ( text instanceof Date ) ) return INVALID ;
70- const date = toDate ( text ) ;
71- return date ? ok ( date ) : INVALID ;
50+ if ( text instanceof Date ) return Number . isNaN ( text . getTime ( ) ) ? INVALID : ok ( text ) ;
51+ if ( typeof text === 'number' ) return Number . isFinite ( text ) ? ok ( new Date ( text ) ) : INVALID ;
52+ if ( typeof text !== 'string' ) return INVALID ;
53+ return fromParsed ( parseDateText ( text ) ) ;
7254}
7355
74- const TRUE_WORDS = new Set ( [ 'true' , 'yes' , '1' ] ) ;
75- const FALSE_WORDS = new Set ( [ 'false' , 'no' , '0' ] ) ;
76-
77- /** `boolean` columns: `true`/`false`, `yes`/`no`, `1`/`0`, case-insensitive. */
56+ /** `boolean` columns: a boolean, or `true`/`false`, `yes`/`no`, `1`/`0` (case-insensitive). */
7857export function coerceBoolean ( text : unknown ) : CoerceResult < boolean | null > {
7958 if ( isBlank ( text ) ) return ok ( null ) ;
8059 if ( typeof text === 'boolean' ) return ok ( text ) ;
8160 if ( typeof text !== 'string' && typeof text !== 'number' ) return INVALID ;
82- const word = String ( text ) . trim ( ) . toLowerCase ( ) ;
83- if ( TRUE_WORDS . has ( word ) ) return ok ( true ) ;
84- if ( FALSE_WORDS . has ( word ) ) return ok ( false ) ;
85- return INVALID ;
61+ return fromParsed ( parseBooleanText ( String ( text ) ) ) ;
8662}
8763
8864/**
89- * `singleSelect` columns: an option value, or an option label, case-insensitive. Returns the
90- * option's value. Without `valueOptions`, any text is accepted .
65+ * `singleSelect` columns: an option value or label, case-insensitive; returns the option's value.
66+ * Without `valueOptions` the model has nothing to choose from, so any scalar is kept as it is .
9167 */
9268export function coerceSingleSelect ( text : unknown , colDef : CoerceColumn ) : CoerceResult < unknown > {
9369 if ( isBlank ( text ) ) return ok ( null ) ;
9470 if ( typeof text !== 'string' && typeof text !== 'number' && typeof text !== 'boolean' ) return INVALID ;
9571 const options = colDef . valueOptions ;
9672 if ( ! options || options . length === 0 ) return ok ( text ) ;
97- const wanted = String ( text ) . trim ( ) . toLowerCase ( ) ;
98- for ( const option of options ) {
99- const value = option !== null && typeof option === 'object' ? option . value : option ;
100- if ( Object . is ( value , text ) || ( value != null && String ( value ) . toLowerCase ( ) === wanted ) ) return ok ( value ) ;
101- }
102- for ( const option of options ) {
103- if ( option !== null && typeof option === 'object' && String ( option . label ) . toLowerCase ( ) === wanted ) return ok ( option . value ) ;
104- }
105- return INVALID ;
73+ return fromParsed ( parseSingleSelectText ( String ( text ) , options ) ) ;
10674}
10775
10876/** Coerces `text` for the column's `type`. */
0 commit comments