fix(export): neutralize spreadsheet formulas in CSV exports

Typed-in values (colony codes, batch numbers, honey types) starting with
= + - @ TAB or CR were written as-is and ran as formulas (HYPERLINK, DDE)
when the export was opened in Excel or LibreOffice. Such text cells now
get a leading apostrophe; numbers keep their sign. Carriage returns are
quoted as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:43:44 +02:00
1 parent f1dc0de3e2
commit 61e32474e4
2 files changed
+32 -3

No files matched your search

@@ -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"');
});
});
+11 -3
View File
@@ -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;