/* ProvSQL Studio: notebook mode (lazy-loaded by app.js, mirroring circuit.js). An ordered list of Markdown + SQL cells executed against a pinned, stateful database session (the "kernel": /api/nb/session + /api/nb/exec), with per-cell results rendered by the same block renderer as the shared result pane (app.js makeBlockRenderer). Persistence: Jupyter nbformat v4 (.ipynb) download / load, plus a localStorage autosave of the working draft. See the Notebook-mode section of doc/source/user/studio.rst. */ (function () { 'use strict'; let env = null; // window.__provsqlStudio, passed by init() let kernel = null; // {sessionId, pid, db} | null let kernelStarting = null; // in-flight ensureKernel() promise let cells = []; // [{id, type, source, outputs, count, htmlSnapshot}] let execCounter = 0; let running = null; // {cellId, requestId} | null let mdLibs = null; // promise for marked + DOMPurify let lastFocused = null; // last cell whose editor had focus let schemaCache = null; // /api/schema payload for the sidebar let tabs = []; // [{id, name, db, doc, kernel, resume}] let activeTabId = null; let connDb = null; // current connection's database (from /api/conn) let nextTabId = 1; let selected = null; // command-mode selected cell (Jupyter-style) let lastDeleted = null; // {cell, index} for the `z` undo let lastDKey = 0; // timestamp of the previous lone `d` press const byId = (id) => document.getElementById(id); const cellsEl = () => byId('nb-cells'); let nextCellId = 1; function newCell(type, source) { return { id: 'c' + (nextCellId++), type, source: source || '', outputs: null, count: null, htmlSnapshot: null }; } /* ──────── tabs as database bindings ──────── */ /* Each tab is one notebook plus the database it is bound to. Both deployments share a single ACTIVE connection (the Playground's PGlite cannot even open two), so tabs multiplex serially: a tab whose binding differs from the live connection shows a banner offering to switch (or to create the database, or to rebind); activating it never switches silently. Loading an .ipynb opens a new tab; booting on a different database than the active tab's binding activates (or creates) a tab bound to it. Inactive tabs park their notebook as an ipynb doc; their kernels stay alive until the tab closes or the page unloads. */ function currentTab() { return tabs.find((t) => t.id === activeTabId) || null; } function flushActiveTab() { const tab = currentTab(); if (!tab) return; tab.doc = toIpynb(); tab.kernel = kernel; tab.resume = { idx: selected ? cells.indexOf(selected) : -1, scrollY: window.scrollY, }; } function persistTabs() { flushActiveTab(); try { localStorage.setItem('ps.nb.tabs', JSON.stringify({ active: activeTabId, tabs: tabs.map((t) => ({ id: t.id, name: t.name, db: t.db, doc: t.doc, resume: t.resume })), })); } catch (e) { /* quota / disabled */ } } function makeTab(name, db, doc) { return { id: 't' + (nextTabId++), name: name || 'Untitled', db: db || connDb || null, doc: doc || null, kernel: null, resume: null }; } function loadTabIntoView(tab) { if (tab.doc) { loadNotebook(tab.doc); } else { defaultNotebook(); } kernel = tab.kernel || null; if (kernel) setKernelChip('alive', `pid ${kernel.pid} · ${kernel.db}`); else setKernelChip('none', 'no kernel'); if (tab.resume) { if (Number.isInteger(tab.resume.idx) && tab.resume.idx >= 0 && tab.resume.idx < cells.length) { selectCell(cells[tab.resume.idx], { scroll: false }); } const y = tab.resume.scrollY; if (Number.isFinite(y) && y > 0) { requestAnimationFrame(() => requestAnimationFrame( () => window.scrollTo(0, y))); } } else { if (cells.length) selectCell(cells[0], { scroll: false }); window.scrollTo(0, 0); } renderTabBar(); updateBindingBanner(); } function activateTab(id) { if (id === activeTabId) return; flushActiveTab(); const tab = tabs.find((t) => t.id === id); if (!tab) return; activeTabId = id; loadTabIntoView(tab); persistTabs(); } // A tab worth dropping silently: never ran anything (no kernel) and // holds no content (the freshly-booted Untitled tab). function tabIsPristine(tab) { if (!tab || tab.kernel) return false; const docCells = (tab.doc && tab.doc.cells) || []; return docCells.every((c) => !String(Array.isArray(c.source) ? c.source.join('') : c.source || '').trim() && !(c.outputs && c.outputs.length)); } function newTab(name, db, doc) { flushActiveTab(); const prev = currentTab(); const tab = makeTab(name, db, doc); tabs.push(tab); activeTabId = tab.id; // Opening a document (example / .ipynb load) from a pristine // Untitled tab replaces it rather than leaving an empty husk // behind. The + button (doc == null) keeps the old tab: making a // second blank tab is exactly its job. if (doc && prev && tabIsPristine(prev)) { tabs.splice(tabs.indexOf(prev), 1); } loadTabIntoView(tab); persistTabs(); return tab; } function closeTab(id) { const idx = tabs.findIndex((t) => t.id === id); if (idx < 0) return; const tab = tabs[idx]; const nonTrivial = tab.doc ? (tab.doc.cells || []).some((c) => String( Array.isArray(c.source) ? c.source.join('') : c.source || '').trim()) : (id === activeTabId && cells.some((c) => (c.source || '').trim())); if (nonTrivial && !window.confirm(`Close tab “${tabDisplayName(tab)}”?`)) return; if (tab.kernel) { fetch(`/api/nb/session/${encodeURIComponent(tab.kernel.sessionId)}`, { method: 'DELETE' }).catch(() => {}); } tabs.splice(idx, 1); if (id === activeTabId) { activeTabId = null; kernel = null; if (tabs.length) activateTab(tabs[Math.max(0, idx - 1)].id); else newTab(); } else { renderTabBar(); persistTabs(); } } // A tab's display name is the first level-1 Markdown heading in its // notebook (the document names itself, like a paper title); the // stored name (file stem / "Untitled") is only the fallback. function headingTabName(tab) { const list = tab.id === activeTabId ? cells.map((c) => ({ md: c.type === 'markdown', src: c.source || '' })) : ((tab.doc && tab.doc.cells) || []).map((c) => ({ md: c.cell_type === 'markdown', src: Array.isArray(c.source) ? c.source.join('') : (c.source || ''), })); for (const c of list) { if (!c.md) continue; let inFence = false; for (const line of String(c.src).split('\n')) { if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const m = line.match(/^#\s+(.+?)\s*#*\s*$/); if (m) return m[1]; } } return null; } function tabDisplayName(tab) { return headingTabName(tab) || tab.name; } function renderTabBar() { const bar = byId('nb-tabs'); if (!bar) return; const esc = env.escapeHtml, escA = env.escapeAttr; bar.innerHTML = tabs.map((t) => { const active = t.id === activeTabId; const foreign = t.db && connDb && t.db !== connDb; const name = tabDisplayName(t); return `` + `${esc(name)}` + (foreign ? `${esc(t.db)}` : '') + `` + ``; }).join('') + ``; } function wireTabBar() { const bar = byId('nb-tabs'); if (!bar) return; bar.addEventListener('click', (e) => { const close = e.target.closest('[data-tab-close]'); if (close) { closeTab(close.dataset.tabClose); return; } if (e.target.closest('#nb-tab-add')) { newTab(); return; } const tabEl = e.target.closest('[data-tab]'); if (tabEl) activateTab(tabEl.dataset.tab); }); } /* Binding banner: the active tab's database vs the live connection. Offers exactly the action that makes sense: "Switch to X" when the bound database exists (is CONNECT-able), "Create X" when it does not -- offering both would be nonsense. */ let dbListCache = null; // GET /api/databases payload async function accessibleDatabases() { if (dbListCache) return dbListCache; try { const resp = await fetch('/api/databases'); if (resp.ok) dbListCache = await resp.json(); } catch (e) { /* fall through */ } return dbListCache || []; } async function updateBindingBanner() { const banner = byId('nb-binding-banner'); if (!banner) return; const tab = currentTab(); const foreign = tab && tab.db && connDb && tab.db !== connDb; banner.hidden = !foreign; if (!foreign) { banner.innerHTML = ''; return; } const exists = (await accessibleDatabases()).includes(tab.db); // The tab may have changed while the list was fetched. if (currentTab() !== tab) return; const esc = env.escapeHtml; banner.innerHTML = ` ` + `This notebook is bound to ${esc(tab.db)}` + `${exists ? '' : ' (which does not exist)'}; ` + `you are connected to ${esc(connDb)}.` + (exists ? ` ` : ` `) + ``; const sw = byId('nb-bind-switch'); if (sw) sw.addEventListener('click', () => switchConnectionTo(tab.db)); const cr = byId('nb-bind-create'); if (cr) cr.addEventListener('click', async () => { const resp = await fetch('/api/databases', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: tab.db }), }); const payload = await resp.json().catch(() => ({})); dbListCache = null; if (resp.status === 409) { // Created elsewhere since the list was cached: just switch. switchConnectionTo(tab.db); return; } if (!resp.ok) { window.alert(payload.error || `HTTP ${resp.status}`); return; } if (payload.warning) window.alert(payload.warning); switchConnectionTo(tab.db); }); byId('nb-bind-keep').addEventListener('click', () => { tab.db = connDb; persistTabs(); renderTabBar(); updateBindingBanner(); }); } async function switchConnectionTo(dbname) { // Persist first: the connection switch reloads the page (and kills // every kernel server-side); on boot the active tab matches the new // database, so the reconcile below leaves it in place. persistTabs(); try { const resp = await fetch('/api/conn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ database: dbname }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload.error || `HTTP ${resp.status}`); return; } } catch (e) { window.alert(`Network error: ${e.message}`); return; } window.location.reload(); } /* ──────── kernel client ──────── */ function setKernelChip(state, label) { const chip = byId('nb-kernel-chip'); const lbl = byId('nb-kernel-label'); if (!chip || !lbl) return; chip.className = 'nb__kernel nb__kernel--' + state; lbl.textContent = label; } async function ensureKernel() { if (kernel) return kernel; if (kernelStarting) return kernelStarting; setKernelChip('starting', 'starting…'); kernelStarting = (async () => { const resp = await fetch('/api/nb/session', { method: 'POST' }); const payload = await resp.json().catch(() => ({})); if (!resp.ok) { setKernelChip('dead', payload.error || `kernel failed (HTTP ${resp.status})`); throw new Error(payload.error || `HTTP ${resp.status}`); } kernel = { sessionId: payload.session_id, pid: payload.pid, db: payload.db }; setKernelChip('alive', `pid ${kernel.pid} · ${kernel.db}`); return kernel; })(); try { return await kernelStarting; } finally { kernelStarting = null; } } function kernelGone(reason) { kernel = null; setKernelChip('dead', reason || 'kernel died – restart it'); } async function shutdownKernel() { if (!kernel) return; const id = kernel.sessionId; kernel = null; setKernelChip('none', 'no kernel'); try { await fetch(`/api/nb/session/${encodeURIComponent(id)}`, { method: 'DELETE' }); } catch (e) { /* server gone: nothing to clean */ } } async function restartKernel() { await shutdownKernel(); // Restart resets the Jupyter-style staleness markers: every cell's // counter goes back to "not run on this kernel". execCounter = 0; for (const c of cells) { c.count = null; } for (const c of cells) updateGutter(c); try { await ensureKernel(); } catch (e) { /* chip already shows it */ } } /* ──────── markdown rendering (vendored marked + DOMPurify) ──────── */ function loadScript(src) { return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('failed to load ' + src)); document.head.appendChild(s); }); } function ensureMdLibs() { if (!mdLibs) { mdLibs = Promise.all([ loadScript('/static/vendor/marked.min.js'), loadScript('/static/vendor/purify.min.js'), ]); } return mdLibs; } async function renderMarkdownInto(el, source) { try { await ensureMdLibs(); const html = window.marked.parse(source, { gfm: true, breaks: false }); el.innerHTML = window.DOMPurify.sanitize(html); // ```sql fences get the same tokenizer as the cell editors (marked // tags them code.language-sql). highlightSql escapes its input, so // feeding it the decoded textContent cannot reintroduce markup. const hl = window.ProvsqlStudio.highlightSql; if (hl) { for (const code of el.querySelectorAll( 'pre code.language-sql, pre code.language-postgresql')) { code.innerHTML = hl(code.textContent); } } } catch (e) { // Renderer unavailable (vendored file missing): degrade to // escaped plain text rather than hiding the user's prose. el.textContent = source; } } /* ──────── cell DOM ──────── */ function cellEl(cell) { return cellsEl().querySelector(`[data-cell-id="${cell.id}"]`); } function updateGutter(cell) { const el = cellEl(cell); if (!el) return; const g = el.querySelector('.nb-cell__count'); if (!g) return; if (running && running.cellId === cell.id) g.textContent = '[*]'; else if (cell.count != null) g.textContent = `[${cell.count}]`; else g.textContent = '[ ]'; } function buildOutputTargets(outEl) { outEl.innerHTML = `
tuples · ms
`; return { head: outEl.querySelector('thead tr'), body: outEl.querySelector('tbody'), count: outEl.querySelector('.nb-out__count'), noun: outEl.querySelector('.nb-out__noun'), banners: outEl.querySelector('.nb-out__banners'), truncated: outEl.querySelector('.nb-out__trunc'), }; } function renderOutputs(cell) { if (cell.type !== 'sql') return; const el = cellEl(cell); if (!el) return; const outEl = el.querySelector('.nb-out'); if (!outEl) return; if (!cell.outputs) { outEl.hidden = true; outEl.innerHTML = ''; return; } outEl.hidden = false; const targets = buildOutputTargets(outEl); const R = window.ProvsqlStudio.makeBlockRenderer(env, targets); const p = cell.outputs; R.renderBlocks(p.blocks || [], !!p.wrapped, p.notices || []); const timeEl = outEl.querySelector('.nb-out__time'); if (timeEl) timeEl.textContent = p.elapsed_ms != null ? p.elapsed_ms : '–'; // Snapshot for the .ipynb text/html bundle (external viewers); the // JSON payload stays the source of truth Studio re-renders from. cell.htmlSnapshot = outEl.innerHTML; } function autosize(ta) { ta.style.height = 'auto'; ta.style.height = Math.max(ta.scrollHeight, 34) + 'px'; } function buildCellDom(cell) { const div = document.createElement('div'); div.className = `nb-cell nb-cell--${cell.type}`; div.dataset.cellId = cell.id; div.innerHTML = `
[ ] ${cell.type === 'sql' ? `` : ''} ${cell.type === 'circuit' ? `` : ''} ${cell.type === 'eval' ? `` : ''}
${cell.type === 'circuit' ? `
Circuit for ${shortToken(cell.token)}
` : ''} ${cell.type === 'eval' ? `
${shortToken(cell.token)}
` : ''} ${cell.type === 'sql' ? ` ${cell.scheme ? ` ${cell.scheme} ` : ''}
` : `
`}
${cell.type === 'sql' ? `` : ''} ${cell.type === 'sql' ? `` : ''}
`; wireCellDom(cell, div); return div; } function wireCellDom(cell, div) { if (cell.type === 'sql') { const ta = div.querySelector('.nb-cell__ta'); const code = div.querySelector('.nb-cell__hl code'); const hl = window.ProvsqlStudio.highlightSql || ((t) => ''); const refresh = () => { code.innerHTML = hl(ta.value); autosize(ta); cell.source = ta.value; scheduleAutosave(); }; ta.value = cell.source; ta.addEventListener('focus', () => { lastFocused = cell; selectCell(cell, { scroll: false }); }); ta.addEventListener('input', refresh); ta.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); runCell(cell); } else if (e.shiftKey && e.key === 'Enter') { e.preventDefault(); ta.blur(); // Jupyter: drop to command mode runSelectedThenAdvance(cell); } else if (e.altKey && e.key === 'Enter') { e.preventDefault(); runAltEnter(cell); } else if (e.key === 'Escape') { e.preventDefault(); ta.blur(); selectCell(cell); } }); div.querySelector('.nb-cell__run').addEventListener('click', () => runCell(cell)); // Initial paint (after insertion, so scrollHeight is real). requestAnimationFrame(refresh); } else if (cell.type === 'markdown') { const view = div.querySelector('.nb-cell__md'); const ta = div.querySelector('.nb-cell__mdta'); let everEdited = false; const startEdit = () => { everEdited = true; ta.value = cell.source; view.hidden = true; ta.hidden = false; autosize(ta); ta.focus(); }; const endEdit = () => { cell.source = ta.value; ta.hidden = true; view.hidden = false; renderMarkdownInto(view, cell.source || '*(empty cell: double-click to edit)*'); scheduleAutosave(); }; ta.addEventListener('focus', () => { lastFocused = cell; selectCell(cell, { scroll: false }); }); view.addEventListener('dblclick', startEdit); ta.addEventListener('blur', endEdit); ta.addEventListener('input', () => autosize(ta)); ta.addEventListener('keydown', (e) => { if (((e.ctrlKey || e.metaKey) || e.shiftKey) && e.key === 'Enter') { e.preventDefault(); ta.blur(); // blur renders via endEdit if (e.shiftKey) runSelectedThenAdvance(cell); } else if (e.key === 'Escape') { e.preventDefault(); ta.blur(); selectCell(cell); } }); renderMarkdownInto(view, cell.source || '*(empty cell: double-click to edit)*'); // Fresh empty markdown cells drop straight into edit mode (the // "+ Markdown" flow); cells *converted* to markdown (the `m` key) // stay in command mode, like Jupyter, so the keymap keeps working. // The everEdited guard stops the deferred auto-edit from // REOPENING the editor when something else (focusCell's synthetic // dblclick) already opened it and the user finished the edit // within the same frame. if (!cell.source && !cell._noAutoEdit) { requestAnimationFrame(() => { if (!everEdited) startEdit(); }); } delete cell._noAutoEdit; } if (cell.type === 'circuit') { div.querySelector('.nb-cell__run').addEventListener('click', () => refreshCircuitCell(cell)); div.querySelector('.nb-circ__eval').addEventListener('click', () => { insertEvalAfter(cell, cell.token); }); div.querySelector('.nb-circ__jump').addEventListener('click', () => { // Same carry mechanism as the where-mode jump button: Circuit // mode preloads the token on arrival. The row's provenance // gate (recorded when the snapshot came from a result-row // click on an rv / non-provsql uuid) rides along so the eval // strip's "Conditioned by" presets to the row, as it does for // in-Circuit-mode clicks. try { sessionStorage.setItem('ps.preloadCircuit', cell.token); if (cell.rowProv) { sessionStorage.setItem('ps.preloadCircuitRowProv', cell.rowProv); } else { sessionStorage.removeItem('ps.preloadCircuitRowProv'); } } catch (e) { /* plain navigation */ } window.location.href = '/circuit'; }); // Saved notebooks carry the scene; paint it without a fetch. if (cell.scene) { requestAnimationFrame(() => { const box = div.querySelector('.nb-circ__canvas'); if (box) paintSceneInto(box, cell.scene); }); } } if (cell.type === 'eval') { div.querySelector('.nb-cell__run').addEventListener('click', () => runEvalCell(cell)); const sem = div.querySelector('.nb-eval__semiring'); const meth = div.querySelector('.nb-eval__method'); const args = div.querySelector('.nb-eval__args'); const map = div.querySelector('.nb-eval__mapping'); sem.addEventListener('change', () => { cell.semiring = sem.value; syncEvalControls(cell, div); scheduleAutosave(); }); meth.addEventListener('change', () => { cell.method = meth.value; scheduleAutosave(); }); args.addEventListener('input', () => { cell.args = args.value; scheduleAutosave(); }); map.addEventListener('change', () => { cell.mapping = map.value; scheduleAutosave(); }); ensureMappings().then((maps) => { for (const m of maps) { const opt = document.createElement('option'); opt.value = m.qname; opt.textContent = `${m.qname} (${m.value_type || '?'})`; if (m.qname === cell.mapping) opt.selected = true; map.appendChild(opt); } }); syncEvalControls(cell, div); if (cell.result) { requestAnimationFrame(() => renderEvalResult(cell)); } } div.querySelector('.nb-cell__actions').addEventListener('click', (e) => { const btn = e.target.closest('[data-act]'); if (!btn) return; const idx = cells.indexOf(cell); if (btn.dataset.act === 'up' && idx > 0) { cells.splice(idx, 1); cells.splice(idx - 1, 0, cell); cellsEl().insertBefore(div, div.previousElementSibling); } else if (btn.dataset.act === 'down' && idx < cells.length - 1) { cells.splice(idx, 1); cells.splice(idx + 1, 0, cell); cellsEl().insertBefore(div.nextElementSibling, div); } else if (btn.dataset.act === 'add-sql' || btn.dataset.act === 'add-md') { const c = newCell(btn.dataset.act === 'add-sql' ? 'sql' : 'markdown'); cells.splice(idx + 1, 0, c); div.after(buildCellDom(c)); focusCell(c); } else if (btn.dataset.act === 'scheme') { cycleScheme(cell, div); return; } else if (btn.dataset.act === 'to-circuit') { // Carry this cell's SQL into Circuit mode through the standard // mode-switch channel: executed cells auto-replay there, drafts // just land in the query box. try { sessionStorage.setItem('ps.sql', cell.source || ''); if ((cell.source || '').trim() && cell.count != null) { sessionStorage.setItem('ps.sql.ran', '1'); } else { sessionStorage.removeItem('ps.sql.ran'); } } catch (e2) { /* sessionStorage disabled: plain navigation */ } window.location.href = '/circuit'; return; } else if (btn.dataset.act === 'del') { if ((cell.source || '').trim() && !window.confirm('Delete this cell?')) return; cells.splice(idx, 1); div.remove(); if (!cells.length) appendCell('sql'); } scheduleAutosave(); }); } // Per-cell scheme cycling (default -> semiring -> where -> boolean). // `undefined` means "follow the toolbar's notebook-level default". const SCHEME_CYCLE = [undefined, 'semiring', 'where', 'boolean']; function cycleScheme(cell, div) { const idx = SCHEME_CYCLE.indexOf(cell.scheme); cell.scheme = SCHEME_CYCLE[(idx + 1) % SCHEME_CYCLE.length]; // Refresh the chip in place (no full rebuild: outputs stay live). let chip = div.querySelector('.nb-cell__scheme'); if (cell.scheme) { if (!chip) { chip = document.createElement('span'); chip.className = 'nb-cell__scheme'; div.querySelector('.nb-cell__editor').before(chip); } chip.textContent = cell.scheme; chip.title = `This cell runs under the ${cell.scheme} provenance ` + 'scheme (overrides the toolbar default)'; } else if (chip) { chip.remove(); } const btn = div.querySelector('[data-act="scheme"]'); if (btn) { btn.title = `Provenance scheme for this cell: ` + `${cell.scheme || 'notebook default'} – click to cycle ` + '(default → semiring → where → boolean)'; } scheduleAutosave(); } function focusCell(cell) { const el = cellEl(cell); if (!el) return; const ta = el.querySelector('.nb-cell__ta, .nb-cell__mdta:not([hidden])'); if (ta) ta.focus(); else el.querySelector('.nb-cell__md')?.dispatchEvent(new Event('dblclick')); } function appendCell(type, source) { const c = newCell(type, source); cells.push(c); cellsEl().appendChild(buildCellDom(c)); return c; } function focusedCell() { // Clicking a toolbar button moves focus to the button itself, so // activeElement rarely sits inside a cell at dispatch time; the // focus-tracked `lastFocused` is the actual "current" cell. const el = document.activeElement && document.activeElement.closest ? document.activeElement.closest('.nb-cell') : null; if (el) { const c = cells.find((x) => x.id === el.dataset.cellId); if (c) return c; } if (selected && cells.includes(selected) && selected.type === 'sql') { return selected; } if (lastFocused && cells.includes(lastFocused)) return lastFocused; return cells.find((c) => c.type === 'sql') || null; } /* ──────── circuit cells ──────── */ /* A circuit cell is a self-contained snapshot of the provenance DAG for one token: the /api/circuit scene (positions computed server-side) painted by the compact renderer below, which reuses circuit.js's CSS vocabulary (.node-group/.node-shape/.node-label/ .edge/.edge-pos) so the snapshot reads exactly like the Circuit mode canvas. The scene JSON is what persists in the .ipynb (plus an image/svg+xml copy for external viewers); on load the cell re-paints from the JSON without a database. Interactivity beyond a depth selector and a refresh deliberately stays in Circuit mode (one click away via the jump link). */ // Child-position labelling rules, mirrored from circuit.js: only // gates whose argument order matters get the digits. const ORDERED_GATES = new Set(['cmp', 'monus', 'agg', 'arith', 'mixture']); const COMMUTATIVE_AGG = new Set(['sum', 'count', 'min', 'max', 'avg']); const COMMUTATIVE_CMP = new Set(['=', '<>', '!=']); const NON_COMMUTATIVE_ARITH = new Set([2, 3]); // MINUS, DIV function shouldLabelChildren(parent) { if (!ORDERED_GATES.has(parent.type)) return false; if (parent.type === 'agg') { return !COMMUTATIVE_AGG.has((parent.info1_name || '').toLowerCase()); } if (parent.type === 'cmp') { return !COMMUTATIVE_CMP.has(parent.info1_name || ''); } if (parent.type === 'arith') { const tag = parent.info1 == null ? null : Number(parent.info1); return Number.isFinite(tag) && NON_COMMUTATIVE_ARITH.has(tag); } return true; } function edgePosLabel(parent, pos) { if (parent.type === 'mixture') { return ({ 1: 'p', 2: 'x', 3: 'y' })[pos] || String(pos); } return String(pos); } function svgEl(tag, attrs) { const el = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v); return el; } function paintSceneInto(container, scene) { container.innerHTML = ''; if (!scene || !scene.nodes || !scene.nodes.length) { container.textContent = '(empty circuit)'; return; } const xs = scene.nodes.map((n) => n.x), ys = scene.nodes.map((n) => n.y); const pad = 40; const minX = Math.min(...xs) - pad, maxX = Math.max(...xs) + pad; const minY = Math.min(...ys) - pad, maxY = Math.max(...ys) + pad; // Presentation attributes double the stylesheet below: SVG attributes // lose to any CSS rule, so the live look is app.css's unchanged -- but // the htmlSnapshot copy of this SVG (the .ipynb image/svg+xml bundle) // renders standalone in external viewers (nbviewer, GitHub), which see // neither app.css nor colors_and_type.css. Hex values are the resolved // --purple-500 / --purple-700 / --fg-muted. const svg = svgEl('svg', { class: 'nb-circ__svg', // Explicit xmlns: innerHTML serialization omits it, and without it // the snapshot is not a parseable standalone SVG document. xmlns: 'http://www.w3.org/2000/svg', viewBox: `${minX} ${minY} ${maxX - minX} ${maxY - minY}`, preserveAspectRatio: 'xMidYMid meet', 'font-family': 'sans-serif', }); // Cap the on-screen size while keeping the aspect ratio: small // scenes render 1:1-ish, large ones shrink to fit the cell. svg.style.maxHeight = '360px'; svg.style.width = '100%'; const nodesById = {}; for (const n of scene.nodes) nodesById[n.id] = n; // Parallel-edge bow, as in circuit.js, so duplicate wires stay // distinguishable. const parallelTotal = {}; for (const e of scene.edges) { const k = `${e.from}|${e.to}`; parallelTotal[k] = (parallelTotal[k] || 0) + 1; } const parallelSeen = {}; for (const e of scene.edges) { const from = nodesById[e.from], to = nodesById[e.to]; if (!from || !to) continue; const k = `${e.from}|${e.to}`; const total = parallelTotal[k]; parallelSeen[k] = (parallelSeen[k] || 0) + 1; const bow = total > 1 ? ((parallelSeen[k] - 1) - (total - 1) / 2) * 18 : 0; svg.appendChild(svgEl('path', { class: 'edge', fill: 'none', stroke: '#5A3E8C', 'stroke-width': 1.6, d: `M ${from.x} ${from.y + 22} ` + `C ${from.x + bow} ${from.y + 50}, ` + `${to.x + bow} ${to.y - 50}, ` + `${to.x} ${to.y - 22}`, })); if (shouldLabelChildren(from) && e.child_pos != null) { const dx = from.x - to.x, dy = from.y - to.y; const len = Math.hypot(dx, dy) || 1; const t = svgEl('text', { class: 'edge-pos', fill: '#8A7A9B', 'font-size': 8, x: (from.x + to.x) / 2 + bow + (-dy / len) * 9, y: (from.y + to.y) / 2 + (dx / len) * 9, }); t.textContent = edgePosLabel(from, e.child_pos); svg.appendChild(t); } } for (const n of scene.nodes) { const g = svgEl('g', { class: `node-group node--${n.type}` + (n.frontier ? ' is-frontier' : ''), transform: `translate(${n.x},${n.y})`, }); g.appendChild(svgEl('circle', { class: 'node-shape', r: 22, fill: '#fff', stroke: '#6B4FA0', 'stroke-width': 2, })); const label = String(n.label == null ? '' : n.label); const t = svgEl('text', { class: 'node-label', fill: '#6B4FA0', 'font-weight': 600, 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-size': label.length > 6 ? 9 : (label.length > 3 ? 11 : 14), }); t.textContent = label; const tip = svgEl('title'); tip.textContent = `${n.type}\n${n.id}`; g.appendChild(tip); g.appendChild(t); svg.appendChild(g); } container.appendChild(svg); } async function refreshCircuitCell(cell) { const el = cellEl(cell); const box = el && el.querySelector('.nb-circ__canvas'); if (!box) return; box.textContent = 'loading…'; try { // No depth parameter: like Circuit mode's loadCircuit, the server // clamps to its configured max_circuit_depth, so the snapshot and // the canvas show the same cut of the DAG. const resp = await fetch( `/api/circuit/${encodeURIComponent(cell.token)}`); const payload = await resp.json(); if (!resp.ok) throw new Error(payload.error || `HTTP ${resp.status}`); cell.scene = payload; paintSceneInto(box, cell.scene); cell.htmlSnapshot = box.innerHTML; } catch (e) { box.textContent = `Could not fetch the circuit: ${e.message}`; } scheduleAutosave(); } // The eval strip's semantics (semiring evaluation, probability of a // Boolean event) apply to plain provenance tokens; an agg_token or // random_variable root gets no Evaluate affordance -- Circuit mode's // dedicated dispatch (distribution profiles, moments, agg inspection) // is the investigation surface for those. function evalApplies(cell) { return cell.tokenKind !== 'agg_token' && cell.tokenKind !== 'random_variable'; } function syncCircuitEvalButton(cell) { const el = cellEl(cell); const btn = el && el.querySelector('.nb-circ__eval'); if (btn) btn.hidden = !evalApplies(cell); } function shortToken(t) { const s = String(t || ''); return s.length > 8 ? s.slice(0, 8) + '…' : s; } // Insert a circuit cell for `token` after `afterCell` -- or, when the // next cell is already a circuit cell, retarget it (repeated clicks // on result UUIDs swap the snapshot instead of piling cells up). function showCircuitFor(token, afterCell, rowProv, tokenKind) { const idx = cells.indexOf(afterCell); let cell = idx >= 0 && cells[idx + 1] && cells[idx + 1].type === 'circuit' ? cells[idx + 1] : null; if (cell) { cell.token = token; cell.rowProv = rowProv || ''; cell.tokenKind = tokenKind || ''; syncCircuitEvalButton(cell); const el = cellEl(cell); const lbl = el && el.querySelector('.nb-circ__token'); if (lbl) { lbl.textContent = shortToken(token); lbl.title = token; } } else { cell = newCell('circuit'); cell.token = token; cell.rowProv = rowProv || ''; cell.tokenKind = tokenKind || ''; const pos = idx >= 0 ? idx + 1 : cells.length; cells.splice(pos, 0, cell); const anchorEl = idx >= 0 ? cellEl(afterCell) : null; const dom = buildCellDom(cell); if (anchorEl) anchorEl.after(dom); else cellsEl().appendChild(dom); } refreshCircuitCell(cell); } /* ──────── evaluation cells ──────── */ /* An evaluation cell is one eval-strip invocation as a cell: token + evaluation (compiled semiring or probability method) + optional arguments / provenance mapping, with the result rendered inline and the invocation recorded in metadata.provsql so a saved notebook replays it. The narrative form of "and the probability of this answer is...". Offered only for plain provenance tokens: agg_token / random_variable roots get no Evaluate affordance (the strip's semantics don't apply to them; Circuit mode's dedicated dispatch -- distribution profiles, moments -- is the place to investigate those). */ const EVAL_SEMIRINGS = [ ['probability', 'Probability'], ['formula', 'Formula'], ['boolexpr', 'Boolean expression'], ['why', 'Why'], ['which', 'Which'], ['how', 'How'], ['counting', 'Counting'], ]; let mappingsCache = null; // /api/provenance_mappings payload async function ensureMappings() { if (mappingsCache) return mappingsCache; try { const resp = await fetch('/api/provenance_mappings'); if (resp.ok) mappingsCache = await resp.json(); } catch (e) { /* mapping picker just stays empty */ } if (!mappingsCache) mappingsCache = []; return mappingsCache; } function syncEvalControls(cell, el) { const isProb = cell.semiring === 'probability'; el.querySelector('.nb-eval__method').hidden = !isProb; el.querySelector('.nb-eval__mapping').hidden = isProb; el.querySelector('.nb-eval__args').hidden = !isProb; } const EVAL_METHODS = [ '', 'exact', 'relative', 'additive', 'independent', 'possible-worlds', 'sieve', 'd-tree', 'tree-decomposition', 'compilation', 'wmc', 'monte-carlo', 'karp-luby', 'stopping-rule', ]; function renderEvalResult(cell) { const el = cellEl(cell); const box = el && el.querySelector('.nb-eval__out'); if (!box) return; if (!cell.result) { box.hidden = true; box.innerHTML = ''; return; } box.hidden = false; const esc = env.escapeHtml; const r = cell.result; if (r.error) { box.innerHTML = `
` + `${esc(r.error)}${r.detail ? ' – ' + esc(r.detail) : ''}
`; return; } const value = (r.result == null) ? '(null)' : (typeof r.result === 'object' ? JSON.stringify(r.result, null, 1) : String(r.result)); const multiline = value.includes('\n') || value.length > 100; box.innerHTML = (multiline ? `
${esc(value)}
` : `${esc(value)}`) + (r.resolved_method ? ` via ${esc(r.resolved_method)}` : '') + (cell.elapsed != null ? ` · ${esc(String(cell.elapsed))} ms` : ''); cell.htmlSnapshot = box.innerHTML; } async function runEvalCell(cell) { const el = cellEl(cell); const box = el && el.querySelector('.nb-eval__out'); if (box) { box.hidden = false; box.textContent = 'evaluating…'; } const body = { token: cell.token, semiring: cell.semiring || 'probability', }; if (cell.semiring === 'probability') { if (cell.method) body.method = cell.method; if (cell.args) body.arguments = cell.args; } else if (cell.mapping) { body.mapping = cell.mapping; } const t0 = performance.now(); let payload; try { const resp = await fetch('/api/evaluate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); payload = await resp.json().catch(() => ({})); if (!resp.ok && !payload.error) payload.error = `HTTP ${resp.status}`; } catch (e) { payload = { error: `Network error: ${e.message}` }; } cell.elapsed = Math.round(performance.now() - t0); cell.result = payload; cell.count = ++execCounter; updateGutter(cell); renderEvalResult(cell); scheduleAutosave(); } // Insert an evaluation cell for `token` after `afterCell` (a circuit // cell's "Evaluate" button). function insertEvalAfter(afterCell, token) { const idx = cells.indexOf(afterCell); const cell = newCell('eval'); cell.token = token; cell.semiring = 'probability'; cell.method = 'exact'; cell.args = ''; cell.mapping = ''; const pos = idx >= 0 ? idx + 1 : cells.length; cells.splice(pos, 0, cell); const anchorEl = idx >= 0 ? cellEl(afterCell) : null; const dom = buildCellDom(cell); if (anchorEl) anchorEl.after(dom); else cellsEl().appendChild(dom); selectCell(cell); scheduleAutosave(); return cell; } /* ──────── sidebar: outline + compact relations ──────── */ /* The where-mode sidebar shows full table contents; next to a cell list that is wasted space. The notebook sidebar is deliberately short: an outline of the Markdown headings (click scrolls to the cell) and a one-line-per-relation schema summary (click inserts the relation name at the cursor; full contents stay one Schema-panel click away). */ function outlineEntries() { const entries = []; for (const c of cells) { if (c.type !== 'markdown') continue; let inFence = false; for (const line of String(c.source || '').split('\n')) { if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/); if (m) entries.push({ cellId: c.id, level: m[1].length, text: m[2] }); } } return entries; } function renderSidebar() { const root = document.getElementById('sidebar-body'); if (!root) return; const esc = env.escapeHtml, escA = env.escapeAttr; const entries = outlineEntries(); const outlineHtml = entries.length ? `` : `

Headings in Markdown cells appear here.

`; let relsHtml = `

loading…

`; if (schemaCache) { const rels = schemaCache; relsHtml = rels.length ? `` : `

No relations.

`; } root.innerHTML = `

Outline

${outlineHtml}

Relations

${relsHtml}
`; } async function refreshSidebarSchema() { try { const resp = await fetch('/api/schema'); if (resp.ok) schemaCache = await resp.json(); } catch (e) { /* keep the stale list */ } renderSidebar(); } // The SQL cell a panel-driven insert targets: the cell whose editor // has (or last had) focus, or null when no "querybox is selected" -- // in which case the inserters below append a fresh cell instead of // clobbering an arbitrary one. function currentSqlCell() { const el = document.activeElement && document.activeElement.closest ? document.activeElement.closest('.nb-cell') : null; if (el) { const c = cells.find((x) => x.id === el.dataset.cellId); if (c && c.type === 'sql') return c; } if (lastFocused && cells.includes(lastFocused) && lastFocused.type === 'sql') return lastFocused; return null; } function insertSql(text) { const cell = currentSqlCell() || appendCell('sql'); const el = cellEl(cell); const ta = el && el.querySelector('.nb-cell__ta'); if (!ta) return; const s = ta.selectionStart != null ? ta.selectionStart : ta.value.length; const e = ta.selectionEnd != null ? ta.selectionEnd : s; ta.value = ta.value.slice(0, s) + text + ta.value.slice(e); ta.setSelectionRange(s + text.length, s + text.length); ta.focus(); ta.dispatchEvent(new Event('input', { bubbles: true })); } // Whole-statement form (the schema panel's SELECT * / add_provenance // / create_provenance_mapping prefills): replace the current cell's // content, or land in a fresh cell when none is selected. function replaceSql(text) { const cell = currentSqlCell() || appendCell('sql'); const el = cellEl(cell); const ta = el && el.querySelector('.nb-cell__ta'); if (!ta) return; ta.value = text; ta.setSelectionRange(text.length, text.length); ta.focus(); ta.dispatchEvent(new Event('input', { bubbles: true })); } function wireSidebar() { const root = document.getElementById('sidebar-body'); if (!root) return; root.addEventListener('click', (e) => { const h = e.target.closest('[data-outline-cell]'); if (h) { const el = cellsEl().querySelector( `[data-cell-id="${CSS.escape(h.dataset.outlineCell)}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); el.classList.add('nb-cell--flash'); setTimeout(() => el.classList.remove('nb-cell--flash'), 1200); } return; } const rel = e.target.closest('[data-rel]'); if (rel) insertSql(rel.dataset.rel); }); } /* ──────── selection + command mode (Jupyter keymap) ──────── */ /* Edit mode is "an editor has focus"; Esc drops to command mode with the cell selected (highlighted border). Command-mode keys follow Jupyter: a/b insert above/below, dd deletes, z undoes the delete, m/y convert to Markdown/SQL, j/k or arrows move the selection, Enter re-enters edit mode, Shift+Enter runs and advances, Ctrl+Enter runs in place. Alt+Enter (both modes) runs and inserts a fresh cell below. */ function selectCell(cell, opts) { selected = cell; for (const el of cellsEl().querySelectorAll('.nb-cell--selected')) { el.classList.remove('nb-cell--selected'); } const el = cell && cellEl(cell); if (el) { el.classList.add('nb-cell--selected'); if (!opts || opts.scroll !== false) { el.scrollIntoView({ block: 'nearest' }); } } } function editCell(cell) { const el = cellEl(cell); if (!el) return; if (cell.type === 'sql') { el.querySelector('.nb-cell__ta')?.focus(); } else if (cell.type === 'markdown') { const ta = el.querySelector('.nb-cell__mdta'); if (ta && ta.hidden) el.querySelector('.nb-cell__md')?.dispatchEvent(new Event('dblclick')); else ta?.focus(); } } function insertCellAt(idx, type) { const c = newCell(type); cells.splice(idx, 0, c); const dom = buildCellDom(c); const next = cells[idx + 1]; const nextEl = next && cellEl(next); if (nextEl) cellsEl().insertBefore(dom, nextEl); else cellsEl().appendChild(dom); scheduleAutosave(); return c; } function deleteCell(cell) { const idx = cells.indexOf(cell); if (idx < 0) return; lastDeleted = { cell, index: idx }; cells.splice(idx, 1); cellEl(cell)?.remove(); if (!cells.length) appendCell('sql'); selectCell(cells[Math.min(idx, cells.length - 1)]); scheduleAutosave(); } function undoDelete() { if (!lastDeleted) return; const { cell, index } = lastDeleted; lastDeleted = null; const idx = Math.min(index, cells.length); cells.splice(idx, 0, cell); const next = cells[idx + 1]; const nextEl = next && cellEl(next); const dom = buildCellDom(cell); if (nextEl) cellsEl().insertBefore(dom, nextEl); else cellsEl().appendChild(dom); updateGutter(cell); renderOutputs(cell); selectCell(cell); scheduleAutosave(); } // m / y conversion: keep the source, drop type-specific baggage // (outputs / counters when leaving sql, rendered view when leaving // markdown). Circuit cells don't convert. SQL going to Markdown is // wrapped in a ```sql fence so it renders as a code block; Markdown // coming back to SQL drops the fence again when the whole cell is a // single fenced block, so m + y round-trips cleanly. function convertCell(cell, type) { if (cell.type === type || cell.type === 'circuit' || cell.type === 'eval') return; if (type === 'markdown' && (cell.source || '').trim()) { cell.source = '```sql\n' + String(cell.source).replace(/\n+$/, '') + '\n```'; } else if (type === 'sql') { const m = String(cell.source || '').trim() .match(/^(```|~~~)[^\n]*\n([\s\S]*?)\n?\1$/); if (m) cell.source = m[2]; } const el = cellEl(cell); cell.type = type; cell.outputs = null; cell.count = null; cell.htmlSnapshot = null; cell._noAutoEdit = true; const dom = buildCellDom(cell); if (el) el.replaceWith(dom); updateGutter(cell); selectCell(cell); scheduleAutosave(); } function selectSibling(delta) { if (!cells.length) return; const idx = selected ? cells.indexOf(selected) : -1; const next = idx < 0 ? 0 : Math.max(0, Math.min(cells.length - 1, idx + delta)); selectCell(cells[next]); } function inEditMode() { const ae = document.activeElement; return !!(ae && (ae.tagName === 'TEXTAREA' || ae.tagName === 'INPUT' || ae.tagName === 'SELECT' || ae.isContentEditable)); } function commandKeydown(e) { if (inEditMode()) return; // edit-mode keys live on the editors if (!selected || !cells.includes(selected)) { // Jupyter's invariant: the selection is never empty -- there is // always exactly one selected cell for command-mode keys to act // on. Re-establish it (visibly) if some path dropped it. if (!cells.length) return; selectCell(cells[0], { scroll: false }); } const idx = cells.indexOf(selected); const plain = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; if (plain && e.key === 'a') { selectCell(insertCellAt(idx, 'sql')); } else if (plain && e.key === 'b') { selectCell(insertCellAt(idx + 1, 'sql')); } else if (plain && e.key === 'd') { const now = Date.now(); if (now - lastDKey < 600) { lastDKey = 0; deleteCell(selected); } else lastDKey = now; return; // don't reset the dd timer below } else if (plain && e.key === 'z') { undoDelete(); } else if (plain && e.key === 'm') { convertCell(selected, 'markdown'); } else if (plain && e.key === 'y') { convertCell(selected, 'sql'); } else if (plain && (e.key === 'j' || e.key === 'ArrowDown')) { e.preventDefault(); selectSibling(+1); } else if (plain && (e.key === 'k' || e.key === 'ArrowUp')) { e.preventDefault(); selectSibling(-1); } else if (plain && e.key === 'Enter') { e.preventDefault(); editCell(selected); } else if (e.key === 'Enter' && e.shiftKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); runSelectedThenAdvance(selected); } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (selected.type === 'sql') runCell(selected); else if (selected.type === 'circuit') refreshCircuitCell(selected); else if (selected.type === 'eval') runEvalCell(selected); } else if (e.key === 'Enter' && e.altKey) { e.preventDefault(); runAltEnter(selected); } else { lastDKey = 0; return; } lastDKey = 0; } // Shift+Enter, Jupyter semantics: run (or render), then select the // next cell in command mode (never opening its editor). The one // exception, also Jupyter's: at the last cell a fresh SQL cell is // created below and opened in edit mode. async function runSelectedThenAdvance(cell) { if (cell.type === 'sql') await runCell(cell); else if (cell.type === 'circuit' && cell.token) await refreshCircuitCell(cell); else if (cell.type === 'eval' && cell.token) await runEvalCell(cell); const idx = cells.indexOf(cell); const atEnd = idx === cells.length - 1; if (atEnd) appendCell('sql'); selectCell(cells[idx + 1]); if (atEnd) editCell(cells[idx + 1]); } // Alt+Enter: run, then insert a fresh SQL cell below and edit it. async function runAltEnter(cell) { if (cell.type === 'sql') await runCell(cell); const c = insertCellAt(cells.indexOf(cell) + 1, 'sql'); selectCell(c); editCell(c); } /* ──────── execution ──────── */ function setRunningUi(on) { byId('nb-run').disabled = on; byId('nb-run-all').disabled = on; byId('nb-restart').disabled = on; byId('nb-interrupt').hidden = !on; } async function runCell(cell) { if (cell.type !== 'sql' || running) return; let k; try { k = await ensureKernel(); } catch (e) { // Surface the failure in the cell too -- the kernel chip alone is // easy to miss when the eye is on the cell that "did nothing". cell.outputs = { blocks: [{ kind: 'error', message: `Could not start a kernel: ${e.message}` }], notices: [] }; renderOutputs(cell); return; } const requestId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; running = { cellId: cell.id, requestId }; updateGutter(cell); setRunningUi(true); const t0 = performance.now(); let payload = null; try { const resp = await fetch('/api/nb/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: k.sessionId, sql: cell.source, // Per-cell override first (e.g. cs7's boolean-provenance // narrative); the toolbar selector is the notebook default. prov_scheme: cell.scheme || byId('nb-scheme').value, request_id: requestId, }), }); payload = await resp.json().catch(() => null); if (!payload) payload = { blocks: [], notices: [] }; if (!resp.ok && payload.error) { // Session-level failure (expired / busy / dead kernel): shape it // like an error block so the cell renders it in place. payload.blocks = [{ kind: 'error', message: payload.error }]; } } catch (e) { payload = { blocks: [{ kind: 'error', message: `Network error: ${e.message}` }], notices: [] }; } finally { running = null; setRunningUi(false); } payload.elapsed_ms = Math.round(performance.now() - t0); cell.outputs = payload; cell.count = ++execCounter; if (payload.kernel_dead) { kernelGone('kernel died – restart it'); cell.count = null; } updateGutter(cell); renderOutputs(cell); scheduleAutosave(); // DDL in a cell (CREATE TABLE, add_provenance, ...) must reflect in // the sidebar and the metadata panels, same as a where-mode exec. refreshSidebarSchema(); if (window.ProvsqlStudio.metadata && window.ProvsqlStudio.metadata.invalidateAll) { window.ProvsqlStudio.metadata.invalidateAll(); } } async function runAll() { for (const cell of cells) { if (cell.type === 'circuit') { if (cell.token) await refreshCircuitCell(cell); continue; } if (cell.type === 'eval') { if (cell.token) await runEvalCell(cell); continue; } if (cell.type !== 'sql' || !(cell.source || '').trim()) continue; await runCell(cell); // A dead kernel aborts the run: later cells would all fail the // same way and bury the actual diagnostic. if (!kernel) break; } } async function interrupt() { if (!running) return; try { await fetch(`/api/cancel/${encodeURIComponent(running.requestId)}`, { method: 'POST' }); } catch (e) { /* the in-flight exec returns either way */ } } /* ──────── nbformat (de)serialization ──────── */ function toLines(text) { // nbformat convention: array of lines, each keeping its newline. const parts = String(text || '').split('\n'); return parts.map((l, i) => (i < parts.length - 1 ? l + '\n' : l)) .filter((l, i, a) => !(i === a.length - 1 && l === '')); } function joinSource(source) { return Array.isArray(source) ? source.join('') : String(source || ''); } function toIpynb() { return { nbformat: 4, nbformat_minor: 5, metadata: { kernelspec: { name: 'provsql-studio', display_name: 'ProvSQL (SQL)', language: 'sql' }, language_info: { name: 'sql' }, provsql: { scheme: byId('nb-scheme').value, // The database binding: which environment this notebook was // authored against (no credentials, just the name). database: (currentTab() && currentTab().db) || connDb || undefined, }, }, cells: cells.map((c) => { if (c.type === 'markdown') { return { cell_type: 'markdown', metadata: {}, source: toLines(c.source) }; } if (c.type === 'eval') { const outputs = []; if (c.result) { const data = { 'application/vnd.provsql.eval+json': c.result }; if (!c.result.error && c.result.result != null) { data['text/plain'] = toLines( typeof c.result.result === 'object' ? JSON.stringify(c.result.result) : String(c.result.result)); } outputs.push({ output_type: 'execute_result', execution_count: c.count, metadata: {}, data }); } return { cell_type: 'code', execution_count: c.count, metadata: { provsql: { cell: 'eval', token: c.token, semiring: c.semiring || 'probability', method: c.method || undefined, arguments: c.args || undefined, mapping: c.mapping || undefined, } }, source: toLines( `-- evaluate ${c.semiring || 'probability'}` + (c.method ? `/${c.method}` : '') + ` on ${c.token}`), outputs, }; } if (c.type === 'circuit') { // A circuit cell is a code cell whose payload lives in // metadata.provsql; the SQL-comment source keeps external // viewers (and a hypothetical SQL kernel) from choking. const outputs = []; if (c.scene) { const data = { 'application/vnd.provsql.scene+json': c.scene }; if (c.htmlSnapshot) data['image/svg+xml'] = toLines(c.htmlSnapshot); outputs.push({ output_type: 'execute_result', execution_count: null, metadata: {}, data }); } return { cell_type: 'code', execution_count: null, metadata: { provsql: { cell: 'circuit', token: c.token, row_prov: c.rowProv || undefined, token_kind: c.tokenKind || undefined } }, source: toLines(`-- circuit ${c.token}`), outputs, }; } const outputs = []; if (c.outputs) { const data = { 'application/vnd.provsql.blocks+json': c.outputs, }; if (c.htmlSnapshot) data['text/html'] = toLines(c.htmlSnapshot); outputs.push({ output_type: 'execute_result', execution_count: c.count, metadata: {}, data }); } return { cell_type: 'code', execution_count: c.count, metadata: { provsql: c.scheme ? { scheme: c.scheme } : {} }, source: toLines(c.source), outputs }; }), }; } function fromIpynb(doc) { if (!doc || doc.nbformat !== 4 || !Array.isArray(doc.cells)) { throw new Error('not an nbformat-4 notebook'); } const loaded = []; for (const c of doc.cells) { if (c.cell_type === 'markdown') { loaded.push(newCell('markdown', joinSource(c.source))); } else if (c.cell_type === 'code' && c.metadata && c.metadata.provsql && c.metadata.provsql.cell === 'eval') { const cell = newCell('eval'); const p = c.metadata.provsql; cell.token = String(p.token || ''); cell.semiring = String(p.semiring || 'probability'); cell.method = String(p.method || ''); cell.args = String(p.arguments || ''); cell.mapping = String(p.mapping || ''); cell.count = (typeof c.execution_count === 'number') ? c.execution_count : null; for (const o of (c.outputs || [])) { const r = o && o.data && o.data['application/vnd.provsql.eval+json']; if (r) { cell.result = r; break; } } loaded.push(cell); } else if (c.cell_type === 'code' && c.metadata && c.metadata.provsql && c.metadata.provsql.cell === 'circuit') { const cell = newCell('circuit'); cell.token = String(c.metadata.provsql.token || ''); cell.rowProv = String(c.metadata.provsql.row_prov || ''); cell.tokenKind = String(c.metadata.provsql.token_kind || ''); for (const o of (c.outputs || [])) { // Re-paint only from the scene JSON (through our own // painter); the svg snapshot is for external viewers. const s = o && o.data && o.data['application/vnd.provsql.scene+json']; if (s) { cell.scene = s; break; } } loaded.push(cell); } else if (c.cell_type === 'code') { const cell = newCell('sql', joinSource(c.source)); const pmeta = (c.metadata && c.metadata.provsql) || {}; if (['semiring', 'where', 'boolean'].includes(pmeta.scheme)) { cell.scheme = pmeta.scheme; } cell.count = (typeof c.execution_count === 'number') ? c.execution_count : null; for (const o of (c.outputs || [])) { // Studio re-renders ONLY from its own JSON payload; the // text/html bundle is for external viewers and is never // injected back (a tampered file must not stored-XSS us). const p = o && o.data && o.data['application/vnd.provsql.blocks+json']; if (p) { cell.outputs = p; break; } } loaded.push(cell); } // raw cells: dropped (Studio has no use for them) } const scheme = doc.metadata && doc.metadata.provsql && doc.metadata.provsql.scheme; return { loaded, scheme }; } function renderAll() { const root = cellsEl(); root.innerHTML = ''; for (const c of cells) root.appendChild(buildCellDom(c)); for (const c of cells) { updateGutter(c); renderOutputs(c); } } // Set the notebook-level scheme AND the cross-mode session channel // (ps.opt.provScheme) that Circuit mode's radio selector restores // from, so a Notebook->Circuit switch keeps the active notebook's // scheme. (Per-notebook metadata still wins when a tab/doc carries // one; the channel reflects whatever is currently active.) function setScheme(value) { if (!['semiring', 'where', 'boolean'].includes(value)) return; byId('nb-scheme').value = value; try { sessionStorage.setItem('ps.opt.provScheme', value); } catch (e) {} } function loadNotebook(doc) { const { loaded, scheme } = fromIpynb(doc); cells = loaded.length ? loaded : [newCell('sql')]; execCounter = cells.reduce((m, c) => Math.max(m, c.count || 0), 0); if (scheme) setScheme(scheme); renderAll(); } /* ──────── persistence: download / file-load / autosave ──────── */ async function download() { const tab = currentTab(); const suggested = (((tab && tabDisplayName(tab)) || 'notebook') .replace(/[^\w.-]+/g, '_')) + '.ipynb'; const json = JSON.stringify(toIpynb(), null, 1); // Real save dialog (destination + filename) where the File System // Access API exists (Chromium, secure context -- the Playground's // main target). Elsewhere (Firefox, plain-http remote hosts) there // is no scriptable picker: fall back to a classic download under // the suggested name and let the browser's own "ask where to save" // setting decide whether a dialog appears -- no prompt boxes. if (window.showSaveFilePicker) { try { const handle = await window.showSaveFilePicker({ suggestedName: suggested, types: [{ description: 'Jupyter notebook', accept: { 'application/x-ipynb+json': ['.ipynb'] } }], }); const w = await handle.createWritable(); await w.write(json); await w.close(); return; } catch (e) { if (e && e.name === 'AbortError') return; // user cancelled // any other failure: fall through to the download path } } const blob = new Blob([json], { type: 'application/x-ipynb+json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = suggested; a.click(); URL.revokeObjectURL(a.href); } let autosaveTimer = null; function flushAutosave() { clearTimeout(autosaveTimer); persistTabs(); } function scheduleAutosave() { clearTimeout(autosaveTimer); autosaveTimer = setTimeout(() => { // Every model change funnels through here, so the debounced tick // doubles as the sidebar/outline + tab-name refresh trigger. renderSidebar(); renderTabBar(); persistTabs(); }, 500); } // Restore the persisted tab set. Returns true when something was // restored; the single-notebook 'ps.nb.autosave' blob from before the // tab era migrates into a lone tab. function restoreTabs() { let saved = null; try { saved = JSON.parse(localStorage.getItem('ps.nb.tabs') || 'null'); } catch (e) { saved = null; } if (!saved || !Array.isArray(saved.tabs) || !saved.tabs.length) { let legacy = null; try { legacy = JSON.parse(localStorage.getItem('ps.nb.autosave') || 'null'); } catch (e) { legacy = null; } if (!legacy) return false; try { localStorage.removeItem('ps.nb.autosave'); } catch (e) {} const db = (legacy.metadata && legacy.metadata.provsql && legacy.metadata.provsql.database) || connDb; tabs = [makeTab('Untitled', db, legacy)]; activeTabId = tabs[0].id; return true; } tabs = saved.tabs.map((t) => ({ id: t.id, name: t.name || 'Untitled', db: t.db || null, doc: t.doc || null, kernel: null, resume: t.resume || null, })); nextTabId = tabs.reduce( (m, t) => Math.max(m, parseInt(String(t.id).slice(1), 10) || 0), 0) + 1; activeTabId = tabs.some((t) => t.id === saved.active) ? saved.active : tabs[0].id; return true; } /* ──────── bundled example notebooks ──────── */ /* The tutorial / case-study notebooks generated from the user guide (studio/scripts/rst2nb.py), served by name from /api/nb/examples/. Opening one is exactly a file load: a new tab bound per the notebook's metadata (the binding banner then offers to create/switch to its database). */ async function populateExamples() { const sel = byId('nb-example'); if (!sel) return; let examples = []; try { const resp = await fetch('/api/nb/examples'); if (resp.ok) examples = await resp.json(); } catch (e) { /* treated as no examples below */ } if (!examples.length) { // Older server (endpoint missing) or a build without the bundled // notebooks: hide the menu rather than leaving a dead control. sel.hidden = true; return; } sel.hidden = false; for (const ex of examples) { const opt = document.createElement('option'); opt.value = ex.name; opt.textContent = ex.title || ex.name; sel.appendChild(opt); } } async function openExample(name) { try { const resp = await fetch(`/api/nb/examples/${encodeURIComponent(name)}`); const doc = await resp.json(); if (!resp.ok) throw new Error(doc.error || `HTTP ${resp.status}`); fromIpynb(doc); // validate before opening a tab const db = (doc.metadata && doc.metadata.provsql && doc.metadata.provsql.database) || connDb; newTab(name, db, doc); } catch (e) { window.alert(`Could not open example ${name}: ${e.message}`); } } /* ──────── boot ──────── */ function defaultNotebook() { // A single empty SQL cell: no boilerplate Markdown, so fresh tabs // stay "Untitled" until the user gives the notebook an H1 heading. cells = [newCell('sql')]; renderAll(); } async function init(studioEnv) { env = studioEnv; // Result-table UUID / agg_token / random_variable cells carry // data-circuit-uuid (makeBlockRenderer marks them in notebook // mode); clicking one materialises the circuit snapshot right // below the SQL cell that produced it. document.addEventListener('keydown', commandKeydown); cellsEl().addEventListener('mousedown', (e) => { const host = e.target.closest('.nb-cell'); const cell = host && cells.find((c) => c.id === host.dataset.cellId); if (cell) selectCell(cell, { scroll: false }); }); cellsEl().addEventListener('click', (e) => { const td = e.target.closest('[data-circuit-uuid]'); if (!td) return; const host = td.closest('.nb-cell'); const cell = host && cells.find((c) => c.id === host.dataset.cellId); if (cell) { showCircuitFor(td.dataset.circuitUuid, cell, td.dataset.rowProv || '', td.dataset.tokenKind || ''); } }); byId('nb-run').addEventListener('click', () => { const c = focusedCell(); if (c) runCell(c); }); byId('nb-run-all').addEventListener('click', runAll); byId('nb-interrupt').addEventListener('click', interrupt); byId('nb-restart').addEventListener('click', () => { if (window.confirm('Restart the kernel? Temp tables and session ' + 'settings will be lost.')) restartKernel(); }); byId('nb-add-sql').addEventListener('click', () => { focusCell(appendCell('sql')); scheduleAutosave(); }); byId('nb-add-md').addEventListener('click', () => { focusCell(appendCell('markdown')); scheduleAutosave(); }); byId('nb-scheme').addEventListener('change', () => { setScheme(byId('nb-scheme').value); scheduleAutosave(); }); byId('nb-save').addEventListener('click', download); byId('nb-load').addEventListener('click', () => byId('nb-load-input').click()); byId('nb-example').addEventListener('change', () => { const name = byId('nb-example').value; byId('nb-example').value = ''; if (name) openExample(name); }); byId('nb-load-input').addEventListener('change', async () => { const input = byId('nb-load-input'); const file = input.files && input.files[0]; input.value = ''; if (!file) return; try { const doc = JSON.parse(await file.text()); // Validate before opening a tab for it. fromIpynb(doc); const name = file.name.replace(/\.ipynb$/i, '') || 'notebook'; const db = (doc.metadata && doc.metadata.provsql && doc.metadata.provsql.database) || connDb; // A loaded notebook always lands in its own tab, bound to the // database recorded in its metadata; the binding banner offers // switch / create / rebind when that is not the live one. newTab(name, db, doc); } catch (e) { window.alert(`Could not load ${file.name}: ${e.message}`); } }); // Best-effort kernel cleanup when the tab goes away; the server's // idle GC is the real backstop. window.addEventListener('pagehide', () => { // The debounced autosave may not have fired yet (e.g. a mode // switch right after an edit); write synchronously so nothing is // lost on navigation. flushAutosave -> persistTabs also records // the per-tab resume point (selection + scroll). flushAutosave(); if (navigator.sendBeacon) { for (const t of tabs) { const k = t.id === activeTabId ? kernel : t.kernel; if (k) navigator.sendBeacon(`/api/nb/session/${k.sessionId}/close`); } } }); wireSidebar(); // Same body-level switch as circuit mode's fingerprint toggle: // formatCell renders every uuid cell as a short/full span pair, and // body.show-uuids flips which one is visible -- no re-render. const uuidBtn = byId('nb-show-uuids'); uuidBtn.addEventListener('click', () => { const on = !document.body.classList.contains('show-uuids'); document.body.classList.toggle('show-uuids', on); uuidBtn.setAttribute('aria-pressed', String(on)); }); wireTabBar(); // Inherit the session's scheme (the Circuit-mode pick) for fresh // notebooks; tabs whose doc records a scheme override it below. try { const saved = sessionStorage.getItem('ps.opt.provScheme'); if (saved && ['semiring', 'where', 'boolean'].includes(saved)) { byId('nb-scheme').value = saved; } } catch (e) { /* sessionStorage disabled */ } // The current database name anchors every binding decision; fetch // it before restoring tabs (one round-trip, also warms /api/conn). try { const resp = await fetch('/api/conn'); if (resp.ok) connDb = (await resp.json()).database || null; } catch (e) { /* banner logic degrades to no-op without connDb */ } if (!restoreTabs()) { tabs = [makeTab('Untitled', connDb, null)]; activeTabId = tabs[0].id; } // Booting on a database different from the active tab's binding // (the user switched databases elsewhere): activate the tab bound // to the new database, or open a fresh one for it -- the previous // tab stays, with its binding shown on the tab. const active = currentTab(); if (active && active.db && connDb && active.db !== connDb) { const match = tabs.find((t) => t.db === connDb); if (match) { activeTabId = match.id; } else { const t = makeTab('Untitled', connDb, null); tabs.push(t); activeTabId = t.id; } } loadTabIntoView(currentTab()); persistTabs(); refreshSidebarSchema(); populateExamples(); // Deep link: /notebook?nb= opens that bundled notebook in // its own tab (an already-open tab of the same name is reused). const wanted = new URLSearchParams(window.location.search).get('nb'); if (wanted) { const existing = tabs.find((t) => t.name === wanted); if (existing) activateTab(existing.id); else openExample(wanted); } } // Mode-switch carry (app.js carryQueryForSwitch): the current cell's // SQL, with ran=true when that cell was executed on this kernel. function currentSqlForCarry() { const cell = currentSqlCell(); if (!cell) return { sql: '', ran: false }; return { sql: cell.source || '', ran: cell.count != null }; } window.ProvsqlNotebook = { init, insertSql, replaceSql, currentSqlForCarry }; })();