|
| 1 | +/** |
| 2 | + * Content is the data structure that represents the spreadsheet content. |
| 3 | + * |
| 4 | + * @example |
| 5 | + * const content: Content = { |
| 6 | + * headings: ['Name', 'Age', 'City'], |
| 7 | + * data: [ |
| 8 | + * ['John Doe', '30', 'New York'], |
| 9 | + * ['Jane Smith', '25', 'London'], |
| 10 | + * ['Bob Johnson', '35', 'Paris'] |
| 11 | + * ] |
| 12 | + * } |
| 13 | + */ |
| 14 | +export interface Content { |
| 15 | + headings: string[] |
| 16 | + data: string[][] |
| 17 | +} |
| 18 | + |
| 19 | +export type SpreadsheetType = 'csv' | 'excel' |
| 20 | + |
| 21 | +export interface SpreadsheetContent { |
| 22 | + content: string | Uint8Array |
| 23 | + type: SpreadsheetType |
| 24 | +} |
| 25 | + |
| 26 | +export interface SpreadsheetOptions { |
| 27 | + type: SpreadsheetType |
| 28 | +} |
| 29 | + |
| 30 | +export type Spreadsheet = { |
| 31 | + create: (data: Content, options: SpreadsheetOptions) => SpreadsheetContent |
| 32 | + generate: (data: Content, options: SpreadsheetOptions) => string | Uint8Array |
| 33 | + generateCSV: (content: Content) => string | SpreadsheetWrapper |
| 34 | + generateExcel: (content: Content) => Uint8Array | SpreadsheetWrapper |
| 35 | + store: (spreadsheet: SpreadsheetContent, path: string) => Promise<void> |
| 36 | + download: (spreadsheet: SpreadsheetContent, filename: string) => Response |
| 37 | +} |
| 38 | + |
| 39 | +export const spreadsheet: Spreadsheet = { |
| 40 | + generate: (data: Content, options: SpreadsheetOptions = { type: 'csv' }): string | Uint8Array => { |
| 41 | + const generators: Record<SpreadsheetType, (content: Content) => string | Uint8Array | SpreadsheetWrapper> = { |
| 42 | + csv: spreadsheet.generateCSV, |
| 43 | + excel: spreadsheet.generateExcel, |
| 44 | + } |
| 45 | + |
| 46 | + const generator = generators[options.type] |
| 47 | + |
| 48 | + if (!generator) { |
| 49 | + throw new Error(`Unsupported spreadsheet type: ${options.type}`) |
| 50 | + } |
| 51 | + |
| 52 | + const result = generator(data) |
| 53 | + if (result instanceof SpreadsheetWrapper) { |
| 54 | + return result.getContent() |
| 55 | + } |
| 56 | + return result |
| 57 | + }, |
| 58 | + |
| 59 | + create: (data: Content, options: SpreadsheetOptions = { type: 'csv' }): SpreadsheetContent => ({ |
| 60 | + content: spreadsheet.generate(data, options), |
| 61 | + type: options.type, |
| 62 | + }), |
| 63 | + |
| 64 | + generateCSV: (content: Content): string | SpreadsheetWrapper => { |
| 65 | + const csvContent = generateCSVContent(content) |
| 66 | + return new SpreadsheetWrapper(csvContent, 'csv') |
| 67 | + }, |
| 68 | + |
| 69 | + generateExcel: (content: Content): Uint8Array | SpreadsheetWrapper => { |
| 70 | + const excelContent = generateExcelContent(content) |
| 71 | + return new SpreadsheetWrapper(excelContent, 'excel') |
| 72 | + }, |
| 73 | + |
| 74 | + store: async ({ content }: SpreadsheetContent, path: string): Promise<void> => { |
| 75 | + try { |
| 76 | + await Bun.write(path, content) |
| 77 | + } catch (error) { |
| 78 | + throw new Error(`Failed to store spreadsheet: ${(error as Error).message}`) |
| 79 | + } |
| 80 | + }, |
| 81 | + |
| 82 | + download: ({ content, type }: SpreadsheetContent, filename: string): Response => { |
| 83 | + const mimeType = type === 'csv' ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' |
| 84 | + const blob = new Blob([content], { type: mimeType }) |
| 85 | + |
| 86 | + return new Response(blob, { |
| 87 | + headers: { |
| 88 | + 'Content-Type': mimeType, |
| 89 | + 'Content-Disposition': `attachment; filename="${filename}"`, |
| 90 | + }, |
| 91 | + }) |
| 92 | + }, |
| 93 | +} |
| 94 | + |
| 95 | +export class SpreadsheetWrapper { |
| 96 | + constructor( |
| 97 | + private content: string | Uint8Array, |
| 98 | + private type: SpreadsheetType, |
| 99 | + ) {} |
| 100 | + |
| 101 | + getContent(): string | Uint8Array { |
| 102 | + return this.content |
| 103 | + } |
| 104 | + |
| 105 | + download(filename: string): Response { |
| 106 | + return spreadsheet.download({ content: this.content, type: this.type }, filename) |
| 107 | + } |
| 108 | + |
| 109 | + store(path: string): Promise<void> { |
| 110 | + return spreadsheet.store({ content: this.content, type: this.type }, path) |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +export function createSpreadsheet(data: Content, options: SpreadsheetOptions = { type: 'csv' }): SpreadsheetWrapper { |
| 115 | + const content = spreadsheet.generate(data, options) |
| 116 | + return new SpreadsheetWrapper(content, options.type) |
| 117 | +} |
| 118 | + |
| 119 | +export function generateCSVContent(content: Content): string { |
| 120 | + const rows = [content.headings, ...content.data] |
| 121 | + return rows.map((row) => row.join(',')).join('\n') |
| 122 | +} |
| 123 | + |
| 124 | +export function generateExcelContent(content: Content): Uint8Array { |
| 125 | + const workbook = Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 126 | + <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> |
| 127 | + <sheets> |
| 128 | + <sheet name="Sheet1" sheetId="1" r:id="rId1"/> |
| 129 | + </sheets> |
| 130 | + </workbook>`) |
| 131 | + |
| 132 | + const worksheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 133 | + <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> |
| 134 | + <sheetData> |
| 135 | + ${[content.headings, ...content.data] |
| 136 | + .map( |
| 137 | + (row, rowIndex) => ` |
| 138 | + <row r="${rowIndex + 1}"> |
| 139 | + ${row |
| 140 | + .map( |
| 141 | + (cell, cellIndex) => ` |
| 142 | + <c r="${String.fromCharCode(65 + cellIndex)}${rowIndex + 1}"> |
| 143 | + <v>${cell}</v> |
| 144 | + </c>`, |
| 145 | + ) |
| 146 | + .join('')} |
| 147 | + </row>`, |
| 148 | + ) |
| 149 | + .join('')} |
| 150 | + </sheetData> |
| 151 | + </worksheet>` |
| 152 | + |
| 153 | + const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 154 | + <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> |
| 155 | + <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> |
| 156 | + <Default Extension="xml" ContentType="application/xml"/> |
| 157 | + <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/> |
| 158 | + <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/> |
| 159 | + </Types>` |
| 160 | + |
| 161 | + const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
| 162 | + <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> |
| 163 | + <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/> |
| 164 | + </Relationships>` |
| 165 | + |
| 166 | + const files: Array<{ name: string; content: string | Uint8Array }> = [ |
| 167 | + { name: '[Content_Types].xml', content: contentTypes }, |
| 168 | + { name: '_rels/.rels', content: rels }, |
| 169 | + { name: 'xl/workbook.xml', content: workbook }, |
| 170 | + { name: 'xl/worksheets/sheet1.xml', content: worksheet }, |
| 171 | + ] |
| 172 | + |
| 173 | + const zipData = files.map((file) => { |
| 174 | + const compressedContent = Bun.gzipSync(Buffer.from(file.content)) |
| 175 | + const header = Buffer.alloc(30 + file.name.length) |
| 176 | + header.write('PK\x03\x04', 0) |
| 177 | + header.writeUInt32LE(0x0008, 4) |
| 178 | + header.writeUInt32LE(compressedContent.length, 18) |
| 179 | + header.writeUInt32LE(Buffer.from(file.content).length, 22) |
| 180 | + header.writeUInt16LE(file.name.length, 26) |
| 181 | + header.write(file.name, 30) |
| 182 | + |
| 183 | + return Buffer.concat([header, compressedContent]) |
| 184 | + }) |
| 185 | + |
| 186 | + const centralDirectory = files.map((file, index) => { |
| 187 | + const header = Buffer.alloc(46 + file.name.length) |
| 188 | + header.write('PK\x01\x02', 0) |
| 189 | + header.writeUInt16LE(0x0014, 4) |
| 190 | + header.writeUInt16LE(0x0008, 6) |
| 191 | + header.writeUInt32LE(0x0008, 8) |
| 192 | + // biome-ignore lint/style/noNonNullAssertion: We know that zipData[index] is not null because we are iterating over files |
| 193 | + header.writeUInt32LE(zipData[index]!.length - 30 - file.name.length, 20) |
| 194 | + header.writeUInt32LE( |
| 195 | + zipData.slice(0, index).reduce((acc, curr) => acc + curr.length, 0), |
| 196 | + 42, |
| 197 | + ) |
| 198 | + header.writeUInt16LE(file.name.length, 28) |
| 199 | + header.write(file.name, 46) |
| 200 | + return header |
| 201 | + }) |
| 202 | + |
| 203 | + const endOfCentralDirectory = Buffer.alloc(22) |
| 204 | + endOfCentralDirectory.write('PK\x05\x06', 0) |
| 205 | + endOfCentralDirectory.writeUInt16LE(files.length, 8) |
| 206 | + endOfCentralDirectory.writeUInt16LE(files.length, 10) |
| 207 | + endOfCentralDirectory.writeUInt32LE( |
| 208 | + centralDirectory.reduce((acc, curr) => acc + curr.length, 0), |
| 209 | + 12, |
| 210 | + ) |
| 211 | + endOfCentralDirectory.writeUInt32LE( |
| 212 | + zipData.reduce((acc, curr) => acc + curr.length, 0), |
| 213 | + 16, |
| 214 | + ) |
| 215 | + |
| 216 | + return Uint8Array.from(Buffer.concat([...zipData, ...centralDirectory, endOfCentralDirectory])) |
| 217 | +} |
0 commit comments