/* ProvSQL Studio: entry script. Wires the shared chrome (mode switcher, example buttons, query form) plus both mode-specific sidebars: where-mode shows source-relation tables with hover-highlight, circuit-mode shows the provenance DAG (lazy-loaded circuit.js). Cross-mode navigation preserves the textarea content via sessionStorage and offers a per-row "→ Circuit" jump in where mode. */ (function () { const mode = document.body.classList.contains('mode-circuit') ? 'circuit' : 'where'; // Metadata caches (schema panel, eval-strip mapping picker, eval-strip // custom-semiring optgroup) lazy-load once and would otherwise stay // stale for the lifetime of the page. We mark them dirty after every // successful /api/exec so the next time each panel opens, it // re-fetches; the toolbar refresh button forces an invalidation // explicitly (e.g. after schema changes outside the Studio session). // Each consumer is responsible for clearing its own flag once reload // succeeds. const Metadata = { schemaDirty: true, mappingsDirty: true, customsDirty: true, // /api/kc/tools cache: which external knowledge compilers and // model counters the backend can actually reach (PATH + // provsql.tool_search_path). Consumed by the circuit-mode eval // strip to hide unselectable options. `null` until the first // successful fetch; the dirty flag is set by invalidateAll so the // refresh button forces a re-fetch (e.g. after installing a // missing tool or editing the tool_search_path GUC). toolsDirty: true, toolsCache: null, invalidateAll() { this.schemaDirty = true; this.mappingsDirty = true; this.customsDirty = true; this.toolsDirty = true; this.toolsCache = null; }, }; window.ProvsqlStudio = window.ProvsqlStudio || {}; window.ProvsqlStudio.metadata = Metadata; // The top-nav popovers (Schema, Config, Tools) are mutually exclusive: // opening one closes the others. Each calls this from its open() before // showing itself (their own click handlers stopPropagation, so the others' // outside-click handlers never fire on their own). const NAV_PANELS = [ { panel: 'schema-panel', btn: 'schema-btn' }, { panel: 'config-panel', btn: 'config-btn' }, { panel: 'tools-panel', btn: 'tools-btn' }, ]; function closeOtherNavPanels(exceptPanelId) { for (const { panel, btn } of NAV_PANELS) { if (panel === exceptPanelId) continue; const p = document.getElementById(panel); const b = document.getElementById(btn); if (p) p.hidden = true; if (b) b.setAttribute('aria-expanded', 'false'); } } // Hide eval-strip options whose external tool is missing on the // backend. The map comes from /api/kc/tools (one round-trip per // refresh) and lists which `eval-args-compiler` and // `eval-args-wmc-tool` values are reachable through the backend's // resolved PATH (plus the provsql.tool_search_path GUC). Already- // selected options that became unavailable are reset to the first // surviving entry so the eval strip never lands the user on an // option that's guaranteed to error out. // // Soft-fail: 501 (extension too old) or a network error leaves // every option visible. That degrades to today's behaviour where // an absent tool surfaces as a probability_evaluate error at run // time, which is acceptable when the discovery surface itself is // not installed. async function refreshToolAvailability() { if (!Metadata.toolsDirty && Metadata.toolsCache) { applyToolAvailability(Metadata.toolsCache); return Metadata.toolsCache; } let data = null; try { const resp = await fetch('/api/kc/tools'); if (resp.ok) data = await resp.json(); } catch (_e) { // network failure: leave dropdowns untouched. } Metadata.toolsCache = data; Metadata.toolsDirty = false; if (data) applyToolAvailability(data); return data; } // Friendly display labels for the tools ProvSQL ships with; any tool an // administrator registers later falls back to its bare registry name. const TOOL_LABELS = { 'd4': 'd4', 'd4v2': 'd4v2', 'c2d': 'c2d', 'minic2d': 'miniC2D', 'dsharp': 'Dsharp', 'panini-obdd': 'Panini → OBDD', 'panini-obdd-and': 'Panini → OBDD[AND]', 'panini-decdnnf': 'Panini → Decision-DNNF', 'ganak': 'Ganak', 'sharpsat-td': 'SharpSAT-TD', 'dpmc': 'DPMC', 'weightmc': 'WeightMC', 'tree-decomposition': 'tree-decomposition', 'interpret-as-dd': 'interpret as d-D', 'default': 'default (fallback chain)', }; const _toolLabel = (name) => TOOL_LABELS[name] || name; // Rebuild a Input gates only${hiddenCount > 0 ? ` (${hiddenCount} hidden)` : ''} `; if (!filtered.length) { body.innerHTML = toggleHtml + '

All provenance-tracked relations carry derived gates. Untick the filter to show them.

'; bindInputOnlyToggle(); return; } relations = filtered; // Quick-nav chips at the top: one per relation, click scrolls the // matching section into view inside the sidebar's own scroll pane. const navHtml = relations.length > 1 ? `` : ''; body.innerHTML = toggleHtml + navHtml + relations.map(rel => { // Skip the rewriter-added `provsql` UUID column when displaying; its // value is already exposed as the row id (used for the hover-highlight). // where_provenance numbers cells by user-column position (1-indexed, // ignoring provsql), which matches the original i+1 since provsql sits // at the end of the column list. const visible = rel.columns .map((c, i) => ({ c, i })) .filter(({ c }) => c.name !== 'provsql'); // When list_relations capped the SELECT, surface the cap inline // ("100 of ~50000 tuples") so the user knows the sidebar isn't a // full mirror of the table. The "~" reflects pg_class.reltuples // being a planner estimate, not an exact count. const tuples = (() => { const shown = rel.rows.length; if (rel.truncated) { const total = rel.estimated_rows; if (total != null && total > shown) { return `${shown} of ~${total} tuples`; } return `${shown}+ tuples (capped)`; } return `${shown} tuples`; })(); return `

${escapeHtml(rel.regclass)}

${tuples} · ${visible.length} cols
${visible.map(({ c }) => { const cls = isRightAlignedType(c.type_name) ? ' class="is-right"' : ''; return `${escapeHtml(c.name)}`; }).join('')} ${rel.rows.map(r => ` ${visible.map(({ c, i }) => { const id = `${rel.regclass}:${r.uuid}:${i+1}`; const cls = isRightAlignedType(c.type_name) ? ' is-right' : ''; return ``; }).join('')} `).join('')}
${formatCell(r.values[i], c.type_name)}
`; }).join(''); bindInputOnlyToggle(); } // Wire the "Input gates only" checkbox emitted by renderRelations. The // toggle persists in sessionStorage and re-renders from the cached // /api/relations response : no extra round-trip just to flip the filter. function bindInputOnlyToggle() { const cb = document.getElementById('opt-input-only'); if (!cb) return; cb.addEventListener('change', () => { setInputOnly(cb.checked); renderRelations(_lastRelations); }); } // Stable, CSS-safe id for a relation's section (avoids periods, quotes, ...). function sectionId(regclass) { return 'rel-' + String(regclass).replace(/[^A-Za-z0-9_]/g, '_'); } // Companion id for the relation's header element (table name + meta). function headerId(regclass) { return 'hdr-' + String(regclass).replace(/[^A-Za-z0-9_]/g, '_'); } function onResultHover(e, on) { const cell = e.target.closest('.wp-result__cell'); if (!cell) return; cell.classList.toggle('is-hover', on); let firstSource = null; (cell.dataset.sources || '').split(';').filter(Boolean).forEach(id => { const el = document.getElementById(id); if (el) { el.classList.toggle('is-source', on); if (on && !firstSource) firstSource = el; } }); // Bring the first highlighted source into view if it's outside the // sidebar's scroll viewport. block:'nearest' avoids unnecessary scroll // when the cell is already visible. if (on && firstSource) { firstSource.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } } /* ──────── Circuit mode ──────── */ /* ──────── query history ──────── */ const HISTORY_KEY = 'ps.history'; const HISTORY_CAP = 50; let _historyCursor = -1; // -1 = current draft; 0..N-1 = nth-most-recent saved entry let _historyDriving = false; // suppress the cursor reset when WE set ta.value function loadHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); const arr = raw ? JSON.parse(raw) : []; return Array.isArray(arr) ? arr : []; } catch { return []; } } function saveHistory(arr) { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0, HISTORY_CAP))); } catch {} } function pushHistory(sql) { const trimmed = String(sql || '').trim(); if (!trimmed) return; const arr = loadHistory(); if (arr.length && arr[0] === trimmed) return; // skip exact-duplicate consecutive entries arr.unshift(trimmed); saveHistory(arr); } function stepHistory(direction) { const arr = loadHistory(); if (!arr.length) return; const ta = document.getElementById('request'); if (_historyCursor === -1) _draft = ta.value; const valueAt = (i) => i === -1 ? (_draft || '') : arr[i]; const current = ta.value; // Skip entries identical to what's already in the textarea so Alt+↑/↓ // always produces a visible change (history can contain consecutive // duplicates separated by other queries, and the draft can match arr[0]). let next = _historyCursor + direction; while (next >= -1 && next < arr.length && valueAt(next) === current) { next += direction; } if (next < -1 || next >= arr.length) return; _historyCursor = next; _historyDriving = true; ta.value = valueAt(next); ta.dispatchEvent(new Event('input')); // refresh syntax highlight _historyDriving = false; } let _draft = ''; // Reset history-cursor when the USER edits the textarea (so the next // Alt+↑ starts from the most-recent entry again). When we set ta.value // ourselves from stepHistory, the synthetic input event must NOT reset // the cursor : that's what _historyDriving guards against. document.getElementById('request').addEventListener('input', () => { if (!_historyDriving) _historyCursor = -1; }); function setupHistoryDropdown() { const btn = document.getElementById('history-btn'); const menu = document.getElementById('history-menu'); if (!btn || !menu) return; function renderMenu() { const arr = loadHistory(); if (!arr.length) { menu.innerHTML = '
  • No saved queries yet.
  • '; return; } const oneLine = (s) => s.replace(/\s+/g, ' ').trim(); menu.innerHTML = arr.map((sql, i) => `
  • ${escapeHtml(oneLine(sql).slice(0, 200))}
  • ` ).join('') + '
  • Clear history
  • '; } function open() { renderMenu(); menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); } function close() { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); } btn.addEventListener('click', (e) => { e.stopPropagation(); if (menu.hidden) open(); else close(); }); document.addEventListener('click', (e) => { if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close(); }); menu.addEventListener('click', (e) => { const li = e.target.closest('li'); if (!li) return; if (li.dataset.clear) { saveHistory([]); renderMenu(); return; } const arr = loadHistory(); const i = Number(li.dataset.i); const sql = arr[i]; if (sql == null) return; const ta = document.getElementById('request'); ta.value = sql; ta.dispatchEvent(new Event('input')); _historyCursor = i; close(); ta.focus(); }); } let _currentConn = null; async function fetchConnInfo() { const el = document.getElementById('conn-info'); // The trigger is the wrapping `; } else { actions = ``; } } // Outer is a div with role=button so the inner action buttons can // be real