@@ -382,6 +382,75 @@ function parseCsvToRows(csv: string, mapping: Record<string, string> = {}): Arra
382382 return out ;
383383}
384384
385+ /**
386+ * Flatten one ExcelJS cell value to the raw string the coercion layer expects.
387+ * ExcelJS hands back rich objects for formulas / hyperlinks / rich text / dates;
388+ * we reduce each to the human-visible text so a server-parsed xlsx yields the
389+ * same cells a CSV export would (dates → ISO, so parseDateCell can re-read them).
390+ */
391+ function xlsxCellToString ( value : any ) : string {
392+ if ( value === null || value === undefined ) return '' ;
393+ if ( typeof value === 'string' ) return value ;
394+ if ( typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' ) return String ( value ) ;
395+ if ( value instanceof Date ) return value . toISOString ( ) ;
396+ if ( typeof value === 'object' ) {
397+ // Formula cell → prefer its computed result.
398+ if ( 'result' in value && value . result !== undefined && value . result !== null ) return xlsxCellToString ( value . result ) ;
399+ // Hyperlink cell → the visible text, not the target.
400+ if ( 'text' in value && typeof value . text === 'string' ) return value . text ;
401+ if ( 'hyperlink' in value && typeof value . hyperlink === 'string' ) return value . hyperlink ;
402+ // Rich text → concatenate runs.
403+ if ( Array . isArray ( value . richText ) ) return value . richText . map ( ( r : any ) => r ?. text ?? '' ) . join ( '' ) ;
404+ if ( 'error' in value && value . error ) return String ( value . error ) ;
405+ }
406+ try { return String ( value ) ; } catch { return '' ; }
407+ }
408+
409+ /**
410+ * Parse an .xlsx workbook (raw bytes) into row objects, mirroring
411+ * {@link parseCsvToRows}: first non-empty row is the header, each subsequent row
412+ * becomes `{ header→cell }` with the optional `mapping` renaming columns. Reads
413+ * the named/indexed `sheet` when given, else the first worksheet. Dynamically
414+ * imports ExcelJS (already a dependency of the export path) so CSV/JSON imports
415+ * don't pay for it.
416+ */
417+ async function parseXlsxToRows (
418+ buffer : Buffer | ArrayBuffer ,
419+ mapping : Record < string , string > = { } ,
420+ sheet ?: string | number ,
421+ ) : Promise < Array < Record < string , any > > > {
422+ const ExcelJS : any = ( await import ( 'exceljs' ) ) . default ?? ( await import ( 'exceljs' ) ) ;
423+ const wb = new ExcelJS . Workbook ( ) ;
424+ await wb . xlsx . load ( buffer ) ;
425+ const ws = sheet !== undefined ? wb . getWorksheet ( sheet as any ) : wb . worksheets [ 0 ] ;
426+ if ( ! ws ) return [ ] ;
427+
428+ const cells : string [ ] [ ] = [ ] ;
429+ ws . eachRow ( { includeEmpty : false } , ( row : any ) => {
430+ const values = row . values as any [ ] ; // 1-based; index 0 is unused
431+ const line : string [ ] = [ ] ;
432+ for ( let c = 1 ; c < values . length ; c ++ ) line . push ( xlsxCellToString ( values [ c ] ) ) ;
433+ cells . push ( line ) ;
434+ } ) ;
435+ while ( cells . length > 0 && cells [ cells . length - 1 ] . every ( c => c === '' ) ) cells . pop ( ) ;
436+ if ( cells . length < 2 ) return [ ] ;
437+
438+ const header = cells [ 0 ] . map ( h => h . trim ( ) ) ;
439+ const fields = header . map ( h => mapping [ h ] ?? h ) ;
440+ const out : Array < Record < string , any > > = [ ] ;
441+ for ( let r = 1 ; r < cells . length ; r ++ ) {
442+ const line = cells [ r ] ;
443+ const obj : Record < string , any > = { } ;
444+ for ( let c = 0 ; c < fields . length ; c ++ ) {
445+ const key = fields [ c ] ;
446+ if ( ! key ) continue ;
447+ obj [ key ] = line [ c ] ?? '' ;
448+ }
449+ out . push ( obj ) ;
450+ }
451+ return out ;
452+ }
453+
385454/**
386455 * Escape a single value into an RFC-4180 CSV cell. Values containing
387456 * commas, quotes, CR, or LF are wrapped in double-quotes with embedded
@@ -3259,19 +3328,32 @@ export class RestServer {
32593328 return out ;
32603329 } ;
32613330
3262- // Build rows[] from either explicit JSON array or CSV text.
3331+ // Build rows[] from JSON array, CSV text, or a base64 xlsx .
32633332 let rows : Array < Record < string , any > > = [ ] ;
32643333 if ( body . format === 'json' && Array . isArray ( body . rows ) ) {
32653334 rows = ( body . rows as Array < Record < string , any > > ) . map ( applyMapping ) ;
32663335 } else if ( ( body . format === 'csv' || typeof body . csv === 'string' ) && typeof body . csv === 'string' ) {
32673336 rows = parseCsvToRows ( body . csv , mapping ) ;
3337+ } else if ( ( body . format === 'xlsx' || typeof body . xlsxBase64 === 'string' ) && typeof body . xlsxBase64 === 'string' ) {
3338+ // Native server-side xlsx parse — the client uploads raw
3339+ // workbook bytes (base64) instead of pre-flattening to CSV.
3340+ try {
3341+ const buf = Buffer . from ( body . xlsxBase64 , 'base64' ) ;
3342+ rows = await parseXlsxToRows ( buf , mapping , body . sheet ) ;
3343+ } catch ( e : any ) {
3344+ res . status ( 400 ) . json ( {
3345+ code : 'INVALID_REQUEST' ,
3346+ error : `Failed to parse xlsx: ${ e ?. message ?? String ( e ) } ` ,
3347+ } ) ;
3348+ return ;
3349+ }
32683350 } else if ( Array . isArray ( body ) ) {
32693351 // Permissive: a bare JSON array at the top level.
32703352 rows = ( body as Array < Record < string , any > > ) . map ( applyMapping ) ;
32713353 } else {
32723354 res . status ( 400 ) . json ( {
32733355 code : 'INVALID_REQUEST' ,
3274- error : 'Provide either format:"csv" with csv text or format:"json" with rows[]' ,
3356+ error : 'Provide format:"csv" with csv text, format:"json" with rows[], or format:"xlsx" with xlsxBase64 ' ,
32753357 } ) ;
32763358 return ;
32773359 }
0 commit comments