/* 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 katexReady = null; // always-resolving promise for the best-effort KaTeX chain let mdRenders = []; // in-flight markdown renders of the current paint 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 connExtVersion = null; // provsql extversion, null when not installed (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) { // Markdown / KaTeX cells render asynchronously and grow the page // after the first paint; wait for this paint's renders to settle // before restoring the saved offset, so returning (e.g. from // Circuit mode) lands on the same content rather than near the top. Promise.allSettled(mdRenders.slice()).then(() => requestAnimationFrame(() => requestAnimationFrame( () => window.scrollTo(0, y)))); } } else { if (cells.length) selectCell(cells[0], { scroll: false }); window.scrollTo(0, 0); } renderTabBar(); updateBindingBanner(); updateProvsqlBanner(); } 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; } // Does a tab hold anything worth a confirm-before-close? The active tab's // live cells, or an inactive tab's stored doc. function tabHasContent(tab) { return tab.doc ? (tab.doc.cells || []).some((c) => String( Array.isArray(c.source) ? c.source.join('') : c.source || '').trim()) : (tab.id === activeTabId && cells.some((c) => (c.source || '').trim())); } function closeTab(id) { const idx = tabs.findIndex((t) => t.id === id); if (idx < 0) return; const tab = tabs[idx]; if (tabHasContent(tab) && !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(); } } // Drop every tab and reopen a single fresh one. One confirm covers the // lot when any tab holds content; each tab's kernel is released. function closeAllTabs() { if (!tabs.length) return; if (tabs.some(tabHasContent) && !window.confirm('Close all tabs and start fresh?')) return; for (const t of tabs) { if (t.kernel) { fetch(`/api/nb/session/${encodeURIComponent(t.kernel.sessionId)}`, { method: 'DELETE' }).catch(() => {}); } } tabs = []; activeTabId = null; kernel = null; newTab(); // one fresh Untitled tab; renders + persists } // 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('') + `` // Discreet "close all" at the far end, only once a second tab exists. + (tabs.length > 1 ? `` : ''); } 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; } if (e.target.closest('#nb-tab-closeall')) { closeAllTabs(); 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 = `` + (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(); }); } /* ProvSQL-missing banner: the live connection's database has no provsql extension, so every provenance query would fail one by one. Offer to install it (CREATE EXTENSION IF NOT EXISTS provsql CASCADE) in one click, rather than making the user hand-type it or leave the notebook. Orthogonal to the binding banner above (that is about which database the tab targets; this is about the connected database's readiness). */ async function updateProvsqlBanner() { const banner = byId('nb-provsql-banner'); if (!banner) return; // A set connDb with a falsy version means /api/conn resolved and // provsql is absent; a failed conn fetch leaves connDb null, so a // connectivity blip does not flash the banner. const missing = !!connDb && !connExtVersion; banner.hidden = !missing; if (!missing) { banner.innerHTML = ''; return; } const esc = env.escapeHtml; banner.innerHTML = `` + ``; const btn = byId('nb-provsql-install'); if (!btn) return; btn.addEventListener('click', async () => { btn.disabled = true; btn.textContent = 'Installing…'; try { const resp = await fetch('/api/install-provsql', { method: 'POST' }); const payload = await resp.json().catch(() => ({})); if (!resp.ok) { window.alert(payload.error || `HTTP ${resp.status}`); btn.disabled = false; btn.textContent = 'Install ProvSQL'; return; } // Committed DDL is visible to every kernel session immediately, so // no reload is needed; just drop the banner and let the chrome's // /api/conn poll pick the version up for its chip. connExtVersion = payload.version || 'installed'; updateProvsqlBanner(); } catch (e) { window.alert(String((e && e.message) || e)); btn.disabled = false; btn.textContent = 'Install ProvSQL'; } }); } 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 loadStyle(href) { return new Promise((resolve, reject) => { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; l.onload = resolve; l.onerror = () => reject(new Error('failed to load ' + href)); document.head.appendChild(l); }); } function ensureMdLibs() { if (!mdLibs) { // marked + DOMPurify are the core renderer: prose must render even if the // optional KaTeX assets are missing, so they alone gate the promise. mdLibs = Promise.all([ loadScript('/static/vendor/marked.min.js'), loadScript('/static/vendor/purify.min.js'), ]); // KaTeX for $…$ / $$…$$ math in Markdown cells; auto-render needs the // katex global, so it is chained after katex.min.js. Loaded best-effort // and off the critical path: a load failure leaves math un-rendered (the // $…$ source shows through) rather than degrading the whole cell to raw // text. loadStyle('/static/vendor/katex.min.css').catch(() => {}); // Tracked in katexReady (which always resolves, even on load failure) // so renderMarkdownInto can await it: math then renders on the first // paint instead of racing the 275 KB katex.min.js, while a missing // asset still only leaves math un-rendered rather than dropping the cell. katexReady = loadScript('/static/vendor/katex.min.js') .then(() => loadScript('/static/vendor/auto-render.min.js')) .catch(() => {}); } 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); // Links (doc references, external URLs) must open in a new tab: a // same-tab navigation fires `pagehide`, which beacons the kernel // session closed -- coming back then reports "unknown or expired // notebook session". Fragment-only links stay in-page. for (const a of el.querySelectorAll('a[href]')) { if (!(a.getAttribute('href') || '').startsWith('#')) { a.target = '_blank'; a.rel = 'noopener noreferrer'; } } // ```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); } } // Render LaTeX math (the rst :math: roles become $…$ / $$…$$). Runs // on the live DOM after sanitize, so KaTeX's spans are not stripped; // its default ignoredTags skip
/, so SQL fences (and any
// ``DO $$ … $$`` dollar-quoting in them) are left untouched.
if (katexReady) await katexReady;
if (window.renderMathInElement) {
window.renderMathInElement(el, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
],
throwOnError: false,
});
}
} 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 = `
`;
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 === 'markdown'
? '' /* markdown cells have no execution count, like Jupyter */
: `[ ]`}
${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);
// Clean pasted / dropped SQL of invisible Unicode (NBSP, zero-width
// characters…) exactly like the shared query box; the sanitizer
// re-fires `input`, so refresh() repaints with the cleaned text.
if (window.ProvsqlStudio.wirePasteSanitizer) {
window.ProvsqlStudio.wirePasteSanitizer(ta);
}
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);
}
});
mdRenders.push(
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, sceneRootFlags(cell.scene));
});
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');
// Hide semirings the evaluator would refuse on a Boolean/absorptive
// root before the user can pick them (parity with Circuit mode); if
// the persisted selection just got hidden, snap to the first sound
// option and refresh the dependent controls.
if (filterEvalSemirings(cell, sem)) sem.value = cell.semiring;
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 -> absorptive -> where -> boolean).
// `undefined` means "follow the toolbar's notebook-level default".
const SCHEME_CYCLE = [undefined, 'semiring', 'absorptive', '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 → absorptive → 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). */
// The circuit "vocabulary" (which wires get positional labels, what they
// say, and how an oversized label is fitted) is shared with Circuit mode
// via circuit-vocab.js -- a plain