scrubdata / frontend /variant_c /index.html
OpenAI Codex
deploy: add sponsor:openai tag (Best Use of Codex) + Codex-hardened build
16dc556
Raw
History Blame Contribute Delete
30.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ScrubData // console</title>
<style>
/* ============ DESIGN TOKENS ============ */
:root {
--bg: #0a0d12;
--bg-1: #0f141b;
--bg-2: #141b24;
--bg-3: #1a232e;
--line: #233040;
--line-soft: #1a2330;
--fg: #d7e0ea;
--fg-dim: #8593a3;
--fg-faint: #5a6878;
--accent: #36e0b0;
--accent-d: #0f3b30;
--warn: #f0a330;
--del: #ff5c6c;
--del-bg: #2a1318;
--add: #36e0b0;
--add-bg: #0f2a22;
--chg: #f0c330;
--chg-bg: #2a2410;
--mono: "SFMono-Regular", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin:0; padding:0; height:100%; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
background-image:
radial-gradient(900px 500px at 85% -10%, rgba(54,224,176,.06), transparent 60%),
linear-gradient(transparent 0, transparent 31px, var(--line-soft) 31px, var(--line-soft) 32px);
background-size: auto, 100% 32px;
}
::selection { background: var(--accent); color:#04130d; }
/* ============ SCROLLBARS ============ */
::-webkit-scrollbar { width:10px; height:10px; }
::-webkit-scrollbar-track { background: var(--bg-1); }
::-webkit-scrollbar-thumb { background: var(--line); border:2px solid var(--bg-1); border-radius:6px; }
::-webkit-scrollbar-thumb:hover { background:#2e3e52; }
/* ============ TOP BAR ============ */
header {
position: sticky; top:0; z-index:50;
display:flex; align-items:center; gap:16px;
padding:0 18px; height:48px;
background: rgba(10,13,18,.86);
backdrop-filter: blur(8px);
border-bottom:1px solid var(--line);
}
.brand { display:flex; align-items:center; gap:9px; font-family:var(--mono); font-weight:600; letter-spacing:.5px; }
.logo {
width:22px; height:22px; border-radius:5px;
background: linear-gradient(140deg, var(--accent), #1f8f72);
display:grid; place-items:center; color:#04130d; font-weight:800; font-size:13px;
box-shadow: 0 0 0 1px rgba(54,224,176,.4), 0 0 18px rgba(54,224,176,.25);
}
.brand small { color:var(--fg-faint); font-weight:400; letter-spacing:2px; font-size:10px; }
.topspace { flex:1; }
.kbd-hint { font-family:var(--mono); font-size:11px; color:var(--fg-faint); }
.kbd-hint kbd {
font-family:var(--mono); background:var(--bg-3); border:1px solid var(--line);
border-bottom-width:2px; border-radius:4px; padding:1px 6px; color:var(--fg-dim); font-size:10px;
}
.status-led {
display:flex; align-items:center; gap:7px; font-family:var(--mono);
font-size:11px; color:var(--fg-dim); padding:4px 10px;
border:1px solid var(--line); border-radius:6px; background:var(--bg-1);
}
.led { width:7px; height:7px; border-radius:50%; background:var(--fg-faint); }
.status-led.idle .led { background:var(--fg-faint); }
.status-led.busy .led { background:var(--warn); box-shadow:0 0 8px var(--warn); animation:pulse 1s infinite; }
.status-led.ok .led { background:var(--accent); box-shadow:0 0 8px var(--accent); }
.status-led.err .led { background:var(--del); box-shadow:0 0 8px var(--del); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
/* ============ LAYOUT ============ */
main { max-width: 1500px; margin:0 auto; padding:22px 18px 80px; }
/* ============ COMMAND BAR ============ */
.cmd {
display:flex; align-items:stretch; gap:0;
border:1px solid var(--line); border-radius:10px; overflow:hidden;
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
box-shadow: 0 8px 30px rgba(0,0,0,.4);
}
.drop {
flex:1; display:flex; align-items:center; gap:14px;
padding:16px 18px; cursor:pointer; position:relative;
transition: background .12s;
}
.drop:hover { background: rgba(54,224,176,.04); }
.drop.drag { background: rgba(54,224,176,.09); }
.drop.drag::after {
content:""; position:absolute; inset:6px; border:1.5px dashed var(--accent); border-radius:7px;
}
.drop-icon {
width:42px; height:42px; flex:none; border-radius:9px;
border:1px solid var(--line); background:var(--bg);
display:grid; place-items:center; color:var(--accent);
}
.drop-icon svg { width:20px; height:20px; }
.drop-txt b { display:block; font-size:13.5px; }
.drop-txt span { color:var(--fg-dim); font-size:12px; font-family:var(--mono); }
.file-tag {
color:var(--accent); font-family:var(--mono); font-weight:600;
}
.cmd-actions { display:flex; align-items:center; gap:0; border-left:1px solid var(--line); }
.btn {
font-family:var(--mono); font-size:12.5px; font-weight:600; letter-spacing:.3px;
border:none; cursor:pointer; padding:0 22px; height:100%;
background:transparent; color:var(--fg-dim); transition:all .12s;
display:flex; align-items:center; gap:8px;
}
.btn:hover { color:var(--fg); background:var(--bg-3); }
.btn-primary {
background: linear-gradient(180deg, #3df0bd, #25c79b);
color:#04130d; padding:0 26px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.25);
}
.btn-primary:hover { background:linear-gradient(180deg, #4dffcb, #2cd6a8); color:#04130d; }
.btn-primary:disabled { opacity:.4; cursor:not-allowed; filter:grayscale(.5); }
.btn .spin {
width:13px; height:13px; border:2px solid rgba(4,19,13,.3); border-top-color:#04130d;
border-radius:50%; animation:rot .6s linear infinite;
}
@keyframes rot { to { transform:rotate(360deg) } }
.subbar {
display:flex; align-items:center; gap:14px; margin-top:10px;
font-family:var(--mono); font-size:11.5px; color:var(--fg-faint);
}
.subbar a { color:var(--accent); text-decoration:none; border-bottom:1px dashed rgba(54,224,176,.4); cursor:pointer; }
.subbar a:hover { border-bottom-style:solid; }
.subbar .sep { color:var(--line); }
/* ============ ERROR ============ */
.err-box {
display:none; margin-top:14px; padding:12px 16px;
border:1px solid #5a2230; border-left:3px solid var(--del); border-radius:8px;
background: var(--del-bg); color:#ffb6bf; font-family:var(--mono); font-size:12px;
}
.err-box.show { display:block; }
.err-box b { color:var(--del); }
/* ============ RESULT GRID ============ */
.results { display:none; margin-top:24px; }
.results.show { display:block; animation:fade .25s ease; }
@keyframes fade { from{opacity:0; transform:translateY(6px)} to{opacity:1; transform:none} }
/* metric strip */
.metrics { display:grid; grid-template-columns: repeat(auto-fit, minmax(150px,1fr)); gap:10px; margin-bottom:18px; }
.metric {
border:1px solid var(--line); border-radius:9px; padding:12px 14px;
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
}
.metric .k { font-family:var(--mono); font-size:10.5px; letter-spacing:1px; text-transform:uppercase; color:var(--fg-faint); }
.metric .v { font-family:var(--mono); font-size:22px; font-weight:700; margin-top:4px; color:var(--fg); }
.metric .v.accent { color:var(--accent); }
.metric .v.chg { color:var(--chg); }
.metric .v.del { color:var(--del); }
.metric .sub { font-size:11px; color:var(--fg-dim); font-family:var(--mono); margin-top:2px; }
.summary-line {
margin-bottom:18px; padding:12px 16px; border-radius:9px;
background:var(--accent-d); border:1px solid #1c5a48; color:#a8f0d8;
font-size:13px; display:flex; gap:10px; align-items:flex-start;
}
.summary-line svg { width:16px; height:16px; flex:none; margin-top:1px; color:var(--accent); }
/* panel + tabs */
.panel { border:1px solid var(--line); border-radius:11px; background:var(--bg-1); overflow:hidden; margin-bottom:18px; }
.panel-head {
display:flex; align-items:center; gap:2px; padding:0 6px; height:42px;
border-bottom:1px solid var(--line); background:var(--bg-2);
}
.panel-title { font-family:var(--mono); font-size:11px; letter-spacing:1.5px; text-transform:uppercase; color:var(--fg-faint); padding:0 12px; }
.tabs { display:flex; gap:2px; margin-left:auto; }
.tab {
font-family:var(--mono); font-size:11.5px; font-weight:600; letter-spacing:.3px;
background:transparent; border:none; color:var(--fg-dim); cursor:pointer;
padding:7px 14px; border-radius:7px; transition:all .12s;
}
.tab:hover { color:var(--fg); background:var(--bg-3); }
.tab.active { color:#04130d; background:var(--accent); }
.legend { display:flex; gap:14px; align-items:center; padding:0 14px; font-family:var(--mono); font-size:10.5px; color:var(--fg-faint); }
.legend .swatch { display:inline-flex; align-items:center; gap:5px; }
.legend i { width:9px; height:9px; border-radius:2px; display:inline-block; }
.legend .add i { background:var(--add); }
.legend .chg i { background:var(--chg); }
.legend .del i { background:var(--del); }
.table-wrap { overflow:auto; max-height: 520px; }
table.grid { border-collapse:separate; border-spacing:0; width:100%; font-family:var(--mono); font-size:12px; }
table.grid th, table.grid td {
padding:6px 11px; border-bottom:1px solid var(--line-soft); border-right:1px solid var(--line-soft);
white-space:nowrap; max-width:340px; overflow:hidden; text-overflow:ellipsis;
}
table.grid thead th {
position:sticky; top:0; z-index:5; background:var(--bg-3); color:var(--fg-dim);
text-align:left; font-weight:600; font-size:11px; letter-spacing:.4px;
border-bottom:1px solid var(--line);
}
table.grid thead th:first-child, table.grid tbody td.rownum {
position:sticky; left:0; z-index:6; background:var(--bg-2);
color:var(--fg-faint); text-align:right; user-select:none; font-size:11px;
}
table.grid thead th:first-child { z-index:7; }
table.grid tbody tr:hover td { background:rgba(54,224,176,.045); }
table.grid tbody tr:hover td.rownum { background:var(--bg-3); }
td.cell-empty { color:var(--fg-faint); font-style:italic; }
/* diff cell states */
td.d-add { background:var(--add-bg); color:#7ff0cf; box-shadow: inset 2px 0 0 var(--add); }
td.d-chg { background:var(--chg-bg); color:#f0d88c; box-shadow: inset 2px 0 0 var(--chg); }
td.d-del { background:var(--del-bg); color:#ff9aa4; box-shadow: inset 2px 0 0 var(--del); }
td.d-chg .old { color:var(--del); text-decoration:line-through; opacity:.6; margin-right:6px; }
td.d-chg .arrow { color:var(--fg-faint); margin:0 4px; }
tr.row-removed td { opacity:.42; background:var(--del-bg); }
tr.row-added td { background:var(--add-bg); }
.empty-note { padding:40px; text-align:center; color:var(--fg-faint); font-family:var(--mono); font-size:12px; }
/* report */
.report-body { padding:18px 22px; font-size:13px; }
.report-body h1, .report-body h2, .report-body h3 { font-family:var(--mono); letter-spacing:.3px; }
.report-body h1 { font-size:17px; border-bottom:1px solid var(--line); padding-bottom:8px; color:var(--fg); }
.report-body h2 { font-size:14px; color:var(--accent); margin-top:20px; }
.report-body h3 { font-size:12.5px; color:var(--fg-dim); margin-top:14px; }
.report-body p { color:var(--fg-dim); }
.report-body code { font-family:var(--mono); background:var(--bg-3); padding:1px 6px; border-radius:4px; color:var(--accent); font-size:12px; }
.report-body ul { padding-left:20px; }
.report-body li { margin:3px 0; color:var(--fg-dim); }
.report-body li::marker { color:var(--accent); }
.report-body strong { color:var(--fg); }
.report-body table { border-collapse:collapse; font-family:var(--mono); font-size:12px; margin:10px 0; }
.report-body table td, .report-body table th { border:1px solid var(--line); padding:5px 10px; }
.report-body table th { background:var(--bg-3); color:var(--fg-dim); }
.report-body hr { border:none; border-top:1px solid var(--line); margin:18px 0; }
/* download dock */
.dock {
position:fixed; bottom:0; left:0; right:0; z-index:40;
display:none; align-items:center; gap:14px; padding:11px 22px;
background:rgba(15,20,27,.94); backdrop-filter:blur(8px); border-top:1px solid var(--line);
}
.dock.show { display:flex; }
.dock .info { font-family:var(--mono); font-size:11.5px; color:var(--fg-dim); }
.dock .info b { color:var(--accent); }
.dock-space { flex:1; }
.btn-download {
font-family:var(--mono); font-size:12.5px; font-weight:700; letter-spacing:.3px;
border:1px solid #1c5a48; border-radius:8px; cursor:pointer;
background:linear-gradient(180deg,#3df0bd,#25c79b); color:#04130d;
padding:10px 20px; display:flex; align-items:center; gap:9px;
box-shadow: 0 0 24px rgba(54,224,176,.3);
}
.btn-download:hover { filter:brightness(1.08); }
.btn-download svg { width:15px; height:15px; }
.ghost { display:none; }
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">S</div>
ScrubData <small>// CONSOLE</small>
</div>
<div class="topspace"></div>
<div class="kbd-hint">press <kbd>⌘</kbd> <kbd>↡</kbd> to clean &nbsp; Β· &nbsp; <kbd>O</kbd> to open file</div>
<div class="status-led idle" id="statusLed"><span class="led"></span><span id="statusTxt">idle</span></div>
</header>
<main>
<!-- COMMAND BAR -->
<div class="cmd">
<label class="drop" id="dropZone">
<input type="file" id="fileInput" class="ghost" accept=".csv,.xlsx,.xls" />
<div class="drop-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 16V4M12 4l-4 4M12 4l4 4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" stroke-linecap="round"/>
</svg>
</div>
<div class="drop-txt">
<b id="dropTitle">Drop a CSV / Excel file</b>
<span id="dropSub">or click to browse Β· .csv .xlsx .xls</span>
</div>
</label>
<div class="cmd-actions">
<button class="btn btn-primary" id="runBtn" disabled>
<span id="runLabel">β–Ά CLEAN IT</span>
</button>
</div>
</div>
<div class="subbar">
<span>No file? Try the deliberately messy sample:</span>
<a id="sampleBtn">load samples/dirty_contacts.csv β†’</a>
<span class="sep">|</span>
<span>raw strings in Β· trustworthy table out Β· nothing silent</span>
</div>
<div class="err-box" id="errBox"><b>ERR</b> <span id="errMsg"></span></div>
<!-- RESULTS -->
<div class="results" id="results">
<div class="metrics" id="metrics"></div>
<div class="summary-line" id="summaryLine">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="9"/>
</svg>
<span id="summaryTxt"></span>
</div>
<!-- DATA PANEL -->
<div class="panel">
<div class="panel-head">
<span class="panel-title">data</span>
<div class="legend" id="legend" style="display:none">
<span class="swatch chg"><i></i>changed</span>
<span class="swatch add"><i></i>added</span>
<span class="swatch del"><i></i>removed</span>
</div>
<div class="tabs">
<button class="tab active" data-view="diff">DIFF</button>
<button class="tab" data-view="before">BEFORE</button>
<button class="tab" data-view="after">AFTER</button>
</div>
</div>
<div class="table-wrap" id="tableWrap"></div>
</div>
<!-- REPORT PANEL -->
<div class="panel">
<div class="panel-head">
<span class="panel-title">change report</span>
</div>
<div class="report-body" id="reportBody"></div>
</div>
</div>
</main>
<!-- DOWNLOAD DOCK -->
<div class="dock" id="dock">
<div class="info">output ready Β· <b id="dockName">scrubbed.csv</b> Β· <span id="dockSize"></span></div>
<div class="dock-space"></div>
<button class="btn-download" id="downloadBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M12 4v11M12 15l-4-4M12 15l4-4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 19h14" stroke-linecap="round"/>
</svg>
DOWNLOAD scrubbed.csv
</button>
</div>
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client";
/* ---------- element refs ---------- */
const $ = (id) => document.getElementById(id);
const fileInput = $("fileInput");
const dropZone = $("dropZone");
const runBtn = $("runBtn");
const runLabel = $("runLabel");
const sampleBtn = $("sampleBtn");
const errBox = $("errBox");
const errMsg = $("errMsg");
const results = $("results");
const tableWrap = $("tableWrap");
const reportBody= $("reportBody");
const dock = $("dock");
const dockSize = $("dockSize");
const downloadBtn = $("downloadBtn");
const statusLed = $("statusLed");
const statusTxt = $("statusTxt");
const legend = $("legend");
/* ---------- state ---------- */
let selectedFile = null; // File object (from input/drop)
let sampleUrl = null; // string path -> handled via handle_file(url)
let lastResult = null; // backend dict
let currentView = "diff";
/* ---------- status helper ---------- */
function setStatus(kind, txt){
statusLed.className = "status-led " + kind;
statusTxt.textContent = txt;
}
function showError(msg){
errMsg.textContent = msg;
errBox.classList.add("show");
setStatus("err","error");
}
function clearError(){ errBox.classList.remove("show"); }
/* ---------- file selection ---------- */
function pickFile(file){
selectedFile = file;
sampleUrl = null;
$("dropTitle").innerHTML = '<span class="file-tag">' + escapeHtml(file.name) + '</span>';
$("dropSub").textContent = (file.size/1024).toFixed(1) + " KB Β· ready";
runBtn.disabled = false;
clearError();
setStatus("idle","ready");
}
function pickSample(){
selectedFile = null;
sampleUrl = "samples/dirty_contacts.csv";
$("dropTitle").innerHTML = '<span class="file-tag">dirty_contacts.csv</span>';
$("dropSub").textContent = "sample dataset Β· ready";
runBtn.disabled = false;
clearError();
setStatus("idle","ready Β· sample");
}
fileInput.addEventListener("change", (e) => {
if (e.target.files && e.target.files[0]) pickFile(e.target.files[0]);
});
sampleBtn.addEventListener("click", (e) => { e.preventDefault(); pickSample(); });
/* drag & drop */
["dragenter","dragover"].forEach(ev =>
dropZone.addEventListener(ev, (e)=>{ e.preventDefault(); dropZone.classList.add("drag"); }));
["dragleave","drop"].forEach(ev =>
dropZone.addEventListener(ev, (e)=>{ e.preventDefault(); dropZone.classList.remove("drag"); }));
dropZone.addEventListener("drop", (e)=>{
if (e.dataTransfer.files && e.dataTransfer.files[0]) pickFile(e.dataTransfer.files[0]);
});
/* ---------- keyboard ---------- */
document.addEventListener("keydown", (e)=>{
if ((e.metaKey || e.ctrlKey) && e.key === "Enter"){ e.preventDefault(); if(!runBtn.disabled) run(); }
else if (e.key.toLowerCase() === "o" && !e.metaKey && !e.ctrlKey && document.activeElement.tagName !== "INPUT"){
e.preventDefault(); fileInput.click();
}
else if (results.classList.contains("show")){
if (e.key === "1") setView("diff");
else if (e.key === "2") setView("before");
else if (e.key === "3") setView("after");
}
});
/* ---------- run cleaning ---------- */
runBtn.addEventListener("click", run);
async function run(){
if (!selectedFile && !sampleUrl) return;
clearError();
runBtn.disabled = true;
runLabel.innerHTML = '<span class="spin"></span> CLEANING';
setStatus("busy","cleaning…");
results.classList.remove("show");
dock.classList.remove("show");
try {
const client = await Client.connect(window.location.origin);
const payload = selectedFile ? handle_file(selectedFile) : handle_file(sampleUrl);
const result = await client.predict("/clean_data", { file_path: payload });
const data = result.data[0];
if (!data || typeof data !== "object") throw new Error("Unexpected response shape from /clean_data.");
lastResult = data;
render(data);
setStatus("ok","done");
} catch (err){
console.error(err);
showError((err && err.message) ? err.message : String(err));
} finally {
runBtn.disabled = false;
runLabel.innerHTML = "β–Ά CLEAN IT";
}
}
/* ---------- render orchestrator ---------- */
function render(data){
const before = Array.isArray(data.before) ? data.before : [];
const after = Array.isArray(data.after) ? data.after : [];
renderMetrics(before, after, data);
$("summaryTxt").textContent = data.summary || "Cleaning complete.";
renderReport(data.report_md || "");
renderTable();
results.classList.add("show");
if (typeof data.csv_text === "string" && data.csv_text.length){
dockSize.textContent = (new Blob([data.csv_text]).size/1024).toFixed(1) + " KB";
dock.classList.add("show");
}
}
function renderMetrics(before, after, data){
const bRows = before.length, aRows = after.length;
const bCols = before[0] ? Object.keys(before[0]).length : 0;
const aCols = after[0] ? Object.keys(after[0]).length : 0;
const { changed } = diffStats(before, after);
const rowsDropped = Math.max(0, bRows - aRows);
const colsDropped = Math.max(0, bCols - aCols);
const cards = [
{ k:"rows", v:aRows, sub: rowsDropped ? "βˆ’"+rowsDropped+" dropped" : "no rows dropped", cls:"accent" },
{ k:"columns", v:aCols, sub: colsDropped ? "βˆ’"+colsDropped+" dropped" : "all kept", cls:"" },
{ k:"cells changed", v:changed, sub:"across overlap", cls: changed ? "chg":"" },
{ k:"rows removed", v:rowsDropped, sub:"dups / empties", cls: rowsDropped ? "del":"" },
];
$("metrics").innerHTML = cards.map(c =>
`<div class="metric"><div class="k">${c.k}</div>
<div class="v ${c.cls}">${c.v}</div><div class="sub">${c.sub}</div></div>`
).join("");
}
/* ---------- diff computation ---------- */
function diffStats(before, after){
const n = Math.min(before.length, after.length);
let changed = 0;
for (let i=0;i<n;i++){
const b=before[i], a=after[i];
const keys = new Set([...Object.keys(b||{}), ...Object.keys(a||{})]);
for (const k of keys){ if (norm(b?.[k]) !== norm(a?.[k])) changed++; }
}
return { changed };
}
function norm(v){ return v===undefined||v===null ? "" : String(v); }
/* ---------- table rendering ---------- */
document.querySelectorAll(".tab").forEach(t =>
t.addEventListener("click", ()=> setView(t.dataset.view)));
function setView(v){
currentView = v;
document.querySelectorAll(".tab").forEach(t => t.classList.toggle("active", t.dataset.view===v));
legend.style.display = v==="diff" ? "flex" : "none";
renderTable();
}
function renderTable(){
if (!lastResult){ return; }
const before = lastResult.before || [];
const after = lastResult.after || [];
if (currentView === "before") tableWrap.innerHTML = plainTable(before);
else if (currentView === "after") tableWrap.innerHTML = plainTable(after);
else tableWrap.innerHTML = diffTable(before, after);
}
function columnsOf(rows){
const seen = []; const set = new Set();
for (const r of rows){ for (const k of Object.keys(r||{})){ if(!set.has(k)){ set.add(k); seen.push(k);} } }
return seen;
}
function cellHtml(val){
const s = norm(val);
if (s === "") return '<td class="cell-empty">βˆ…</td>';
return `<td title="${escapeHtml(s)}">${escapeHtml(s)}</td>`;
}
function plainTable(rows){
if (!rows.length) return `<div class="empty-note">no rows</div>`;
const cols = columnsOf(rows);
let h = '<table class="grid"><thead><tr><th>#</th>';
h += cols.map(c=>`<th>${escapeHtml(c)}</th>`).join("");
h += '</tr></thead><tbody>';
rows.forEach((r,i)=>{
h += `<tr><td class="rownum">${i+1}</td>`;
h += cols.map(c=>cellHtml(r?.[c])).join("");
h += '</tr>';
});
return h + '</tbody></table>';
}
/* row-aligned diff: align by index for the overlap, mark extra before-rows as removed,
extra after-rows as added. Columns are the union (after first), so dropped columns
appear as removed-cells. */
function diffTable(before, after){
if (!before.length && !after.length) return `<div class="empty-note">no data</div>`;
const beforeCols = columnsOf(before);
const afterCols = columnsOf(after);
const afterSet = new Set(afterCols);
const beforeSet = new Set(beforeCols);
// column order: after columns first, then any before-only (dropped) columns at end
const cols = [...afterCols, ...beforeCols.filter(c=>!afterSet.has(c))];
const n = Math.min(before.length, after.length);
let h = '<table class="grid"><thead><tr><th>#</th>';
h += cols.map(c=>{
const dropped = !afterSet.has(c);
return `<th${dropped?' style="color:var(--del)"':''}>${escapeHtml(c)}${dropped?' βœ•':''}</th>`;
}).join("");
h += '</tr></thead><tbody>';
for (let i=0;i<n;i++){
const b=before[i]||{}, a=after[i]||{};
h += `<tr><td class="rownum">${i+1}</td>`;
h += cols.map(c=>{
const dropped = !afterSet.has(c);
if (dropped){
const bv = norm(b[c]);
return bv==="" ? '<td class="d-del cell-empty">βˆ…</td>'
: `<td class="d-del" title="${escapeHtml(bv)}">${escapeHtml(bv)}</td>`;
}
const bv = norm(b[c]), av = norm(a[c]);
const newCol = !beforeSet.has(c);
if (newCol || (bv==="" && av!=="" && !(c in b))){
return av==="" ? '<td class="d-add cell-empty">βˆ…</td>'
: `<td class="d-add" title="${escapeHtml(av)}">${escapeHtml(av)}</td>`;
}
if (bv === av){
return av==="" ? '<td class="cell-empty">βˆ…</td>'
: `<td title="${escapeHtml(av)}">${escapeHtml(av)}</td>`;
}
// changed cell β€” show old β†’ new
const oldDisp = bv==="" ? "βˆ…" : escapeHtml(bv);
const newDisp = av==="" ? "βˆ…" : escapeHtml(av);
return `<td class="d-chg" title="${escapeHtml(bv)} β†’ ${escapeHtml(av)}">`+
`<span class="old">${oldDisp}</span><span class="arrow">β†’</span>${newDisp}</td>`;
}).join("");
h += '</tr>';
}
// removed rows (in before, no aligned after)
for (let i=n;i<before.length;i++){
const b=before[i]||{};
h += `<tr class="row-removed"><td class="rownum">${i+1}</td>`;
h += cols.map(c=> cellHtml(b[c]) ).join("");
h += '</tr>';
}
// added rows (in after, beyond before)
for (let i=n;i<after.length;i++){
const a=after[i]||{};
h += `<tr class="row-added"><td class="rownum">+${i+1}</td>`;
h += cols.map(c=> afterSet.has(c) ? cellHtml(a[c]) : '<td class="cell-empty">βˆ…</td>').join("");
h += '</tr>';
}
return h + '</tbody></table>';
}
/* ---------- minimal markdown renderer ---------- */
function renderReport(md){
reportBody.innerHTML = mdToHtml(md || "_No report returned._");
}
function mdToHtml(md){
const lines = md.replace(/\r\n/g,"\n").split("\n");
let out = [], inUl=false, inTable=false, tableBuf=[];
const flushUl = ()=>{ if(inUl){ out.push("</ul>"); inUl=false; } };
const flushTable = ()=>{
if(!inTable) return;
// tableBuf rows -> html (skip separator row of dashes)
const rows = tableBuf.filter(r => !/^\s*\|?[\s:|-]+\|?\s*$/.test(r));
let t = "<table>";
rows.forEach((r,idx)=>{
const cells = r.replace(/^\||\|$/g,"").split("|").map(c=>c.trim());
const tag = idx===0 ? "th":"td";
t += "<tr>"+cells.map(c=>`<${tag}>${inline(c)}</${tag}>`).join("")+"</tr>";
});
t += "</table>";
out.push(t); inTable=false; tableBuf=[];
};
for (let raw of lines){
const line = raw.replace(/\s+$/,"");
if (line.trim().startsWith("|") && line.includes("|")){ flushUl(); inTable=true; tableBuf.push(line); continue; }
else flushTable();
if (/^#{1,6}\s/.test(line)){
flushUl();
const lvl = line.match(/^#+/)[0].length;
out.push(`<h${Math.min(lvl,3)}>${inline(line.replace(/^#+\s*/,""))}</h${Math.min(lvl,3)}>`);
} else if (/^\s*[-*]\s+/.test(line)){
if(!inUl){ out.push("<ul>"); inUl=true; }
out.push(`<li>${inline(line.replace(/^\s*[-*]\s+/,""))}</li>`);
} else if (/^\s*---+\s*$/.test(line)){
flushUl(); out.push("<hr/>");
} else if (line.trim()===""){
flushUl();
} else {
flushUl(); out.push(`<p>${inline(line)}</p>`);
}
}
flushUl(); flushTable();
return out.join("\n");
}
function inline(s){
s = escapeHtml(s);
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
return s;
}
/* ---------- download ---------- */
downloadBtn.addEventListener("click", ()=>{
if (!lastResult || typeof lastResult.csv_text !== "string") return;
const blob = new Blob([lastResult.csv_text], { type:"text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "scrubbed.csv";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(url), 1000);
});
/* ---------- util ---------- */
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({
"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"
}[c]));
}
</script>
</body>
</html>