@@ -30,39 +30,54 @@ interface PeopleSheet {
3030}
3131
3232/**
33- * The first sheet as text rows keyed by header. Delimited text is read verbatim so the server
34- * interprets dates the same way for every file; spreadsheet date cells become `YYYY-MM-DD`.
33+ * The first sheet as text rows keyed by header, one per sheet row (blank ones included) so the
34+ * row numbers in the preview match the spreadsheet. Delimited text is read verbatim so the server
35+ * interprets dates the same way for every file. A spreadsheet date cell is read from its stored
36+ * value, not the text its locale displays, so `31/01/2024` and `1/31/24` both arrive as
37+ * `2024-01-31`.
3538 */
3639async function readPeopleSheet ( file : File ) : Promise < PeopleSheet > {
3740 if ( file . size > MAX_FILE_BYTES ) throw new Error ( 'Choose a file under 5 MB' )
3841 const XLSX = await import ( 'xlsx' )
39- const delimited = / \. ( c s v | t s v ) $ / i. test ( file . name )
4042 const workbook = XLSX . read ( await file . arrayBuffer ( ) , {
4143 type : 'array' ,
42- raw : delimited ,
43- cellDates : ! delimited ,
44+ raw : / \. ( c s v | t s v ) $ / i. test ( file . name ) ,
4445 dense : true ,
4546 } )
4647 const sheet = workbook . Sheets [ workbook . SheetNames [ 0 ] ?? '' ]
47- if ( ! sheet ) throw new Error ( 'The file has no sheets' )
48- const [ headerRow = [ ] , ...body ] = XLSX . utils . sheet_to_json < unknown [ ] > ( sheet , {
49- header : 1 ,
50- raw : false ,
51- dateNF : 'yyyy-mm-dd' ,
52- defval : '' ,
53- blankrows : false ,
54- } )
55- const headers = headerRow . map ( ( value ) => String ( value ) . trim ( ) )
56- const columns = headers . flatMap ( ( header , index ) => ( header ? [ { header, index } ] : [ ] ) )
48+ if ( ! sheet ?. [ '!ref' ] ) throw new Error ( 'The file has no rows' )
49+ const range = XLSX . utils . decode_range ( sheet [ '!ref' ] )
50+ const grid = sheet [ '!data' ] ?? [ ]
51+ const text = ( row : number , column : number ) : string => {
52+ const cell = grid [ row ] ?. [ column ]
53+ if ( ! cell || cell . v === undefined || cell . v === null ) return ''
54+ if ( cell . t === 'n' && XLSX . SSF . is_date ( cell . z ?? '' ) ) {
55+ const date = XLSX . SSF . parse_date_code ( Number ( cell . v ) )
56+ return `${ date . y } -${ String ( date . m ) . padStart ( 2 , '0' ) } -${ String ( date . d ) . padStart ( 2 , '0' ) } `
57+ }
58+ return String ( cell . w ?? cell . v ) . trim ( )
59+ }
60+ const columns : { header : string ; index : number } [ ] = [ ]
61+ for ( let index = range . s . c ; index <= range . e . c ; index ++ ) {
62+ const header = text ( range . s . r , index )
63+ if ( ! header ) continue
64+ if ( columns . some ( ( column ) => column . header === header ) ) {
65+ throw new Error ( `The ${ header } column appears more than once` )
66+ }
67+ columns . push ( { header, index } )
68+ }
5769 if ( ! columns . length ) throw new Error ( 'The first row should name the columns' )
58- if ( body . length > PEOPLE_IMPORT_MAX_ROWS ) {
70+ const rowCount = range . e . r - range . s . r
71+ if ( rowCount > PEOPLE_IMPORT_MAX_ROWS ) {
5972 throw new Error ( `Import at most ${ PEOPLE_IMPORT_MAX_ROWS } rows at a time` )
6073 }
6174 return {
6275 fileName : file . name ,
6376 headers : columns . map ( ( { header } ) => header ) ,
64- rows : body . map ( ( row ) =>
65- Object . fromEntries ( columns . map ( ( { header, index } ) => [ header , String ( row [ index ] ?? '' ) ] ) )
77+ rows : Array . from ( { length : rowCount } , ( _ , offset ) =>
78+ Object . fromEntries (
79+ columns . map ( ( { header, index } ) => [ header , text ( range . s . r + 1 + offset , index ) ] )
80+ )
6681 ) ,
6782 }
6883}
0 commit comments