diff --git a/packages/api/src/files/documents/crud.spec.ts b/packages/api/src/files/documents/crud.spec.ts index 2a5086869f8..25eeeea702e 100644 --- a/packages/api/src/files/documents/crud.spec.ts +++ b/packages/api/src/files/documents/crud.spec.ts @@ -214,6 +214,29 @@ 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' + + '"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', + ); + }); + 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..68413626020 100644 --- a/packages/api/src/files/documents/crud.ts +++ b/packages/api/src/files/documents/crud.ts @@ -3,6 +3,7 @@ import yauzl from 'yauzl'; import { megabyte, excelMimeTypes, FileSources } from 'librechat-data-provider'; import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import type { MistralOCRUploadResult } from '~/types'; +import { fillUnformattedDates } from './spreadsheetDates'; import { assertSafeZipSize } from './zipSafety'; type FileParseFn = (file: Express.Multer.File) => Promise; @@ -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,7 +118,8 @@ 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 }); + fillUnformattedDates(workbook, SSF); let text = ''; for (const sheetName of workbook.SheetNames) { 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 new file mode 100644 index 00000000000..d9dec84f0ef Binary files /dev/null and b/packages/api/src/files/documents/sample-dotted-dates.xlsx differ 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 */ + } + } + } +}