/** * Minimal, dependency-free CSV serialization for the admin export endpoints. * RFC-4180 style: fields containing a comma, double-quote, CR or LF are wrapped * in double-quotes with embedded quotes doubled. Everything is coerced to a * string first; null/undefined become empty fields. */ export function csvField(v: unknown): string { let s: string; if (v === null || v === undefined) s = ''; else if (typeof v === 'string') s = v; else if (typeof v === 'object') s = JSON.stringify(v); else s = String(v); // Spreadsheet formula-injection guard: a field whose first character is one // of = + - @ (or a leading tab/CR) is interpreted as a formula by Excel / // Sheets / LibreOffice. Neutralize it by prefixing a single quote BEFORE the // RFC-4180 quote-escaping below, so the value renders as literal text. if (s.length > 0 && /^[=+\-@\t\r]/.test(s)) { s = `'${s}`; } if (/[",\r\n]/.test(s)) { return `"${s.replace(/"/g, '""')}"`; } return s; } export function csvRow(values: unknown[]): string { return values.map(csvField).join(','); } /** * Build a full CSV document (header + rows). A leading row is always the * provided header. Lines are CRLF-terminated for maximal spreadsheet * compatibility. */ export function toCsv(header: string[], rows: unknown[][]): string { const lines = [csvRow(header), ...rows.map(csvRow)]; return lines.join('\r\n') + '\r\n'; } /** * Max rows any single export will emit. Keeps a runaway export bounded; the * caller notes the cap in the report when hit. */ export const EXPORT_MAX_ROWS = 10000;