diff --git a/apps/sim/lib/file-parsers/sheet-display-text.test.ts b/apps/sim/lib/file-parsers/sheet-display-text.test.ts index c44b887dc0e..355da1cd223 100644 --- a/apps/sim/lib/file-parsers/sheet-display-text.test.ts +++ b/apps/sim/lib/file-parsers/sheet-display-text.test.ts @@ -230,4 +230,19 @@ describe('normalizeSheetDisplayText', () => { expect(inside.w).toBe('2026-03-04') expect(outside.w).toBe('3/5/2026') }) + + it('keeps the file text for a date format longer than Excel allows', () => { + const sheet = XLSX.utils.aoa_to_sheet([['a']]) + const at = new Date(Date.UTC(2024, 2, 15, 14, 30)) + sheet.A1 = { t: 'd', v: at, z: `m/d/yyyy h:mm${'"x"'.repeat(100)}`, w: '3/15/2024 14:30' } + sheet.B1 = { t: 'd', v: at, z: `m/d/yyyy h:mm${'"x"'.repeat(80)}`, w: '3/15/2024 14:30' } + sheet.C1 = { t: 'd', v: at, z: `[h]:mm${'"x"'.repeat(100)}`, w: '14:30' } + sheet['!ref'] = 'A1:C1' + + normalizeSheetDisplayText(sheet, XLSX.utils.decode_range('A1:C1'), XLSX.utils) + + expect(sheet.A1.w).toBe('3/15/2024 14:30') + expect(sheet.B1.w).toBe('2024-03-15T14:30:00') + expect(sheet.C1.w).toBe('14:30') + }) }) diff --git a/apps/sim/lib/file-parsers/sheet-display-text.ts b/apps/sim/lib/file-parsers/sheet-display-text.ts index e9a16134381..7c968e4a1a6 100644 --- a/apps/sim/lib/file-parsers/sheet-display-text.ts +++ b/apps/sim/lib/file-parsers/sheet-display-text.ts @@ -23,6 +23,15 @@ const GENERAL_SIGNIFICANT_DIGITS = 15 const ELAPSED_TOKEN = /\[(h+|m+|s+)\]/i +/** + * Excel rejects a custom number format longer than 255 characters, so a longer + * `cell.z` is not a real format. It comes verbatim from the workbook's + * `styles.xml`, and parsing it would let one cell cost unbounded CPU. + */ +const MAX_NUMBER_FORMAT_LENGTH = 255 + +const TIME_SEPARATOR = /[:\s]/ + /** * Strips the parts of a number format that carry no date tokens: quoted * literals, backslash escapes, bracketed colour/condition/elapsed sections and @@ -48,14 +57,25 @@ export function isTimeOnlyFormat(format: string): boolean { if (/[yd]/.test(tokens)) return false if (!/[hms]/.test(tokens)) return false for (const match of tokens.matchAll(/m+/g)) { - const before = tokens.slice(0, match.index).replace(/[:\s]+$/, '') - const after = tokens.slice(match.index + match[0].length).replace(/^[:\s]+/, '') - const isMinutes = before.endsWith('h') || after.startsWith('s') - if (!isMinutes) return false + if (!isMinutesRun(tokens, match.index, match.index + match[0].length)) return false } return true } +/** + * Whether the `m` run at `[start, end)` sits next to an hour or a seconds + * token, skipping `:` and whitespace. Each separator span is walked at most + * once from each neighbouring run, so classifying a whole format is linear. + */ +function isMinutesRun(tokens: string, start: number, end: number): boolean { + let before = start - 1 + while (before >= 0 && TIME_SEPARATOR.test(tokens[before])) before-- + if (tokens[before] === 'h') return true + let after = end + while (after < tokens.length && TIME_SEPARATOR.test(tokens[after])) after++ + return tokens[after] === 's' +} + const MS_PER_SECOND = 1000 /** @@ -106,6 +126,9 @@ function isGeneralFormat(format: unknown): boolean { * Elapsed-time formats (`[h]:mm`, `[mm]:ss`) are durations, not moments; * `cellDates` still parses them into a `Date`, so their `w` (`30:00`) is kept. * + * A date whose format is longer than Excel allows is not a real format, so it + * keeps the text the file rendered for it too. + * * A number with no format at all is treated as General too. Every other * number keeps the text the file rendered for it, so a LibreOffice workbook * indexes as LibreOffice showed it (`0,5` in a German locale). @@ -133,7 +156,12 @@ export function normalizeSheetDisplayText( if (cell.t === 'd' && cell.v instanceof Date) { const format = typeof cell.z === 'string' ? cell.z : undefined - if (format !== undefined && ELAPSED_TOKEN.test(format)) continue + if ( + format !== undefined && + (format.length > MAX_NUMBER_FORMAT_LENGTH || ELAPSED_TOKEN.test(format)) + ) { + continue + } cell.w = isoDateText(cell.v, format) } else if (cell.t === 'n' && typeof cell.v === 'number' && isGeneralFormat(cell.z)) { cell.w = generalNumberText(cell.v)