From a1f3bf6cb5e55390076699cad3f5ec4cee1b5bf4 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Mon, 7 Sep 2026 13:19:30 +0200 Subject: [PATCH 1/3] fix(security): replace excel4node with write-excel-file to remove vulnerability --- packages/plugin-export-advanced/package.json | 2 +- packages/plugin-export-advanced/src/index.ts | 2 +- .../src/renderers/xlsx.ts | 69 +++++--------- .../plugin-export-advanced/test/index.test.ts | 21 ++++ yarn.lock | 95 +++---------------- 5 files changed, 59 insertions(+), 130 deletions(-) diff --git a/packages/plugin-export-advanced/package.json b/packages/plugin-export-advanced/package.json index d2767e7ab2..7d0938be9e 100644 --- a/packages/plugin-export-advanced/package.json +++ b/packages/plugin-export-advanced/package.json @@ -12,7 +12,7 @@ "directory": "packages/plugin-export-advanced" }, "dependencies": { - "excel4node": "^1.8.0" + "write-excel-file": "^4.1.1" }, "devDependencies": { "@forestadmin/datasource-customizer": "1.71.3", diff --git a/packages/plugin-export-advanced/src/index.ts b/packages/plugin-export-advanced/src/index.ts index e1ba021753..5f1f4cd8c5 100644 --- a/packages/plugin-export-advanced/src/index.ts +++ b/packages/plugin-export-advanced/src/index.ts @@ -89,7 +89,7 @@ export async function addExportAdvanced( const renderer = renderers[format]; const records = await context.collection.list(context.filter, fields); - const output = renderer.handler(records, fields); + const output = await renderer.handler(records, fields); return resultBuilder.file(output, `${filename}${format}`, renderer.mimeType); }, diff --git a/packages/plugin-export-advanced/src/renderers/xlsx.ts b/packages/plugin-export-advanced/src/renderers/xlsx.ts index a9762c939b..d121158ea4 100644 --- a/packages/plugin-export-advanced/src/renderers/xlsx.ts +++ b/packages/plugin-export-advanced/src/renderers/xlsx.ts @@ -1,61 +1,40 @@ -import type { Readable } from 'stream'; +import type { Cell } from 'write-excel-file/node'; -import xl from 'excel4node'; +import writeXlsxFile from 'write-excel-file/node'; import getFieldValue from '../utils/get-field-value'; -function getExcel4NodeTypeFromValue(value: unknown): string { +function toCell(value: unknown): Cell { if (value === null || value === undefined) return null; - if (typeof value === 'boolean') return 'bool'; - if (typeof value === 'number') return 'number'; + if (typeof value === 'boolean') return { type: Boolean, value }; + if (typeof value === 'number') return { type: Number, value }; + if (value instanceof Date) return { type: Date, value, format: 'yyyy-mm-dd hh:mm:ss' }; if (typeof value === 'string') { - if (value === 'true' || value === 'false') return 'bool'; - if (!Number.isNaN(Number(value))) return 'number'; - if (!Number.isNaN(Date.parse(value))) return 'date'; + if (value === 'true' || value === 'false') return { type: Boolean, value: value === 'true' }; - return 'string'; - } - - if (value instanceof Date) return 'date'; + if (value.trim() !== '' && !Number.isNaN(Number(value))) { + return { type: Number, value: Number(value) }; + } - return 'string'; -} + const timestamp = Date.parse(value); -function castValue(value) { - switch (getExcel4NodeTypeFromValue(value)) { - case 'bool': - return Boolean(value); - case 'number': - return Number(value); - case 'string': - return String(value); - case 'date': - return new Date(value); - default: - return String(value); - } -} - -export default function render(records: Record[], projection: string[]): Readable { - const wb = new xl.Workbook(); - const ws = wb.addWorksheet('Export'); + if (!Number.isNaN(timestamp)) { + return { type: Date, value: new Date(timestamp), format: 'yyyy-mm-dd hh:mm:ss' }; + } - for (const [index, name] of projection.entries()) { - ws.cell(1, index + 1).string(name); + return { type: String, value }; } - for (const [row, record] of records.entries()) { - for (const [col, name] of projection.entries()) { - const value = getFieldValue(record, name); - const castedValue = castValue(value); - const excel4NodeCellFunction = getExcel4NodeTypeFromValue(castedValue); + return { type: String, value: String(value) }; +} - if (value !== null && value !== undefined) { - ws.cell(2 + row, 1 + col)[excel4NodeCellFunction](castedValue); - } - } - } +export default function render( + records: Record[], + projection: string[], +): Promise { + const header: Cell[] = projection.map(name => ({ type: String, value: name })); + const rows = records.map(record => projection.map(name => toCell(getFieldValue(record, name)))); - return wb.writeToBuffer(); + return writeXlsxFile([header, ...rows]).toBuffer(); } diff --git a/packages/plugin-export-advanced/test/index.test.ts b/packages/plugin-export-advanced/test/index.test.ts index f758c80b47..e1fbe4009b 100644 --- a/packages/plugin-export-advanced/test/index.test.ts +++ b/packages/plugin-export-advanced/test/index.test.ts @@ -129,6 +129,27 @@ describe('plugin-export-advanced', () => { expect((result as FileResult).mimeType).toStrictEqual(mimeType); expect((result as FileResult).name).toStrictEqual(`file${format}`); }); + + test('the xlsx export should be a non-empty workbook', async () => { + const result = await dataSource.getCollection('books').execute( + factories.caller.build(), + 'Export books (advanced)', + { + Format: '.xlsx', + Filename: 'file', + Fields: ['id', 'title', 'isPublished', 'publishedAt', 'author:id', 'author:fullname'], + }, + factories.filter.build(), + ); + + const chunks: Uint8Array[] = []; + for await (const chunk of (result as FileResult).stream) chunks.push(chunk as Uint8Array); + const buffer = Buffer.concat(chunks); + + expect(buffer.length).toBeGreaterThan(0); + // xlsx is a zip archive: it must start with the "PK" local-file-header magic bytes. + expect(buffer.subarray(0, 2).toString('latin1')).toStrictEqual('PK'); + }); }); describe('When providing settings (on datasource)', () => { diff --git a/yarn.lock b/yarn.lock index deb0cf8076..0205ce972f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8274,23 +8274,6 @@ eventsource@^3.0.2: dependencies: eventsource-parser "^3.0.1" -excel4node@^1.8.0: - version "1.8.2" - resolved "https://registry.yarnpkg.com/excel4node/-/excel4node-1.8.2.tgz#2d2f8b2ae56a3d3c7ae6a29bb2652b25807b021f" - integrity sha512-v5BZZy8y4cibFQ/xvztUleAoyYmIBol1qTKWuDWZZPpFGBAy4P7qkswdpBkTkQgLIQ/WkCpyV/P6liW4mIb/wQ== - dependencies: - deepmerge "^4.2.2" - image-size "^1.0.2" - jszip "^3.10.0" - lodash.get "^4.4.2" - lodash.isequal "^4.5.0" - lodash.isundefined "^3.0.1" - lodash.reduce "^4.6.0" - lodash.uniqueid "^4.0.1" - mime "^3.0.0" - uuid "^9.0.0" - xmlbuilder "^15.1.1" - execa@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" @@ -8870,6 +8853,11 @@ fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +fflate@^0.8.2: + version "0.8.3" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc" + integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA== + figures@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -10076,18 +10064,6 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== -image-size@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.0.2.tgz#d778b6d0ab75b2737c1556dd631652eb963bc486" - integrity sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg== - dependencies: - queue "6.0.2" - -immediate@~3.0.5: - version "3.0.6" - resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" - integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== - import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -11599,16 +11575,6 @@ jsonwebtoken@^9.0.0: ms "^2.1.1" semver "^7.5.4" -jszip@^3.10.0: - version "3.10.1" - resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" - integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== - dependencies: - lie "~3.3.0" - pako "~1.0.2" - readable-stream "~2.3.6" - setimmediate "^1.0.5" - just-diff-apply@^5.2.0: version "5.5.0" resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-5.5.0.tgz#771c2ca9fa69f3d2b54e7c3f5c1dfcbcc47f9f0f" @@ -12005,13 +11971,6 @@ libnpmversion@^8.0.3: proc-log "^6.0.0" semver "^7.3.7" -lie@~3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" - integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== - dependencies: - immediate "~3.0.5" - light-my-request@6.6.0, light-my-request@^6.0.0: version "6.6.0" resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-6.6.0.tgz#c9448772323f65f33720fb5979c7841f14060add" @@ -12204,11 +12163,6 @@ lodash.isstring@^4.0.1: resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== -lodash.isundefined@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz#23ef3d9535565203a66cefd5b830f848911afb48" - integrity sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA== - lodash.kebabcase@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" @@ -12234,11 +12188,6 @@ lodash.once@^4.0.0: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== -lodash.reduce@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" - integrity sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw== - lodash.snakecase@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" @@ -12259,11 +12208,6 @@ lodash.uniqby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww== -lodash.uniqueid@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/lodash.uniqueid/-/lodash.uniqueid-4.0.1.tgz#3268f26a7c88e4f4b1758d679271814e31fa5b26" - integrity sha512-GQQWaIeGlL6DIIr06kj1j6sSmBxyNMwI8kaX9aKpHR/XsMTiaXDVPNPAkiboOTK9OJpTJF/dXT3xYoFQnj386Q== - lodash.upperfirst@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" @@ -12869,11 +12813,6 @@ mime@2.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== -mime@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" - integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A== - mime@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/mime/-/mime-4.1.0.tgz#ec55df7aa21832a36d44f0bbee5c04639b27802f" @@ -14516,11 +14455,6 @@ pacote@^21.0.0, pacote@^21.0.2, pacote@^21.0.4: ssri "^13.0.0" tar "^7.4.3" -pako@~1.0.2: - version "1.0.11" - resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" - integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== - parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -15362,13 +15296,6 @@ queue-microtask@^1.1.2, queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -queue@6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65" - integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== - dependencies: - inherits "~2.0.3" - quick-format-unescaped@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-3.0.3.tgz#fb3e468ac64c01d22305806c39f121ddac0d1fb9" @@ -18545,6 +18472,13 @@ wrappy@1, wrappy@1.0.2: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +write-excel-file@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/write-excel-file/-/write-excel-file-4.1.1.tgz#b2c7065605d87b8d43601f63c4a726b7fba1be3c" + integrity sha512-MUnCnNtQrcZek832ZcU24uU0rSphFmKPD1DvIjXOlygVb93CV7Tme6H3jUTkxsMmjB2W7HIzERzjqTi5kui71A== + dependencies: + fflate "^0.8.2" + write-file-atomic@5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" @@ -18605,11 +18539,6 @@ xml-naming@^0.3.0: resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.3.0.tgz#46c1e18bfe2858479982dd2accf34d16e749eda2" integrity sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ== -xmlbuilder@^15.1.1: - version "15.1.1" - resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" - integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== - xtend@^4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" From 5a6cf6e70acba21034195b9d72f38c4af4ca4ed3 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Mon, 7 Sep 2026 14:19:28 +0200 Subject: [PATCH 2/3] test(plugin-export-advanced): cover toCell numeric-string and non-primitive branches Export toCell and unit-test the cast paths qlty flagged as uncovered: numeric string to Number, non-primitive to String, and the false boolean-string branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/renderers/xlsx.ts | 2 +- .../test/renderers/xlsx.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-export-advanced/test/renderers/xlsx.test.ts diff --git a/packages/plugin-export-advanced/src/renderers/xlsx.ts b/packages/plugin-export-advanced/src/renderers/xlsx.ts index d121158ea4..45cc8dd27c 100644 --- a/packages/plugin-export-advanced/src/renderers/xlsx.ts +++ b/packages/plugin-export-advanced/src/renderers/xlsx.ts @@ -4,7 +4,7 @@ import writeXlsxFile from 'write-excel-file/node'; import getFieldValue from '../utils/get-field-value'; -function toCell(value: unknown): Cell { +export function toCell(value: unknown): Cell { if (value === null || value === undefined) return null; if (typeof value === 'boolean') return { type: Boolean, value }; if (typeof value === 'number') return { type: Number, value }; diff --git a/packages/plugin-export-advanced/test/renderers/xlsx.test.ts b/packages/plugin-export-advanced/test/renderers/xlsx.test.ts new file mode 100644 index 0000000000..7d2f634975 --- /dev/null +++ b/packages/plugin-export-advanced/test/renderers/xlsx.test.ts @@ -0,0 +1,18 @@ +import { toCell } from '../../src/renderers/xlsx'; + +describe('xlsx toCell', () => { + test.each([ + ['true', true], + ['false', false], + ])('casts the boolean string "%s" to a boolean cell', (input, expected) => { + expect(toCell(input)).toStrictEqual({ type: Boolean, value: expected }); + }); + + test('casts a numeric string to a number cell', () => { + expect(toCell('42')).toStrictEqual({ type: Number, value: 42 }); + }); + + test('casts a non-primitive value to a string cell', () => { + expect(toCell({ foo: 'bar' })).toStrictEqual({ type: String, value: '[object Object]' }); + }); +}); From dba86e3407e373743997478efdbd0f97654597f6 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Tue, 8 Sep 2026 12:46:26 +0200 Subject: [PATCH 3/3] fix(plugin-export-advanced): preserve sheet name, date format, and reject non-finite values Address review on the excel4node -> write-excel-file migration: - name the worksheet 'Export' again (default was 'Sheet1', breaking Export!A1 refs) - match excel4node's default 'm/d/yy' date format instead of yyyy-mm-dd hh:mm:ss - send non-finite numbers and invalid dates as text/empty instead of shipping NaN/Infinity, which made Excel offer to repair the file - guard numeric strings with Number.isFinite so 'Infinity'/'1e309' stay text - extract the date format into a file-local constant Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/renderers/xlsx.ts | 23 +++++++++++++++---- .../test/renderers/xlsx.test.ts | 14 +++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/plugin-export-advanced/src/renderers/xlsx.ts b/packages/plugin-export-advanced/src/renderers/xlsx.ts index 45cc8dd27c..f6f93896ee 100644 --- a/packages/plugin-export-advanced/src/renderers/xlsx.ts +++ b/packages/plugin-export-advanced/src/renderers/xlsx.ts @@ -4,23 +4,36 @@ import writeXlsxFile from 'write-excel-file/node'; import getFieldValue from '../utils/get-field-value'; +// Matches excel4node's default date format so exports stay identical after the migration. +const XLSX_DATE_FORMAT = 'm/d/yy'; + export function toCell(value: unknown): Cell { if (value === null || value === undefined) return null; if (typeof value === 'boolean') return { type: Boolean, value }; - if (typeof value === 'number') return { type: Number, value }; - if (value instanceof Date) return { type: Date, value, format: 'yyyy-mm-dd hh:mm:ss' }; + + if (typeof value === 'number') { + return Number.isFinite(value) + ? { type: Number, value } + : { type: String, value: String(value) }; + } + + if (value instanceof Date) { + return Number.isFinite(value.getTime()) + ? { type: Date, value, format: XLSX_DATE_FORMAT } + : null; + } if (typeof value === 'string') { if (value === 'true' || value === 'false') return { type: Boolean, value: value === 'true' }; - if (value.trim() !== '' && !Number.isNaN(Number(value))) { + if (value.trim() !== '' && Number.isFinite(Number(value))) { return { type: Number, value: Number(value) }; } const timestamp = Date.parse(value); if (!Number.isNaN(timestamp)) { - return { type: Date, value: new Date(timestamp), format: 'yyyy-mm-dd hh:mm:ss' }; + return { type: Date, value: new Date(timestamp), format: XLSX_DATE_FORMAT }; } return { type: String, value }; @@ -36,5 +49,5 @@ export default function render( const header: Cell[] = projection.map(name => ({ type: String, value: name })); const rows = records.map(record => projection.map(name => toCell(getFieldValue(record, name)))); - return writeXlsxFile([header, ...rows]).toBuffer(); + return writeXlsxFile([header, ...rows], { sheet: 'Export' }).toBuffer(); } diff --git a/packages/plugin-export-advanced/test/renderers/xlsx.test.ts b/packages/plugin-export-advanced/test/renderers/xlsx.test.ts index 7d2f634975..39ea68d888 100644 --- a/packages/plugin-export-advanced/test/renderers/xlsx.test.ts +++ b/packages/plugin-export-advanced/test/renderers/xlsx.test.ts @@ -15,4 +15,18 @@ describe('xlsx toCell', () => { test('casts a non-primitive value to a string cell', () => { expect(toCell({ foo: 'bar' })).toStrictEqual({ type: String, value: '[object Object]' }); }); + + // A non-finite number would ship as NaN/Infinity and make Excel repair the file. + test('falls back to a string cell for a non-finite number', () => { + expect(toCell(NaN)).toStrictEqual({ type: String, value: 'NaN' }); + expect(toCell(Infinity)).toStrictEqual({ type: String, value: 'Infinity' }); + }); + + test('drops an invalid Date to an empty cell', () => { + expect(toCell(new Date('nope'))).toBeNull(); + }); + + test.each(['Infinity', '1e309'])('keeps the non-finite numeric string "%s" as text', input => { + expect(toCell(input)).toStrictEqual({ type: String, value: input }); + }); });