mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-11 15:07:32 +00:00
Realtime message times were 9.3px (0.58rem), Realtime meta chips and phone Mesh stat labels 9.9px, Mesh, Plugins and Contacts badges about 10.5px, and the Contacts path count compounded to 10.5px as a <small> inside a badge. All now sit at the 0.7rem floor DESIGN.md sets, and the path count inherits the badge size in a lighter weight. Phone layouts still fit at 390px. Monospace was five different stacks, three of them ending in 'Courier New'. base.html now defines --font-mono (the ui-monospace stack from DESIGN.md) and points Bootstrap's --bs-font-monospace at it, and logs, realtime, mesh, contacts, greeter, config and the legacy .log-entry all read it.
1317 lines
57 KiB
HTML
1317 lines
57 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Plugins - MeshCore Bot{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.plugins-toolbar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 5;
|
|
background-color: var(--bg-color, #fff);
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
[data-theme="dark"] .plugins-toolbar { background-color: var(--bg-color, #1a1a1a); }
|
|
|
|
.plugin-card {
|
|
border: 1px solid var(--border-color);
|
|
background-color: var(--card-bg);
|
|
border-left: 3px solid #0d6efd;
|
|
}
|
|
[data-theme="dark"] .plugin-card { border-left-color: #6ea8fe; }
|
|
.plugin-card.disabled { border-left-color: var(--border-color); }
|
|
.plugin-card.disabled .plugin-title { color: var(--text-muted, #6c757d); }
|
|
|
|
/* Compact, clickable header */
|
|
.plugin-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
cursor: pointer;
|
|
padding: 0.5rem 0.85rem;
|
|
user-select: none;
|
|
}
|
|
.plugin-header .form-switch { cursor: pointer; }
|
|
.plugin-title { font-weight: 600; }
|
|
.plugin-headtext { /* button reset */
|
|
min-width: 0; /* allow truncation */
|
|
background: none;
|
|
border: 0;
|
|
padding: 0;
|
|
color: inherit;
|
|
font: inherit;
|
|
text-align: start;
|
|
cursor: pointer;
|
|
}
|
|
.plugin-headtext:focus-visible {
|
|
outline: 2px solid var(--bs-primary, #0d6efd);
|
|
outline-offset: 3px;
|
|
border-radius: 2px;
|
|
}
|
|
.plugin-desc {
|
|
display: block;
|
|
color: var(--text-muted, #6c757d);
|
|
font-size: 0.8rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.plugin-section-label {
|
|
font-family: var(--bs-font-monospace, monospace);
|
|
font-size: 0.72rem;
|
|
color: var(--text-muted, #6c757d);
|
|
margin-left: 0.4rem;
|
|
}
|
|
.plugin-chevron { color: var(--text-muted, #6c757d); transition: transform 0.15s ease; }
|
|
.plugin-card.expanded .plugin-chevron { transform: rotate(180deg); }
|
|
|
|
.plugin-save-status { margin-left: 0.5rem; font-size: 0.85rem; }
|
|
.plugin-group-heading { margin: 1.25rem 0 0.6rem; }
|
|
.form-text { font-size: 0.76rem; margin-top: 0.1rem; }
|
|
.plugin-card .card-body { padding: 0.85rem; }
|
|
.plugin-card .form-label { margin-bottom: 0.2rem; font-size: 0.85rem; }
|
|
.restart-note { font-size: 0.78rem; color: #b8860b; }
|
|
[data-theme="dark"] .restart-note { color: #e0b84d; }
|
|
.plugin-count-badge { font-size: 0.7rem; }
|
|
.dynamic-section { border-top: 1px solid var(--border-color); padding-top: 0.75rem; }
|
|
.dynamic-row .dyn-key { font-family: var(--bs-font-monospace, monospace); }
|
|
.repeating-section { border-top: 1px solid var(--border-color); padding-top: 0.75rem; }
|
|
.rb-block { border: 1px solid var(--border-color); background-color: var(--bg-secondary, #f8f9fa); }
|
|
[data-theme="dark"] .rb-block { background-color: rgba(255,255,255,0.03); }
|
|
.rb-block .card-header { background-color: transparent; }
|
|
.field-group > summary {
|
|
cursor: pointer;
|
|
padding: 0.35rem 0;
|
|
border-top: 1px dashed var(--border-color);
|
|
list-style-position: inside;
|
|
}
|
|
.field-group > summary:hover { color: var(--text-color); }
|
|
.section-badge {
|
|
font-family: var(--bs-font-monospace, monospace);
|
|
font-weight: 500;
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted, #6c757d);
|
|
background-color: var(--bg-secondary, #f1f3f5);
|
|
border: 1px solid var(--border-color);
|
|
vertical-align: middle;
|
|
}
|
|
|
|
/* Template reference panel, opened by the { } button on template fields */
|
|
.template-ref-btn { font-family: var(--bs-font-monospace, monospace); font-weight: 600; }
|
|
.template-ref {
|
|
margin-top: 0.4rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.375rem;
|
|
background-color: var(--bg-secondary);
|
|
font-size: 0.8rem;
|
|
}
|
|
.template-ref-intro { color: var(--text-muted); margin-bottom: 0.4rem; }
|
|
.template-ref-heading {
|
|
font-weight: 600;
|
|
font-size: 0.72rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--text-muted);
|
|
margin: 0.6rem 0 0.3rem;
|
|
}
|
|
.template-ref-list { display: grid; gap: 0.25rem 1rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 24rem), 1fr)); }
|
|
.template-ref-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: baseline;
|
|
gap: 0.2rem 0.5rem;
|
|
}
|
|
.template-ref-desc { color: var(--text-color); flex: 1 1 14rem; min-width: 0; }
|
|
.template-chip {
|
|
font-family: var(--bs-font-monospace, monospace);
|
|
font-size: 0.76rem;
|
|
padding: 0.05rem 0.4rem;
|
|
color: var(--text-color);
|
|
background-color: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
white-space: nowrap;
|
|
}
|
|
.template-chip:hover, .template-chip:focus-visible {
|
|
border-color: #0d6efd;
|
|
color: var(--text-color);
|
|
background-color: var(--card-bg);
|
|
}
|
|
[data-theme="dark"] .template-chip:hover,
|
|
[data-theme="dark"] .template-chip:focus-visible { border-color: #6ea8fe; }
|
|
.template-ref-filters {
|
|
display: grid;
|
|
grid-template-columns: max-content 1fr;
|
|
gap: 0.4rem 0.6rem;
|
|
align-items: start;
|
|
}
|
|
.template-chip-example { margin-top: 0.15rem; }
|
|
.template-chip-example { color: var(--text-muted); white-space: normal; text-align: left; overflow-wrap: anywhere; }
|
|
.template-ref-notes { color: var(--text-muted); margin: 0.6rem 0 0; padding-left: 1.1rem; }
|
|
.template-ref-notes li { overflow-wrap: anywhere; }
|
|
.template-preview { display: grid; gap: 0.3rem; margin-bottom: 0.3rem; }
|
|
.template-preview-row {
|
|
display: grid;
|
|
grid-template-columns: 9.5rem 1fr auto;
|
|
gap: 0.5rem;
|
|
align-items: start;
|
|
}
|
|
.template-preview-label { color: var(--text-muted); }
|
|
.template-preview-output {
|
|
font-family: var(--bs-font-monospace, monospace);
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
background-color: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.25rem;
|
|
padding: 0.15rem 0.4rem;
|
|
min-width: 0;
|
|
}
|
|
.template-preview-output.empty { color: var(--text-muted); font-style: italic; }
|
|
.template-preview-output.has-error { border-color: #dc3545; }
|
|
.template-preview-error { grid-column: 2 / -1; color: #b02a37; font-size: 0.76rem; }
|
|
[data-theme="dark"] .template-preview-output.has-error { border-color: #ea868f; }
|
|
[data-theme="dark"] .template-preview-error { color: #ea868f; }
|
|
.template-preview-bytes { color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
|
@media (max-width: 575.98px) {
|
|
.template-ref-filters { grid-template-columns: 1fr; gap: 0.15rem; }
|
|
.template-ref-filters > div:nth-child(2n) { margin-bottom: 0.35rem; }
|
|
.template-preview-row { grid-template-columns: 1fr auto; }
|
|
.template-preview-label { grid-column: 1 / -1; }
|
|
.template-preview-error { grid-column: 1 / -1; }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h1 class="h3 mb-0"><i class="fas fa-puzzle-piece me-2"></i>Plugins</h1>
|
|
<button class="btn btn-outline-secondary btn-sm" id="plugins-refresh-btn">
|
|
<i class="fas fa-sync-alt me-1"></i>Refresh
|
|
</button>
|
|
</div>
|
|
<p class="text-muted small mb-2">
|
|
Toggle commands and services on or off and edit their settings. Changes are
|
|
written to <code>config.ini</code> (a timestamped backup is taken on each save)
|
|
and the bot hot-reloads command settings within a few seconds.
|
|
</p>
|
|
|
|
<div class="plugins-toolbar mb-3">
|
|
<div class="row g-2 align-items-center">
|
|
<div class="col-12 col-md">
|
|
<div class="input-group input-group-sm">
|
|
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
|
<input type="text" class="form-control" id="plugins-filter" aria-label="Filter plugins"
|
|
placeholder="Filter by name, section, or description…" autocomplete="off">
|
|
<button class="btn btn-outline-secondary" type="button" id="plugins-filter-clear" title="Clear" aria-label="Clear filter">
|
|
<i class="fas fa-times" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="plugins-enabled-only">
|
|
<label class="form-check-label small" for="plugins-enabled-only">Enabled only</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-outline-secondary btn-sm" id="plugins-expand-all">
|
|
<i class="fas fa-angle-double-down me-1"></i>Expand all
|
|
</button>
|
|
<button class="btn btn-outline-secondary btn-sm" id="plugins-collapse-all">
|
|
<i class="fas fa-angle-double-up me-1"></i>Collapse all
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="plugins-loading" class="text-center py-5">
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
<div class="mt-2 text-muted">Loading plugins…</div>
|
|
</div>
|
|
<div id="plugins-error" class="alert alert-danger d-none"></div>
|
|
<div id="plugins-empty" class="text-muted fst-italic d-none py-3">No plugins match your filter.</div>
|
|
|
|
<div id="commands-group" class="d-none">
|
|
<h2 class="h5 plugin-group-heading">
|
|
<i class="fas fa-terminal me-2"></i>Commands
|
|
<span class="badge bg-secondary plugin-count-badge" id="commands-count"></span>
|
|
</h2>
|
|
<div id="commands-list"></div>
|
|
</div>
|
|
<div id="services-group" class="d-none">
|
|
<h2 class="h5 plugin-group-heading">
|
|
<i class="fas fa-server me-2"></i>Services
|
|
<span class="badge bg-secondary plugin-count-badge" id="services-count"></span>
|
|
</h2>
|
|
<div id="services-list"></div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script nonce="{{ g.csp_nonce }}">
|
|
class PluginsManager {
|
|
constructor() {
|
|
this.loadingEl = document.getElementById('plugins-loading');
|
|
this.errorEl = document.getElementById('plugins-error');
|
|
this.emptyEl = document.getElementById('plugins-empty');
|
|
this.commandsGroup = document.getElementById('commands-group');
|
|
this.servicesGroup = document.getElementById('services-group');
|
|
this.commandsList = document.getElementById('commands-list');
|
|
this.servicesList = document.getElementById('services-list');
|
|
this.commandsCount = document.getElementById('commands-count');
|
|
this.servicesCount = document.getElementById('services-count');
|
|
this.filterInput = document.getElementById('plugins-filter');
|
|
this.enabledOnly = document.getElementById('plugins-enabled-only');
|
|
this.cards = []; // { el, collapse, kind, enabled, searchText }
|
|
|
|
document.getElementById('plugins-refresh-btn').addEventListener('click', () => this.load());
|
|
document.getElementById('plugins-expand-all').addEventListener('click', () => this.setAll(true));
|
|
document.getElementById('plugins-collapse-all').addEventListener('click', () => this.setAll(false));
|
|
document.getElementById('plugins-filter-clear').addEventListener('click', () => {
|
|
this.filterInput.value = ''; this.applyFilter();
|
|
});
|
|
this.filterInput.addEventListener('input', () => this.applyFilter());
|
|
this.enabledOnly.addEventListener('change', () => this.applyFilter());
|
|
}
|
|
|
|
async load() {
|
|
this.loadingEl.classList.remove('d-none');
|
|
this.errorEl.classList.add('d-none');
|
|
this.commandsList.replaceChildren();
|
|
this.servicesList.replaceChildren();
|
|
this.cards = [];
|
|
try {
|
|
const resp = await fetch('/api/plugins');
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Failed to load plugins');
|
|
const plugins = data.plugins || [];
|
|
const commands = plugins.filter(p => p.kind === 'command');
|
|
const services = plugins.filter(p => p.kind === 'service');
|
|
commands.forEach(p => this.commandsList.appendChild(this.renderCard(p)));
|
|
services.forEach(p => this.servicesList.appendChild(this.renderCard(p)));
|
|
this.commandsCount.textContent = commands.length;
|
|
this.servicesCount.textContent = services.length;
|
|
this.commandsGroup.classList.toggle('d-none', commands.length === 0);
|
|
this.servicesGroup.classList.toggle('d-none', services.length === 0);
|
|
this.applyFilter();
|
|
} catch (err) {
|
|
this.errorEl.textContent = 'Error: ' + err.message;
|
|
this.errorEl.classList.remove('d-none');
|
|
} finally {
|
|
this.loadingEl.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
setAll(expand) {
|
|
this.cards.forEach(c => {
|
|
// Expand only affects visible cards (don't blow open a filtered list);
|
|
// collapse clears everything so nothing stays open while hidden.
|
|
if (expand) {
|
|
if (c.el.classList.contains('d-none')) return;
|
|
c.setOpen(true);
|
|
} else {
|
|
c.setOpen(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
applyFilter() {
|
|
const q = this.filterInput.value.trim().toLowerCase();
|
|
const enabledOnly = this.enabledOnly.checked;
|
|
let visibleCommands = 0, visibleServices = 0;
|
|
this.cards.forEach(c => {
|
|
const matches = (!q || c.searchText.includes(q)) && (!enabledOnly || c.enabled);
|
|
c.el.classList.toggle('d-none', !matches);
|
|
if (matches) { c.kind === 'command' ? visibleCommands++ : visibleServices++; }
|
|
});
|
|
this.commandsCount.textContent = visibleCommands;
|
|
this.servicesCount.textContent = visibleServices;
|
|
this.commandsGroup.classList.toggle('d-none', visibleCommands === 0);
|
|
this.servicesGroup.classList.toggle('d-none', visibleServices === 0);
|
|
const nothing = (visibleCommands + visibleServices) === 0 && this.cards.length > 0;
|
|
this.emptyEl.classList.toggle('d-none', !nothing);
|
|
}
|
|
|
|
// Render a single plugin card (collapsed by default).
|
|
renderCard(plugin) {
|
|
const card = document.createElement('section');
|
|
card.className = 'card mb-2 plugin-card' + (plugin.enabled ? '' : ' disabled');
|
|
|
|
const toggleId = `enable-${plugin.kind}-${plugin.name}`;
|
|
const bodyId = `body-${plugin.kind}-${plugin.name}`;
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'plugin-header';
|
|
const switchWrapper = document.createElement('div');
|
|
switchWrapper.className = 'form-check form-switch mb-0';
|
|
const toggle = document.createElement('input');
|
|
toggle.className = 'form-check-input';
|
|
toggle.type = 'checkbox';
|
|
toggle.role = 'switch';
|
|
toggle.id = toggleId;
|
|
toggle.checked = Boolean(plugin.enabled);
|
|
toggle.setAttribute('aria-label', `Enable ${String(plugin.label ?? plugin.name)}`);
|
|
switchWrapper.appendChild(toggle);
|
|
// The title area is a real button so the card opens from the keyboard;
|
|
// the enable switch stays outside it.
|
|
const headText = document.createElement('button');
|
|
headText.type = 'button';
|
|
headText.className = 'flex-grow-1 plugin-headtext';
|
|
headText.setAttribute('aria-expanded', 'false');
|
|
headText.setAttribute('aria-controls', bodyId);
|
|
const title = document.createElement('span');
|
|
title.className = 'plugin-title';
|
|
title.textContent = String(plugin.label ?? '');
|
|
const section = document.createElement('span');
|
|
section.className = 'plugin-section-label';
|
|
section.textContent = `[${String(plugin.section ?? '')}]`;
|
|
const description = document.createElement('span');
|
|
description.className = 'plugin-desc';
|
|
description.textContent = String(plugin.description ?? '');
|
|
headText.append(title, section, description);
|
|
const chevron = document.createElement('i');
|
|
chevron.className = 'fas fa-chevron-down plugin-chevron';
|
|
chevron.setAttribute('aria-hidden', 'true');
|
|
header.append(switchWrapper, headText, chevron);
|
|
card.appendChild(header);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'collapse';
|
|
body.id = bodyId;
|
|
|
|
const inner = document.createElement('div');
|
|
inner.className = 'card-body border-top';
|
|
|
|
const form = document.createElement('form');
|
|
form.setAttribute('novalidate', '');
|
|
|
|
// Inputs keyed by config key, with their schema field for validation.
|
|
const inputs = [];
|
|
|
|
// Field grid: dense responsive columns sized to field type.
|
|
const grid = document.createElement('div');
|
|
grid.className = 'row g-2';
|
|
form.appendChild(grid);
|
|
|
|
const addField = (field, value, targetGrid = grid) => {
|
|
const col = document.createElement('div');
|
|
col.className = fieldColClass(field);
|
|
const { wrapper, input } = renderField(field, value, plugin);
|
|
col.appendChild(wrapper);
|
|
targetGrid.appendChild(col);
|
|
// Capture the original value so saves only send changed fields
|
|
// (keeps config.ini minimal and preserves preset-driven defaults).
|
|
inputs.push({ field, input, original: readInput(field, input) });
|
|
};
|
|
|
|
// Render grouped fields ("group" attr) inside a collapsed <details> so
|
|
// advanced settings are present but tucked away.
|
|
const addGroupedFields = (fields) => {
|
|
const groupNames = [...new Set(fields.map(f => f.group))];
|
|
groupNames.forEach(gname => {
|
|
const details = document.createElement('details');
|
|
details.className = 'col-12 field-group mt-1';
|
|
const summary = document.createElement('summary');
|
|
summary.className = 'fw-semibold small text-muted';
|
|
summary.textContent = gname;
|
|
details.appendChild(summary);
|
|
const gGrid = document.createElement('div');
|
|
gGrid.className = 'row g-2 mt-1';
|
|
details.appendChild(gGrid);
|
|
grid.appendChild(details);
|
|
fields.filter(f => f.group === gname).forEach(field => addField(field, undefined, gGrid));
|
|
});
|
|
};
|
|
|
|
const schemaKeys = new Set((plugin.fields || []).filter(f => !f.section).map(f => f.key));
|
|
const values = plugin.values || {};
|
|
// Keys managed by a dynamic editor on this same section (by prefix) are
|
|
// excluded from the raw "Other config values" list to avoid duplication.
|
|
const ownPrefixes = (plugin.dynamic_sections || [])
|
|
.filter(d => d.section === plugin.section && d.key_prefix)
|
|
.map(d => d.key_prefix.toLowerCase());
|
|
// Repeating blocks (e.g. mqtt1_*, mqtt2_*) are managed by their own editor.
|
|
const blockPatterns = (plugin.repeating_blocks || [])
|
|
.map(rb => new RegExp('^' + rb.id.toLowerCase() + '\\d+_'));
|
|
const extraKeys = Object.keys(values).filter(k =>
|
|
!schemaKeys.has(k)
|
|
&& !ownPrefixes.some(p => k.toLowerCase().startsWith(p))
|
|
&& !blockPatterns.some(rx => rx.test(k.toLowerCase())));
|
|
|
|
if (plugin.has_schema) {
|
|
plugin.fields.filter(f => !f.group).forEach(field => addField(field));
|
|
addGroupedFields(plugin.fields.filter(f => f.group));
|
|
if (extraKeys.length) {
|
|
const sub = document.createElement('div');
|
|
sub.className = 'col-12 text-muted small mt-1';
|
|
sub.textContent = 'Other config values';
|
|
grid.appendChild(sub);
|
|
extraKeys.forEach(key => addField({ key, label: key, type: 'str' }, values[key]));
|
|
}
|
|
} else if (extraKeys.length === 0) {
|
|
// Nothing beyond the on/off toggle in the header.
|
|
const note = document.createElement('p');
|
|
note.className = 'text-muted small fst-italic mb-0';
|
|
note.textContent = 'This command has no additional settings — use the on/off toggle above.';
|
|
form.insertBefore(note, grid);
|
|
} else {
|
|
const note = document.createElement('p');
|
|
note.className = 'text-muted small mb-2';
|
|
note.textContent = 'No typed schema for this plugin; editing raw values.';
|
|
form.insertBefore(note, grid);
|
|
extraKeys.forEach(key => addField({ key, label: key, type: 'str' }, values[key]));
|
|
}
|
|
|
|
// Dynamic key/value section editors (add/edit/delete rows).
|
|
const dynamicEditors = [];
|
|
(plugin.dynamic_sections || []).forEach(ds => {
|
|
const editor = buildDynamicEditor(ds);
|
|
form.appendChild(editor.el);
|
|
dynamicEditors.push(editor);
|
|
});
|
|
|
|
// Repeating structured-block editors (e.g. PacketCapture MQTT brokers).
|
|
const blockEditors = [];
|
|
(plugin.repeating_blocks || []).forEach(rb => {
|
|
const editor = buildRepeatingBlockEditor(rb);
|
|
form.appendChild(editor.el);
|
|
blockEditors.push(editor);
|
|
});
|
|
|
|
// Service restart caveat.
|
|
if (plugin.kind === 'service') {
|
|
const restart = document.createElement('div');
|
|
restart.className = 'restart-note mt-3';
|
|
restart.innerHTML = '<i class="fas fa-info-circle me-1"></i>' +
|
|
'Turning a service on or off takes effect after the next bot restart. ' +
|
|
'Setting changes apply on reload.';
|
|
form.appendChild(restart);
|
|
}
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'd-flex align-items-center mt-3';
|
|
const saveBtn = document.createElement('button');
|
|
saveBtn.type = 'button';
|
|
saveBtn.className = 'btn btn-primary btn-sm';
|
|
saveBtn.innerHTML = '<i class="fas fa-save me-1"></i>Save';
|
|
const status = document.createElement('span');
|
|
status.className = 'plugin-save-status';
|
|
actions.appendChild(saveBtn);
|
|
actions.appendChild(status);
|
|
form.appendChild(actions);
|
|
|
|
inner.appendChild(form);
|
|
body.appendChild(inner);
|
|
card.appendChild(body);
|
|
|
|
const collapse = new bootstrap.Collapse(body, { toggle: false });
|
|
|
|
const setOpen = (open) => {
|
|
card.classList.toggle('expanded', open);
|
|
headText.setAttribute('aria-expanded', String(open));
|
|
if (open) collapse.show(); else collapse.hide();
|
|
};
|
|
// Header click expands/collapses; clicking the enable switch must not.
|
|
// Clicks on the title button bubble here too, so keyboard activation
|
|
// goes through the same path.
|
|
header.addEventListener('click', (e) => {
|
|
if (e.target.closest('.form-switch')) return;
|
|
setOpen(!card.classList.contains('expanded'));
|
|
});
|
|
// The enable switch only changes enabled state + dim style.
|
|
toggle.addEventListener('change', () => {
|
|
card.classList.toggle('disabled', !toggle.checked);
|
|
});
|
|
|
|
saveBtn.addEventListener('click', () =>
|
|
this.save(plugin, toggle, inputs, saveBtn, status, dynamicEditors, blockEditors));
|
|
|
|
const searchText = `${plugin.label} ${plugin.section} ${plugin.description || ''}`.toLowerCase();
|
|
this.cards.push({ el: card, setOpen, kind: plugin.kind, enabled: plugin.enabled, searchText,
|
|
setEnabled: (v) => { card.classList.toggle('disabled', !v); } });
|
|
|
|
return card;
|
|
}
|
|
|
|
async save(plugin, toggle, inputs, saveBtn, status, dynamicEditors = [], blockEditors = []) {
|
|
// Validate + collect only fields that changed from their loaded value.
|
|
const values = {};
|
|
let firstError = null;
|
|
let changedCount = 0;
|
|
inputs.forEach(({ field, input, original }) => {
|
|
clearInvalid(input);
|
|
const raw = readInput(field, input);
|
|
if (String(raw) === String(original)) return; // unchanged → skip
|
|
changedCount++;
|
|
const err = validateClient(field, raw);
|
|
if (err) {
|
|
markInvalid(input, err);
|
|
if (!firstError) firstError = err;
|
|
}
|
|
values[field.id || field.key] = raw;
|
|
});
|
|
|
|
// Dynamic sections: collect rows, validate keys, detect changes.
|
|
const dynamicPayload = {};
|
|
dynamicEditors.forEach(ed => {
|
|
const rows = ed.getRows();
|
|
const err = validateDynamicRows(rows);
|
|
if (err && !firstError) firstError = err;
|
|
if (JSON.stringify(rows) !== ed.original) changedCount++;
|
|
dynamicPayload[ed.section] = rows;
|
|
});
|
|
|
|
// Repeating blocks: collect blocks, detect changes.
|
|
const blocksPayload = {};
|
|
blockEditors.forEach(ed => {
|
|
const blocks = ed.getBlocks();
|
|
if (JSON.stringify(blocks) !== ed.original) changedCount++;
|
|
blocksPayload[ed.id] = blocks;
|
|
});
|
|
|
|
if (firstError) {
|
|
this.setStatus(status, firstError, 'danger');
|
|
return;
|
|
}
|
|
const enableChanged = (toggle.checked !== plugin.enabled);
|
|
if (changedCount === 0 && !enableChanged) {
|
|
this.setStatus(status, 'No changes to save.', 'muted');
|
|
return;
|
|
}
|
|
|
|
saveBtn.disabled = true;
|
|
const original = saveBtn.innerHTML;
|
|
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
|
this.setStatus(status, '', '');
|
|
try {
|
|
const resp = await fetch(`/api/plugins/${plugin.kind}/${plugin.name}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify({
|
|
section: plugin.section,
|
|
enabled: toggle.checked,
|
|
values: values,
|
|
dynamic_sections: dynamicPayload,
|
|
repeating_blocks: blocksPayload,
|
|
}),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) {
|
|
if (data.errors) {
|
|
inputs.forEach(({ field, input }) => {
|
|
const err = data.errors[field.id || field.key];
|
|
if (err) markInvalid(input, err);
|
|
});
|
|
throw new Error('Please fix the highlighted fields');
|
|
}
|
|
throw new Error(data.error || 'Save failed');
|
|
}
|
|
let msg = 'Saved.';
|
|
if (data.restart_required) msg += ' Restart the bot to start/stop this service.';
|
|
else if (data.reload_queued) msg += ' Bot will reload shortly.';
|
|
this.setStatus(status, msg, 'success');
|
|
// Keep filter state (enabled-only) accurate.
|
|
plugin.enabled = toggle.checked;
|
|
const entry = this.cards.find(c => c.el.contains(saveBtn));
|
|
if (entry) entry.enabled = toggle.checked;
|
|
// Reset baselines so an immediate re-save is a no-op.
|
|
inputs.forEach(io => { io.original = readInput(io.field, io.input); });
|
|
dynamicEditors.forEach(ed => { ed.original = JSON.stringify(ed.getRows()); });
|
|
blockEditors.forEach(ed => { ed.original = JSON.stringify(ed.getBlocks()); });
|
|
} catch (err) {
|
|
this.setStatus(status, err.message, 'danger');
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = original;
|
|
}
|
|
}
|
|
|
|
setStatus(el, msg, type) {
|
|
el.textContent = msg;
|
|
el.className = 'plugin-save-status' + (type ? ' text-' + type : '');
|
|
if (type === 'success') {
|
|
setTimeout(() => { el.textContent = ''; el.className = 'plugin-save-status'; }, 5000);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Field rendering & validation helpers -------------------------------
|
|
|
|
// `plugin` ({kind, name}) is only needed by template fields, for their preview.
|
|
function renderField(field, overrideValue, plugin) {
|
|
const rendered = renderFieldControl(field, overrideValue, plugin);
|
|
linkFieldLabel(rendered.wrapper, rendered.input);
|
|
return rendered;
|
|
}
|
|
|
|
// Tie the field's visible label and help text to its control, so screen
|
|
// readers announce the setting's name instead of a bare "edit text".
|
|
function linkFieldLabel(wrapper, input) {
|
|
const label = wrapper.querySelector(':scope > label.form-label');
|
|
if (label && !label.htmlFor) {
|
|
if (!input.id) input.id = 'fld-' + Math.random().toString(36).slice(2, 9);
|
|
if (input.labels !== undefined) {
|
|
label.htmlFor = input.id;
|
|
} else {
|
|
label.id = input.id + '-label';
|
|
input.setAttribute('aria-labelledby', label.id);
|
|
}
|
|
}
|
|
const help = wrapper.querySelector(':scope > .form-text');
|
|
if (help && !input.hasAttribute('aria-describedby')) {
|
|
if (!input.id) input.id = 'fld-' + Math.random().toString(36).slice(2, 9);
|
|
help.id = input.id + '-help';
|
|
input.setAttribute('aria-describedby', help.id);
|
|
}
|
|
}
|
|
|
|
function renderFieldControl(field, overrideValue, plugin) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'mb-1';
|
|
const value = overrideValue !== undefined ? overrideValue : field.value;
|
|
|
|
let input;
|
|
if (field.type === 'bool') {
|
|
// Label on top (like every other field) so the switch lines up with
|
|
// adjacent inputs when fields share a row.
|
|
const uid = 'sw-' + Math.random().toString(36).slice(2, 9);
|
|
const label = document.createElement('label');
|
|
label.className = 'form-label d-block';
|
|
label.htmlFor = uid;
|
|
label.textContent = field.label;
|
|
if (field.section) label.appendChild(sectionBadge(field.section));
|
|
wrapper.appendChild(label);
|
|
const sw = document.createElement('div');
|
|
sw.className = 'form-check form-switch mb-0';
|
|
input = document.createElement('input');
|
|
input.className = 'form-check-input';
|
|
input.type = 'checkbox';
|
|
input.role = 'switch';
|
|
input.id = uid;
|
|
input.checked = (value === true || value === 'true');
|
|
sw.appendChild(input);
|
|
wrapper.appendChild(sw);
|
|
if (field.help) wrapper.appendChild(helpText(field.help));
|
|
return { wrapper, input };
|
|
}
|
|
|
|
const label = document.createElement('label');
|
|
label.className = 'form-label';
|
|
label.textContent = field.label + (field.required ? ' *' : '');
|
|
if (field.section) label.appendChild(sectionBadge(field.section));
|
|
wrapper.appendChild(label);
|
|
|
|
if (field.type === 'enum') {
|
|
input = document.createElement('select');
|
|
input.className = 'form-select form-select-sm';
|
|
(field.options || []).forEach(opt => {
|
|
const o = document.createElement('option');
|
|
o.value = opt.value;
|
|
o.textContent = opt.label || opt.value;
|
|
if (String(value) === String(opt.value)) o.selected = true;
|
|
input.appendChild(o);
|
|
});
|
|
wrapper.appendChild(input);
|
|
} else if (field.type === 'int' || field.type === 'float') {
|
|
const group = document.createElement('div');
|
|
group.className = 'input-group input-group-sm has-validation';
|
|
input = document.createElement('input');
|
|
input.className = 'form-control';
|
|
input.type = 'number';
|
|
if (field.min !== undefined && field.min !== null) input.min = field.min;
|
|
if (field.max !== undefined && field.max !== null) input.max = field.max;
|
|
input.step = field.type === 'float' ? 'any' : '1';
|
|
input.value = (value !== undefined && value !== null) ? value : '';
|
|
group.appendChild(input);
|
|
if (field.unit) {
|
|
const unit = document.createElement('span');
|
|
unit.className = 'input-group-text';
|
|
unit.textContent = field.unit;
|
|
group.appendChild(unit);
|
|
}
|
|
const fb = document.createElement('div');
|
|
fb.className = 'invalid-feedback';
|
|
group.appendChild(fb);
|
|
wrapper.appendChild(group);
|
|
if (field.help) wrapper.appendChild(helpText(field.help));
|
|
return { wrapper, input };
|
|
} else if (field.type === 'password') {
|
|
const group = document.createElement('div');
|
|
group.className = 'input-group input-group-sm has-validation';
|
|
input = document.createElement('input');
|
|
input.className = 'form-control';
|
|
input.type = 'password';
|
|
input.autocomplete = 'new-password';
|
|
if (field.pattern) input.pattern = field.pattern;
|
|
input.value = (value !== undefined && value !== null) ? value : '';
|
|
input.placeholder = field.has_value ? '(saved — enter new value to change)' : '';
|
|
group.appendChild(input);
|
|
const toggleBtn = document.createElement('button');
|
|
toggleBtn.type = 'button';
|
|
toggleBtn.className = 'btn btn-outline-secondary';
|
|
toggleBtn.title = 'Show / hide value';
|
|
toggleBtn.setAttribute('aria-label', 'Show value');
|
|
toggleBtn.setAttribute('aria-pressed', 'false');
|
|
const toggleIcon = document.createElement('i');
|
|
toggleIcon.className = 'fas fa-eye';
|
|
toggleIcon.setAttribute('aria-hidden', 'true');
|
|
toggleBtn.appendChild(toggleIcon);
|
|
toggleBtn.addEventListener('click', () => {
|
|
const showing = input.type === 'text';
|
|
input.type = showing ? 'password' : 'text';
|
|
toggleIcon.classList.toggle('fa-eye', showing);
|
|
toggleIcon.classList.toggle('fa-eye-slash', !showing);
|
|
toggleBtn.setAttribute('aria-pressed', String(!showing));
|
|
});
|
|
group.appendChild(toggleBtn);
|
|
const fb = document.createElement('div');
|
|
fb.className = 'invalid-feedback';
|
|
group.appendChild(fb);
|
|
wrapper.appendChild(group);
|
|
if (field.help) wrapper.appendChild(helpText(field.help));
|
|
return { wrapper, input };
|
|
} else if (field.template) {
|
|
const group = document.createElement('div');
|
|
group.className = 'input-group input-group-sm has-validation';
|
|
input = document.createElement('input');
|
|
input.className = 'form-control font-monospace';
|
|
input.type = 'text';
|
|
input.spellcheck = false;
|
|
if (field.pattern) input.pattern = field.pattern;
|
|
input.value = (value !== undefined && value !== null) ? value : '';
|
|
const refBtn = document.createElement('button');
|
|
refBtn.type = 'button';
|
|
refBtn.className = 'btn btn-outline-secondary template-ref-btn';
|
|
refBtn.textContent = '{ }';
|
|
const fb = document.createElement('div');
|
|
fb.className = 'invalid-feedback';
|
|
group.append(input, refBtn, fb);
|
|
wrapper.appendChild(group);
|
|
if (field.help) wrapper.appendChild(helpText(field.help));
|
|
wrapper.appendChild(buildTemplateReference(field, input, refBtn, plugin));
|
|
return { wrapper, input };
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.className = 'form-control form-control-sm';
|
|
input.type = 'text';
|
|
if (field.pattern) input.pattern = field.pattern;
|
|
input.value = (value !== undefined && value !== null)
|
|
? (Array.isArray(value) ? value.join(', ') : value) : '';
|
|
if (field.type === 'list') input.placeholder = 'comma, separated, values';
|
|
wrapper.appendChild(input);
|
|
}
|
|
|
|
const feedback = document.createElement('div');
|
|
feedback.className = 'invalid-feedback';
|
|
wrapper.appendChild(feedback);
|
|
if (field.help) wrapper.appendChild(helpText(field.help));
|
|
return { wrapper, input };
|
|
}
|
|
|
|
// --- Template reference panel (placeholders, filters, live preview) -------
|
|
|
|
let templateRefSeq = 0;
|
|
|
|
// Insert text at the input's cursor (replacing any selection) and keep focus
|
|
// there, so several chips can be clicked in a row.
|
|
function insertAtCursor(input, text) {
|
|
const start = input.selectionStart ?? input.value.length;
|
|
const end = input.selectionEnd ?? input.value.length;
|
|
input.setRangeText(text, start, end, 'end');
|
|
input.focus();
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
function refChip(label, title, onClick) {
|
|
const chip = document.createElement('button');
|
|
chip.type = 'button';
|
|
chip.className = 'btn btn-sm template-chip';
|
|
chip.textContent = label;
|
|
chip.title = title;
|
|
chip.addEventListener('click', onClick);
|
|
return chip;
|
|
}
|
|
|
|
function refHeading(text) {
|
|
const h = document.createElement('div');
|
|
h.className = 'template-ref-heading';
|
|
h.textContent = text;
|
|
return h;
|
|
}
|
|
|
|
function buildTemplateReference(field, input, refBtn, plugin) {
|
|
const spec = field.template;
|
|
const piped = spec.syntax === 'piped';
|
|
const panelId = 'tref-' + (++templateRefSeq);
|
|
|
|
const panel = document.createElement('div');
|
|
panel.className = 'template-ref d-none';
|
|
panel.id = panelId;
|
|
panel.setAttribute('role', 'region');
|
|
panel.setAttribute('aria-label', field.label + ' placeholders');
|
|
refBtn.setAttribute('aria-controls', panelId);
|
|
refBtn.setAttribute('aria-expanded', 'false');
|
|
refBtn.setAttribute('aria-label', 'Placeholders for ' + field.label);
|
|
refBtn.title = piped ? 'Placeholders, filters and preview'
|
|
: (spec.previewable ? 'Placeholders and preview' : 'Placeholders');
|
|
|
|
const intro = document.createElement('p');
|
|
intro.className = 'template-ref-intro';
|
|
intro.textContent = piped
|
|
? 'Click to insert at the cursor. Filters go inside a placeholder, after its name: {field|filter:arg}.'
|
|
: 'Click to insert at the cursor. This field takes placeholders only; filters and conditionals don\'t work here.';
|
|
panel.appendChild(intro);
|
|
// The preview goes first: it's what you watch while typing.
|
|
const previewSlot = document.createElement('div');
|
|
panel.appendChild(previewSlot);
|
|
|
|
panel.appendChild(refHeading('Placeholders'));
|
|
const list = document.createElement('div');
|
|
list.className = 'template-ref-list';
|
|
(spec.placeholders || []).forEach(p => {
|
|
const row = document.createElement('div');
|
|
row.className = 'template-ref-row';
|
|
const token = '{' + p.name + '}';
|
|
row.appendChild(refChip(token, 'Insert ' + token, () => insertAtCursor(input, token)));
|
|
if (p.help) {
|
|
const desc = document.createElement('span');
|
|
desc.className = 'template-ref-desc';
|
|
desc.textContent = p.help;
|
|
row.appendChild(desc);
|
|
}
|
|
list.appendChild(row);
|
|
});
|
|
panel.appendChild(list);
|
|
|
|
if (piped && spec.filters) {
|
|
panel.appendChild(refHeading('Filters and conditionals'));
|
|
const flist = document.createElement('div');
|
|
flist.className = 'template-ref-filters';
|
|
spec.filters.forEach(f => {
|
|
const token = '|' + f.name + (f.arg ? ':' : '');
|
|
const label = '|' + f.name + (f.arg ? ':' + f.arg : '');
|
|
const chipCell = document.createElement('div');
|
|
chipCell.appendChild(refChip(label, 'Insert ' + token, () => insertAtCursor(input, token)));
|
|
const body = document.createElement('div');
|
|
const desc = document.createElement('div');
|
|
desc.textContent = f.help + (f.aliases && f.aliases.length ? ` Also: ${f.aliases.join(', ')}.` : '');
|
|
body.appendChild(desc);
|
|
if (f.example) {
|
|
const ex = refChip(f.example, 'Insert this example', () => insertAtCursor(input, f.example));
|
|
ex.classList.add('template-chip-example');
|
|
body.appendChild(ex);
|
|
}
|
|
flist.append(chipCell, body);
|
|
});
|
|
panel.appendChild(flist);
|
|
}
|
|
|
|
const notes = [...(spec.notes || []), ...(spec.syntax_notes || [])];
|
|
if (notes.length) {
|
|
const ul = document.createElement('ul');
|
|
ul.className = 'template-ref-notes';
|
|
notes.forEach(n => {
|
|
const li = document.createElement('li');
|
|
li.textContent = n;
|
|
ul.appendChild(li);
|
|
});
|
|
panel.appendChild(ul);
|
|
}
|
|
|
|
let refreshPreview = () => {};
|
|
if (spec.previewable && plugin) {
|
|
previewSlot.appendChild(refHeading('Preview'));
|
|
const preview = document.createElement('div');
|
|
preview.className = 'template-preview';
|
|
preview.setAttribute('aria-live', 'polite');
|
|
previewSlot.appendChild(preview);
|
|
const sampleNote = document.createElement('div');
|
|
sampleNote.className = 'template-ref-intro mb-0';
|
|
sampleNote.textContent = (spec.sample_note ? spec.sample_note + ' ' : '')
|
|
+ 'Byte counts are UTF-8; a channel message holds 155 bytes less the bot name and ": ".';
|
|
previewSlot.appendChild(sampleNote);
|
|
|
|
let timer = null;
|
|
let seq = 0;
|
|
const render = async () => {
|
|
const mine = seq;
|
|
try {
|
|
const resp = await fetch(`/api/plugins/${plugin.kind}/${plugin.name}/template-preview`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
body: JSON.stringify({ key: field.key, template: input.value }),
|
|
});
|
|
const data = await resp.json();
|
|
if (mine !== seq) return; // the field changed while this was in flight
|
|
if (!resp.ok) throw new Error(data.error || 'Preview failed');
|
|
const rows = [];
|
|
if (data.note) {
|
|
const note = document.createElement('div');
|
|
note.className = 'template-ref-intro mb-0';
|
|
note.textContent = data.note;
|
|
rows.push(note);
|
|
}
|
|
if (!data.scenarios.length && !data.note) {
|
|
const none = document.createElement('div');
|
|
none.className = 'template-ref-intro mb-0';
|
|
none.textContent = 'Nothing to preview.';
|
|
rows.push(none);
|
|
}
|
|
preview.replaceChildren(...rows, ...data.scenarios.map(s => {
|
|
const row = document.createElement('div');
|
|
row.className = 'template-preview-row';
|
|
const label = document.createElement('div');
|
|
label.className = 'template-preview-label';
|
|
label.textContent = s.label;
|
|
const out = document.createElement('div');
|
|
out.className = 'template-preview-output' + (s.output ? '' : ' empty')
|
|
+ (s.error ? ' has-error' : '');
|
|
out.textContent = s.output || (s.error ? '(nothing sent)' : '(empty)');
|
|
const bytes = document.createElement('div');
|
|
bytes.className = 'template-preview-bytes';
|
|
bytes.textContent = s.bytes + ' B';
|
|
row.append(label, out, bytes);
|
|
if (s.error) {
|
|
const err = document.createElement('div');
|
|
err.className = 'template-preview-error';
|
|
const icon = document.createElement('i');
|
|
icon.className = 'fas fa-exclamation-triangle me-1';
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
err.append(icon, document.createTextNode(s.error));
|
|
row.appendChild(err);
|
|
}
|
|
return row;
|
|
}));
|
|
} catch (err) {
|
|
if (mine !== seq) return;
|
|
const msg = document.createElement('div');
|
|
msg.className = 'text-danger small';
|
|
msg.textContent = err.message;
|
|
preview.replaceChildren(msg);
|
|
}
|
|
};
|
|
refreshPreview = () => {
|
|
// Invalidate any request in flight now, not when the timer fires: a
|
|
// response that lands inside the debounce would show a stale template.
|
|
seq++;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(render, 300);
|
|
};
|
|
input.addEventListener('input', () => {
|
|
if (!panel.classList.contains('d-none')) refreshPreview();
|
|
});
|
|
}
|
|
|
|
const setOpen = (open) => {
|
|
panel.classList.toggle('d-none', !open);
|
|
refBtn.setAttribute('aria-expanded', String(open));
|
|
refBtn.classList.toggle('active', open);
|
|
if (open) refreshPreview();
|
|
};
|
|
refBtn.addEventListener('click', () => setOpen(panel.classList.contains('d-none')));
|
|
panel.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') { setOpen(false); refBtn.focus(); }
|
|
});
|
|
return panel;
|
|
}
|
|
|
|
function helpText(text) {
|
|
const el = document.createElement('div');
|
|
el.className = 'form-text';
|
|
el.textContent = text;
|
|
return el;
|
|
}
|
|
|
|
// Choose a responsive grid width for a field so short controls pack densely.
|
|
// A field may override with "width": sm | md | lg | full.
|
|
function fieldColClass(field) {
|
|
switch (field.width) {
|
|
case 'full': return 'col-12';
|
|
case 'lg': return 'col-12 col-lg-8';
|
|
case 'md': return 'col-12 col-sm-6 col-xl-4';
|
|
case 'sm': return 'col-6 col-md-4 col-xl-3';
|
|
}
|
|
if (field.template) return 'col-12'; // room for the reference panel
|
|
if (field.type === 'bool') return 'col-12 col-sm-6 col-lg-4';
|
|
if (field.type === 'int' || field.type === 'float' || field.type === 'enum') {
|
|
return 'col-6 col-md-4 col-xl-3';
|
|
}
|
|
return 'col-12 col-sm-6 col-xl-4'; // str / list / password — medium
|
|
}
|
|
|
|
// Badge marking a field that reads/writes a shared section (not the plugin's own).
|
|
function sectionBadge(section) {
|
|
const b = document.createElement('span');
|
|
b.className = 'badge section-badge ms-2';
|
|
b.title = 'Shared setting in [' + section + '] — changing it affects all commands that use it';
|
|
b.textContent = '[' + section + ']';
|
|
return b;
|
|
}
|
|
|
|
// --- Dynamic key/value section editor (add/edit/delete rows) -------------
|
|
|
|
function buildDynamicEditor(ds) {
|
|
const el = document.createElement('div');
|
|
el.className = 'dynamic-section mt-3';
|
|
|
|
const head = document.createElement('div');
|
|
const heading = document.createElement('div');
|
|
heading.className = 'fw-semibold small mb-1';
|
|
heading.textContent = String(ds.label ?? '');
|
|
head.appendChild(heading);
|
|
if (ds.help) head.appendChild(helpText(ds.help));
|
|
el.appendChild(head);
|
|
|
|
// Column headers
|
|
const colhead = document.createElement('div');
|
|
colhead.className = 'row g-2 text-muted small mt-1 d-none d-md-flex';
|
|
const keyHeading = document.createElement('div');
|
|
keyHeading.className = 'col-md-5';
|
|
keyHeading.textContent = String(ds.key_label ?? '');
|
|
const valueHeading = document.createElement('div');
|
|
valueHeading.className = 'col-md-6';
|
|
valueHeading.textContent = String(ds.value_label ?? '');
|
|
colhead.append(keyHeading, valueHeading);
|
|
el.appendChild(colhead);
|
|
|
|
const rowsContainer = document.createElement('div');
|
|
rowsContainer.className = 'dynamic-rows';
|
|
el.appendChild(rowsContainer);
|
|
|
|
const addRow = (key = '', value = '') => {
|
|
const row = document.createElement('div');
|
|
row.className = 'row g-2 align-items-center mb-1 dynamic-row';
|
|
const kc = document.createElement('div'); kc.className = 'col-12 col-md-5';
|
|
const ki = document.createElement('input');
|
|
ki.type = 'text'; ki.className = 'form-control form-control-sm dyn-key';
|
|
ki.placeholder = ds.key_placeholder || ''; ki.value = key;
|
|
ki.setAttribute('aria-label', `${String(ds.label ?? 'Entry')} key`);
|
|
kc.appendChild(ki);
|
|
const vc = document.createElement('div'); vc.className = 'col-10 col-md-6';
|
|
const vi = document.createElement('input');
|
|
vi.type = 'text'; vi.className = 'form-control form-control-sm dyn-value';
|
|
vi.placeholder = ds.value_placeholder || ''; vi.value = value;
|
|
vi.setAttribute('aria-label', `${String(ds.label ?? 'Entry')} value`);
|
|
vc.appendChild(vi);
|
|
const bc = document.createElement('div'); bc.className = 'col-2 col-md-1 text-end';
|
|
const rm = document.createElement('button');
|
|
rm.type = 'button'; rm.className = 'btn btn-outline-danger btn-sm';
|
|
rm.title = 'Remove'; rm.setAttribute('aria-label', 'Remove entry');
|
|
rm.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
|
rm.addEventListener('click', () => row.remove());
|
|
bc.appendChild(rm);
|
|
row.appendChild(kc); row.appendChild(vc); row.appendChild(bc);
|
|
rowsContainer.appendChild(row);
|
|
return row;
|
|
};
|
|
|
|
(ds.items || []).forEach(it => addRow(it.key, it.value));
|
|
|
|
const addBtn = document.createElement('button');
|
|
addBtn.type = 'button';
|
|
addBtn.className = 'btn btn-outline-secondary btn-sm mt-1';
|
|
addBtn.innerHTML = '<i class="fas fa-plus me-1"></i>Add';
|
|
addBtn.addEventListener('click', () => {
|
|
const row = addRow('', '');
|
|
row.querySelector('.dyn-key').focus();
|
|
});
|
|
el.appendChild(addBtn);
|
|
|
|
const getRows = () => {
|
|
const out = [];
|
|
rowsContainer.querySelectorAll('.dynamic-row').forEach(r => {
|
|
const key = r.querySelector('.dyn-key').value.trim();
|
|
const value = r.querySelector('.dyn-value').value;
|
|
if (key === '' && value.trim() === '') return; // skip blank rows
|
|
out.push({ key, value });
|
|
});
|
|
return out;
|
|
};
|
|
|
|
// Snapshot via getRows() so change-detection matches the save serialization.
|
|
return { section: ds.section, el, getRows, original: JSON.stringify(getRows()) };
|
|
}
|
|
|
|
function validateDynamicRows(rows) {
|
|
const seen = new Set();
|
|
for (const { key } of rows) {
|
|
if (key === '') return 'A list row is missing its key';
|
|
if (/[=:\[\]\n\r]/.test(key)) return `Invalid key "${key}": cannot contain = : [ ] or newlines`;
|
|
if (key[0] === '#' || key[0] === ';') return `Invalid key "${key}": cannot start with # or ;`;
|
|
if (seen.has(key.toLowerCase())) return `Duplicate key "${key}"`;
|
|
seen.add(key.toLowerCase());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Repeating structured-block editor (add/remove numbered blocks) ------
|
|
|
|
function buildRepeatingBlockEditor(rb) {
|
|
const el = document.createElement('div');
|
|
el.className = 'repeating-section mt-3';
|
|
const head = document.createElement('div');
|
|
const heading = document.createElement('div');
|
|
heading.className = 'fw-semibold small mb-1';
|
|
heading.textContent = String(rb.label ?? '');
|
|
head.appendChild(heading);
|
|
if (rb.help) head.appendChild(helpText(rb.help));
|
|
el.appendChild(head);
|
|
|
|
const blocksContainer = document.createElement('div');
|
|
el.appendChild(blocksContainer);
|
|
const schemaKeys = new Set(rb.fields.map(f => f.key));
|
|
|
|
const renumber = () => {
|
|
blocksContainer.querySelectorAll('.rb-block').forEach((card, i) => {
|
|
const t = card.querySelector('.rb-block-title');
|
|
const name = `${rb.item_label} ${i + 1}`;
|
|
if (t) t.textContent = name;
|
|
card.querySelector('.rb-block-enable')?.setAttribute('aria-label', `Enable ${name}`);
|
|
card.querySelector('.rb-block-remove')?.setAttribute('aria-label', `Remove ${name}`);
|
|
});
|
|
};
|
|
|
|
const addBlock = (block) => {
|
|
const values = (block && block.values) || {};
|
|
// Preserve sub-keys not covered by the schema (e.g. jwt overrides).
|
|
const extras = {};
|
|
Object.keys(values).forEach(k => { if (!schemaKeys.has(k)) extras[k] = values[k]; });
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'card mb-2 rb-block';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'card-header d-flex align-items-center gap-2 py-2';
|
|
const swWrap = document.createElement('div');
|
|
swWrap.className = 'form-check form-switch mb-0';
|
|
const sw = document.createElement('input');
|
|
sw.className = 'form-check-input rb-block-enable'; sw.type = 'checkbox'; sw.role = 'switch';
|
|
sw.checked = (block ? block.enabled !== false : true);
|
|
swWrap.appendChild(sw);
|
|
const title = document.createElement('span');
|
|
title.className = 'rb-block-title fw-semibold flex-grow-1';
|
|
const rm = document.createElement('button');
|
|
rm.type = 'button'; rm.className = 'btn btn-outline-danger btn-sm';
|
|
rm.title = 'Remove'; rm.className += ' rb-block-remove';
|
|
rm.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
|
rm.addEventListener('click', () => { card.remove(); renumber(); });
|
|
header.append(swWrap, title, rm);
|
|
card.appendChild(header);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'card-body py-2';
|
|
const grid = document.createElement('div');
|
|
grid.className = 'row g-2';
|
|
const inputs = [];
|
|
rb.fields.forEach(field => {
|
|
const col = document.createElement('div');
|
|
col.className = fieldColClass(field);
|
|
const { wrapper, input } = renderField(field, values[field.key]);
|
|
col.appendChild(wrapper);
|
|
grid.appendChild(col);
|
|
inputs.push({ field, input });
|
|
});
|
|
body.appendChild(grid);
|
|
card.appendChild(body);
|
|
|
|
// Dim the body when the block is disabled.
|
|
const applyDim = () => { body.style.opacity = sw.checked ? '' : '0.55'; };
|
|
sw.addEventListener('change', applyDim); applyDim();
|
|
|
|
card._rb = { sw, inputs, extras };
|
|
blocksContainer.appendChild(card);
|
|
renumber();
|
|
};
|
|
|
|
(rb.blocks || []).forEach(addBlock);
|
|
|
|
const addBtn = document.createElement('button');
|
|
addBtn.type = 'button';
|
|
addBtn.className = 'btn btn-outline-secondary btn-sm mt-1';
|
|
const addIcon = document.createElement('i');
|
|
addIcon.className = 'fas fa-plus me-1';
|
|
addBtn.append(addIcon, document.createTextNode(`Add ${String(rb.item_label ?? '')}`));
|
|
addBtn.addEventListener('click', () => addBlock({ enabled: true, values: {} }));
|
|
el.appendChild(addBtn);
|
|
|
|
const getBlocks = () => {
|
|
const out = [];
|
|
blocksContainer.querySelectorAll('.rb-block').forEach(card => {
|
|
const { sw, inputs, extras } = card._rb;
|
|
const values = { ...extras };
|
|
inputs.forEach(({ field, input }) => { values[field.key] = readInput(field, input); });
|
|
out.push({ enabled: sw.checked, values });
|
|
});
|
|
return out;
|
|
};
|
|
|
|
return { id: rb.id, el, getBlocks, original: JSON.stringify(getBlocks()) };
|
|
}
|
|
|
|
// Locate a field's wrapper so we can find its .invalid-feedback even when the
|
|
// input is nested inside an .input-group (number fields with a unit suffix).
|
|
function fieldWrapper(input) {
|
|
return input.closest('.input-group, .mb-1, .form-check') || input.parentElement;
|
|
}
|
|
|
|
function markInvalid(input, message) {
|
|
input.classList.add('is-invalid');
|
|
const fb = fieldWrapper(input).querySelector('.invalid-feedback');
|
|
if (fb) { fb.textContent = message; fb.style.display = 'block'; }
|
|
}
|
|
|
|
function clearInvalid(input) {
|
|
input.classList.remove('is-invalid');
|
|
const fb = fieldWrapper(input).querySelector('.invalid-feedback');
|
|
if (fb) { fb.textContent = ''; fb.style.display = ''; }
|
|
}
|
|
|
|
function readInput(field, input) {
|
|
if (field.type === 'bool') return input.checked;
|
|
return input.value.trim();
|
|
}
|
|
|
|
function validateClient(field, raw) {
|
|
const empty = (raw === '' || raw === null || raw === undefined);
|
|
if (field.type === 'bool') return null;
|
|
if (empty) {
|
|
if (field.type === 'password' && field.has_value) return null; // leave unchanged
|
|
return field.required ? `${field.label} is required` : null;
|
|
}
|
|
if (field.type === 'int' || field.type === 'float') {
|
|
const num = Number(raw);
|
|
if (Number.isNaN(num)) return `${field.label} must be a number`;
|
|
if (field.type === 'int' && !Number.isInteger(num)) return `${field.label} must be a whole number`;
|
|
if (field.min !== undefined && field.min !== null && num < field.min) return `${field.label} must be ≥ ${field.min}`;
|
|
if (field.max !== undefined && field.max !== null && num > field.max) return `${field.label} must be ≤ ${field.max}`;
|
|
}
|
|
if (field.type === 'enum') {
|
|
const allowed = (field.options || []).map(o => String(o.value));
|
|
if (!allowed.includes(String(raw))) return `${field.label} is invalid`;
|
|
}
|
|
if ((field.type === 'str' || field.type === 'password') && field.pattern) {
|
|
if (!new RegExp('^(?:' + field.pattern + ')$').test(raw)) return `${field.label} has an invalid format`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.pluginsManager = new PluginsManager();
|
|
window.pluginsManager.load();
|
|
});
|
|
</script>
|
|
{% endblock %}
|