diff --git a/app/lib/download.ts b/app/lib/download.ts new file mode 100644 index 00000000..d5df2dcd --- /dev/null +++ b/app/lib/download.ts @@ -0,0 +1,72 @@ +/** + * Download data as CSV file. + * + * @param headers - Array of header names. If omitted and data is array of objects, keys will be taken from first object. + * @param data - Array of rows. Each row can be either an object (key -> value) or an array of values. + * @param filename - optional file name to use for downloaded file + * @param delimiter - csv delimiter (default is comma) + * @param addBOM - add UTF-8 BOM to the file to make Excel detect UTF-8 correctly + */ +export function downloadCsv( + headers: string[] | null, + data: Array | any[]>, + filename = 'data.csv', + delimiter = ',', + addBOM = true, +) { + if (!Array.isArray(data) || data.length === 0) { + // still create an empty CSV containing only headers + const csvHeader = headers ? headers.join(delimiter) : ''; + const csvString = addBOM ? '\uFEFF' + csvHeader : csvHeader; + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + return; + } + + // if headers not provided and rows are objects, take keys from first object + let _headers: string[] | null = headers; + if (!_headers) { + const firstRow = data[0]; + if (typeof firstRow === 'object' && !Array.isArray(firstRow)) { + _headers = Object.keys(firstRow); + } else if (Array.isArray(firstRow)) { + // if rows are arrays and no headers provided, we won't add header row + _headers = null; + } + } + + const escape = (val: unknown) => { + if (val === null || typeof val === 'undefined') return ''; + const str = String(val); + const needsQuoting = str.includes(delimiter) || str.includes('\n') || str.includes('\r') || str.includes('"'); + if (!needsQuoting) return str; + return '"' + str.replace(/"/g, '""') + '"'; + }; + + const rows: string[] = data.map((row) => { + if (Array.isArray(row)) { + return row.map(escape).join(delimiter); + } + // object row - map using headers if available, otherwise use object values + if (_headers && Array.isArray(_headers)) { + return _headers.map(h => escape((row as Record)[h])).join(delimiter); + } + return Object.values(row).map(escape).join(delimiter); + }); + + const headerRow = _headers ? _headers.join(delimiter) : null; + const csvString = (addBOM ? '\uFEFF' : '') + [headerRow, ...rows].filter(Boolean).join('\r\n'); + + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}