Files
trip-plan/tests/helpers/csv.js
T
grabowski f272e74b84 Add daily expense tracking with sort/split and CSV export
Standalone expenses (date, description, category, amount) with the same
equal/own/payer split machinery as entry costs, merged into the costs
panel and settle-up as a single expense bucket. Self-fetching card
with per-day grouping, client-side sorting, and quick-add. CSV export
interleaves expenses with priced entries, one share column per member
(UTF-8 BOM, RFC 4180, formula-injection guard on text cells).

Also fixes trip deletion, which hit a foreign-key violation and rolled
back for any trip with checklist items.
2026-08-06 17:55:04 +07:00

62 lines
2.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared CSV reader for the expense-export tests (tests/expenses-export.test.js
// and tests/expense-csv.test.js). Not a test file — it registers no tests and
// is therefore not listed in tests/index.js.
import assert from 'node:assert/strict';
// U+FEFF. buildExpenseCsv returns the complete file including the BOM, so both
// the pure function's output and the HTTP body must carry it.
export const BOM = '';
// A strict RFC 4180 reader. It doubles as an assertion: it rejects a bare LF or
// CR outside a quoted field, so a file with LF endings fails here.
export function parseCsv(text) {
assert.ok(text.startsWith(BOM), 'CSV must be prefixed with the UTF-8 BOM');
const body = text.slice(1);
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let i = 0; i < body.length; i += 1) {
const ch = body[i];
if (quoted) {
if (ch !== '"') { field += ch; continue; }
if (body[i + 1] === '"') { field += '"'; i += 1; continue; }
quoted = false;
} else if (ch === '"') {
quoted = true;
} else if (ch === ',') {
row.push(field);
field = '';
} else if (ch === '\r' && body[i + 1] === '\n') {
row.push(field);
field = '';
rows.push(row);
row = [];
i += 1;
} else if (ch === '\n' || ch === '\r') {
assert.fail('bare CR/LF outside a quoted field — line endings must be CRLF');
} else {
field += ch;
}
}
if (field !== '' || row.length) {
row.push(field);
rows.push(row);
}
assert.equal(quoted, false, 'unterminated quoted field');
return rows;
}
// Turn the parsed grid into header + row objects keyed by column name.
export function readCsv(text) {
const grid = parseCsv(text);
const header = grid[0];
const rows = grid.slice(1).map((cells) => {
assert.equal(cells.length, header.length, `row has ${cells.length} cells, header has ${header.length}`);
return Object.fromEntries(cells.map((c, i) => [header[i], c]));
});
return { header, rows };
}