{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"lib-export","type":"registry:lib","title":"export helpers","description":"Export rows as CSV or Excel (.xlsx), in the browser and with no library: a CSV writer, a small .xlsx writer (the","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/dashral/lib/export.ts","type":"registry:file","target":"components/dashral/lib/export.ts","content":"// Export rows as CSV or Excel (.xlsx), in the browser and with no library: a CSV writer, a small .xlsx writer (the\n// spreadsheet's XML files in an uncompressed zip) and a download helper. Used by DataTable's export menu.\n\nexport type ExportCell = string | number | boolean | Date | null | undefined\nexport type ExportFormat = \"csv\" | \"xlsx\"\n\n/**\n * A spreadsheet opens a CSV cell starting with = + - @ (or a tab or return) as a formula, which can run commands on\n * the reader's computer (\"CSV injection\"). Such text is kept as text with a leading apostrophe. Numbers are safe.\n */\nfunction safeText(text: string) {\n  return /^[=+\\-@\\t\\r]/.test(text) ? `'${text}` : text\n}\n\nconst pad = (n: number) => String(n).padStart(2, \"0\")\n/** \"2026-10-15\", or \"2026-10-15 14:30\" when there's a time of day (in the reader's zone). */\nfunction formatDate(date: Date) {\n  const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`\n  return date.getHours() || date.getMinutes() ? `${day} ${pad(date.getHours())}:${pad(date.getMinutes())}` : day\n}\n\n/** Rows (the first one the header) as CSV text: comma-separated, quoted where needed, CRLF line ends. */\nexport function toCsv(rows: ExportCell[][]): string {\n  const cell = (value: ExportCell) => {\n    if (value === null || value === undefined) return \"\"\n    const text = value instanceof Date ? formatDate(value) : typeof value === \"string\" ? safeText(value) : String(value)\n    return /[\",\\r\\n]/.test(text) ? `\"${text.replaceAll('\"', '\"\"')}\"` : text\n  }\n  return rows.map((row) => row.map(cell).join(\",\")).join(\"\\r\\n\")\n}\n\n// ---------- .xlsx ----------\n\nconst escapeXml = (text: string) =>\n  text.replace(/[<>&\"]/g, (c) => ({ \"<\": \"&lt;\", \">\": \"&gt;\", \"&\": \"&amp;\", '\"': \"&quot;\" })[c]!).replace(/[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f]/g, \"\")\n\n/** \"A\", \"B\", … \"Z\", \"AA\" for a 0-based column index. */\nfunction columnName(index: number) {\n  let name = \"\"\n  for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) name = String.fromCharCode(65 + ((n - 1) % 26)) + name\n  return name\n}\n\n/** Excel's date number: days since 1899-12-30, in the reader's local time. */\nconst excelDate = (date: Date) => (date.getTime() - date.getTimezoneOffset() * 60_000) / 86_400_000 + 25569\n\nfunction sheetXml(rows: ExportCell[][]) {\n  // Column widths from the longest text in each column (in characters), within reason.\n  const widths: number[] = []\n  rows.forEach((row) =>\n    row.forEach((value, c) => {\n      const length = value instanceof Date ? 16 : value === null || value === undefined ? 0 : String(value).length\n      widths[c] = Math.max(widths[c] ?? 8, Math.min(60, length + 2))\n    })\n  )\n  const body = rows\n    .map((row, r) => {\n      const cells = row\n        .map((value, c) => {\n          const ref = `${columnName(c)}${r + 1}`\n          // Style 1 is the bold header.\n          const header = r === 0 ? ' s=\"1\"' : \"\"\n          if (value === null || value === undefined || value === \"\") return \"\"\n          if (typeof value === \"number\" && Number.isFinite(value)) return `<c r=\"${ref}\"${header}><v>${value}</v></c>`\n          if (typeof value === \"boolean\") return `<c r=\"${ref}\" t=\"b\"${header}><v>${value ? 1 : 0}</v></c>`\n          // Style 2 is a date with its time, 3 a date alone.\n          if (value instanceof Date) {\n            const style = value.getHours() || value.getMinutes() ? 2 : 3\n            return `<c r=\"${ref}\" s=\"${style}\"><v>${excelDate(value)}</v></c>`\n          }\n          // Text goes in as an inline string: Excel never reads it as a formula.\n          return `<c r=\"${ref}\" t=\"inlineStr\"${header}><is><t xml:space=\"preserve\">${escapeXml(String(value))}</t></is></c>`\n        })\n        .join(\"\")\n      return `<row r=\"${r + 1}\">${cells}</row>`\n    })\n    .join(\"\")\n  return (\n    `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>` +\n    `<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">` +\n    // The header row stays in view while scrolling.\n    `<sheetViews><sheetView workbookViewId=\"0\"><pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/></sheetView></sheetViews>` +\n    `<cols>${widths.map((w, i) => `<col min=\"${i + 1}\" max=\"${i + 1}\" width=\"${w}\" customWidth=\"1\"/>`).join(\"\")}</cols>` +\n    `<sheetData>${body}</sheetData></worksheet>`\n  )\n}\n\nconst stylesXml =\n  `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>` +\n  `<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">` +\n  `<numFmts count=\"2\"><numFmt numFmtId=\"164\" formatCode=\"yyyy-mm-dd hh:mm\"/><numFmt numFmtId=\"165\" formatCode=\"yyyy-mm-dd\"/></numFmts>` +\n  `<fonts count=\"2\"><font><sz val=\"11\"/><name val=\"Calibri\"/></font><font><b/><sz val=\"11\"/><name val=\"Calibri\"/></font></fonts>` +\n  `<fills count=\"2\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills>` +\n  `<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>` +\n  `<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>` +\n  `<cellXfs count=\"4\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>` +\n  `<xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/>` +\n  `<xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/>` +\n  `<xf numFmtId=\"165\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/></cellXfs>` +\n  `<cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles>` +\n  `</styleSheet>`\n\nfunction workbookFiles(rows: ExportCell[][], sheetName: string): [string, string][] {\n  // Sheet names: at most 31 characters, none of : \\ / ? * [ ].\n  const name = escapeXml(sheetName.replace(/[:\\\\/?*[\\]]/g, \" \").slice(0, 31) || \"Sheet1\")\n  return [\n    [\n      \"[Content_Types].xml\",\n      `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">` +\n        `<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>` +\n        `<Default Extension=\"xml\" ContentType=\"application/xml\"/>` +\n        `<Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>` +\n        `<Override PartName=\"/xl/worksheets/sheet1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>` +\n        `<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/></Types>`,\n    ],\n    [\n      \"_rels/.rels\",\n      `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">` +\n        `<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/></Relationships>`,\n    ],\n    [\n      \"xl/workbook.xml\",\n      `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">` +\n        `<sheets><sheet name=\"${name}\" sheetId=\"1\" r:id=\"rId1\"/></sheets></workbook>`,\n    ],\n    [\n      \"xl/_rels/workbook.xml.rels\",\n      `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">` +\n        `<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet1.xml\"/>` +\n        `<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/></Relationships>`,\n    ],\n    [\"xl/worksheets/sheet1.xml\", sheetXml(rows)],\n    [\"xl/styles.xml\", stylesXml],\n  ]\n}\n\nlet crcTable: Uint32Array | undefined\nfunction crc32(bytes: Uint8Array) {\n  crcTable ??= Uint32Array.from({ length: 256 }, (_, n) => {\n    let c = n\n    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1\n    return c >>> 0\n  })\n  let crc = 0xffffffff\n  for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8)\n  return (crc ^ 0xffffffff) >>> 0\n}\n\n/** Files in a zip archive, stored without compression (Excel reads that fine; the files are small). */\nfunction zip(files: [string, string][]) {\n  const encoder = new TextEncoder()\n  const parts: Uint8Array[] = []\n  const central: Uint8Array[] = []\n  let offset = 0\n  for (const [name, text] of files) {\n    const nameBytes = encoder.encode(name)\n    const data = encoder.encode(text)\n    const crc = crc32(data)\n    const local = new DataView(new ArrayBuffer(30))\n    local.setUint32(0, 0x04034b50, true) // local file header\n    local.setUint16(4, 20, true) // version needed\n    local.setUint16(6, 0x0800, true) // UTF-8 names\n    local.setUint32(14, crc, true)\n    local.setUint32(18, data.length, true) // compressed size (stored)\n    local.setUint32(22, data.length, true)\n    local.setUint16(26, nameBytes.length, true)\n    parts.push(new Uint8Array(local.buffer), nameBytes, data)\n\n    const entry = new DataView(new ArrayBuffer(46))\n    entry.setUint32(0, 0x02014b50, true) // central directory entry\n    entry.setUint16(4, 20, true)\n    entry.setUint16(6, 20, true)\n    entry.setUint16(8, 0x0800, true)\n    entry.setUint32(16, crc, true)\n    entry.setUint32(20, data.length, true)\n    entry.setUint32(24, data.length, true)\n    entry.setUint16(28, nameBytes.length, true)\n    entry.setUint32(42, offset, true)\n    central.push(new Uint8Array(entry.buffer), nameBytes)\n    offset += 30 + nameBytes.length + data.length\n  }\n  const centralSize = central.reduce((sum, part) => sum + part.length, 0)\n  const end = new DataView(new ArrayBuffer(22))\n  end.setUint32(0, 0x06054b50, true) // end of central directory\n  end.setUint16(8, files.length, true)\n  end.setUint16(10, files.length, true)\n  end.setUint32(12, centralSize, true)\n  end.setUint32(16, offset, true)\n  const all = [...parts, ...central, new Uint8Array(end.buffer)]\n  const out = new Uint8Array(new ArrayBuffer(all.reduce((sum, part) => sum + part.length, 0)))\n  let at = 0\n  for (const part of all) {\n    out.set(part, at)\n    at += part.length\n  }\n  return out\n}\n\n/** Rows (the first one the header) as an .xlsx workbook with one sheet. */\nexport function toXlsx(rows: ExportCell[][], sheetName = \"Sheet1\"): Uint8Array<ArrayBuffer> {\n  return zip(workbookFiles(rows, sheetName))\n}\n\n/** Saves a file in the browser. */\nexport function downloadFile(content: BlobPart, filename: string, type: string) {\n  const url = URL.createObjectURL(new Blob([content], { type }))\n  const link = Object.assign(document.createElement(\"a\"), { href: url, download: filename })\n  document.body.append(link)\n  link.click()\n  link.remove()\n  setTimeout(() => URL.revokeObjectURL(url), 1000)\n}\n\n/**\n * Downloads rows (the first one the header) as `filename.csv` or `filename.xlsx`. The CSV starts with a byte order\n * mark, so Excel reads accents and other scripts (Arabic, CJK) correctly.\n */\nexport function exportRows(format: ExportFormat, rows: ExportCell[][], filename: string) {\n  const base = filename.replace(/\\.(csv|xlsx)$/i, \"\")\n  if (format === \"csv\") downloadFile(`﻿${toCsv(rows)}`, `${base}.csv`, \"text/csv;charset=utf-8\")\n  else downloadFile(toXlsx(rows, base), `${base}.xlsx`, \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\")\n}\n"}]}