juxscript 1.1.204 → 1.1.206
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/lib/components/dataframe.d.ts +7 -2
- package/lib/components/dataframe.d.ts.map +1 -1
- package/lib/components/dataframe.js +219 -296
- package/lib/components/dataframe.ts +237 -330
- package/lib/storage/TabularDriver.d.ts.map +1 -1
- package/lib/storage/TabularDriver.js +7 -4
- package/lib/storage/TabularDriver.ts +7 -4
- package/package.json +1 -1
|
@@ -217,6 +217,11 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
217
217
|
}
|
|
218
218
|
});
|
|
219
219
|
const sheetNames = Object.keys(sheets);
|
|
220
|
+
if (sheetNames.length === 0) {
|
|
221
|
+
this._updateStatus('No data found in file', 'error');
|
|
222
|
+
this.state.loading = false;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
220
225
|
await this._driver.store(file.name, sheets[sheetNames[0]], { source: file.name });
|
|
221
226
|
if (sheetNames.length > 1) {
|
|
222
227
|
this._renderMultiSheet(sheets, file.name);
|
|
@@ -251,7 +256,6 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
251
256
|
const wrapper = document.getElementById(this._id);
|
|
252
257
|
if (!wrapper)
|
|
253
258
|
return;
|
|
254
|
-
// Clean up existing content
|
|
255
259
|
const existingTable = wrapper.querySelector('.jux-table-wrapper');
|
|
256
260
|
if (existingTable)
|
|
257
261
|
existingTable.remove();
|
|
@@ -262,11 +266,10 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
262
266
|
this._sheets.set(name, df);
|
|
263
267
|
});
|
|
264
268
|
const sheetNames = Object.keys(sheets);
|
|
265
|
-
// Build tabs using the Tabs component
|
|
266
269
|
const tabDefs = sheetNames.map(name => ({
|
|
267
270
|
id: name,
|
|
268
271
|
label: name,
|
|
269
|
-
content: ''
|
|
272
|
+
content: ''
|
|
270
273
|
}));
|
|
271
274
|
this._tabs = new Tabs(`${this._id}-tabs`, {
|
|
272
275
|
tabs: tabDefs,
|
|
@@ -275,15 +278,12 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
275
278
|
this._tabs.bind('tabChange', (tabId) => {
|
|
276
279
|
this._df = this._sheets.get(tabId) || null;
|
|
277
280
|
});
|
|
278
|
-
// Create container for tabs
|
|
279
281
|
const tabsContainer = document.createElement('div');
|
|
280
282
|
tabsContainer.className = 'jux-dataframe-tabs';
|
|
281
283
|
wrapper.appendChild(tabsContainer);
|
|
282
284
|
this._tabs.render(tabsContainer);
|
|
283
|
-
|
|
284
|
-
sheetNames.forEach((sheetName, idx) => {
|
|
285
|
+
sheetNames.forEach((sheetName) => {
|
|
285
286
|
const df = sheets[sheetName];
|
|
286
|
-
const panelId = `${this._id}-tabs-${sheetName}-panel`;
|
|
287
287
|
const table = new Table(`${this._id}-table-${sheetName}`, {
|
|
288
288
|
striped: this._tableOptions.striped,
|
|
289
289
|
hoverable: this._tableOptions.hoverable,
|
|
@@ -294,48 +294,13 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
294
294
|
});
|
|
295
295
|
const columnDefs = df.columns.map(col => ({ key: col, label: col }));
|
|
296
296
|
table.columns(columnDefs).rows(df.toRows());
|
|
297
|
-
// Add settings button to tab panel
|
|
298
297
|
const settingsBtn = new Button(`${this._id}-settings-${sheetName}`, {
|
|
299
298
|
label: '⚙️ Import Settings',
|
|
300
299
|
variant: 'ghost',
|
|
301
300
|
size: 'small'
|
|
302
301
|
});
|
|
303
302
|
settingsBtn.bind('click', () => this._showReshapeModal());
|
|
304
|
-
// Use addTabContent to add components
|
|
305
303
|
this._tabs.addTabContent(sheetName, [settingsBtn, table]);
|
|
306
|
-
if (this._tableOptions.filterable) {
|
|
307
|
-
// Add filter input above table
|
|
308
|
-
const panel = document.getElementById(panelId);
|
|
309
|
-
if (panel) {
|
|
310
|
-
const filterContainer = document.createElement('div');
|
|
311
|
-
filterContainer.className = 'jux-dataframe-filter';
|
|
312
|
-
const input = document.createElement('input');
|
|
313
|
-
input.type = 'text';
|
|
314
|
-
input.placeholder = `Filter ${sheetName}...`;
|
|
315
|
-
input.className = 'jux-input-element jux-dataframe-filter-input';
|
|
316
|
-
const iconEl = renderIcon('search');
|
|
317
|
-
iconEl.style.width = '16px';
|
|
318
|
-
iconEl.style.height = '16px';
|
|
319
|
-
const iconWrap = document.createElement('span');
|
|
320
|
-
iconWrap.className = 'jux-dataframe-filter-icon';
|
|
321
|
-
iconWrap.appendChild(iconEl);
|
|
322
|
-
filterContainer.appendChild(iconWrap);
|
|
323
|
-
filterContainer.appendChild(input);
|
|
324
|
-
input.addEventListener('input', () => {
|
|
325
|
-
const text = input.value.toLowerCase();
|
|
326
|
-
if (!text) {
|
|
327
|
-
table.rows(df.toRows());
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
const filtered = df.filter((row) => Object.values(row).some(v => v !== null && v !== undefined && String(v).toLowerCase().includes(text)));
|
|
331
|
-
table.rows(filtered.toRows());
|
|
332
|
-
});
|
|
333
|
-
const tableWrapper = panel.querySelector('.jux-table-wrapper');
|
|
334
|
-
if (tableWrapper) {
|
|
335
|
-
panel.insertBefore(filterContainer, tableWrapper);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
304
|
});
|
|
340
305
|
const totalRows = Object.values(sheets).reduce((sum, df) => sum + df.height, 0);
|
|
341
306
|
this._updateStatus(`${sourceName} — ${sheetNames.length} sheets, ${totalRows} total rows`, 'success');
|
|
@@ -378,7 +343,6 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
378
343
|
const emptyColumns = cols.filter(c => {
|
|
379
344
|
if (!c.startsWith('__EMPTY'))
|
|
380
345
|
return false;
|
|
381
|
-
// Check if ALL values in this column are null/empty
|
|
382
346
|
return rows.every(row => {
|
|
383
347
|
const val = row[c];
|
|
384
348
|
return val === null || val === undefined || String(val).trim() === '';
|
|
@@ -411,7 +375,6 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
411
375
|
}
|
|
412
376
|
else {
|
|
413
377
|
this._updateStatus(`${sourceName} — ${this._df.height} rows × ${this._df.width} cols`, 'success');
|
|
414
|
-
// Always show settings button if we have raw file data
|
|
415
378
|
if (this._rawFileData) {
|
|
416
379
|
requestAnimationFrame(() => {
|
|
417
380
|
const statusEl = document.getElementById(`${this._id}-status`);
|
|
@@ -453,27 +416,6 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
453
416
|
}
|
|
454
417
|
return false;
|
|
455
418
|
}
|
|
456
|
-
_detectLikelyHeaderRow(df) {
|
|
457
|
-
const rows = df.toRows();
|
|
458
|
-
for (let i = 0; i < Math.min(rows.length, 10); i++) {
|
|
459
|
-
const row = rows[i];
|
|
460
|
-
const values = Object.values(row);
|
|
461
|
-
const nonEmpty = values.filter(v => v !== null && v !== undefined && String(v).trim() !== '');
|
|
462
|
-
if (nonEmpty.length < values.length * 0.5)
|
|
463
|
-
continue;
|
|
464
|
-
const nonNumericCount = nonEmpty.filter(v => {
|
|
465
|
-
const str = String(v).trim();
|
|
466
|
-
return isNaN(Number(str)) && str !== '';
|
|
467
|
-
}).length;
|
|
468
|
-
if (nonNumericCount >= nonEmpty.length * 0.7 && i > 0) {
|
|
469
|
-
// i is the index in toRows(). Row 0 of the file was already consumed
|
|
470
|
-
// as the header during the initial parse, so the actual 0-based file
|
|
471
|
-
// row index is i + 1.
|
|
472
|
-
return i + 1;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return 0;
|
|
476
|
-
}
|
|
477
419
|
/* ═══════════════════════════════════════════════════
|
|
478
420
|
* RESHAPE MODAL
|
|
479
421
|
* ═══════════════════════════════════════════════════ */
|
|
@@ -496,24 +438,121 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
496
438
|
this._reshapeModalRendered = false;
|
|
497
439
|
}
|
|
498
440
|
}
|
|
441
|
+
_escapeHtml(text) {
|
|
442
|
+
const div = document.createElement('div');
|
|
443
|
+
div.textContent = text;
|
|
444
|
+
return div.innerHTML;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Build a clickable preview table from raw row data.
|
|
448
|
+
* Each row stores its actual sheet row index via data-sheet-row attribute.
|
|
449
|
+
* Returns the table HTML string.
|
|
450
|
+
*/
|
|
451
|
+
_buildClickablePreviewHTML(rawRows, selectedSheetRow) {
|
|
452
|
+
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 12px;">';
|
|
453
|
+
for (const { sheetRow, values } of rawRows) {
|
|
454
|
+
const isHeader = (sheetRow === selectedSheetRow);
|
|
455
|
+
const isSkipped = (sheetRow < selectedSheetRow);
|
|
456
|
+
let rowStyle = 'border-bottom: 1px solid hsl(var(--border)); cursor: pointer; transition: background 0.1s;';
|
|
457
|
+
if (isHeader) {
|
|
458
|
+
rowStyle += 'background: hsl(142 71% 45% / 0.15); font-weight: 600;';
|
|
459
|
+
}
|
|
460
|
+
else if (isSkipped) {
|
|
461
|
+
rowStyle += 'background: hsl(var(--muted) / 0.4); color: hsl(var(--muted-foreground)); font-style: italic; opacity: 0.7;';
|
|
462
|
+
}
|
|
463
|
+
html += `<tr data-sheet-row="${sheetRow}" style="${rowStyle}" onmouseover="this.style.outline='2px solid hsl(142 71% 45% / 0.5)'" onmouseout="this.style.outline=''">`;
|
|
464
|
+
// Row index cell
|
|
465
|
+
html += `<td style="padding: 8px 12px; width: 60px; font-weight: 600; color: hsl(var(--muted-foreground)); border-right: 1px solid hsl(var(--border)); text-align: center; user-select: none;">`;
|
|
466
|
+
if (isHeader) {
|
|
467
|
+
html += `<span style="color: hsl(142 71% 45%);">▶ ${sheetRow}</span>`;
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
html += `${sheetRow}`;
|
|
471
|
+
}
|
|
472
|
+
html += '</td>';
|
|
473
|
+
// Show first 6 columns
|
|
474
|
+
const displayCols = values.slice(0, 6);
|
|
475
|
+
displayCols.forEach(val => {
|
|
476
|
+
const displayVal = val != null ? String(val).substring(0, 20) : '';
|
|
477
|
+
const cellStyle = isHeader
|
|
478
|
+
? 'padding: 8px 12px; font-weight: 600; color: hsl(var(--foreground));'
|
|
479
|
+
: 'padding: 8px 12px;';
|
|
480
|
+
html += `<td style="${cellStyle}">${this._escapeHtml(displayVal)}</td>`;
|
|
481
|
+
});
|
|
482
|
+
if (values.length > 6) {
|
|
483
|
+
html += `<td style="padding: 8px 12px; color: hsl(var(--muted-foreground));">…</td>`;
|
|
484
|
+
}
|
|
485
|
+
// Status badge
|
|
486
|
+
html += `<td style="padding: 8px 12px; text-align: right; white-space: nowrap; user-select: none;">`;
|
|
487
|
+
if (isHeader) {
|
|
488
|
+
html += '<span style="background: hsl(142 71% 45%); color: white; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600;">HEADER</span>';
|
|
489
|
+
}
|
|
490
|
+
else if (isSkipped) {
|
|
491
|
+
html += '<span style="color: hsl(var(--muted-foreground)); font-size: 10px;">skipped</span>';
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
html += '<span style="color: hsl(var(--muted-foreground)); font-size: 10px;">data</span>';
|
|
495
|
+
}
|
|
496
|
+
html += '</td></tr>';
|
|
497
|
+
}
|
|
498
|
+
html += '</table>';
|
|
499
|
+
return html;
|
|
500
|
+
}
|
|
499
501
|
async _showExcelReshapeModal() {
|
|
500
502
|
if (!this._rawFileData?.file)
|
|
501
503
|
return;
|
|
502
504
|
this._cleanupReshapeModal();
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
505
|
+
// Read raw cells from the file
|
|
506
|
+
const XLSX = await import('xlsx');
|
|
507
|
+
const buffer = await this._rawFileData.file.arrayBuffer();
|
|
508
|
+
const workbook = XLSX.read(buffer, {
|
|
509
|
+
type: 'array',
|
|
510
|
+
sheetRows: 20,
|
|
511
|
+
dense: false
|
|
512
|
+
});
|
|
513
|
+
const sheetName = workbook.SheetNames[0];
|
|
514
|
+
const worksheet = workbook.Sheets[sheetName];
|
|
515
|
+
const ref = worksheet['!ref'];
|
|
516
|
+
if (!ref)
|
|
517
|
+
return;
|
|
518
|
+
const range = XLSX.utils.decode_range(ref);
|
|
519
|
+
const endRow = Math.min(range.e.r, 14); // Show up to 15 rows
|
|
520
|
+
const startCol = range.s.c;
|
|
521
|
+
const endCol = range.e.c;
|
|
522
|
+
const readCellValue = (r, c) => {
|
|
523
|
+
const addr = XLSX.utils.encode_cell({ r, c });
|
|
524
|
+
const cell = worksheet[addr];
|
|
525
|
+
if (!cell)
|
|
526
|
+
return null;
|
|
527
|
+
if (cell.w !== undefined)
|
|
528
|
+
return cell.w;
|
|
529
|
+
if (cell.v !== undefined)
|
|
530
|
+
return cell.v;
|
|
531
|
+
return null;
|
|
532
|
+
};
|
|
533
|
+
// Build raw row data with actual sheet row indices
|
|
534
|
+
const rawRows = [];
|
|
535
|
+
for (let r = 0; r <= endRow; r++) {
|
|
536
|
+
const values = [];
|
|
537
|
+
for (let c = startCol; c <= endCol; c++) {
|
|
538
|
+
values.push(readCellValue(r, c));
|
|
513
539
|
}
|
|
540
|
+
rawRows.push({ sheetRow: r, values });
|
|
514
541
|
}
|
|
515
|
-
|
|
516
|
-
|
|
542
|
+
// Auto-detect best header row
|
|
543
|
+
let selectedSheetRow = 0;
|
|
544
|
+
for (const { sheetRow, values } of rawRows) {
|
|
545
|
+
const nonEmpty = values.filter(v => v !== null && v !== undefined && String(v).trim() !== '');
|
|
546
|
+
if (nonEmpty.length < values.length * 0.5)
|
|
547
|
+
continue;
|
|
548
|
+
const nonNumeric = nonEmpty.filter(v => {
|
|
549
|
+
const str = String(v).trim();
|
|
550
|
+
return isNaN(Number(str)) && str !== '';
|
|
551
|
+
}).length;
|
|
552
|
+
if (nonNumeric >= nonEmpty.length * 0.7) {
|
|
553
|
+
selectedSheetRow = sheetRow;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
517
556
|
}
|
|
518
557
|
this._reshapeModal = new Modal(`${this._id}-reshape-modal`, {
|
|
519
558
|
title: 'Excel Import Settings',
|
|
@@ -523,25 +562,11 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
523
562
|
});
|
|
524
563
|
const modalContentHTML = `
|
|
525
564
|
<div style="margin-bottom: 1rem;">
|
|
526
|
-
<
|
|
527
|
-
|
|
528
|
-
</label>
|
|
529
|
-
<input
|
|
530
|
-
type="number"
|
|
531
|
-
id="${this._id}-header-row"
|
|
532
|
-
class="jux-input-element"
|
|
533
|
-
value="${suggestedRow}"
|
|
534
|
-
min="0"
|
|
535
|
-
max="50"
|
|
536
|
-
style="width: 100%;"
|
|
537
|
-
/>
|
|
538
|
-
<div id="${this._id}-reshape-hint" class="jux-reshape-hint" style="margin-top: 0.5rem; padding: 0.75rem; background: hsl(var(--muted) / 0.5); border-radius: var(--radius); font-size: 0.875rem;">
|
|
539
|
-
</div>
|
|
565
|
+
<div id="${this._id}-reshape-hint" style="padding: 0.75rem; background: hsl(var(--muted) / 0.5); border-radius: var(--radius); font-size: 0.875rem;"></div>
|
|
566
|
+
<input type="hidden" id="${this._id}-header-row" value="${selectedSheetRow}" />
|
|
540
567
|
</div>
|
|
541
|
-
<div
|
|
542
|
-
<div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">
|
|
543
|
-
Preview
|
|
544
|
-
</div>
|
|
568
|
+
<div>
|
|
569
|
+
<div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">Click a row to select it as the header:</div>
|
|
545
570
|
<div id="${this._id}-preview" style="font-family: ui-monospace, monospace; font-size: 12px; background: hsl(var(--muted) / 0.3); border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 0; overflow: hidden; max-height: 400px; overflow-y: auto;"></div>
|
|
546
571
|
</div>
|
|
547
572
|
`;
|
|
@@ -592,122 +617,73 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
592
617
|
this._reshapeModal.render(document.body);
|
|
593
618
|
this._reshapeModalRendered = true;
|
|
594
619
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
595
|
-
const headerRowInput = document.getElementById(`${this._id}-header-row`);
|
|
596
620
|
const previewDiv = document.getElementById(`${this._id}-preview`);
|
|
597
621
|
const hintDiv = document.getElementById(`${this._id}-reshape-hint`);
|
|
598
|
-
const
|
|
622
|
+
const hiddenInput = document.getElementById(`${this._id}-header-row`);
|
|
623
|
+
const updateHint = (row) => {
|
|
599
624
|
if (!hintDiv)
|
|
600
625
|
return;
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
626
|
+
const vals = rawRows.find(r => r.sheetRow === row)?.values ?? [];
|
|
627
|
+
const headerNames = vals.filter((v) => v != null && String(v).trim() !== '').map((v) => String(v).trim());
|
|
628
|
+
const preview = headerNames.slice(0, 4).join(', ') + (headerNames.length > 4 ? '…' : '');
|
|
629
|
+
if (row > 0) {
|
|
630
|
+
hintDiv.innerHTML = `Row <strong>${row}</strong> selected as header. Columns: <code>${this._escapeHtml(preview)}</code>. Rows 0–${row - 1} will be skipped.`;
|
|
604
631
|
}
|
|
605
632
|
else {
|
|
606
|
-
hintDiv.innerHTML = `Row <strong>0</strong> (first row)
|
|
633
|
+
hintDiv.innerHTML = `Row <strong>0</strong> (first row) selected as header. Columns: <code>${this._escapeHtml(preview)}</code>`;
|
|
607
634
|
}
|
|
608
635
|
};
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
636
|
+
const renderPreview = (selected) => {
|
|
637
|
+
if (!previewDiv)
|
|
638
|
+
return;
|
|
639
|
+
previewDiv.innerHTML = this._buildClickablePreviewHTML(rawRows, selected);
|
|
640
|
+
// Wire click handlers on each row
|
|
641
|
+
previewDiv.querySelectorAll('tr[data-sheet-row]').forEach(tr => {
|
|
642
|
+
tr.addEventListener('click', () => {
|
|
643
|
+
const rowIdx = parseInt(tr.dataset.sheetRow);
|
|
644
|
+
hiddenInput.value = String(rowIdx);
|
|
645
|
+
updateHint(rowIdx);
|
|
646
|
+
renderPreview(rowIdx);
|
|
617
647
|
});
|
|
618
|
-
|
|
619
|
-
if (!rawSheet) {
|
|
620
|
-
if (previewDiv)
|
|
621
|
-
previewDiv.textContent = 'No data found';
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
// rawSheet.columns = row 0 values (when parsed with headerRow=0)
|
|
625
|
-
// rawSheet.toRows() = rows 1+ (data rows when parsed with headerRow=0)
|
|
626
|
-
const rawCols = rawSheet.columns;
|
|
627
|
-
const rawRows = rawSheet.toRows();
|
|
628
|
-
// Build HTML table showing raw file structure
|
|
629
|
-
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 12px;">';
|
|
630
|
-
// We need to show rows 0 through headerRow+7 (or so)
|
|
631
|
-
// Row 0 = rawCols, Row 1+ = rawRows[i-1]
|
|
632
|
-
const totalRowsToShow = Math.min(headerRow + 8, rawRows.length + 1);
|
|
633
|
-
for (let fileRow = 0; fileRow < totalRowsToShow; fileRow++) {
|
|
634
|
-
const isHeader = (fileRow === headerRow);
|
|
635
|
-
const isSkipped = (fileRow < headerRow);
|
|
636
|
-
let rowStyle = 'border-bottom: 1px solid hsl(var(--border));';
|
|
637
|
-
if (isHeader) {
|
|
638
|
-
rowStyle += 'background: hsl(142 71% 45% / 0.15); font-weight: 600;';
|
|
639
|
-
}
|
|
640
|
-
else if (isSkipped) {
|
|
641
|
-
rowStyle += 'background: hsl(var(--muted) / 0.4); color: hsl(var(--muted-foreground)); font-style: italic; opacity: 0.7;';
|
|
642
|
-
}
|
|
643
|
-
html += `<tr style="${rowStyle}">`;
|
|
644
|
-
// Row index cell
|
|
645
|
-
html += `<td style="padding: 8px 12px; width: 60px; font-weight: 600; color: hsl(var(--muted-foreground)); border-right: 1px solid hsl(var(--border)); text-align: center;">`;
|
|
646
|
-
if (isHeader) {
|
|
647
|
-
html += `<span style="color: hsl(142 71% 45%);">▶ ${fileRow}</span>`;
|
|
648
|
-
}
|
|
649
|
-
else {
|
|
650
|
-
html += `${fileRow}`;
|
|
651
|
-
}
|
|
652
|
-
html += '</td>';
|
|
653
|
-
// Get values for this file row
|
|
654
|
-
let values;
|
|
655
|
-
if (fileRow === 0) {
|
|
656
|
-
values = rawCols;
|
|
657
|
-
}
|
|
658
|
-
else {
|
|
659
|
-
values = rawRows[fileRow - 1] ? Object.values(rawRows[fileRow - 1]) : [];
|
|
660
|
-
}
|
|
661
|
-
// Show first 6 columns
|
|
662
|
-
const displayCols = values.slice(0, 6);
|
|
663
|
-
displayCols.forEach(val => {
|
|
664
|
-
const displayVal = val != null ? String(val).substring(0, 20) : '';
|
|
665
|
-
const cellStyle = isHeader
|
|
666
|
-
? 'padding: 8px 12px; font-weight: 600; color: hsl(var(--foreground));'
|
|
667
|
-
: 'padding: 8px 12px;';
|
|
668
|
-
html += `<td style="${cellStyle}">${this._escapeHtml(displayVal)}</td>`;
|
|
669
|
-
});
|
|
670
|
-
if (values.length > 6) {
|
|
671
|
-
html += `<td style="padding: 8px 12px; color: hsl(var(--muted-foreground));">…</td>`;
|
|
672
|
-
}
|
|
673
|
-
// Status badge cell
|
|
674
|
-
html += `<td style="padding: 8px 12px; text-align: right; white-space: nowrap;">`;
|
|
675
|
-
if (isHeader) {
|
|
676
|
-
html += '<span style="background: hsl(142 71% 45%); color: white; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600;">HEADER</span>';
|
|
677
|
-
}
|
|
678
|
-
else if (isSkipped) {
|
|
679
|
-
html += '<span style="color: hsl(var(--muted-foreground)); font-size: 10px;">skipped</span>';
|
|
680
|
-
}
|
|
681
|
-
else {
|
|
682
|
-
html += '<span style="color: hsl(var(--muted-foreground)); font-size: 10px;">data</span>';
|
|
683
|
-
}
|
|
684
|
-
html += '</td>';
|
|
685
|
-
html += '</tr>';
|
|
686
|
-
}
|
|
687
|
-
html += '</table>';
|
|
688
|
-
if (previewDiv) {
|
|
689
|
-
previewDiv.innerHTML = html;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
catch (err) {
|
|
693
|
-
if (previewDiv)
|
|
694
|
-
previewDiv.textContent = `Error: ${err.message}`;
|
|
695
|
-
}
|
|
648
|
+
});
|
|
696
649
|
};
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
updatePreview();
|
|
650
|
+
updateHint(selectedSheetRow);
|
|
651
|
+
renderPreview(selectedSheetRow);
|
|
700
652
|
this._reshapeModal.open();
|
|
701
653
|
}
|
|
702
|
-
_escapeHtml(text) {
|
|
703
|
-
const div = document.createElement('div');
|
|
704
|
-
div.textContent = text;
|
|
705
|
-
return div.innerHTML;
|
|
706
|
-
}
|
|
707
654
|
_showCSVReshapeModal() {
|
|
708
|
-
if (!this._rawFileData)
|
|
655
|
+
if (!this._rawFileData?.text)
|
|
709
656
|
return;
|
|
710
657
|
this._cleanupReshapeModal();
|
|
658
|
+
const text = this._rawFileData.text;
|
|
659
|
+
const detected = this._driver._detectDelimiter(text);
|
|
660
|
+
// Parse raw lines
|
|
661
|
+
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
|
662
|
+
const rawRows = [];
|
|
663
|
+
const maxPreviewRows = Math.min(lines.length, 15);
|
|
664
|
+
for (let i = 0; i < maxPreviewRows; i++) {
|
|
665
|
+
if (!lines[i]) {
|
|
666
|
+
rawRows.push({ sheetRow: i, values: [''] });
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const values = this._driver._parseLine(lines[i], detected);
|
|
670
|
+
rawRows.push({ sheetRow: i, values });
|
|
671
|
+
}
|
|
672
|
+
// Auto-detect header row
|
|
673
|
+
let selectedRow = 0;
|
|
674
|
+
for (const { sheetRow, values } of rawRows) {
|
|
675
|
+
const nonEmpty = values.filter((v) => v.trim() !== '');
|
|
676
|
+
if (nonEmpty.length < values.length * 0.5)
|
|
677
|
+
continue;
|
|
678
|
+
const nonNumeric = nonEmpty.filter((v) => {
|
|
679
|
+
const trimmed = v.trim();
|
|
680
|
+
return isNaN(Number(trimmed)) && trimmed !== '';
|
|
681
|
+
}).length;
|
|
682
|
+
if (nonNumeric >= nonEmpty.length * 0.7) {
|
|
683
|
+
selectedRow = sheetRow;
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
711
687
|
this._reshapeModal = new Modal(`${this._id}-reshape-modal`, {
|
|
712
688
|
title: 'CSV Import Settings',
|
|
713
689
|
size: 'large',
|
|
@@ -725,12 +701,11 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
725
701
|
</select>
|
|
726
702
|
</div>
|
|
727
703
|
<div style="margin-bottom: 1rem;">
|
|
728
|
-
<
|
|
729
|
-
<input type="
|
|
704
|
+
<div id="${this._id}-reshape-hint" style="padding: 0.75rem; background: hsl(var(--muted) / 0.5); border-radius: var(--radius); font-size: 0.875rem;"></div>
|
|
705
|
+
<input type="hidden" id="${this._id}-header-row" value="${selectedRow}" />
|
|
730
706
|
</div>
|
|
731
|
-
<div
|
|
732
|
-
|
|
733
|
-
<div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">Preview</div>
|
|
707
|
+
<div>
|
|
708
|
+
<div style="font-weight: 600; margin-bottom: 0.5rem; color: hsl(var(--foreground));">Click a row to select it as the header:</div>
|
|
734
709
|
<div id="${this._id}-preview" style="font-family: monospace; font-size: 12px; background: hsl(var(--muted) / 0.3); border: 1px solid hsl(var(--border)); border-radius: var(--radius); padding: 0; overflow: hidden; max-height: 400px; overflow-y: auto;"></div>
|
|
735
710
|
</div>
|
|
736
711
|
`;
|
|
@@ -746,12 +721,10 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
746
721
|
label: 'Apply & Re-import',
|
|
747
722
|
variant: 'primary',
|
|
748
723
|
click: async () => {
|
|
749
|
-
if (!this._rawFileData?.text)
|
|
750
|
-
return;
|
|
751
724
|
const delimiterSelect = document.getElementById(`${this._id}-delimiter`);
|
|
752
|
-
const
|
|
725
|
+
const hiddenInput = document.getElementById(`${this._id}-header-row`);
|
|
753
726
|
const delim = delimiterSelect.value;
|
|
754
|
-
const headerRow = parseInt(
|
|
727
|
+
const headerRow = parseInt(hiddenInput.value) || 0;
|
|
755
728
|
this.state.loading = true;
|
|
756
729
|
this._updateStatus('Re-parsing with new settings...', 'loading');
|
|
757
730
|
try {
|
|
@@ -775,109 +748,59 @@ export class DataFrameComponent extends BaseComponent {
|
|
|
775
748
|
this._reshapeModalRendered = true;
|
|
776
749
|
requestAnimationFrame(() => {
|
|
777
750
|
const delimiterSelect = document.getElementById(`${this._id}-delimiter`);
|
|
778
|
-
const headerRowInput = document.getElementById(`${this._id}-header-row`);
|
|
779
751
|
const previewDiv = document.getElementById(`${this._id}-preview`);
|
|
780
752
|
const hintDiv = document.getElementById(`${this._id}-reshape-hint`);
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const detectedHeaderRow = this._driver._detectHeaderRow(this._rawFileData.text, detected);
|
|
786
|
-
if (headerRowInput)
|
|
787
|
-
headerRowInput.value = String(detectedHeaderRow);
|
|
788
|
-
}
|
|
789
|
-
const updateHint = () => {
|
|
753
|
+
const hiddenInput = document.getElementById(`${this._id}-header-row`);
|
|
754
|
+
if (delimiterSelect)
|
|
755
|
+
delimiterSelect.value = detected;
|
|
756
|
+
const updateHint = (row) => {
|
|
790
757
|
if (!hintDiv)
|
|
791
758
|
return;
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
759
|
+
const vals = rawRows.find(r => r.sheetRow === row)?.values ?? [];
|
|
760
|
+
const headerNames = vals.filter((v) => v != null && String(v).trim() !== '').map((v) => String(v).trim());
|
|
761
|
+
const preview = headerNames.slice(0, 4).join(', ') + (headerNames.length > 4 ? '…' : '');
|
|
762
|
+
if (row > 0) {
|
|
763
|
+
hintDiv.innerHTML = `Row <strong>${row}</strong> selected as header. Columns: <code>${this._escapeHtml(preview)}</code>. Rows 0–${row - 1} will be skipped.`;
|
|
796
764
|
}
|
|
797
765
|
else {
|
|
798
|
-
hintDiv.innerHTML = `Row <strong>0</strong> (first row)
|
|
766
|
+
hintDiv.innerHTML = `Row <strong>0</strong> (first row) selected as header. Columns: <code>${this._escapeHtml(preview)}</code>`;
|
|
799
767
|
}
|
|
800
768
|
};
|
|
801
|
-
const
|
|
802
|
-
if (!this._rawFileData?.text)
|
|
803
|
-
return;
|
|
769
|
+
const reparse = () => {
|
|
804
770
|
const delim = delimiterSelect?.value || ',';
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
delimiter: delim,
|
|
811
|
-
headerRow: 0,
|
|
812
|
-
hasHeader: true,
|
|
813
|
-
maxRows: headerRow + 10
|
|
814
|
-
});
|
|
815
|
-
const rawCols = rawDf.columns;
|
|
816
|
-
const rawRows = rawDf.toRows();
|
|
817
|
-
// Build HTML table
|
|
818
|
-
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 11px;">';
|
|
819
|
-
const totalRows = Math.min(headerRow + 8, rawRows.length + 1);
|
|
820
|
-
for (let i = 0; i < totalRows; i++) {
|
|
821
|
-
const isHeader = (i === headerRow);
|
|
822
|
-
const isSkipped = (i < headerRow);
|
|
823
|
-
let rowStyle = 'border-bottom: 1px solid hsl(var(--border));';
|
|
824
|
-
if (isHeader) {
|
|
825
|
-
rowStyle += 'background: hsl(var(--primary) / 0.15); font-weight: bold;';
|
|
826
|
-
}
|
|
827
|
-
else if (isSkipped) {
|
|
828
|
-
rowStyle += 'background: hsl(var(--muted) / 0.3); color: hsl(var(--muted-foreground)); font-style: italic;';
|
|
829
|
-
}
|
|
830
|
-
html += `<tr style="${rowStyle}">`;
|
|
831
|
-
// Row index
|
|
832
|
-
html += `<td style="padding: 6px 8px; width: 50px; color: hsl(var(--muted-foreground)); font-weight: 500;">`;
|
|
833
|
-
html += isHeader ? `<strong>→ ${i}</strong>` : `${i}`;
|
|
834
|
-
html += '</td>';
|
|
835
|
-
// Data
|
|
836
|
-
let values;
|
|
837
|
-
if (i === 0) {
|
|
838
|
-
values = rawCols;
|
|
839
|
-
}
|
|
840
|
-
else if (i - 1 < rawRows.length) {
|
|
841
|
-
values = Object.values(rawRows[i - 1]);
|
|
842
|
-
}
|
|
843
|
-
else {
|
|
844
|
-
values = [];
|
|
845
|
-
}
|
|
846
|
-
values.slice(0, 6).forEach(val => {
|
|
847
|
-
const displayVal = val != null ? String(val).substring(0, 25) : '';
|
|
848
|
-
html += `<td style="padding: 6px 8px;">${this._escapeHtml(displayVal)}</td>`;
|
|
849
|
-
});
|
|
850
|
-
if (values.length > 6) {
|
|
851
|
-
html += `<td style="padding: 6px 8px; color: hsl(var(--muted-foreground));">...</td>`;
|
|
852
|
-
}
|
|
853
|
-
// Status
|
|
854
|
-
html += `<td style="padding: 6px 8px; text-align: right; font-size: 10px;">`;
|
|
855
|
-
if (isHeader) {
|
|
856
|
-
html += '<span style="background: hsl(var(--primary)); color: white; padding: 2px 6px; border-radius: 4px;">HEADER</span>';
|
|
857
|
-
}
|
|
858
|
-
else if (isSkipped) {
|
|
859
|
-
html += '<span style="color: hsl(var(--muted-foreground));">skipped</span>';
|
|
860
|
-
}
|
|
861
|
-
else {
|
|
862
|
-
html += '<span style="color: hsl(var(--success));">data</span>';
|
|
863
|
-
}
|
|
864
|
-
html += '</td>';
|
|
865
|
-
html += '</tr>';
|
|
771
|
+
rawRows.length = 0;
|
|
772
|
+
for (let i = 0; i < maxPreviewRows; i++) {
|
|
773
|
+
if (!lines[i]) {
|
|
774
|
+
rawRows.push({ sheetRow: i, values: [''] });
|
|
775
|
+
continue;
|
|
866
776
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
previewDiv.innerHTML = html;
|
|
870
|
-
}
|
|
871
|
-
catch (err) {
|
|
872
|
-
if (previewDiv)
|
|
873
|
-
previewDiv.textContent = `Error: ${err.message}`;
|
|
777
|
+
const values = this._driver._parseLine(lines[i], delim);
|
|
778
|
+
rawRows.push({ sheetRow: i, values });
|
|
874
779
|
}
|
|
875
780
|
};
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
781
|
+
const renderPreview = (selected) => {
|
|
782
|
+
if (!previewDiv)
|
|
783
|
+
return;
|
|
784
|
+
previewDiv.innerHTML = this._buildClickablePreviewHTML(rawRows, selected);
|
|
785
|
+
previewDiv.querySelectorAll('tr[data-sheet-row]').forEach(tr => {
|
|
786
|
+
tr.addEventListener('click', () => {
|
|
787
|
+
const rowIdx = parseInt(tr.dataset.sheetRow);
|
|
788
|
+
hiddenInput.value = String(rowIdx);
|
|
789
|
+
updateHint(rowIdx);
|
|
790
|
+
renderPreview(rowIdx);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
};
|
|
794
|
+
if (delimiterSelect) {
|
|
795
|
+
delimiterSelect.addEventListener('change', () => {
|
|
796
|
+
reparse();
|
|
797
|
+
const current = parseInt(hiddenInput.value) || 0;
|
|
798
|
+
updateHint(current);
|
|
799
|
+
renderPreview(current);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
updateHint(selectedRow);
|
|
803
|
+
renderPreview(selectedRow);
|
|
881
804
|
this._reshapeModal.open();
|
|
882
805
|
});
|
|
883
806
|
}
|