Skip to content

Commit e0b71c4

Browse files
waleedlatif1claude
andcommitted
fix(parsers): render elapsed, time-only and General cells as Excel does
Elapsed formats (`[h]:mm`, `[mm]:ss`) are durations; `cellDates` still parses them into a Date, so the ISO rewrite fabricated `1900-01-01T06:00:00` where Excel shows `30:00`. Their SSF-rendered `w` is now kept. A time-only cell was decided by its epoch year, which breaks in a 1904 workbook where `h:mm:ss` landed on `1904-01-01T12:29:59`; the decision now comes from the format (no `y`/`d`, and every `m` run beside hours or seconds), verified for xlsx, xls, xlsb and ods in both epochs. General numbers round fractions to Excel's 15 significant digits (`=0.1+0.2` reads `0.3`) while integers stay exact. The Files viewer read its workbook without `cellDates`/`cellNF`, which left the normalizer overwriting every rendered `w`; the read now lives in `readXlsxWorkbook` with the display options, and its test builds the fixture through that read path. A tab or line break inside a cell no longer splits the row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 542d925 commit e0b71c4

6 files changed

Lines changed: 235 additions & 36 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
55
import * as XLSX from 'xlsx'
66
import {
77
readXlsxPreviewData,
8+
readXlsxWorkbook,
89
XLSX_MAX_COLUMNS,
910
XLSX_MAX_ROWS,
1011
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data'
@@ -75,19 +76,35 @@ describe('readXlsxPreviewData', () => {
7576
})
7677

7778
/**
78-
* The viewer reads the workbook without `cellDates`, so a date arrives as a
79-
* number carrying the file's formatted text; `raw: false` shows that text
80-
* instead of the serial, and a General number keeps its full digits.
79+
* Built through the viewer's own read path rather than by hand-setting `z`,
80+
* so the assertions cover the read options as well as the conversion.
8181
*/
82+
function typedWorkbook(): ArrayBuffer {
83+
const sheet = XLSX.utils.aoa_to_sheet([['Issued', 'Rate', 'Card', 'Elapsed']])
84+
sheet.A2 = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), z: 'm/d/yyyy' }
85+
sheet.B2 = { t: 'n', v: 0.2, z: '0%' }
86+
sheet.C2 = { t: 'n', v: 4111111111111111 }
87+
sheet.D2 = { t: 'n', v: 1.25, z: '[h]:mm' }
88+
sheet['!ref'] = 'A1:D2'
89+
const book = XLSX.utils.book_new()
90+
XLSX.utils.book_append_sheet(book, sheet, 'Ledger')
91+
const bytes = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer
92+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
93+
}
94+
8295
it('shows display text rather than stored values', () => {
83-
const sheet = XLSX.utils.aoa_to_sheet([['Issued', 'Rate', 'Card']])
84-
sheet.A2 = { t: 'n', v: 46085, z: 'yyyy-mm-dd', w: '2026-03-04' }
85-
sheet.B2 = { t: 'n', v: 0.2, z: '0%', w: '20%' }
86-
sheet.C2 = { t: 'n', v: 4111111111111111, z: 'General', w: '4.11111E+15' }
87-
sheet['!ref'] = 'A1:C2'
96+
const workbook = readXlsxWorkbook(XLSX, typedWorkbook())
8897

89-
const result = readXlsxPreviewData(XLSX, sheet)
98+
const result = readXlsxPreviewData(XLSX, workbook.Sheets.Ledger)
99+
100+
expect(result.rows).toEqual([['2026-03-04', '20%', '4111111111111111', '30:00']])
101+
})
102+
103+
it('reads the workbook with the display-text options', () => {
104+
const read = vi.fn(XLSX.read)
105+
106+
readXlsxWorkbook({ read, utils: XLSX.utils }, typedWorkbook())
90107

91-
expect(result.rows).toEqual([['2026-03-04', '20%', '4111111111111111']])
108+
expect(read.mock.calls[0][1]).toMatchObject({ type: 'array', cellDates: true, cellNF: true })
92109
})
93110
})

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
1-
import type { WorkSheet } from 'xlsx'
2-
import { normalizeSheetDisplayText } from '@/lib/file-parsers/sheet-display-text'
1+
import type { WorkBook, WorkSheet } from 'xlsx'
2+
import {
3+
normalizeSheetDisplayText,
4+
SHEET_DISPLAY_READ_OPTIONS,
5+
} from '@/lib/file-parsers/sheet-display-text'
36

47
export const XLSX_MAX_ROWS = 1_000
58
export const XLSX_MAX_COLUMNS = 200
69

710
interface XlsxModule {
11+
read: typeof import('xlsx').read
812
utils: Pick<typeof import('xlsx').utils, 'decode_range' | 'encode_cell' | 'sheet_to_json'>
913
}
1014

15+
/**
16+
* Reads a workbook for preview with the options that make its cells carry
17+
* display text: without `cellDates` a date arrives as a bare serial and
18+
* without `cellNF` no cell has a format, so every rendered `w` would be
19+
* overwritten as a General number.
20+
*/
21+
export function readXlsxWorkbook(XLSX: XlsxModule, data: ArrayBuffer): WorkBook {
22+
return XLSX.read(new Uint8Array(data), { type: 'array', ...SHEET_DISPLAY_READ_OPTIONS })
23+
}
24+
1125
interface XlsxPreviewData {
1226
headers: string[]
1327
rows: string[][]
@@ -19,20 +33,21 @@ export function readXlsxPreviewData(XLSX: XlsxModule, sheet: WorkSheet): XlsxPre
1933
const declaredRange = XLSX.utils.decode_range(sheet['!ref'] || 'A1')
2034
const lastPreviewRow = Math.min(declaredRange.e.r, declaredRange.s.r + XLSX_MAX_ROWS)
2135
const lastPreviewColumn = Math.min(declaredRange.e.c, declaredRange.s.c + XLSX_MAX_COLUMNS - 1)
22-
const window = {
36+
const previewRange = {
2337
s: declaredRange.s,
2438
e: { r: lastPreviewRow, c: lastPreviewColumn },
2539
}
2640

2741
/**
2842
* Shown as the text a user sees in Excel: `raw: false` emits each cell's
29-
* formatted text so a date cell reads as a date rather than its serial.
43+
* formatted text, so a sheet read through {@link readXlsxWorkbook} shows a
44+
* date as ISO text and `20%` rather than a serial and `0.2`.
3045
*/
31-
normalizeSheetDisplayText(sheet, window, XLSX.utils)
46+
normalizeSheetDisplayText(sheet, previewRange, XLSX.utils)
3247
const previewRows = XLSX.utils.sheet_to_json<string[]>(sheet, {
3348
header: 1,
3449
raw: false,
35-
range: window,
50+
range: previewRange,
3651
})
3752

3853
return {

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1010
import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll'
1111
import {
1212
readXlsxPreviewData,
13+
readXlsxWorkbook,
1314
XLSX_MAX_COLUMNS,
1415
XLSX_MAX_ROWS,
1516
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data'
@@ -55,7 +56,7 @@ export const XlsxPreview = memo(function XlsxPreview({
5556
setRenderError(null)
5657
await assertOoxmlPreviewWithinLimits(data)
5758
const XLSX = await import('xlsx')
58-
const workbook = XLSX.read(new Uint8Array(data), { type: 'array' })
59+
const workbook = readXlsxWorkbook(XLSX, data)
5960
if (!cancelled) {
6061
workbookRef.current = workbook
6162
setSheetNames(workbook.SheetNames)

‎apps/sim/lib/file-parsers/sheet-display-text.test.ts‎

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import * as XLSX from 'xlsx'
6-
import { isoDateText, normalizeSheetDisplayText } from '@/lib/file-parsers/sheet-display-text'
6+
import {
7+
generalNumberText,
8+
isoDateText,
9+
isTimeOnlyFormat,
10+
normalizeSheetDisplayText,
11+
} from '@/lib/file-parsers/sheet-display-text'
712
import { XlsxParser } from '@/lib/file-parsers/xlsx-parser'
813

914
/**
@@ -54,9 +59,8 @@ describe('XlsxParser display text', () => {
5459
'TRUE',
5560
'$2,500.00',
5661
'4111111111111111',
57-
'0.30000000000000004',
58-
'left',
59-
'right',
62+
'0.3',
63+
'left right',
6064
])
6165
})
6266

@@ -66,6 +70,60 @@ describe('XlsxParser display text', () => {
6670
expect(dataRow(result.content).slice(0, 2)).toEqual(['2026-03-04', '2026-03-04T12:00:00'])
6771
})
6872

73+
/**
74+
* A time-of-day serial lands on 1899-12-31 in a 1900 workbook and on
75+
* 1904-01-01 in a 1904 one, so the decision must come from the format, not
76+
* the epoch date. Elapsed formats are durations Excel shows as `30:00`.
77+
*/
78+
describe.each([
79+
['xlsx', false],
80+
['xlsx', true],
81+
['xls', false],
82+
['xls', true],
83+
['xlsb', false],
84+
['xlsb', true],
85+
] as const)('time cells in %s (date1904: %s)', (bookType, date1904) => {
86+
function timeWorkbook(): Buffer {
87+
const sheet = XLSX.utils.aoa_to_sheet([['Clock', 'Meridiem', 'Elapsed', 'Minutes', 'Month']])
88+
sheet.A2 = { t: 'n', v: 0.520821759, z: 'h:mm:ss' }
89+
sheet.B2 = { t: 'n', v: 0.75, z: 'hh:mm AM/PM' }
90+
sheet.C2 = { t: 'n', v: 1.25, z: '[h]:mm' }
91+
sheet.D2 = { t: 'n', v: 0.5, z: '[mm]:ss' }
92+
sheet.E2 = { t: 'n', v: 46085, z: 'mmm' }
93+
sheet['!ref'] = 'A1:E2'
94+
const book = XLSX.utils.book_new()
95+
XLSX.utils.book_append_sheet(book, sheet, 'Times')
96+
if (date1904) book.Workbook = { WBProps: { date1904: true } }
97+
return XLSX.write(book, { type: 'buffer', bookType }) as Buffer
98+
}
99+
100+
it('renders time-only cells as times and elapsed cells as durations', async () => {
101+
const result = await new XlsxParser().parseBuffer(timeWorkbook())
102+
103+
const row = dataRow(result.content)
104+
expect(row.slice(0, 4)).toEqual(['12:29:59', '18:00:00', '30:00', '720:00'])
105+
expect(row[4]).toMatch(/^\d{4}-\d{2}-\d{2}$/)
106+
})
107+
})
108+
109+
/**
110+
* The SheetJS ODS writer stores each serial as the cell text, so an elapsed
111+
* cell reads back its serial; time-only cells still come from the format.
112+
*/
113+
it.each([false, true])('renders time-only cells from ods (date1904: %s)', async (date1904) => {
114+
const sheet = XLSX.utils.aoa_to_sheet([['Clock']])
115+
sheet.A2 = { t: 'n', v: 0.520821759, z: 'h:mm:ss' }
116+
sheet['!ref'] = 'A1:A2'
117+
const book = XLSX.utils.book_new()
118+
XLSX.utils.book_append_sheet(book, sheet, 'Times')
119+
if (date1904) book.Workbook = { WBProps: { date1904: true } }
120+
const buffer = XLSX.write(book, { type: 'buffer', bookType: 'ods' }) as Buffer
121+
122+
const result = await new XlsxParser().parseBuffer(buffer)
123+
124+
expect(dataRow(result.content)).toEqual(['12:29:59'])
125+
})
126+
69127
it.each(['xls', 'xlsb'] as const)('renders the same display text from %s', async (bookType) => {
70128
const result = await new XlsxParser().parseBuffer(typedWorkbook(bookType))
71129

@@ -116,10 +174,43 @@ describe('isoDateText', () => {
116174
expect(isoDateText(new Date(Number.NaN))).toBe('')
117175
})
118176

119-
it('renders a duration or time-of-day cell without the 1899 epoch date', () => {
177+
it('renders a formatless date before 1900 as a time of day', () => {
120178
expect(isoDateText(new Date(Date.UTC(1899, 11, 30, 0, 30, 0)))).toBe('00:30:00')
121179
expect(isoDateText(new Date(Date.UTC(1899, 11, 31, 13, 5, 9)))).toBe('13:05:09')
122180
})
181+
182+
it('decides time of day from the format whatever the epoch date', () => {
183+
expect(isoDateText(new Date(Date.UTC(1904, 0, 1, 12, 29, 59)), 'h:mm:ss')).toBe('12:29:59')
184+
expect(isoDateText(new Date(Date.UTC(1899, 11, 31, 12, 0, 0)), 'yyyy-mm-dd')).toBe(
185+
'1899-12-31T12:00:00'
186+
)
187+
})
188+
})
189+
190+
describe('isTimeOnlyFormat', () => {
191+
it.each(['h:mm:ss', 'hh:mm AM/PM', 'h:mm', 'mm:ss', '[$-409]h:mm:ss', 'hh"h"mm'])(
192+
'treats %s as time only',
193+
(format) => {
194+
expect(isTimeOnlyFormat(format)).toBe(true)
195+
}
196+
)
197+
198+
it.each(['m/d/yyyy', 'yyyy-mm-dd hh:mm', 'mmm', 'd-mmm', 'mmmm yyyy', 'General'])(
199+
'treats %s as a date',
200+
(format) => {
201+
expect(isTimeOnlyFormat(format)).toBe(false)
202+
}
203+
)
204+
})
205+
206+
describe('generalNumberText', () => {
207+
it('keeps integers exact and rounds fractions to 15 significant digits', () => {
208+
expect(generalNumberText(4111111111111111)).toBe('4111111111111111')
209+
expect(generalNumberText(Number.MAX_SAFE_INTEGER)).toBe('9007199254740991')
210+
expect(generalNumberText(0.1 + 0.2)).toBe('0.3')
211+
expect(generalNumberText(1063.8425)).toBe('1063.8425')
212+
expect(generalNumberText(1.22464679914735e-16)).toBe('1.22464679914735e-16')
213+
})
123214
})
124215

125216
describe('normalizeSheetDisplayText', () => {
@@ -139,6 +230,18 @@ describe('normalizeSheetDisplayText', () => {
139230
expect(sheet.D1.w).toBe('9')
140231
})
141232

233+
it('keeps the rendered duration of an elapsed-time cell', () => {
234+
const sheet = XLSX.utils.aoa_to_sheet([['a']])
235+
sheet.A1 = { t: 'd', v: new Date(Date.UTC(1900, 0, 1, 6)), z: '[h]:mm', w: '30:00' }
236+
sheet.B1 = { t: 'd', v: new Date(Date.UTC(1899, 11, 31, 12)), z: '[mm]:ss', w: '720:00' }
237+
sheet['!ref'] = 'A1:B1'
238+
239+
normalizeSheetDisplayText(sheet, XLSX.utils.decode_range('A1:B1'), XLSX.utils)
240+
241+
expect(sheet.A1.w).toBe('30:00')
242+
expect(sheet.B1.w).toBe('720:00')
243+
})
244+
142245
it('touches only the window on a dense sheet', () => {
143246
const sheet = XLSX.utils.aoa_to_sheet([['a']], { dense: true })
144247
const inside = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), w: '3/4/2026' } as XLSX.CellObject

‎apps/sim/lib/file-parsers/sheet-display-text.ts‎

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,70 @@ interface CellLookup {
1818
encode_cell: (address: CellAddress) => string
1919
}
2020

21+
/** Excel shows 15 significant digits for a General-formatted number. */
22+
const GENERAL_SIGNIFICANT_DIGITS = 15
23+
24+
const ELAPSED_TOKEN = /\[(h+|m+|s+)\]/i
25+
26+
/**
27+
* Strips the parts of a number format that carry no date tokens: quoted
28+
* literals, backslash escapes, bracketed colour/condition/elapsed sections and
29+
* the AM/PM markers whose `m` is not a month.
30+
*/
31+
function dateTokensOf(format: string): string {
32+
return format
33+
.replace(/"[^"]*"/g, '')
34+
.replace(/\\./g, '')
35+
.replace(/\[[^\]]*\]/g, '')
36+
.replace(/am\/pm|a\/p/gi, '')
37+
.toLowerCase()
38+
}
39+
40+
/**
41+
* Whether a date format shows a time of day and nothing else, such as
42+
* `h:mm:ss` or `hh:mm AM/PM`. Any `y` or `d` token is a date, and so is an `m`
43+
* run that is not next to hours or seconds, which is how Excel tells a month
44+
* from minutes.
45+
*/
46+
export function isTimeOnlyFormat(format: string): boolean {
47+
const tokens = dateTokensOf(format)
48+
if (/[yd]/.test(tokens)) return false
49+
if (!/[hms]/.test(tokens)) return false
50+
for (const match of tokens.matchAll(/m+/g)) {
51+
const before = tokens.slice(0, match.index).replace(/[:\s]+$/, '')
52+
const after = tokens.slice(match.index + match[0].length).replace(/^[:\s]+/, '')
53+
const isMinutes = before.endsWith('h') || after.startsWith('s')
54+
if (!isMinutes) return false
55+
}
56+
return true
57+
}
58+
2159
/**
2260
* Excel dates carry no zone. Emit the UTC fields SheetJS parsed the serial
2361
* into, without a trailing `Z`, and drop the time when it is midnight.
62+
*
63+
* A time-of-day cell is decided from its format, because the epoch date its
64+
* serial lands on differs between 1900 and 1904 workbooks. Without a format,
65+
* a date before 1900 can only be a fraction of a day and is shown as a time.
2466
*/
25-
export function isoDateText(date: Date): string {
67+
export function isoDateText(date: Date, format?: string): string {
2668
if (Number.isNaN(date.getTime())) return ''
2769
const iso = date.toISOString()
28-
/** A serial below 1 is a duration or time of day; Excel shows it without the 1899 epoch date. */
29-
if (date.getUTCFullYear() < 1900) return iso.slice(11, 19)
70+
const timeOnly = format === undefined ? date.getUTCFullYear() < 1900 : isTimeOnlyFormat(format)
71+
if (timeOnly) return iso.slice(11, 19)
3072
return iso.endsWith('T00:00:00.000Z') ? iso.slice(0, 10) : iso.slice(0, 19)
3173
}
3274

75+
/**
76+
* Renders a General-formatted number the way Excel displays it: integers in
77+
* full, so 16-digit identifiers keep every digit, and fractions rounded to 15
78+
* significant digits, so `=0.1+0.2` reads `0.3`.
79+
*/
80+
export function generalNumberText(value: number): string {
81+
if (Number.isInteger(value)) return String(value)
82+
return String(Number(value.toPrecision(GENERAL_SIGNIFICANT_DIGITS)))
83+
}
84+
3385
function isGeneralFormat(format: unknown): boolean {
3486
return format === undefined || format === 'General'
3587
}
@@ -42,7 +94,10 @@ function isGeneralFormat(format: unknown): boolean {
4294
* percent, boolean and text cells, but not for dates (locale-shaped, such as
4395
* `3/4/2026`) or General-formatted numbers (Excel's 11-character rendering
4496
* turns `4111111111111111` into `4.11111E+15`, losing digits of numeric IDs).
45-
* Dates become ISO text and General numbers print their full stored value.
97+
* Dates become ISO text and General numbers print as Excel displays them.
98+
*
99+
* Elapsed-time formats (`[h]:mm`, `[mm]:ss`) are durations, not moments;
100+
* `cellDates` still parses them into a `Date`, so their `w` (`30:00`) is kept.
46101
*
47102
* A number with no format at all is treated as General too. Every other
48103
* number keeps the text the file rendered for it, so a LibreOffice workbook
@@ -70,9 +125,11 @@ export function normalizeSheetDisplayText(
70125
if (!cell) continue
71126

72127
if (cell.t === 'd' && cell.v instanceof Date) {
73-
cell.w = isoDateText(cell.v)
128+
const format = typeof cell.z === 'string' ? cell.z : undefined
129+
if (format !== undefined && ELAPSED_TOKEN.test(format)) continue
130+
cell.w = isoDateText(cell.v, format)
74131
} else if (cell.t === 'n' && typeof cell.v === 'number' && isGeneralFormat(cell.z)) {
75-
cell.w = String(cell.v)
132+
cell.w = generalNumberText(cell.v)
76133
}
77134
}
78135
}

0 commit comments

Comments
 (0)