sf-data-extractor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,682 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const fs = require('fs').promises;
5
+ const { execSync } = require('child_process');
6
+ const path = require('path');
7
+ const archiver = require('archiver');
8
+ const {openResource} = require('open-resource');
9
+
10
+
11
+ // Package info
12
+ const PACKAGE_VERSION = '1.0.0';
13
+ const PACKAGE_NAME = 'sf-data-extractor';
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name('sf-data-extractor')
19
+ .description('Extract Salesforce data based on load plan and generate HTML report')
20
+ .version('1.0.0')
21
+ .requiredOption('-o, --org <username>', 'Salesforce org username or alias')
22
+ .requiredOption('-l, --load-plan <file>', 'Path to load-plan.json file')
23
+ .option('-d, --output-dir <directory>', 'Output directory for results', './sf-extract-output')
24
+ .option('-z, --zip', 'Create a zip file containing all CSV files')
25
+ .parse(process.argv);
26
+
27
+ const options = program.opts();
28
+
29
+ async function main() {
30
+ try {
31
+ console.log('šŸš€ Starting Salesforce Data Extraction...\n');
32
+
33
+ // Read load plan
34
+ console.log(`šŸ“„ Reading load plan from: ${options.loadPlan}`);
35
+ const loadPlanContent = await fs.readFile(options.loadPlan, 'utf8');
36
+ const loadPlan = JSON.parse(loadPlanContent);
37
+
38
+ // Create output directory
39
+ await fs.mkdir(options.outputDir, { recursive: true });
40
+
41
+ // Extract data for each object
42
+ const results = [];
43
+
44
+ for (let i = 0; i < loadPlan.length; i++) {
45
+ const obj = loadPlan[i];
46
+ console.log(`\n[${i + 1}/${loadPlan.length}] Processing ${obj.object}...`);
47
+
48
+ try {
49
+ const csvPath = path.join(options.outputDir, `${obj.object}.csv`);
50
+ const command = `sf data query -q "${obj.query}" -o ${options.org} -r csv > "${csvPath}"`;
51
+
52
+ console.log(` ā³ Executing query...`);
53
+ execSync(command, { stdio: 'inherit' });
54
+
55
+ // Read CSV data
56
+ const csvData = await fs.readFile(csvPath, 'utf8');
57
+ const rows = csvData.trim().split('\n');
58
+ const recordCount = Math.max(0, rows.length - 1);
59
+
60
+ results.push({
61
+ object: obj.object,
62
+ query: obj.query,
63
+ recordCount: recordCount,
64
+ csvPath: csvPath,
65
+ csvData: csvData,
66
+ status: 'success'
67
+ });
68
+
69
+ console.log(` āœ… Extracted ${recordCount} records`);
70
+ } catch (error) {
71
+ console.error(` āŒ Error extracting ${obj.object}:`, error.message);
72
+ results.push({
73
+ object: obj.object,
74
+ query: obj.query,
75
+ recordCount: 0,
76
+ error: error.message,
77
+ status: 'error'
78
+ });
79
+ }
80
+ }
81
+
82
+ // Generate HTML report
83
+ console.log('\nšŸ“Š Generating HTML report...');
84
+ const htmlPath = path.join(options.outputDir, 'report.html');
85
+ const html = generateHTML(results, options.org);
86
+ await fs.writeFile(htmlPath, html);
87
+
88
+ // Create ZIP file if requested
89
+ if (options.zip) {
90
+ console.log('\nšŸ“¦ Creating ZIP archive...');
91
+ const zipPath = path.join(options.outputDir, 'data-export.zip');
92
+ await createZipArchive(results, options.outputDir, zipPath);
93
+ console.log(`āœ… ZIP file created: ${zipPath}`);
94
+ }
95
+
96
+ console.log(`\n✨ Extraction complete!`);
97
+ console.log(`šŸ“ Output directory: ${options.outputDir}`);
98
+ console.log(`🌐 Open report: ${htmlPath}`);
99
+ openResource(htmlPath);
100
+ if (options.zip) {
101
+ console.log(`šŸ“¦ ZIP archive: ${path.join(options.outputDir, 'data-export.zip')}`);
102
+ }
103
+
104
+ // Summary
105
+ const successCount = results.filter(r => r.status === 'success').length;
106
+ const totalRecords = results.reduce((sum, r) => sum + r.recordCount, 0);
107
+ console.log(`šŸ“ˆ Summary: ${successCount}/${results.length} objects extracted, ${totalRecords} total records`);
108
+
109
+ } catch (error) {
110
+ console.error('āŒ Fatal error:', error.message);
111
+ process.exit(1);
112
+ }
113
+ }
114
+
115
+ async function createZipArchive(results, outputDir, zipPath) {
116
+ return new Promise((resolve, reject) => {
117
+ const output = require('fs').createWriteStream(zipPath);
118
+ const archive = archiver('zip', {
119
+ zlib: { level: 9 } // Maximum compression
120
+ });
121
+
122
+ output.on('close', () => {
123
+ const sizeMB = (archive.pointer() / 1024 / 1024).toFixed(2);
124
+ console.log(` šŸ“Š Total size: ${sizeMB} MB`);
125
+ resolve();
126
+ });
127
+
128
+ archive.on('error', (err) => {
129
+ reject(err);
130
+ });
131
+
132
+ archive.on('warning', (err) => {
133
+ if (err.code === 'ENOENT') {
134
+ console.warn(' āš ļø Warning:', err.message);
135
+ } else {
136
+ reject(err);
137
+ }
138
+ });
139
+
140
+ archive.pipe(output);
141
+
142
+ // Add each CSV file to the archive
143
+ let addedCount = 0;
144
+ results.forEach(result => {
145
+ if (result.status === 'success' && result.csvPath) {
146
+ const fileName = path.basename(result.csvPath);
147
+ archive.file(result.csvPath, { name: `csv/${fileName}` });
148
+ addedCount++;
149
+ }
150
+ });
151
+
152
+ console.log(` šŸ“„ Adding ${addedCount} CSV files to archive...`);
153
+
154
+ archive.finalize();
155
+ });
156
+ }
157
+
158
+ function generateHTML(results, orgUsername) {
159
+ const dataJson = JSON.stringify(results.map(r => ({
160
+ object: r.object,
161
+ query: r.query,
162
+ recordCount: r.recordCount,
163
+ status: r.status,
164
+ error: r.error,
165
+ data: parseCSV(r.csvData || '')
166
+ })));
167
+
168
+ return `<!DOCTYPE html>
169
+ <html lang="en" class="dark">
170
+ <head>
171
+ <meta charset="UTF-8">
172
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
173
+ <title>Salesforce Data Extract Report</title>
174
+ <script src="https://cdn.tailwindcss.com"></script>
175
+ <link rel="icon" type="image/x-icon" href="https://mohan-chinnappan-n5.github.io/dfv/img/mc_favIcon.ico" />
176
+ <script>
177
+ tailwind.config = {
178
+ darkMode: 'class',
179
+ theme: {
180
+ extend: {
181
+ colors: {
182
+ dark: {
183
+ bg: '#0f172a',
184
+ surface: '#1e293b',
185
+ border: '#334155'
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ </script>
192
+ <style>
193
+ body { font-family: 'Inter', system-ui, sans-serif; }
194
+ .scrollbar-thin::-webkit-scrollbar { width: 8px; height: 8px; }
195
+ .scrollbar-thin::-webkit-scrollbar-track { background: #1e293b; }
196
+ .scrollbar-thin::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
197
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover { background: #64748b; }
198
+ </style>
199
+ </head>
200
+ <body class="bg-dark-bg text-gray-100">
201
+ <div class="flex h-screen overflow-hidden">
202
+ <!-- Left Sidebar -->
203
+ <div class="w-80 bg-dark-surface border-r border-dark-border flex flex-col">
204
+ <div class="p-6 border-b border-dark-border">
205
+ <h1 class="text-2xl font-bold text-white mb-2">šŸ“Š Data Extract</h1>
206
+ <p class="text-sm text-gray-400">Org: <span class="text-blue-400">${orgUsername}</span></p>
207
+ <p class="text-sm text-gray-400">Date: ${new Date().toLocaleString()}</p>
208
+ </div>
209
+
210
+ <div class="p-4 border-b border-dark-border">
211
+ <input
212
+ type="text"
213
+ id="objectSearch"
214
+ placeholder="Search objects..."
215
+ class="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
216
+ />
217
+ </div>
218
+
219
+ <div class="flex-1 overflow-y-auto scrollbar-thin p-4" id="objectList">
220
+ <!-- Objects will be populated here -->
221
+ </div>
222
+
223
+ <div class="p-4 border-t border-dark-border text-xs text-gray-400">
224
+ <div class="flex justify-between mb-1">
225
+ <span>Total Objects:</span>
226
+ <span id="totalObjects" class="text-white font-semibold">0</span>
227
+ </div>
228
+ <div class="flex justify-between">
229
+ <span>Total Records:</span>
230
+ <span id="totalRecords" class="text-white font-semibold">0</span>
231
+ </div>
232
+ </div>
233
+ </div>
234
+
235
+ <!-- Right Content Area -->
236
+ <div class="flex-1 flex flex-col overflow-hidden">
237
+ <div class="p-6 border-b border-dark-border bg-dark-surface">
238
+ <div id="objectHeader">
239
+ <h2 class="text-xl font-semibold text-gray-400">Select an object to view details</h2>
240
+ </div>
241
+ </div>
242
+
243
+ <div class="flex-1 overflow-auto scrollbar-thin p-6" id="contentArea">
244
+ <div class="text-center text-gray-500 mt-20">
245
+ <svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
246
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
247
+ </svg>
248
+ <p>Select an object from the left panel to view its data</p>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </div>
253
+
254
+ <script>
255
+ const data = ${dataJson};
256
+ let currentObject = null;
257
+ let currentSort = { column: null, direction: 'asc' };
258
+ let currentPage = 1;
259
+ let rowsPerPage = 50;
260
+ let filteredData = [];
261
+
262
+ function init() {
263
+ renderObjectList(data);
264
+ updateSummary();
265
+ setupSearch();
266
+ }
267
+
268
+ function renderObjectList(objects) {
269
+ const list = document.getElementById('objectList');
270
+ list.innerHTML = objects.map((obj, idx) => {
271
+ const statusColor = obj.status === 'success' ? 'bg-green-500' : 'bg-red-500';
272
+ return \`
273
+ <div
274
+ class="p-3 mb-2 bg-dark-bg hover:bg-slate-700 rounded-lg cursor-pointer transition-colors border border-dark-border hover:border-blue-500"
275
+ onclick="selectObject(\${idx})"
276
+ id="obj-\${idx}"
277
+ >
278
+ <div class="flex items-start justify-between mb-1">
279
+ <span class="font-medium text-sm text-white">\${obj.object}</span>
280
+ <span class="w-2 h-2 rounded-full \${statusColor} mt-1"></span>
281
+ </div>
282
+ <div class="text-xs text-gray-400">
283
+ \${obj.status === 'success' ? \`\${obj.recordCount} records\` : 'Error'}
284
+ </div>
285
+ </div>
286
+ \`;
287
+ }).join('');
288
+ }
289
+
290
+ function selectObject(idx) {
291
+ currentObject = data[idx];
292
+
293
+ // Update active state
294
+ document.querySelectorAll('[id^="obj-"]').forEach(el => {
295
+ el.classList.remove('border-blue-500', 'bg-slate-700');
296
+ });
297
+ document.getElementById(\`obj-\${idx}\`).classList.add('border-blue-500', 'bg-slate-700');
298
+
299
+ renderObjectDetails(currentObject);
300
+ }
301
+
302
+ function renderObjectDetails(obj) {
303
+ const header = document.getElementById('objectHeader');
304
+ const content = document.getElementById('contentArea');
305
+
306
+ // Reset pagination
307
+ currentPage = 1;
308
+ filteredData = obj.data || [];
309
+
310
+ header.innerHTML = \`
311
+ <div class="flex items-center justify-between">
312
+ <div>
313
+ <h2 class="text-2xl font-bold text-white">\${obj.object}</h2>
314
+ <p class="text-sm text-gray-400 mt-1">\${obj.recordCount} records extracted</p>
315
+ </div>
316
+ <button
317
+ onclick="exportCSV()"
318
+ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors"
319
+ >
320
+ šŸ“„ Export CSV
321
+ </button>
322
+ </div>
323
+ \`;
324
+
325
+ if (obj.status === 'error') {
326
+ content.innerHTML = \`
327
+ <div class="bg-red-900/20 border border-red-500 rounded-lg p-4 mb-6">
328
+ <h3 class="font-semibold text-red-400 mb-2">āŒ Error</h3>
329
+ <p class="text-sm text-gray-300">\${obj.error}</p>
330
+ </div>
331
+ <div class="bg-dark-surface rounded-lg p-4">
332
+ <h3 class="font-semibold mb-2">Query</h3>
333
+ <pre class="text-xs bg-dark-bg p-3 rounded overflow-x-auto text-gray-300">\${obj.query}</pre>
334
+ </div>
335
+ \`;
336
+ return;
337
+ }
338
+
339
+ content.innerHTML = \`
340
+ <div class="space-y-4">
341
+ <div class="bg-dark-surface rounded-lg p-4">
342
+ <h3 class="font-semibold mb-2 text-white">SOQL Query</h3>
343
+ <pre class="text-xs bg-dark-bg p-3 rounded overflow-x-auto text-gray-300 border border-dark-border">\${obj.query}</pre>
344
+ </div>
345
+
346
+ <div class="bg-dark-surface rounded-lg p-4">
347
+ <div class="flex items-center justify-between mb-4 flex-wrap gap-3">
348
+ <h3 class="font-semibold text-white">Data Table</h3>
349
+ <div class="flex items-center gap-3 flex-wrap">
350
+ <select
351
+ id="rowsPerPageSelect"
352
+ onchange="changeRowsPerPage()"
353
+ class="px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
354
+ >
355
+ <option value="25">25 rows</option>
356
+ <option value="50" selected>50 rows</option>
357
+ <option value="100">100 rows</option>
358
+ <option value="250">250 rows</option>
359
+ <option value="500">500 rows</option>
360
+ <option value="-1">All rows</option>
361
+ </select>
362
+ <input
363
+ type="text"
364
+ id="tableSearch"
365
+ placeholder="Search in table..."
366
+ onkeyup="filterTable()"
367
+ class="px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64"
368
+ />
369
+ </div>
370
+ </div>
371
+ <div class="overflow-x-auto">
372
+ \${renderDataTable(obj.data)}
373
+ </div>
374
+ <div id="paginationContainer"></div>
375
+ </div>
376
+ </div>
377
+ \`;
378
+ }
379
+
380
+ function renderDataTable(data) {
381
+ if (!data || data.length === 0) {
382
+ return '<p class="text-gray-500 text-center py-8">No data available</p>';
383
+ }
384
+
385
+ filteredData = data;
386
+ const headers = Object.keys(data[0]);
387
+ const totalPages = rowsPerPage === -1 ? 1 : Math.ceil(filteredData.length / rowsPerPage);
388
+ const startIdx = rowsPerPage === -1 ? 0 : (currentPage - 1) * rowsPerPage;
389
+ const endIdx = rowsPerPage === -1 ? filteredData.length : startIdx + rowsPerPage;
390
+ const pageData = filteredData.slice(startIdx, endIdx);
391
+
392
+ const tableHTML = \`
393
+ <table class="w-full text-sm" id="dataTable">
394
+ <thead class="bg-dark-bg sticky top-0">
395
+ <tr>
396
+ \${headers.map(h => \`
397
+ <th class="px-4 py-3 text-left font-semibold text-gray-300 cursor-pointer hover:text-white border-b border-dark-border whitespace-nowrap" onclick="sortTable('\${h}')">
398
+ <div class="flex items-center gap-2">
399
+ \${h}
400
+ <span class="sort-indicator text-gray-600">⇅</span>
401
+ </div>
402
+ </th>
403
+ \`).join('')}
404
+ </tr>
405
+ </thead>
406
+ <tbody id="tableBody">
407
+ \${pageData.map((row, idx) => \`
408
+ <tr class="border-b border-dark-border hover:bg-slate-700/50 transition-colors">
409
+ \${headers.map(h => \`
410
+ <td class="px-4 py-3 text-gray-300 whitespace-nowrap">\${escapeHtml(row[h] || '')}</td>
411
+ \`).join('')}
412
+ </tr>
413
+ \`).join('')}
414
+ </tbody>
415
+ </table>
416
+ \`;
417
+
418
+ // Render pagination after table
419
+ setTimeout(() => renderPagination(totalPages), 0);
420
+
421
+ return tableHTML;
422
+ }
423
+
424
+ function renderPagination(totalPages) {
425
+ const container = document.getElementById('paginationContainer');
426
+ if (!container || totalPages <= 1) {
427
+ if (container) container.innerHTML = '';
428
+ return;
429
+ }
430
+
431
+ const startRecord = (currentPage - 1) * rowsPerPage + 1;
432
+ const endRecord = Math.min(currentPage * rowsPerPage, filteredData.length);
433
+
434
+ let pages = [];
435
+
436
+ // Always show first page
437
+ pages.push(1);
438
+
439
+ // Show pages around current page
440
+ for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
441
+ pages.push(i);
442
+ }
443
+
444
+ // Always show last page
445
+ if (totalPages > 1) pages.push(totalPages);
446
+
447
+ // Remove duplicates and sort
448
+ pages = [...new Set(pages)].sort((a, b) => a - b);
449
+
450
+ container.innerHTML = \`
451
+ <div class="flex items-center justify-between mt-4 pt-4 border-t border-dark-border">
452
+ <div class="text-sm text-gray-400">
453
+ Showing <span class="text-white font-semibold">\${startRecord}</span> to
454
+ <span class="text-white font-semibold">\${endRecord}</span> of
455
+ <span class="text-white font-semibold">\${filteredData.length}</span> records
456
+ </div>
457
+
458
+ <div class="flex items-center gap-2">
459
+ <button
460
+ onclick="goToPage(\${currentPage - 1})"
461
+ \${currentPage === 1 ? 'disabled' : ''}
462
+ class="px-3 py-1 bg-dark-bg border border-dark-border rounded hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
463
+ >
464
+ ← Previous
465
+ </button>
466
+
467
+ \${pages.map((page, idx) => {
468
+ const prevPage = idx > 0 ? pages[idx - 1] : 0;
469
+ const showEllipsis = page - prevPage > 1;
470
+
471
+ return \`
472
+ \${showEllipsis ? '<span class="px-2 text-gray-600">...</span>' : ''}
473
+ <button
474
+ onclick="goToPage(\${page})"
475
+ class="px-3 py-1 rounded transition-colors text-sm \${page === currentPage ? 'bg-blue-600 text-white font-semibold' : 'bg-dark-bg border border-dark-border hover:bg-slate-700'}"
476
+ >
477
+ \${page}
478
+ </button>
479
+ \`;
480
+ }).join('')}
481
+
482
+ <button
483
+ onclick="goToPage(\${currentPage + 1})"
484
+ \${currentPage === totalPages ? 'disabled' : ''}
485
+ class="px-3 py-1 bg-dark-bg border border-dark-border rounded hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
486
+ >
487
+ Next →
488
+ </button>
489
+ </div>
490
+ </div>
491
+ \`;
492
+ }
493
+
494
+ function goToPage(page) {
495
+ const totalPages = Math.ceil(filteredData.length / rowsPerPage);
496
+ if (page < 1 || page > totalPages) return;
497
+
498
+ currentPage = page;
499
+ renderObjectDetails(currentObject);
500
+ }
501
+
502
+ function changeRowsPerPage() {
503
+ const select = document.getElementById('rowsPerPageSelect');
504
+ rowsPerPage = parseInt(select.value);
505
+ currentPage = 1;
506
+ renderObjectDetails(currentObject);
507
+ }
508
+
509
+ function sortTable(column) {
510
+ if (!currentObject || !currentObject.data) return;
511
+
512
+ if (currentSort.column === column) {
513
+ currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
514
+ } else {
515
+ currentSort.column = column;
516
+ currentSort.direction = 'asc';
517
+ }
518
+
519
+ currentObject.data.sort((a, b) => {
520
+ const aVal = a[column] || '';
521
+ const bVal = b[column] || '';
522
+
523
+ const compare = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
524
+ return currentSort.direction === 'asc' ? compare : -compare;
525
+ });
526
+
527
+ renderObjectDetails(currentObject);
528
+ }
529
+
530
+ function filterTable() {
531
+ const search = document.getElementById('tableSearch').value.toLowerCase();
532
+
533
+ if (!search) {
534
+ filteredData = currentObject.data || [];
535
+ } else {
536
+ filteredData = (currentObject.data || []).filter(row => {
537
+ return Object.values(row).some(val =>
538
+ String(val).toLowerCase().includes(search)
539
+ );
540
+ });
541
+ }
542
+
543
+ currentPage = 1;
544
+ renderObjectDetails(currentObject);
545
+ }
546
+
547
+ function exportCSV() {
548
+ if (!currentObject || !currentObject.data) return;
549
+
550
+ const headers = Object.keys(currentObject.data[0]);
551
+ const csv = [
552
+ headers.join(','),
553
+ ...currentObject.data.map(row =>
554
+ headers.map(h => {
555
+ const val = row[h] || '';
556
+ return \`"\${String(val).replace(/"/g, '""')}"\`;
557
+ }).join(',')
558
+ )
559
+ ].join('\\n');
560
+
561
+ const blob = new Blob([csv], { type: 'text/csv' });
562
+ const url = URL.createObjectURL(blob);
563
+ const a = document.createElement('a');
564
+ a.href = url;
565
+ a.download = \`\${currentObject.object}.csv\`;
566
+ a.click();
567
+ URL.revokeObjectURL(url);
568
+ }
569
+
570
+ function setupSearch() {
571
+ document.getElementById('objectSearch').addEventListener('keyup', (e) => {
572
+ const search = e.target.value.toLowerCase();
573
+ const filtered = data.filter(obj =>
574
+ obj.object.toLowerCase().includes(search)
575
+ );
576
+ renderObjectList(filtered);
577
+ });
578
+ }
579
+
580
+ function updateSummary() {
581
+ document.getElementById('totalObjects').textContent = data.length;
582
+ const total = data.reduce((sum, obj) => sum + obj.recordCount, 0);
583
+ document.getElementById('totalRecords').textContent = total.toLocaleString();
584
+ }
585
+
586
+ function parseCSV(csv) {
587
+ if (!csv) return [];
588
+ const lines = csv.trim().split('\\n');
589
+ if (lines.length < 2) return [];
590
+
591
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
592
+ return lines.slice(1).map(line => {
593
+ const values = parseCSVLine(line);
594
+ const obj = {};
595
+ headers.forEach((h, i) => {
596
+ obj[h] = values[i] || '';
597
+ });
598
+ return obj;
599
+ });
600
+ }
601
+
602
+ function parseCSVLine(line) {
603
+ const result = [];
604
+ let current = '';
605
+ let inQuotes = false;
606
+
607
+ for (let i = 0; i < line.length; i++) {
608
+ const char = line[i];
609
+ const next = line[i + 1];
610
+
611
+ if (char === '"' && inQuotes && next === '"') {
612
+ current += '"';
613
+ i++;
614
+ } else if (char === '"') {
615
+ inQuotes = !inQuotes;
616
+ } else if (char === ',' && !inQuotes) {
617
+ result.push(current.trim());
618
+ current = '';
619
+ } else {
620
+ current += char;
621
+ }
622
+ }
623
+ result.push(current.trim());
624
+ return result;
625
+ }
626
+
627
+ function escapeHtml(text) {
628
+ const div = document.createElement('div');
629
+ div.textContent = text;
630
+ return div.innerHTML;
631
+ }
632
+
633
+ init();
634
+ </script>
635
+ </body>
636
+ </html>`;
637
+ }
638
+
639
+ function parseCSV(csvData) {
640
+ if (!csvData) return [];
641
+
642
+ const lines = csvData.trim().split('\n');
643
+ if (lines.length < 2) return [];
644
+
645
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
646
+
647
+ return lines.slice(1).map(line => {
648
+ const values = parseCSVLine(line);
649
+ const obj = {};
650
+ headers.forEach((h, idx) => {
651
+ obj[h] = values[idx] ? values[idx].replace(/^"|"$/g, '') : '';
652
+ });
653
+ return obj;
654
+ });
655
+ }
656
+
657
+ function parseCSVLine(line) {
658
+ const result = [];
659
+ let current = '';
660
+ let inQuotes = false;
661
+
662
+ for (let i = 0; i < line.length; i++) {
663
+ const char = line[i];
664
+ const next = line[i + 1];
665
+
666
+ if (char === '"' && inQuotes && next === '"') {
667
+ current += '"';
668
+ i++;
669
+ } else if (char === '"') {
670
+ inQuotes = !inQuotes;
671
+ } else if (char === ',' && !inQuotes) {
672
+ result.push(current);
673
+ current = '';
674
+ } else {
675
+ current += char;
676
+ }
677
+ }
678
+ result.push(current);
679
+ return result;
680
+ }
681
+
682
+ main();