MCP Server
Active
1
0
Files
Organizations_register/vdvc-register-viewer.html

797 lines
27 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VDVC Register Viewer</title>
<style>
:root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
html, body { height: 100%; }
body { margin: 0; display: flex; flex-direction: column; overflow: hidden; }
/* Keep the upper row */
header {
padding: 12px 16px;
border-bottom: 1px solid #ddd;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
background: #fff;
}
header .meta { margin-left: auto; opacity: 0.85; font-size: 12px; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; border: 1px solid #ddd; font-size: 12px; background: #fff; }
.pill.warn { border-color: #f2c200; }
.pill.bad { border-color: #d92d20; }
main { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
/* New single-column editor UI */
.controls {
padding: 10px 12px;
border-bottom: 1px solid #eee;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
background: #fff;
}
.controls .spacer { flex: 1; }
.controls input[type="search"]{
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 8px;
min-width: 260px;
}
button {
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 8px;
background: #fff;
cursor: pointer;
}
button:disabled { opacity: 0.55; cursor: not-allowed; }
.status {
padding: 8px 12px;
font-size: 12px;
border-bottom: 1px solid #eee;
background: #fff;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tableWrap { flex: 1; min-height: 0; overflow: auto; background: #fff; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
thead th {
position: sticky; top: 0;
background: #fafafa;
z-index: 1;
text-align: left;
font-weight: 600;
font-size: 12px;
padding: 10px 8px;
border-bottom: 1px solid #e5e5e5;
cursor: pointer;
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tbody td {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
tbody tr:hover { background: #fcfcfc; }
tbody tr.selected { background: #eef6ff; }
tbody tr.group { background: #f7f7f7; }
tbody tr.group td { font-weight: 600; }
.cell input{
width: 100%;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
font: inherit;
color: inherit;
}
.cell input:focus{
outline: none;
border-color: #9ec5fe;
background: #fff;
}
.muted { opacity: .7; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.hint { opacity: .8; }
/* Column widths */
th.col-mincode, td.col-mincode { width: 110px; }
th.col-minname, td.col-minname { width: 260px; }
th.col-orgcode, td.col-orgcode { width: 110px; }
th.col-nmr, td.col-nmr { width: 160px; }
th.col-prefix, td.col-prefix { width: 120px; }
th.col-orgname, td.col-orgname { width: 420px; }
th.col-actions, td.col-actions { width: 110px; }
td.col-actions { white-space: nowrap; }
/* In-page modal (works in sandbox without allow-modals) */
.modal-backdrop{
position: fixed; inset: 0;
background: rgba(0,0,0,.35);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal{
width: min(560px, calc(100vw - 24px));
background: #fff;
border: 1px solid #ddd;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,.2);
overflow: hidden;
}
.modal header{
border-bottom: 1px solid #eee;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 10px;
}
.modal header strong{ flex: 1; }
.modal header button{
padding: 6px 10px;
border-radius: 8px;
}
.modal .body{ padding: 12px 14px; display: grid; gap: 10px; }
.modal .body label{ font-size: 12px; opacity: .85; display: grid; gap: 6px; }
.modal .body input{
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 8px;
}
.modal .footer{
border-top: 1px solid #eee;
padding: 10px 14px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
.modal .footer button.primary{
border-color: #2b6cb0;
font-weight: 600;
}
</style>
</head>
<body>
<!-- KEEP THIS UPPER ROW -->
<header>
<strong>VDVC Register Viewer</strong>
<span class="pill" id="dirtyPill">clean</span>
<span class="pill" id="connPill">not connected</span>
<div class="meta" id="meta"></div>
</header>
<main>
<!-- Replace old button row + Info with one-column table editor controls -->
<div class="controls">
<input id="search" type="search" placeholder="Search (ministry / organization / codes)..." />
<button id="btnAddMinistry" type="button">Add ministry</button>
<button id="btnAddOrg" type="button">Add organization</button>
<button id="btnDelete" type="button" disabled>Delete selected</button>
<div class="spacer"></div>
<span class="pill" id="xmlStatus">XML: unknown</span>
</div>
<div class="status" id="status">
<span class="hint">Select a ministry (gray row) or an organization row. Edit cells directly. Use the parent “Save” button to commit.</span>
</div>
<div class="tableWrap">
<table id="grid" aria-label="VDVC register">
<thead>
<tr>
<th class="col-mincode" data-sort="ministryCode">Min code</th>
<th class="col-minname" data-sort="ministryName">Ministry name</th>
<th class="col-orgcode" data-sort="orgCode">Org code</th>
<th class="col-nmr mono" data-sort="nmr">NMR</th>
<th class="col-prefix" data-sort="docPrefix">Doc prefix</th>
<th class="col-orgname" data-sort="orgName">Organization name</th>
<th class="col-actions">Action</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<!-- hidden regenerated XML -->
<textarea id="xmlOut" style="display:none"></textarea>
</main>
<!-- In-page modal -->
<div class="modal-backdrop" id="modalBackdrop" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<header>
<strong id="modalTitle">Modal</strong>
<button type="button" id="modalClose"></button>
</header>
<div class="body" id="modalBody"></div>
<div class="footer" id="modalFooter"></div>
</div>
</div>
<script>
const els = {
dirtyPill: document.getElementById('dirtyPill'),
connPill: document.getElementById('connPill'),
meta: document.getElementById('meta'),
status: document.getElementById('status'),
xmlStatus: document.getElementById('xmlStatus'),
search: document.getElementById('search'),
btnAddMinistry: document.getElementById('btnAddMinistry'),
btnAddOrg: document.getElementById('btnAddOrg'),
btnDelete: document.getElementById('btnDelete'),
tbody: document.getElementById('tbody'),
xmlOut: document.getElementById('xmlOut'),
grid: document.getElementById('grid'),
modalBackdrop: document.getElementById('modalBackdrop'),
modalTitle: document.getElementById('modalTitle'),
modalBody: document.getElementById('modalBody'),
modalFooter: document.getElementById('modalFooter'),
modalClose: document.getElementById('modalClose'),
};
let payload = null;
let dirty = false;
// Model:
// ministries: [{ code, name, organizations: [{ code, nmr, docPrefix, name }] }]
let model = { ministries: [], meta: { version: '1.0', created: '', lang: '' } };
// UI state
let selected = { type: null, minIndex: -1, orgIndex: -1 };
let sort = { key: null, dir: 1 }; // dir: 1 asc, -1 desc
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function setDirty(v) {
dirty = !!v;
els.dirtyPill.textContent = dirty ? 'dirty' : 'clean';
parent.postMessage({ type: 'PGV_DIRTY', dirty }, '*');
}
function setConnected(v) {
els.connPill.textContent = v ? 'connected' : 'not connected';
}
function setStatus(msg) {
if (!msg) return;
els.status.textContent = msg;
}
function setXmlStatus(ok, msg) {
els.xmlStatus.textContent = ok ? ('XML OK' + (msg ? ' · ' + msg : '')) : ('XML ERROR' + (msg ? ' · ' + msg : ''));
els.xmlStatus.classList.toggle('bad', !ok);
els.xmlStatus.classList.toggle('warn', !ok);
}
// ---------- Modal (in-page; sandbox-safe) ----------
function openModal({ title, bodyHtml, buttons }) {
els.modalTitle.textContent = title;
els.modalBody.innerHTML = bodyHtml;
els.modalFooter.innerHTML = '';
function close() {
els.modalBackdrop.style.display = 'none';
els.modalBackdrop.setAttribute('aria-hidden', 'true');
}
els.modalClose.onclick = close;
els.modalBackdrop.onclick = (e) => { if (e.target === els.modalBackdrop) close(); };
for (const b of buttons) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = b.label;
if (b.primary) btn.classList.add('primary');
btn.onclick = () => b.onClick({ close });
els.modalFooter.appendChild(btn);
}
els.modalBackdrop.style.display = 'flex';
els.modalBackdrop.setAttribute('aria-hidden', 'false');
const firstInput = els.modalBody.querySelector('input');
if (firstInput) setTimeout(() => firstInput.focus(), 0);
}
// ---------- Fetch via parent proxy (avoids Origin:null CORS) ----------
function pgvFetch(url, timeoutMs = 15000) {
// If not in iframe / no parent proxy, try direct fetch as fallback.
const canUseParent = !!(window.parent && window.parent !== window);
if (!canUseParent) {
return fetch(url, { credentials: 'same-origin' }).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
});
}
return new Promise((resolve, reject) => {
const t = setTimeout(() => {
window.removeEventListener('message', onMsg);
reject(new Error('PGV_FETCH timeout'));
}, timeoutMs);
function onMsg(ev) {
const m = ev.data;
if (!m || typeof m !== 'object') return;
if (m.type !== 'PGV_FETCH_RESULT') return;
if (m.url !== url) return;
clearTimeout(t);
window.removeEventListener('message', onMsg);
if (m.ok) resolve(m.text);
else reject(new Error(m.error || 'fetch failed'));
}
window.addEventListener('message', onMsg);
parent.postMessage({ type: 'PGV_FETCH', url }, '*');
});
}
async function loadXmlText() {
// Prefer explicit target from payload (raw URL)
if (payload?.targets?.xml) {
return await pgvFetch(payload.targets.xml);
}
// fallback: build raw URL if payload has repoLink/branch/path
if (payload?.repoLink && payload?.branch && payload?.path) {
const raw = `${payload.repoLink}/raw/branch/${payload.branch}/${payload.path}`;
return await pgvFetch(raw);
}
throw new Error('No payload.targets.xml and cannot build raw URL.');
}
// ---------- XML parse/build ----------
function parseXml(xmlText) {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, 'application/xml');
const perr = doc.getElementsByTagName('parsererror')[0];
if (perr) throw new Error(perr.textContent || 'XML parse error');
const root = doc.documentElement;
if (!root || root.nodeName !== 'vdvcRegister') throw new Error('Root element must be <vdvcRegister>');
const out = {
ministries: [],
meta: {
version: root.getAttribute('version') || '1.0',
created: root.getAttribute('created') || '',
lang: root.getAttribute('xml:lang') || root.getAttribute('lang') || ''
}
};
const mins = Array.from(root.getElementsByTagName('ministry'));
for (const m of mins) {
const min = {
code: m.getAttribute('code') || '',
name: m.getAttribute('name') || '',
organizations: [],
};
const orgs = Array.from(m.getElementsByTagName('organization'));
for (const o of orgs) {
const nameEl = o.getElementsByTagName('name')[0];
min.organizations.push({
code: o.getAttribute('code') || '',
nmr: o.getAttribute('nmr') || '',
docPrefix: o.getAttribute('docPrefix') || '',
name: (nameEl && nameEl.textContent) ? nameEl.textContent : '',
});
}
out.ministries.push(min);
}
return out;
}
function formatXml(xml) {
const PADDING = ' ';
const reg = /(>)(<)(\/*)/g;
let formatted = '';
let pad = 0;
xml = xml.replace(reg, '$1\n$2$3');
xml.split('\n').forEach((node) => {
if (!node.trim()) return;
let indent = 0;
if (node.match(/^<\//)) pad = Math.max(pad - 1, 0);
else if (node.match(/^<[^!?][^>]*[^\/]>$/)) indent = 1;
formatted += PADDING.repeat(pad) + node.trim() + '\n';
pad += indent;
});
return formatted.trim() + '\n';
}
function buildXmlFromModel() {
const doc = document.implementation.createDocument('', '', null);
const root = doc.createElement('vdvcRegister');
root.setAttribute('xmlns', 'http://vdvc.gov.lv/schema/vdvc-register');
root.setAttribute('version', model.meta.version || '1.0');
if (model.meta.created) root.setAttribute('created', model.meta.created);
if (model.meta.lang) root.setAttribute('xml:lang', model.meta.lang);
doc.appendChild(root);
for (const m of model.ministries) {
const min = doc.createElement('ministry');
if (m.code) min.setAttribute('code', m.code);
min.setAttribute('name', m.name || '');
for (const o of m.organizations) {
const org = doc.createElement('organization');
if (o.code) org.setAttribute('code', o.code);
if (o.nmr) org.setAttribute('nmr', o.nmr);
if (o.docPrefix) org.setAttribute('docPrefix', o.docPrefix);
const nm = doc.createElement('name');
nm.textContent = o.name || '';
org.appendChild(nm);
min.appendChild(org);
}
root.appendChild(min);
}
return formatXml(new XMLSerializer().serializeToString(doc));
}
// ---------- Grid helpers ----------
function applySort() {
if (!sort.key) return;
const key = sort.key;
const dir = sort.dir;
// sort ministries if key is ministry-based
if (key === 'ministryCode' || key === 'ministryName') {
model.ministries.sort((a, b) => {
const av = key === 'ministryCode' ? (a.code || '') : (a.name || '');
const bv = key === 'ministryCode' ? (b.code || '') : (b.name || '');
return String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' }) * dir;
});
}
// sort organizations inside each ministry for org-based keys
if (key === 'orgCode' || key === 'nmr' || key === 'docPrefix' || key === 'orgName') {
model.ministries.forEach((m) => {
m.organizations.sort((a, b) => {
const av =
key === 'orgCode' ? (a.code || '') :
key === 'nmr' ? (a.nmr || '') :
key === 'docPrefix' ? (a.docPrefix || '') :
(a.name || '');
const bv =
key === 'orgCode' ? (b.code || '') :
key === 'nmr' ? (b.nmr || '') :
key === 'docPrefix' ? (b.docPrefix || '') :
(b.name || '');
return String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' }) * dir;
});
});
}
}
function render() {
const q = (els.search.value || '').trim().toLowerCase();
applySort();
els.tbody.innerHTML = '';
model.ministries.forEach((m, mi) => {
// group row
if (!q || `${m.code} ${m.name}`.toLowerCase().includes(q) || m.organizations.some(o => `${o.code} ${o.nmr} ${o.docPrefix} ${o.name}`.toLowerCase().includes(q))) {
const trG = document.createElement('tr');
trG.className = 'group' + (selected.type === 'ministry' && selected.minIndex === mi ? ' selected' : '');
trG.innerHTML = `
<td class="col-mincode"><span class="mono">${escapeHtml(m.code)}</span></td>
<td class="col-minname" colspan="5">${escapeHtml(m.name)} <span class="muted">(ministry)</span></td>
<td class="col-actions"><button type="button">Select</button></td>
`;
trG.addEventListener('click', () => selectMinistry(mi));
els.tbody.appendChild(trG);
// org rows
const orgs = m.organizations;
if (orgs.length === 0) {
const trE = document.createElement('tr');
trE.innerHTML = `
<td class="col-mincode"><span class="mono">${escapeHtml(m.code)}</span></td>
<td class="col-minname">${escapeHtml(m.name)}</td>
<td class="col-orgcode muted" colspan="4">No organizations</td>
<td class="col-actions"><button type="button">Add</button></td>
`;
trE.querySelector('button')?.addEventListener('click', (ev) => {
ev.preventDefault();
selectMinistry(mi);
addOrganization();
});
els.tbody.appendChild(trE);
} else {
orgs.forEach((o, oi) => {
const hay = `${m.code} ${m.name} ${o.code} ${o.nmr} ${o.docPrefix} ${o.name}`.toLowerCase();
if (q && !hay.includes(q)) return;
const tr = document.createElement('tr');
if (selected.type === 'org' && selected.minIndex === mi && selected.orgIndex === oi) tr.classList.add('selected');
tr.innerHTML = `
<td class="col-mincode"><span class="mono">${escapeHtml(m.code)}</span></td>
<td class="col-minname"><div class="cell"><input data-field="ministryName" value="${escapeHtml(m.name)}" /></div></td>
<td class="col-orgcode"><div class="cell"><input class="mono" data-field="orgCode" value="${escapeHtml(o.code)}" /></div></td>
<td class="col-nmr"><div class="cell"><input class="mono" data-field="nmr" value="${escapeHtml(o.nmr)}" /></div></td>
<td class="col-prefix"><div class="cell"><input data-field="docPrefix" value="${escapeHtml(o.docPrefix)}" /></div></td>
<td class="col-orgname"><div class="cell"><input data-field="orgName" value="${escapeHtml(o.name)}" /></div></td>
<td class="col-actions"><button type="button">Select</button></td>
`;
tr.addEventListener('click', () => selectOrg(mi, oi));
tr.querySelectorAll('input').forEach((inp) => {
inp.addEventListener('input', () => {
const field = inp.getAttribute('data-field');
if (!field) return;
if (field === 'ministryName') m.name = inp.value || '';
else if (field === 'orgCode') o.code = inp.value || '';
else if (field === 'nmr') o.nmr = inp.value || '';
else if (field === 'docPrefix') o.docPrefix = inp.value || '';
else if (field === 'orgName') o.name = inp.value || '';
onModelChanged();
});
});
els.tbody.appendChild(tr);
});
}
}
});
}
function selectMinistry(mi) {
selected = { type: 'ministry', minIndex: mi, orgIndex: -1 };
els.btnDelete.disabled = false;
setStatus(`Selected ministry: ${model.ministries[mi]?.code || ''} ${model.ministries[mi]?.name || ''}`);
render();
}
function selectOrg(mi, oi) {
selected = { type: 'org', minIndex: mi, orgIndex: oi };
els.btnDelete.disabled = false;
const m = model.ministries[mi];
const o = m?.organizations?.[oi];
setStatus(`Selected org: ${m?.code || ''}${o?.code || ''} ${o?.name || ''}`);
render();
}
// ---------- Actions (no prompt/confirm) ----------
function addMinistry() {
openModal({
title: 'Add ministry',
bodyHtml: `
<label>Ministry code
<input id="mCode" placeholder="e.g., 01" />
</label>
<label>Ministry name
<input id="mName" placeholder="e.g., Ministry of ..." />
</label>
`,
buttons: [
{ label: 'Cancel', onClick: ({ close }) => close() },
{
label: 'Add',
primary: true,
onClick: ({ close }) => {
const code = (document.getElementById('mCode').value || '').trim();
const name = (document.getElementById('mName').value || '').trim();
if (!code) { setStatus('Ministry code is required.'); return; }
model.ministries.push({ code, name, organizations: [] });
selected = { type: 'ministry', minIndex: model.ministries.length - 1, orgIndex: -1 };
onModelChanged();
render();
close();
}
}
]
});
}
function addOrganization() {
const mi = selected.type ? selected.minIndex : -1;
if (mi < 0 || mi >= model.ministries.length) {
setStatus('Select a ministry first (click a gray ministry row), then click “Add organization”.');
return;
}
openModal({
title: 'Add organization',
bodyHtml: `
<label>Organization code
<input id="oCode" placeholder="e.g., 0001" />
</label>
<label>NMR
<input id="oNmr" placeholder="e.g., 90000038578" />
</label>
<label>Doc prefix
<input id="oPrefix" placeholder="e.g., 01-0001" />
</label>
<label>Organization name
<input id="oName" placeholder="Organization name" />
</label>
`,
buttons: [
{ label: 'Cancel', onClick: ({ close }) => close() },
{
label: 'Add',
primary: true,
onClick: ({ close }) => {
const o = {
code: (document.getElementById('oCode').value || '').trim(),
nmr: (document.getElementById('oNmr').value || '').trim(),
docPrefix: (document.getElementById('oPrefix').value || '').trim(),
name: (document.getElementById('oName').value || '').trim(),
};
model.ministries[mi].organizations.push(o);
selected = { type: 'org', minIndex: mi, orgIndex: model.ministries[mi].organizations.length - 1 };
onModelChanged();
render();
close();
}
}
]
});
}
function deleteSelected() {
if (!selected.type) return;
const msg = selected.type === 'ministry'
? 'Delete the selected ministry and all its organizations?'
: 'Delete the selected organization?';
openModal({
title: 'Confirm delete',
bodyHtml: `<div>${escapeHtml(msg)}</div>`,
buttons: [
{ label: 'Cancel', onClick: ({ close }) => close() },
{
label: 'Delete',
primary: true,
onClick: ({ close }) => {
const mi = selected.minIndex;
if (mi < 0 || mi >= model.ministries.length) return;
if (selected.type === 'ministry') {
model.ministries.splice(mi, 1);
selected = { type: null, minIndex: -1, orgIndex: -1 };
} else {
const oi = selected.orgIndex;
const orgs = model.ministries[mi].organizations;
if (oi >= 0 && oi < orgs.length) orgs.splice(oi, 1);
selected = { type: 'ministry', minIndex: mi, orgIndex: -1 };
}
onModelChanged();
render();
close();
}
}
]
});
}
function onModelChanged() {
try {
const xml = buildXmlFromModel();
els.xmlOut.value = xml;
setXmlStatus(true);
setDirty(true);
// Let host know the primary file content changed (host should wire this into Save)
parent.postMessage({ type: 'PGV_SET_CONTENT', path: payload?.path, content: xml }, '*');
} catch (e) {
setXmlStatus(false, String(e?.message || e));
setDirty(true);
}
}
function bindEvents() {
els.search.addEventListener('input', () => render());
els.btnAddMinistry.addEventListener('click', addMinistry);
els.btnAddOrg.addEventListener('click', addOrganization);
els.btnDelete.addEventListener('click', deleteSelected);
// Click header to sort
els.grid.querySelectorAll('thead th[data-sort]').forEach((th) => {
th.addEventListener('click', () => {
const key = th.getAttribute('data-sort');
if (!key) return;
if (sort.key === key) sort.dir = sort.dir * -1;
else { sort.key = key; sort.dir = 1; }
render();
});
});
}
async function initWithPayload(p) {
payload = p || null;
setConnected(true);
// keep the existing look: "main vdvc-register.xml"
const br = payload?.branch || '';
const path = payload?.path || '';
els.meta.textContent = `${br} ${path}`.trim();
bindEvents();
try {
setStatus('Loading XML...');
const xmlText = await loadXmlText();
model = parseXml(xmlText);
setXmlStatus(true);
setDirty(false);
els.xmlOut.value = buildXmlFromModel();
setStatus('Loaded. Select a ministry and start editing.');
render();
} catch (e) {
setXmlStatus(false, String(e?.message || e));
setStatus('Failed to load XML. (Host must provide PGV_FETCH proxy.)');
console.error(e);
}
}
// ---- Message handling ----
window.addEventListener('message', (ev) => {
const msg = ev.data;
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'PGV_INIT') {
initWithPayload(msg.payload || null);
}
if (msg.type === 'PGV_COMMIT_OK') {
setDirty(false);
setStatus('Saved (commit) OK.');
}
if (msg.type === 'PGV_COMMIT_ERR') {
setStatus('Save (commit) failed.');
}
});
// Boot handshake
parent.postMessage({ type: 'PGV_READY' }, '*');
</script>
</body>
</html>