Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/api/src/files/documents/crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions packages/api/src/files/documents/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
Expand Down Expand Up @@ -109,15 +110,16 @@ async function wordDocToText(file: Express.Multer.File): Promise<string> {
async function excelSheetToText(file: Express.Multer.File): Promise<string> {
// 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
* validator for it (yauzl would reject it as malformed anyway). */
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) {
Expand Down
11 changes: 11 additions & 0 deletions packages/api/src/files/documents/html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/api/src/files/documents/html.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -770,7 +771,8 @@ export async function excelSheetToHtml(buffer: Buffer): Promise<string> {
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
Expand Down
Binary file not shown.
45 changes: 45 additions & 0 deletions packages/api/src/files/documents/spreadsheetDates.ts
Original file line number Diff line number Diff line change
@@ -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 */
}
}
}
}
Loading