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/README.md +222 -0
- package/index.js +682 -0
- package/load-plans/load-plan-account-oppty.json +28 -0
- package/package.json +30 -0
- package/sf-extract-output/Account.csv +1509 -0
- package/sf-extract-output/Opportunity.csv +709 -0
- package/sf-extract-output/data-export.zip +0 -0
- package/sf-extract-output/report.html +469 -0
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();
|