// Open-WikiTable viewer — static, single-page. // State machine: load index.json → render sidebar → on click/nav fetch records/.json // and render question + SQL + tables panel. (function () { 'use strict'; // ── State ───────────────────────────────────────────────────────── const state = { index: [], // [{qid, dataset, question, n_hard, n_pos, n_neg}] filtered: [], // indices into state.index selectedQid: null, datasetFilter: 'all', search: '', cache: new Map(), // qid -> record JSON activeBucket: 'hard_positive', }; // ── DOM ──────────────────────────────────────────────────────────── const dom = { list: document.getElementById('qidList'), main: document.getElementById('mainPane'), search: document.getElementById('searchBox'), filter: document.getElementById('datasetFilter'), prev: document.getElementById('prevBtn'), next: document.getElementById('nextBtn'), counter: document.getElementById('counter'), }; // ── Utils ────────────────────────────────────────────────────────── const escapeHtml = (s) => String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; // Light SQL highlight — keywords / strings / numbers / functions only. const SQL_KEYWORDS = new Set([ 'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'AS', 'ON', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'OUTER', 'GROUP', 'BY', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'DISTINCT', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'ASC', 'DESC', 'UNION', 'ALL', 'EXISTS', 'WITH', ]); const SQL_FUNCS = new Set([ 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'ROUND', 'ABS', 'COALESCE', 'LOWER', 'UPPER', 'LENGTH', ]); function highlightSql(sql) { // Tokenize: strings, comments are rare. Match string-literal, number, word. const parts = []; const re = /('[^']*'|"[^"]*"|\b\d+(?:\.\d+)?\b|[A-Za-z_][A-Za-z_0-9]*|[\s\S])/g; let m; while ((m = re.exec(sql)) !== null) { const tok = m[0]; if (/^['"]/.test(tok)) { parts.push(`${escapeHtml(tok)}`); } else if (/^\d/.test(tok)) { parts.push(`${escapeHtml(tok)}`); } else if (/^[A-Za-z_]/.test(tok)) { const upper = tok.toUpperCase(); if (SQL_KEYWORDS.has(upper)) { parts.push(`${escapeHtml(tok)}`); } else if (SQL_FUNCS.has(upper)) { parts.push(`${escapeHtml(tok)}`); } else { parts.push(escapeHtml(tok)); } } else { parts.push(escapeHtml(tok)); } } return parts.join(''); } // ── Filtering ────────────────────────────────────────────────────── function recomputeFiltered() { const q = state.search.trim().toLowerCase(); const ds = state.datasetFilter; const filtered = []; for (let i = 0; i < state.index.length; i++) { const e = state.index[i]; if (ds !== 'all' && e.dataset !== ds) continue; if (q) { // Search qid / question. We don't pre-load SQL/answers in index, so this // is a question/qid match only. Cheap and covers most needs. const hay = (e.qid + ' ' + e.question).toLowerCase(); if (!hay.includes(q)) continue; } filtered.push(i); } state.filtered = filtered; } // ── Sidebar render ───────────────────────────────────────────────── function renderSidebar() { const html = []; for (const i of state.filtered) { const e = state.index[i]; const sel = e.qid === state.selectedQid ? ' selected' : ''; html.push( `
  • ` + `${e.dataset}${escapeHtml(e.qid)}` + `${escapeHtml(e.question)}` + `
  • ` ); } dom.list.innerHTML = html.join(''); updateCounter(); } function updateCounter() { const total = state.index.length; const shown = state.filtered.length; const sel = state.selectedQid; let pos = 0; if (sel) { const idx = state.filtered.findIndex((i) => state.index[i].qid === sel); pos = idx >= 0 ? idx + 1 : 0; } if (pos) { dom.counter.textContent = `${pos} / ${shown}` + (shown !== total ? ` (of ${total})` : ''); } else { dom.counter.textContent = `${shown}` + (shown !== total ? ` of ${total}` : ''); } dom.prev.disabled = !sel || pos <= 1; dom.next.disabled = !sel || pos >= shown; } function scrollSelectedIntoView() { const node = dom.list.querySelector('li.selected'); if (node && typeof node.scrollIntoView === 'function') { node.scrollIntoView({ block: 'nearest' }); } } // ── Record fetching ─────────────────────────────────────────────── async function loadRecord(qid) { if (state.cache.has(qid)) return state.cache.get(qid); const res = await fetch(`records/${encodeURIComponent(qid)}.json`); if (!res.ok) throw new Error(`fetch records/${qid}.json failed: ${res.status}`); const rec = await res.json(); state.cache.set(qid, rec); return rec; } // ── Main pane render ────────────────────────────────────────────── function renderEmpty() { dom.main.innerHTML = `
    Select a question on the left to begin.
    `; } function renderLoading() { dom.main.innerHTML = `
    Loading record…
    `; } function pickInitialBucket(rec) { if (rec.tables.hard_positive.length) return 'hard_positive'; if (rec.tables.positive.length) return 'positive'; if (rec.tables.negative.length) return 'negative'; return 'hard_positive'; } function renderRecord(rec) { state.activeBucket = pickInitialBucket(rec); const answers = Array.isArray(rec.answer) ? rec.answer : [rec.answer]; const answersHtml = answers.length ? answers.map((a) => `${escapeHtml(a)}`).join('') : `(empty)`; const html = `
    ${escapeHtml(rec.dataset)} ${escapeHtml(rec.question_id)} 📊 ${escapeHtml(rec.original_table_id)}
    ${escapeHtml(rec.question)}
    Answer${answers.length > 1 ? 's' : ''} (${answers.length}) ${answersHtml}

    Reference SQL

    ${highlightSql(rec.sql || '')}

    Candidate tables

    ${renderTabButton('hard_positive', '⭐ Hard positive', rec.tables.hard_positive.length)} ${renderTabButton('positive', '➕ Positive', rec.tables.positive.length)} ${renderTabButton('negative', '➖ Negative', rec.tables.negative.length)}
    ${renderBucket(rec, state.activeBucket)}
    `; dom.main.innerHTML = html; // wire copy button const copyBtn = document.getElementById('copyQidBtn'); if (copyBtn) { copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(rec.question_id).then(() => { copyBtn.textContent = '✓ copied'; copyBtn.classList.add('copied'); setTimeout(() => { copyBtn.textContent = '📋 copy qid'; copyBtn.classList.remove('copied'); }, 1200); }); }); } // wire bucket tabs document.querySelectorAll('#bucketTabs .tables-tab').forEach((btn) => { btn.addEventListener('click', () => { const b = btn.getAttribute('data-bucket'); if (btn.disabled || b === state.activeBucket) return; state.activeBucket = b; document.querySelectorAll('#bucketTabs .tables-tab').forEach((x) => x.classList.toggle('active', x === btn)); document.getElementById('bucketBody').innerHTML = renderBucket(rec, b); }); }); } function renderTabButton(bucket, label, count) { const active = bucket === state.activeBucket ? ' active' : ''; const disabled = count === 0 ? ' disabled' : ''; return ( `` ); } function renderBucket(rec, bucket) { const items = rec.tables[bucket] || []; if (!items.length) { const labelMap = { hard_positive: 'No hard-positive chunk recorded for this question.', positive: 'No partial-positive chunk recorded for this question.', negative: 'No BM25-distractor recorded for this question.', }; return `
    ${escapeHtml(labelMap[bucket] || 'empty')}
    `; } return items.map((c) => renderChunk(c, bucket)).join(''); } function renderChunk(c, bucket) { const breadParts = []; if (c.page_title) breadParts.push(`${escapeHtml(c.page_title)}`); if (c.section_title) breadParts.push(`${escapeHtml(c.section_title)}`); const bread = breadParts.join(``); const caption = c.caption ? `${escapeHtml(c.caption)}` : ''; const head = (c.header || []).map((h) => `${escapeHtml(h)}`).join(''); const body = (c.rows || []).map( (row) => `${row.map((cell) => `${escapeHtml(cell)}`).join('')}` ).join(''); return `
    ${bread}${caption}
    chunk #${c.chunk_id} ${escapeHtml(c.name || '')}
    ${head}${body}
    `; } // ── Navigation ──────────────────────────────────────────────────── async function selectQid(qid, pushHash = true) { state.selectedQid = qid; renderSidebar(); scrollSelectedIntoView(); if (pushHash) { const h = `#${encodeURIComponent(qid)}`; if (window.location.hash !== h) window.history.replaceState(null, '', h); } renderLoading(); try { const rec = await loadRecord(qid); // Make sure user hasn't selected something else while loading if (state.selectedQid !== qid) return; renderRecord(rec); } catch (e) { dom.main.innerHTML = `
    Failed to load record: ${escapeHtml(e.message)}
    `; } } function step(delta) { if (!state.selectedQid) { if (state.filtered.length) selectQid(state.index[state.filtered[0]].qid); return; } const cur = state.filtered.findIndex((i) => state.index[i].qid === state.selectedQid); if (cur < 0) return; const next = cur + delta; if (next < 0 || next >= state.filtered.length) return; selectQid(state.index[state.filtered[next]].qid); } // ── Wiring ──────────────────────────────────────────────────────── function wire() { dom.list.addEventListener('click', (ev) => { const li = ev.target.closest('li.qid-row'); if (li) selectQid(li.getAttribute('data-qid')); }); dom.search.addEventListener('input', debounce(() => { state.search = dom.search.value; recomputeFiltered(); renderSidebar(); }, 120)); dom.filter.addEventListener('click', (ev) => { const btn = ev.target.closest('.filter-pill'); if (!btn) return; const ds = btn.getAttribute('data-dataset'); if (ds === state.datasetFilter) return; state.datasetFilter = ds; dom.filter.querySelectorAll('.filter-pill').forEach((b) => b.classList.toggle('active', b === btn)); recomputeFiltered(); renderSidebar(); }); dom.prev.addEventListener('click', () => step(-1)); dom.next.addEventListener('click', () => step(+1)); document.addEventListener('keydown', (ev) => { if (ev.target && (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA')) { if (ev.key === 'Escape') ev.target.blur(); return; } if (ev.key === 'ArrowLeft') { ev.preventDefault(); step(-1); } else if (ev.key === 'ArrowRight') { ev.preventDefault(); step(+1); } else if (ev.key === '/') { ev.preventDefault(); dom.search.focus(); dom.search.select(); } else if (ev.key === '1' || ev.key === '2' || ev.key === '3') { const map = { '1': 'hard_positive', '2': 'positive', '3': 'negative' }; const tab = document.querySelector(`#bucketTabs .tables-tab[data-bucket="${map[ev.key]}"]`); if (tab && !tab.disabled) tab.click(); } }); window.addEventListener('hashchange', () => { const qid = decodeURIComponent(window.location.hash.slice(1)); if (qid && qid !== state.selectedQid) selectQid(qid, /*pushHash=*/false); }); } // ── Boot ────────────────────────────────────────────────────────── async function boot() { wire(); dom.main.innerHTML = `
    Loading index…
    `; try { const res = await fetch('index.json'); if (!res.ok) throw new Error(`index.json ${res.status}`); state.index = await res.json(); } catch (e) { dom.main.innerHTML = `
    Failed to load index.json: ${escapeHtml(e.message)}
    `; return; } recomputeFiltered(); renderSidebar(); // Honour deep link if present, else show empty placeholder. const hashQid = decodeURIComponent(window.location.hash.slice(1)); if (hashQid && state.index.some((e) => e.qid === hashQid)) { selectQid(hashQid, /*pushHash=*/false); } else { renderEmpty(); } } boot(); })();