diff --git a/ApiCore-Backend/src/export/csv-field.spec.ts b/ApiCore-Backend/src/export/csv-field.spec.ts new file mode 100644 index 0000000..55b80fe --- /dev/null +++ b/ApiCore-Backend/src/export/csv-field.spec.ts @@ -0,0 +1,21 @@ +import { toCsvField } from './export.service'; + +describe('toCsvField', () => { + it('neutralizes cells that a spreadsheet would run as a formula', () => { + expect(toCsvField('=HYPERLINK("http://evil","x")')).toBe( + `"'=HYPERLINK(""http://evil"",""x"")"`, + ); + expect(toCsvField('+1+1')).toBe("'+1+1"); + expect(toCsvField('-2')).toBe("'-2"); + expect(toCsvField('@SUM(A1)')).toBe("'@SUM(A1)"); + }); + + it('keeps numbers, including negative ones, as numbers', () => { + expect(toCsvField(-2.5)).toBe('-2.5'); + expect(toCsvField(0)).toBe('0'); + }); + + it('quotes carriage returns too', () => { + expect(toCsvField('a\rb')).toBe('"a\rb"'); + }); +}); diff --git a/ApiCore-Backend/src/export/export.service.ts b/ApiCore-Backend/src/export/export.service.ts index 0870c4d..a43dfc6 100644 --- a/ApiCore-Backend/src/export/export.service.ts +++ b/ApiCore-Backend/src/export/export.service.ts @@ -71,12 +71,20 @@ function stringifyCsvValue(value: unknown): string { return JSON.stringify(value); } -function toCsvField(value: unknown): string { +// A text cell starting with one of these is run as a formula by Excel and +// LibreOffice (=HYPERLINK(...), DDE). Typed-in values such as a colony code +// are prefixed with ' so they show as text; real numbers are not strings +// and keep their sign. +const FORMULA_TRIGGER = /^[=+\-@\t\r]/; + +export function toCsvField(value: unknown): string { if (value === null || value === undefined) { return ''; } - const text = stringifyCsvValue(value); - if (/[",\n]/.test(text)) { + const raw = stringifyCsvValue(value); + const text = + typeof value === 'string' && FORMULA_TRIGGER.test(raw) ? `'${raw}` : raw; + if (/[",\r\n]/.test(text)) { return `"${text.replace(/"/g, '""')}"`; } return text;