The five AI_ASSIST_ switches and the findings they produce were reachable only from the AI tab, which is a long way from the page a finding is about. Nothing was synchronised because nothing needed to be: findings are one file per finding, and every surface is a view over that store with actions going to the same endpoint. Acting on the Watchdog tab shows on the AI tab because they are not two copies. The strip reloads rather than editing its own row, which is the only way they could have drifted apart. Each strip shows one page's kinds. Actions are whatever the server offers for that row, so Move appears on media findings without this card knowing what a move is.
553 lines
26 KiB
PHP
553 lines
26 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Draws a parsed conf field as the control it deserves, and reads the edits back out. One
|
|
// implementation for every page that edits settings, so a switch looks and behaves the same
|
|
// wherever it is met.
|
|
//
|
|
// WHY THIS EXISTS
|
|
// Ten pages edited settings and nine had written their own vocabulary to do it — nine sets of
|
|
// inline CSS, six different toggle switches, and two pages that had both claimed the vv-set-*
|
|
// prefix with disjoint class names, so it read as shared and was not. Unraid swaps tabs by AJAX
|
|
// without unloading, so two of those stylesheets can be live in one document at once.
|
|
//
|
|
// Each of them started for the same reason: the shared set could render a conf field as a text
|
|
// box and nothing else, and every page eventually needed one control it did not have. The fix
|
|
// is not a convention that asks people not to do that again — it is having the control here.
|
|
//
|
|
// WHAT DECIDES A CONTROL
|
|
// Not this file. confform.php infers a widget from the conf itself — the value's shape and the
|
|
// comments already written above it — and this renders whatever it was told. That split is
|
|
// deliberate: the schema lives in the conf, the appearance lives here, and neither can quietly
|
|
// start deciding the other.
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// The control is a view of the value, never a second copy of it.
|
|
// Every control carries data-key, data-file, data-type and data-orig, and the payload is
|
|
// rebuilt by reading the DOM at save time. There is no parallel model to fall out of step
|
|
// with what is on screen.
|
|
//
|
|
// Only what changed is sent.
|
|
// A field whose value still equals data-orig is omitted. A settings page that submits all
|
|
// four hundred fields rewrites four hundred lines to change one, and every one of those
|
|
// rewrites is a chance to lose a comment.
|
|
//
|
|
// A value is returned exactly as it was typed.
|
|
// No trimming beyond the edges, no case folding, no normalising true to 1. The conf is
|
|
// shell source and the consuming script decides what it means.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Everything is escaped on the way in.
|
|
// Values come from conf files that hold paths, globs and quotes. Rendering goes through
|
|
// one escape helper and attributes through another — vvEscHtml does not escape quotes, so
|
|
// an attribute needs vvEscAttr or a path containing one closes it and the rest executes.
|
|
//
|
|
// Secrets render masked and are never pre-filled from a stale read.
|
|
// A masked field left untouched sends nothing, so a save cannot round-trip a credential
|
|
// back through the browser.
|
|
//
|
|
// Nothing here writes. The payload goes to api/confform.php, which owns the allowlist, and
|
|
// from there to vv_conf_edit(), which owns the lock, the backup and the rollback.
|
|
//
|
|
// EXPORTS
|
|
// vv_conf_ui_assets() the renderer, emitted once per page
|
|
//
|
|
// DEPENDS ON
|
|
// include/confform.php the widget each field was inferred to want
|
|
// api/confform.php ?sections= to read, POST changes to write
|
|
// css/varaverk.css the vv-cf-* family these classes belong to
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
// Required, not assumed. This file emits the renderer for fields that confform.php parses, and
|
|
// the pages that include it also read its registries — VV_UI_SECTION_SURFACES among them. Left to
|
|
// whichever page happened to have pulled it in first, that works everywhere it was tested and
|
|
// fatals on the one page that included this without it, which is what took the AI tab blank.
|
|
require_once __DIR__ . '/confform.php';
|
|
|
|
// ── A findings strip for one subject page ─────────────────────────────────────────────────────
|
|
// The AI tab owns the full findings card — every kind, every action, the repair banner. This is
|
|
// the same store seen through a keyhole: one page's kinds, on the page that page is about.
|
|
//
|
|
// There is no synchronisation to build and none was built. Findings are one file per finding under
|
|
// AI_DATA_DIR; every surface is a view over that store and every action goes to the same endpoint,
|
|
// so acting on the Watchdog tab is visible on the AI tab because they are not two copies. The only
|
|
// thing that could desynchronise them is caching the list, which is why this does not.
|
|
//
|
|
// Gated on vv_ai_ui_on() like every other AI surface: on a node without the model there is nothing
|
|
// producing findings, and a permanently empty card is a worse answer than no card.
|
|
function vv_ai_findings_strip(string $prefix, array $kinds, string $title): void {
|
|
if (!vv_ai_ui_on()) return;
|
|
$k = json_encode(array_values($kinds), JSON_UNESCAPED_SLASHES);
|
|
?>
|
|
<div class="vv-card" id="<?= htmlspecialchars($prefix) ?>-card" style="margin-top:12px;display:none;">
|
|
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px;">
|
|
<span style="font-size:11px;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.07em;">
|
|
<?= htmlspecialchars($title) ?></span>
|
|
<span id="<?= htmlspecialchars($prefix) ?>-n" style="font-size:10px;color:#3a3a3a;"></span>
|
|
<a href="/Varaverk?tab=ai" style="margin-left:auto;font-size:10px;color:#4a6a8a;text-decoration:none;">
|
|
full list on the AI tab →</a>
|
|
</div>
|
|
<div id="<?= htmlspecialchars($prefix) ?>-rows"></div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
const PFX = <?= json_encode($prefix) ?>, KINDS = <?= $k ?>;
|
|
const card = document.getElementById(PFX + '-card');
|
|
const rows = document.getElementById(PFX + '-rows');
|
|
if (!card || !rows) return;
|
|
|
|
function load() {
|
|
fetch('/plugins/varaverk/api/ai.php?action=findings')
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
const all = (d && d.findings) || [];
|
|
const mine = all.filter(f => KINDS.includes(f.kind));
|
|
if (!mine.length) { card.style.display = 'none'; return; }
|
|
card.style.display = '';
|
|
document.getElementById(PFX + '-n').textContent = mine.length;
|
|
rows.innerHTML = mine.map(f => {
|
|
const sev = f.severity || f.sys_level || '';
|
|
const col = sev === 'error' ? '#ef5350' : sev === 'warn' ? '#ffb74d' : '#666';
|
|
// Actions are the server's answer, not this card's guess — the same list the AI tab
|
|
// renders. Pressing here and pressing there are the same call on the same record.
|
|
const acts = Object.keys(f.actions || {}).filter(a => a !== 'cancel').map(a =>
|
|
`<button class="vv-btn-sm" data-fid="${vvEscAttr(f.id)}" data-act="${vvEscAttr(a)}"
|
|
title="${vvEscAttr(f.actions[a])}">${vvEscHtml(a)}</button>`).join(' ');
|
|
return `<div style="border-left:2px solid ${col};padding:5px 0 5px 8px;margin-bottom:6px;">
|
|
<div style="font-size:11px;color:#bbb;">${vvEscHtml(f.subject || '?')}
|
|
<span style="color:#3a3a3a;font-size:9px;margin-left:5px;">${vvEscHtml(f.observed || '')}</span></div>
|
|
<div style="font-size:10px;color:#555;line-height:1.5;margin:2px 0 4px;">${vvEscHtml(f.evidence || '')}</div>
|
|
<div style="display:flex;gap:4px;">${acts}</div>
|
|
</div>`;
|
|
}).join('');
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// Delegated, and it reloads rather than mutating the row: the store is the truth and a card that
|
|
// edited its own copy would be the one place these could disagree.
|
|
rows.addEventListener('click', ev => {
|
|
const b = ev.target.closest('[data-fid]');
|
|
if (!b) return;
|
|
b.disabled = true;
|
|
fetch('/plugins/varaverk/api/ai.php', { method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
|
body: new URLSearchParams({ action: 'finding_action', id: b.dataset.fid, act: b.dataset.act }) })
|
|
.then(r => r.json()).then(() => load()).catch(() => { b.disabled = false; });
|
|
});
|
|
|
|
load();
|
|
setInterval(load, 60000);
|
|
})();
|
|
</script>
|
|
<?php
|
|
}
|
|
|
|
// A whole settings card: disclosure, filter, save, and the fields for one subject. Emitted by a
|
|
// page in one line rather than assembled there.
|
|
//
|
|
// This exists because the alternative was six copies. The AI tab, the Scheduler and the Settings
|
|
// page each grew their own load/filter/dirty/save cycle, and the third one was written by copying
|
|
// the second — which is exactly how this plugin ended up with nine settings vocabularies and six
|
|
// toggles. Everything subtle here is subtle because it already cost something once: the filter
|
|
// hides rather than removes so a pending edit survives it, the save rebases instead of refetching
|
|
// so the field under the cursor does not repaint, and a refused write leaves the row dirty
|
|
// because it genuinely did not happen.
|
|
//
|
|
// $prefix composes every id, so a page may hold more than one card
|
|
// $match which sections — a whole word matched against section headers, or "*" for all
|
|
// $title the card's heading
|
|
function vv_conf_ui_card(string $prefix, string $match, string $title = 'Settings'): void {
|
|
vv_conf_ui_assets();
|
|
$p = htmlspecialchars($prefix, ENT_QUOTES);
|
|
?>
|
|
<div class="vv-cf-card" id="<?= $p ?>-card">
|
|
<div class="vv-cf-card-h" id="<?= $p ?>-t">
|
|
<span class="vv-cf-card-c">▶</span>
|
|
<span class="vv-cf-card-l"><?= htmlspecialchars($title) ?></span>
|
|
<input type="text" class="vv-cf-card-f" id="<?= $p ?>-filter"
|
|
placeholder="filter…" style="display:none">
|
|
<span class="vv-cf-card-s" id="<?= $p ?>-sum"></span>
|
|
<button class="vv-cf-card-save" id="<?= $p ?>-save" type="button"
|
|
style="display:none" disabled>Save</button>
|
|
</div>
|
|
<div id="<?= $p ?>-body" style="display:none">
|
|
<div id="<?= $p ?>-fields"><p class="vv-cf-empty">opens when you do</p></div>
|
|
</div>
|
|
</div>
|
|
<script>VvConfCard(<?= json_encode($prefix) ?>, <?= json_encode($match) ?>);</script>
|
|
<?php
|
|
}
|
|
|
|
function vv_conf_ui_assets(): void {
|
|
static $done = false;
|
|
if ($done) return;
|
|
$done = true;
|
|
?>
|
|
<script>
|
|
(function () {
|
|
if (window.VvConfUI) return;
|
|
|
|
// The page's own escape helpers where they exist. They are global in Varaverk.page for exactly
|
|
// this reason, and a second private copy here is a second thing to fix when one of them is
|
|
// found to be wrong.
|
|
const esc = s => (window.vvEscHtml ? vvEscHtml(s) : String(s == null ? '' : s)
|
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'));
|
|
const escA = s => (window.vvEscAttr ? vvEscAttr(s) : String(s == null ? '' : s)
|
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
.replace(/"/g,'"').replace(/'/g,'''));
|
|
|
|
// Shared by every control. data-orig is what "changed" is measured against, and it is the
|
|
// parsed value rather than anything the page derived — so a control that renders a value
|
|
// differently than it stores it still compares against the truth.
|
|
function attrs(f) {
|
|
return ` data-key="${escA(f.key)}" data-file="${escA(f.file)}"`
|
|
+ ` data-type="${escA(f.type || 'scalar')}" data-orig="${escA(f.value == null ? '' : f.value)}"`;
|
|
}
|
|
|
|
function boolCtl(f) {
|
|
const on = String(f.value).toLowerCase() === 'true';
|
|
return `<div class="vv-cf-ctl">
|
|
<label class="vv-cf-toggle">
|
|
<span class="vv-cf-track${on ? ' on' : ''}"></span>
|
|
<input type="checkbox" hidden${attrs(f)} data-ctl="bool"${on ? ' checked' : ''}>
|
|
<span class="vv-cf-state">${on ? 'true' : 'false'}</span>
|
|
</label>
|
|
</div>`;
|
|
}
|
|
|
|
function intCtl(f) {
|
|
const min = (f.min !== undefined && f.min !== null) ? ` min="${escA(f.min)}"` : '';
|
|
const max = (f.max !== undefined && f.max !== null) ? ` max="${escA(f.max)}"` : '';
|
|
const unit = f.unit ? `<span class="vv-cf-unit">${esc(f.unit)}</span>` : '';
|
|
return `<div class="vv-cf-ctl">
|
|
<input type="number" class="vv-cf-scalar vv-cf-int"${min}${max}
|
|
value="${escA(f.value)}"${attrs(f)} data-ctl="int">${unit}
|
|
</div>`;
|
|
}
|
|
|
|
function enumCtl(f) {
|
|
const cur = String(f.value);
|
|
const opts = (f.choices || []).map(c =>
|
|
`<option value="${escA(c.value)}"${c.value === cur ? ' selected' : ''}>${esc(c.value)}</option>`
|
|
).join('');
|
|
// The hint belongs to the selected choice, so it is rebuilt on change rather than listing
|
|
// every option's explanation at once — which on a five-way choice is a paragraph.
|
|
const hints = {};
|
|
(f.choices || []).forEach(c => { if (c.hint) hints[c.value] = c.hint; });
|
|
const curHint = hints[cur] || '';
|
|
return `<div class="vv-cf-ctl">
|
|
<select class="vv-cf-sel"${attrs(f)} data-ctl="enum"
|
|
data-hints="${escA(JSON.stringify(hints))}">${opts}</select>
|
|
</div><div class="vv-cf-opthint">${esc(curHint)}</div>`;
|
|
}
|
|
|
|
function secretCtl(f) {
|
|
// Rendered from the real value so the field is genuinely editable, but typed as a password so
|
|
// it is not read over a shoulder or captured in a screenshot of the tab.
|
|
return `<div class="vv-cf-ctl">
|
|
<input type="password" class="vv-cf-scalar vv-cf-secret" autocomplete="new-password"
|
|
value="${escA(f.value)}"${attrs(f)} data-ctl="text">
|
|
<button type="button" class="vv-cf-reveal" data-reveal>Show</button>
|
|
</div>`;
|
|
}
|
|
|
|
// The gutter is seeded here and then kept in step by the input/scroll handlers in wire().
|
|
// It cannot be produced once and left: the numbers follow the textarea's value, which is the
|
|
// thing the operator is about to change.
|
|
function linesCtl(f) {
|
|
const v = String(f.value == null ? '' : f.value);
|
|
const n = v.split('\n').length;
|
|
return `<div class="vv-cf-arraywrap">
|
|
<pre class="vv-cf-lines" aria-hidden="true">${gutterFor(v)}</pre>
|
|
<textarea class="vv-cf-array" rows="${Math.min(Math.max(n, 3), 20)}"${attrs(f)}
|
|
data-ctl="text" spellcheck="false">${esc(v)}</textarea>
|
|
</div>`;
|
|
}
|
|
|
|
function gutterFor(v) {
|
|
const n = String(v).split('\n').length;
|
|
let s = '';
|
|
for (let i = 1; i <= n; i++) s += i + '\n';
|
|
return s;
|
|
}
|
|
|
|
// Kept together, because they are two halves of one illusion: the gutter is a separate element
|
|
// that has to be renumbered when the text changes and scrolled when the text scrolls, or the
|
|
// numbers drift away from the lines they belong to.
|
|
function syncGutter(ta) {
|
|
const g = ta.parentElement && ta.parentElement.querySelector('.vv-cf-lines');
|
|
if (!g) return;
|
|
g.textContent = gutterFor(ta.value);
|
|
g.scrollTop = ta.scrollTop;
|
|
}
|
|
function scrollGutter(ta) {
|
|
const g = ta.parentElement && ta.parentElement.querySelector('.vv-cf-lines');
|
|
if (g) g.scrollTop = ta.scrollTop;
|
|
}
|
|
|
|
function textCtl(f) {
|
|
return `<div class="vv-cf-ctl">
|
|
<input type="text" class="vv-cf-scalar" value="${escA(f.value)}"${attrs(f)}
|
|
data-ctl="text" spellcheck="false">
|
|
</div>`;
|
|
}
|
|
|
|
const CONTROLS = { bool: boolCtl, int: intCtl, enum: enumCtl, secret: secretCtl,
|
|
lines: linesCtl, path: textCtl, text: textCtl };
|
|
|
|
// n is the running number across the whole panel, not the position within a group. It gives
|
|
// every setting one unambiguous handle — "number 12" beats "the third one under Docker
|
|
// Watchdog" when someone is reading it back to you over the phone. The stripe is driven by the
|
|
// same counter rather than :nth-child, because the group header is also a child and would
|
|
// throw the parity off inside every section.
|
|
function fieldHtml(f, n) {
|
|
const draw = CONTROLS[f.widget] || textCtl;
|
|
// The description is the conf's own comment. It is the only documentation most of these
|
|
// settings have, and it is why the form is readable at all.
|
|
const desc = f.desc ? `<div class="vv-cf-desc">${esc(f.desc)}</div>` : '';
|
|
return `<div class="vv-cf-field${n % 2 === 0 ? ' vv-cf-alt' : ''}" data-field="${escA(f.key)}">
|
|
<span class="vv-cf-num">${n}</span>
|
|
<span class="vv-cf-dot"></span>
|
|
<div class="vv-cf-body">
|
|
<div class="vv-cf-key">${esc(f.key)}</div>
|
|
${desc}
|
|
${draw(f)}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Takes the counter by reference so numbering continues across groups. Returning it would have
|
|
// worked too, but every caller would then be responsible for threading it correctly and one of
|
|
// them eventually would not.
|
|
function groupHtml(g, counter) {
|
|
const fields = (g.fields || []).map(f => fieldHtml(f, ++counter.n)).join('');
|
|
if (!fields) return '';
|
|
return `<div class="vv-cf-group">
|
|
<div class="vv-cf-group-header">${esc(g.subsection || g.name || '')}
|
|
<span class="vv-cf-file">${esc(g.file || '')}</span>
|
|
</div>
|
|
${fields}
|
|
</div>`;
|
|
}
|
|
|
|
// Reading a control back. Kept beside the drawing so a new control cannot be added without a
|
|
// matching way to read it — the two halves drifting is how a field silently stops saving.
|
|
function readCtl(el) {
|
|
if (el.dataset.ctl === 'bool') return el.checked ? 'true' : 'false';
|
|
return el.value;
|
|
}
|
|
|
|
window.VvConfUI = {
|
|
// The markup on its own, for a caller composing it into something larger — the Scheduler's
|
|
// advanced view wraps it in a Config block alongside the header and README sections. Whoever
|
|
// uses this owes the DOM a hydrate() afterwards.
|
|
html(groups) {
|
|
const counter = { n: 0 };
|
|
return (groups || []).map(g => groupHtml(g, counter)).join('');
|
|
},
|
|
|
|
// Everything that can only be done once the markup is in the document. Separate from html()
|
|
// because a caller that built a bigger string still needs this half, and separate from wire()
|
|
// because wire() is idempotent per container while this must run after every re-render.
|
|
hydrate(into) {
|
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
|
if (!box) return;
|
|
// Seeded from the rendered DOM rather than at build time, so a textarea the browser sized
|
|
// differently than expected still gets a gutter matching what is on screen.
|
|
box.querySelectorAll('.vv-cf-array').forEach(syncGutter);
|
|
return box;
|
|
},
|
|
|
|
render(into, groups) {
|
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
|
if (!box) return;
|
|
const html = this.html(groups);
|
|
box.innerHTML = html || '<p class="vv-cf-empty">Nothing configurable here.</p>';
|
|
this.hydrate(box);
|
|
return box;
|
|
},
|
|
|
|
// Only what actually differs from what was parsed. See the note on partial saves above.
|
|
collect(into) {
|
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
|
if (!box) return [];
|
|
const out = [];
|
|
box.querySelectorAll('[data-ctl]').forEach(el => {
|
|
const now = readCtl(el);
|
|
if (now === el.dataset.orig) return;
|
|
out.push({ file: el.dataset.file, key: el.dataset.key,
|
|
type: el.dataset.type || 'scalar', value: now });
|
|
});
|
|
return out;
|
|
},
|
|
|
|
dirtyCount(into) { return this.collect(into).length; },
|
|
|
|
// Called after a successful save so the next collect() measures against what is now on disk,
|
|
// without a refetch. A reload would also work and would repaint every control the operator
|
|
// is looking at, including the one they just changed.
|
|
commit(into) {
|
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
|
if (!box) return;
|
|
box.querySelectorAll('[data-ctl]').forEach(el => { el.dataset.orig = readCtl(el); });
|
|
box.querySelectorAll('.vv-cf-field.dirty').forEach(f => f.classList.remove('dirty'));
|
|
},
|
|
|
|
// One delegated listener for a whole container, however many fields it holds.
|
|
wire(into, onChange) {
|
|
const box = typeof into === 'string' ? document.getElementById(into) : into;
|
|
if (!box || box.dataset.vvWired === '1') return;
|
|
box.dataset.vvWired = '1';
|
|
|
|
const refresh = el => {
|
|
const field = el.closest('.vv-cf-field');
|
|
if (field) field.classList.toggle('dirty', readCtl(el) !== el.dataset.orig);
|
|
if (onChange) onChange();
|
|
};
|
|
|
|
box.addEventListener('input', e => {
|
|
if (!e.target.dataset.ctl) return;
|
|
if (e.target.classList.contains('vv-cf-array')) syncGutter(e.target);
|
|
refresh(e.target);
|
|
});
|
|
// Capture: scroll does not bubble, so a listener on the container never sees a textarea
|
|
// scrolling inside it any other way.
|
|
box.addEventListener('scroll', e => {
|
|
if (e.target.classList && e.target.classList.contains('vv-cf-array')) scrollGutter(e.target);
|
|
}, true);
|
|
box.addEventListener('change', e => {
|
|
const el = e.target;
|
|
if (!el.dataset.ctl) return;
|
|
if (el.dataset.ctl === 'bool') {
|
|
// The switch and the word beside it are both views of the checkbox, which is the only
|
|
// thing actually holding the state.
|
|
const wrap = el.closest('.vv-cf-toggle');
|
|
if (wrap) {
|
|
const track = wrap.querySelector('.vv-cf-track');
|
|
const state = wrap.querySelector('.vv-cf-state');
|
|
if (track) track.classList.toggle('on', el.checked);
|
|
if (state) state.textContent = el.checked ? 'true' : 'false';
|
|
}
|
|
}
|
|
if (el.dataset.ctl === 'enum') {
|
|
const hint = el.closest('.vv-cf-body')?.querySelector('.vv-cf-opthint');
|
|
if (hint) {
|
|
let hints = {};
|
|
try { hints = JSON.parse(el.dataset.hints || '{}'); } catch (_) {}
|
|
hint.textContent = hints[el.value] || '';
|
|
}
|
|
}
|
|
refresh(el);
|
|
});
|
|
|
|
box.addEventListener('click', e => {
|
|
const b = e.target.closest('[data-reveal]');
|
|
if (!b) return;
|
|
const inp = b.closest('.vv-cf-ctl')?.querySelector('input');
|
|
if (!inp) return;
|
|
const shown = inp.type === 'text';
|
|
inp.type = shown ? 'password' : 'text';
|
|
b.textContent = shown ? 'Show' : 'Hide';
|
|
});
|
|
},
|
|
};
|
|
|
|
// One card, self-contained. Everything a page used to write for itself.
|
|
window.VvConfCard = function (prefix, match) {
|
|
const $ = s => document.getElementById(prefix + '-' + s);
|
|
const API = '/plugins/varaverk/api/confform.php';
|
|
let loaded = false;
|
|
|
|
const dirty = () => {
|
|
const n = VvConfUI.dirtyCount(prefix + '-fields');
|
|
$('save').disabled = (n === 0);
|
|
$('sum').textContent = n ? (n + ' unsaved') : '';
|
|
$('sum').className = 'vv-cf-card-s' + (n ? ' warn' : '');
|
|
};
|
|
|
|
function load() {
|
|
if (loaded) return;
|
|
loaded = true;
|
|
fetch(API + '?sections=' + encodeURIComponent(match))
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (!d.ok) throw new Error(d.error || 'could not be read');
|
|
VvConfUI.render(prefix + '-fields', d.groups || []);
|
|
VvConfUI.wire(prefix + '-fields', dirty);
|
|
dirty();
|
|
})
|
|
.catch(e => {
|
|
// Retryable — closing and reopening tries again rather than leaving a permanent error.
|
|
loaded = false;
|
|
$('fields').innerHTML = '<p class="vv-cf-empty">could not be read: '
|
|
+ String(e.message || e) + '</p>';
|
|
});
|
|
}
|
|
|
|
$('t').addEventListener('click', e => {
|
|
// The filter and the save live in the header; clicking either must not collapse the card.
|
|
if (e.target.closest('#' + prefix + '-filter, #' + prefix + '-save')) return;
|
|
const open = $('body').style.display === 'none';
|
|
$('body').style.display = open ? '' : 'none';
|
|
$('t').classList.toggle('open', open);
|
|
$('filter').style.display = open ? '' : 'none';
|
|
$('save').style.display = open ? '' : 'none';
|
|
// First open only, so collapsing to look at something else does not discard edits.
|
|
if (open) load();
|
|
});
|
|
|
|
// Hides whole sections, never individual fields — a setting means little without the heading
|
|
// saying what it belongs to. display:none rather than removal, so a field edited before
|
|
// filtering is still dirty and still saved: a filter that silently dropped pending edits
|
|
// would be a data-loss bug wearing a search box.
|
|
$('filter').addEventListener('input', () => {
|
|
const q = $('filter').value.trim().toLowerCase();
|
|
$('fields').querySelectorAll('.vv-cf-group').forEach(g => {
|
|
g.classList.toggle('vv-filtered', q !== '' && !g.textContent.toLowerCase().includes(q));
|
|
});
|
|
});
|
|
|
|
$('save').addEventListener('click', () => {
|
|
const changes = VvConfUI.collect(prefix + '-fields');
|
|
if (!changes.length) return;
|
|
$('save').disabled = true;
|
|
$('sum').textContent = 'saving…';
|
|
$('sum').className = 'vv-cf-card-s';
|
|
|
|
// URLSearchParams, not FormData — a multipart POST to this plugin's endpoints hangs with
|
|
// no status ever returned.
|
|
fetch(API, { method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
|
body: new URLSearchParams({ changes: JSON.stringify(changes) }) })
|
|
.then(r => r.text())
|
|
.then(t => {
|
|
let d;
|
|
try { d = JSON.parse(t); }
|
|
catch (_) {
|
|
throw new Error(t.trim() ? 'unparseable response'
|
|
: 'empty response — rejected before the endpoint ran');
|
|
}
|
|
if (!d.ok) throw new Error(d.error || 'save failed');
|
|
// Rebased, not refetched: the values on screen are the values on disk now, and a
|
|
// reload would repaint the control under the cursor.
|
|
VvConfUI.commit(prefix + '-fields');
|
|
dirty();
|
|
$('sum').textContent = 'saved ' + changes.length
|
|
+ ' change' + (changes.length > 1 ? 's' : '');
|
|
$('sum').className = 'vv-cf-card-s ok';
|
|
})
|
|
.catch(e => {
|
|
// Left dirty on purpose. A refused write changed nothing, and clearing the marks
|
|
// would say it had.
|
|
$('sum').textContent = String(e.message || e);
|
|
$('sum').className = 'vv-cf-card-s bad';
|
|
dirty();
|
|
});
|
|
});
|
|
};
|
|
})();
|
|
</script>
|
|
<?php
|
|
}
|