From 34f9b2506bf32e98729e1fd456d30f6d0266ec71 Mon Sep 17 00:00:00 2001 From: Sindri Date: Wed, 23 Sep 2026 12:18:49 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=85=20fix:=20Render=20Dotted=20Dat?= =?UTF-8?q?e=20Formats=20in=20Parsed=20Spreadsheets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SheetJS 0.20.3 treats `.` in a number format as fractional seconds, so formatting a date cell with `dd.mm.yyyy`, `d.m.yyyy` or `dd.mm` throws and the cell keeps only its raw value. `sheet_to_csv` then prints the Excel serial (`46265` instead of `31.08.2026`), and the model has to convert every date by hand. Dotted date formats are the default in many European locales, including Icelandic bank exports. Read the workbook with `cellNF` so number formats stay on the cells, and for number cells that have a date format but no formatted text, render them again with the dots quoted as literals, honouring the workbook's 1904 date system. Every other cell is left as SheetJS formatted it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/api/src/files/documents/crud.spec.ts | 20 +++++++++++ packages/api/src/files/documents/crud.ts | 33 ++++++++++++++++-- .../files/documents/sample-dotted-dates.xlsx | Bin 0 -> 5044 bytes 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/files/documents/sample-dotted-dates.xlsx diff --git a/packages/api/src/files/documents/crud.spec.ts b/packages/api/src/files/documents/crud.spec.ts index 2a5086869f8..f5c3c067e8d 100644 --- a/packages/api/src/files/documents/crud.spec.ts +++ b/packages/api/src/files/documents/crud.spec.ts @@ -214,6 +214,26 @@ describe('Document Parser', () => { await expect(parseDocument({ file })).resolves.toBeDefined(); }); + test('parseDocument() renders dates with dotted number formats instead of serials', async () => { + const file = { + originalname: 'sample-dotted-dates.xlsx', + path: path.join(__dirname, 'sample-dotted-dates.xlsx'), + mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + } as Express.Multer.File; + + const document = await parseDocument({ file }); + + expect(document.text).toBe( + 'Dates:\n' + + 'Format,Value\n' + + 'dd.mm.yyyy,31.08.2026\n' + + 'dd.mm,01.09\n' + + 'd.m.yyyy,7.8.2026\n' + + 'dd/mm/yyyy,31/08/2026\n' + + '"#,##0.00","1,234.50"\n', + ); + }); + test('parseDocument() parses empty xlsx with only sheet name', async () => { const file = { originalname: 'empty.xlsx', diff --git a/packages/api/src/files/documents/crud.ts b/packages/api/src/files/documents/crud.ts index 4457a30b3d6..bcd6d340737 100644 --- a/packages/api/src/files/documents/crud.ts +++ b/packages/api/src/files/documents/crud.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import yauzl from 'yauzl'; import { megabyte, excelMimeTypes, FileSources } from 'librechat-data-provider'; import type { TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { WorkSheet } from 'xlsx'; import type { MistralOCRUploadResult } from '~/types'; import { assertSafeZipSize } from './zipSafety'; @@ -109,7 +110,7 @@ async function wordDocToText(file: Express.Multer.File): Promise { async function excelSheetToText(file: Express.Multer.File): Promise { // xlsx CDN build (0.20.x) does not bind fs internally when dynamically imported; // readFile() fails with "Cannot access file". read() takes a pre-loaded Buffer instead. - const { read, utils } = await import('xlsx'); + const { read, utils, SSF } = await import('xlsx'); const data = await fs.promises.readFile(file.path); /* Reject zip-bomb XLSX/ODS before SheetJS's internal extractor runs. * `.xls` (BIFF/CFB) is not a ZIP — magic-byte check skips the @@ -117,11 +118,13 @@ async function excelSheetToText(file: Express.Multer.File): Promise { if (data.length >= 4 && data[0] === 0x50 && data[1] === 0x4b) { await assertSafeZipSize(data, { name: file.originalname ?? 'spreadsheet' }); } - const workbook = read(data, { type: 'buffer' }); + const workbook = read(data, { type: 'buffer', cellNF: true }); + const date1904 = workbook.Workbook?.WBProps?.date1904 === true; let text = ''; for (const sheetName of workbook.SheetNames) { const worksheet = workbook.Sheets[sheetName]; + fillUnformattedDates(worksheet, SSF, date1904); const worksheetAsCsvString = utils.sheet_to_csv(worksheet); text += `${sheetName}:\n${worksheetAsCsvString}\n`; } @@ -129,6 +132,32 @@ async function excelSheetToText(file: Express.Multer.File): Promise { return text; } +/** + * SheetJS reads `.` in a number format as the start of fractional seconds, so date formats such + * as `dd.mm.yyyy` throw while formatting and the cell is left with only its raw serial number, + * which `sheet_to_csv` then prints. Quoting those dots (but not `.0` fractional seconds) makes + * them literal separators. + */ +function fillUnformattedDates( + worksheet: WorkSheet, + ssf: (typeof import('xlsx'))['SSF'], + date1904: boolean, +): void { + for (const [address, cell] of Object.entries(worksheet)) { + if (address.startsWith('!') || cell.t !== 'n' || cell.w != null) { + continue; + } + if (typeof cell.z !== 'string' || !ssf.is_date(cell.z)) { + continue; + } + try { + cell.w = ssf.format(cell.z.replace(/\.(?!0)/g, '"."'), cell.v, { date1904 }); + } catch { + /* Keep the raw value when the format still cannot be rendered */ + } + } +} + /** * Parses OpenDocument Text (.odt) by extracting the body text from content.xml. * Uses regex-based XML extraction scoped to : paragraph/heading diff --git a/packages/api/src/files/documents/sample-dotted-dates.xlsx b/packages/api/src/files/documents/sample-dotted-dates.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e5613f26024fe9fb3567edf679cd6ccb45800bce GIT binary patch literal 5044 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 literal 0 HcmV?d00001 From dd89e86c45ec0d9dd73ebd0e6fde4c9807a90314 Mon Sep 17 00:00:00 2001 From: Sindri Date: Mon, 28 Sep 2026 10:15:27 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=85=20fix:=20Quote=20dotted=20date?= =?UTF-8?q?=20formats=20safely=20and=20apply=20them=20to=20the=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dot-quoting regex rewrote every `.` in the number format, including dots inside `"..."` literals and after a backslash escape. A format such as `d. mmmm yyyy "kl." hh:mm` became unbalanced, SheetJS still threw, and the cell stayed a serial. Skip quoted runs and escaped characters so only bare separator dots are quoted. The rich spreadsheet preview reads the workbook on its own path and never ran the date fill, so the same cell showed a serial in the preview and a date in the chat text. Move the helper to `spreadsheetDates.ts` as a workbook-level function and call it from both `excelSheetToText` and `excelSheetToHtml`. Extend the fixture with a quoted-literal format, a backslash-escaped format and a fractional-seconds format, and cover the preview path in html.spec. Co-Authored-By: Claude Fable 5.1 --- packages/api/src/files/documents/crud.spec.ts | 5 +- packages/api/src/files/documents/crud.ts | 31 +----------- packages/api/src/files/documents/html.spec.ts | 11 +++++ packages/api/src/files/documents/html.ts | 4 +- .../files/documents/sample-dotted-dates.xlsx | Bin 5044 -> 17631 bytes .../src/files/documents/spreadsheetDates.ts | 45 ++++++++++++++++++ 6 files changed, 65 insertions(+), 31 deletions(-) create mode 100644 packages/api/src/files/documents/spreadsheetDates.ts diff --git a/packages/api/src/files/documents/crud.spec.ts b/packages/api/src/files/documents/crud.spec.ts index f5c3c067e8d..25eeeea702e 100644 --- a/packages/api/src/files/documents/crud.spec.ts +++ b/packages/api/src/files/documents/crud.spec.ts @@ -230,7 +230,10 @@ describe('Document Parser', () => { 'dd.mm,01.09\n' + 'd.m.yyyy,7.8.2026\n' + 'dd/mm/yyyy,31/08/2026\n' + - '"#,##0.00","1,234.50"\n', + '"#,##0.00","1,234.50"\n' + + '"d. mmmm yyyy ""kl."" hh:mm",31. August 2026 kl. 12:35\n' + + 'dd\\.mm\\.yyyy,31.08.2026\n' + + 'hh:mm:ss.0,12:35:00.4\n', ); }); diff --git a/packages/api/src/files/documents/crud.ts b/packages/api/src/files/documents/crud.ts index bcd6d340737..68413626020 100644 --- a/packages/api/src/files/documents/crud.ts +++ b/packages/api/src/files/documents/crud.ts @@ -2,8 +2,8 @@ import * as fs from 'fs'; import yauzl from 'yauzl'; import { megabyte, excelMimeTypes, FileSources } from 'librechat-data-provider'; import type { TextItem } from 'pdfjs-dist/types/src/display/api'; -import type { WorkSheet } from 'xlsx'; import type { MistralOCRUploadResult } from '~/types'; +import { fillUnformattedDates } from './spreadsheetDates'; import { assertSafeZipSize } from './zipSafety'; type FileParseFn = (file: Express.Multer.File) => Promise; @@ -119,12 +119,11 @@ async function excelSheetToText(file: Express.Multer.File): Promise { await assertSafeZipSize(data, { name: file.originalname ?? 'spreadsheet' }); } const workbook = read(data, { type: 'buffer', cellNF: true }); - const date1904 = workbook.Workbook?.WBProps?.date1904 === true; + fillUnformattedDates(workbook, SSF); let text = ''; for (const sheetName of workbook.SheetNames) { const worksheet = workbook.Sheets[sheetName]; - fillUnformattedDates(worksheet, SSF, date1904); const worksheetAsCsvString = utils.sheet_to_csv(worksheet); text += `${sheetName}:\n${worksheetAsCsvString}\n`; } @@ -132,32 +131,6 @@ async function excelSheetToText(file: Express.Multer.File): Promise { return text; } -/** - * SheetJS reads `.` in a number format as the start of fractional seconds, so date formats such - * as `dd.mm.yyyy` throw while formatting and the cell is left with only its raw serial number, - * which `sheet_to_csv` then prints. Quoting those dots (but not `.0` fractional seconds) makes - * them literal separators. - */ -function fillUnformattedDates( - worksheet: WorkSheet, - ssf: (typeof import('xlsx'))['SSF'], - date1904: boolean, -): void { - for (const [address, cell] of Object.entries(worksheet)) { - if (address.startsWith('!') || cell.t !== 'n' || cell.w != null) { - continue; - } - if (typeof cell.z !== 'string' || !ssf.is_date(cell.z)) { - continue; - } - try { - cell.w = ssf.format(cell.z.replace(/\.(?!0)/g, '"."'), cell.v, { date1904 }); - } catch { - /* Keep the raw value when the format still cannot be rendered */ - } - } -} - /** * Parses OpenDocument Text (.odt) by extracting the body text from content.xml. * Uses regex-based XML extraction scoped to : paragraph/heading diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index 6725b577be6..e704773327f 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -289,6 +289,17 @@ describe('Office HTML producers', () => { expect(html).toContain('Sheet One'); expect(html).toContain('Second Sheet'); }); + + test('renders dates with dotted number formats instead of serials', async () => { + const html = await excelSheetToHtml(readFixture('sample-dotted-dates.xlsx')); + expect(html).toContain('31.08.2026'); + expect(html).toContain('01.09'); + expect(html).toContain('7.8.2026'); + expect(html).toContain('31. August 2026 kl. 12:35'); + expect(html).toContain('12:35:00.4'); + expect(html).not.toContain('46265'); + expect(html).not.toContain('46241'); + }); }); describe('csvToHtml', () => { diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts index 96e41d91077..507459057b2 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -1,6 +1,7 @@ import yauzl from 'yauzl'; import { excelMimeTypes, megabyte } from 'librechat-data-provider'; import { tryLibreOfficePreview } from './libreoffice'; +import { fillUnformattedDates } from './spreadsheetDates'; import { assertSafeZipSize } from './zipSafety'; /** @@ -770,7 +771,8 @@ export async function excelSheetToHtml(buffer: Buffer): Promise { await assertSafeZipSize(buffer, { name: 'spreadsheet' }); } const XLSX = await import('xlsx'); - const workbook = XLSX.read(buffer, { type: 'buffer' }); + const workbook = XLSX.read(buffer, { type: 'buffer', cellNF: true }); + fillUnformattedDates(workbook, XLSX.SSF); const sheets = await renderWorkbookSheets(workbook, XLSX); /* The per-sheet HTML from `sheet_to_html` is generally well-formed but we * still sanitize it (defense in depth). The chrome (tab strip, banners) is diff --git a/packages/api/src/files/documents/sample-dotted-dates.xlsx b/packages/api/src/files/documents/sample-dotted-dates.xlsx index e5613f26024fe9fb3567edf679cd6ccb45800bce..d9dec84f0ef51be2e442943365610a7c29165b8a 100644 GIT binary patch literal 17631 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 literal 5044 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 diff --git a/packages/api/src/files/documents/spreadsheetDates.ts b/packages/api/src/files/documents/spreadsheetDates.ts new file mode 100644 index 00000000000..2963c3e9340 --- /dev/null +++ b/packages/api/src/files/documents/spreadsheetDates.ts @@ -0,0 +1,45 @@ +import type { CellObject, WorkBook } from 'xlsx'; + +/** The subset of SheetJS `SSF` used here; the package types it as `any`. */ +interface SpreadsheetFormatter { + is_date(fmt: string): boolean; + format(fmt: string, value: number, options?: { date1904?: boolean }): string; +} + +/** + * Quote every `.` in a number format so SheetJS treats it as a literal separator. + * Skips dots inside `"..."` literals, backslash-escaped dots and `.0` fractional seconds. + */ +function quoteFormatDots(fmt: string): string { + return fmt.replace(/"[^"]*"|\\.|\.(?!0)/g, (match) => (match === '.' ? '"."' : match)); +} + +/** + * SheetJS reads `.` in a number format as the start of fractional seconds, so date formats such + * as `dd.mm.yyyy` throw while formatting and the cell is left with only its raw serial number. + * Renders those cells again with the dots quoted; every other cell keeps what SheetJS produced. + * The workbook must have been read with `cellNF: true` so the format is available on `cell.z`. + */ +export function fillUnformattedDates(workbook: WorkBook, ssf: SpreadsheetFormatter): void { + const date1904 = workbook.Workbook?.WBProps?.date1904 === true; + for (const sheetName of workbook.SheetNames) { + const worksheet = workbook.Sheets[sheetName]; + for (const address in worksheet) { + if (address.startsWith('!')) { + continue; + } + const cell = worksheet[address] as CellObject; + if (cell.t !== 'n' || cell.w != null || typeof cell.v !== 'number') { + continue; + } + if (typeof cell.z !== 'string' || !ssf.is_date(cell.z)) { + continue; + } + try { + cell.w = ssf.format(quoteFormatDots(cell.z), cell.v, { date1904 }); + } catch { + /* Keep the raw value when the format still cannot be rendered */ + } + } + } +}