Create MeshCentral workspace

This commit is contained in:
2026-07-11 10:44:42 -05:00
commit 37a8586d34
34 changed files with 6930 additions and 0 deletions
@@ -0,0 +1,685 @@
'use strict';
/**
* Plugin Name: GracePress Theme Studio
* Description: MeshCentral UI theme inventory and profile editor for GracePress-managed MeshCentral servers.
* Version: 0.0.3
* Stage: Alpha
* Release Channel: Internal
* Public Release: No
* Public Candidate: No
* Category: MeshCentral
* Settings Slug: gracepress-theme-studio
* WordPress.org Slug:
* MeshCentral Short Name: gpthemestudio
* MeshCentral Config URL: https://christit.com/gracepress/gracepress-theme-studio.mcplugin.json
* MeshCentral Compatibility: >=1.1.0
* Requires PHP: 8.0
* Icon: 🧩
*/
const fs = require('fs');
const path = require('path');
const querystring = require('querystring');
const PLUGIN = {
name: 'GracePress Theme Studio',
shortName: 'gpthemestudio',
version: '0.0.3',
};
const PROFILE_DIR = 'themes';
const DEFAULT_PROFILE_ID = 'meshcentral-default';
const ACTIVE_FILE = 'active-theme.json';
const CONTROL_GROUPS = [
{
id: 'brand',
label: 'Brand',
description: 'Visible identity and non-CSS presentation values.',
fields: [
{ key: 'brand.title', label: 'Site title', type: 'text' },
{ key: 'brand.subtitle', label: 'Subtitle', type: 'text' },
{ key: 'brand.logoUrl', label: 'Logo URL', type: 'text', placeholder: 'https://...' },
{ key: 'brand.footerText', label: 'Footer text', type: 'text', placeholder: 'Terms & Privacy' },
],
},
{
id: 'color',
label: 'Colors',
description: 'Primary MeshCentral chrome, panel, link, and state colors.',
fields: [
{ key: 'color.headerBackground', label: 'Header background', type: 'color' },
{ key: 'color.headerText', label: 'Header text', type: 'color' },
{ key: 'color.leftbarBackground', label: 'Icon rail background', type: 'color' },
{ key: 'color.sidebarBackground', label: 'Sidebar background', type: 'color' },
{ key: 'color.tabActiveBackground', label: 'Active tab', type: 'color' },
{ key: 'color.panelBackground', label: 'Panel background', type: 'color' },
{ key: 'color.panelText', label: 'Panel text', type: 'color' },
{ key: 'color.link', label: 'Links', type: 'color' },
{ key: 'color.success', label: 'Success', type: 'color' },
{ key: 'color.warning', label: 'Warning', type: 'color' },
{ key: 'color.danger', label: 'Danger', type: 'color' },
],
},
{
id: 'font',
label: 'Typography',
description: 'Dropdown-driven font decisions instead of blank freehand values.',
fields: [
{
key: 'font.family',
label: 'Font family',
type: 'select',
options: [
'Arial, Helvetica, sans-serif',
'Inter, Arial, Helvetica, sans-serif',
'Segoe UI, Arial, Helvetica, sans-serif',
'Roboto, Arial, Helvetica, sans-serif',
'Georgia, serif',
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
],
},
{ key: 'font.baseSize', label: 'Base size', type: 'select', options: ['14px', '15px', '16px', '17px', '18px'] },
{ key: 'font.headingSize', label: 'Heading size', type: 'select', options: ['24px', '28px', '32px', '36px', '40px'] },
],
},
{
id: 'layout',
label: 'Layout',
description: 'Stable layout tokens for later CSS generation.',
fields: [
{ key: 'layout.density', label: 'Density', type: 'select', options: ['compact', 'default', 'comfortable'] },
{ key: 'layout.leftbarWidth', label: 'Icon rail width', type: 'select', options: ['60px', '68px', '75px', '84px', '96px'] },
{ key: 'layout.borderRadius', label: 'Border radius', type: 'select', options: ['0px', '3px', '5px', '8px'] },
],
},
{
id: 'icons',
label: 'Icons',
description: 'Icon pack selection. Custom pack upload/generation comes after the mapping is proven.',
fields: [
{ key: 'icons.set', label: 'Icon set', type: 'select', options: ['meshcentral-classic', 'gracepress-classic', 'gracepress-flat', 'custom-pack'] },
{ key: 'icons.size', label: 'Icon size', type: 'select', options: ['40px', '48px', '56px', '64px'] },
{ key: 'icons.packPath', label: 'Custom pack path', type: 'text', placeholder: '/path/to/icons' },
],
},
{
id: 'advanced',
label: 'Advanced',
description: 'Recorded but not globally injected until rollback and startup loading are built.',
fields: [
{ key: 'advanced.customCss', label: 'Custom CSS notes', type: 'textarea' },
],
},
];
module.exports.gpthemestudio = function(parent) {
const obj = { parent };
obj.exports = [];
ensureProfileStore(parent);
console.log(`${PLUGIN.name} loaded.`);
obj.handleAdminReq = function(req, res, user) {
handleAdminRequest(parent, req, res, user);
};
return obj;
};
function handleAdminRequest(parent, req, res, user) {
if (req && req.method === 'POST') {
readBody(req, function(err, body) {
const username = getUsername(user);
let notice = '';
let error = '';
if (err) {
error = err.message;
} else {
const result = handleAction(parent, body);
notice = result.notice || '';
error = result.error || '';
}
const state = loadState(parent);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderAdmin(username, state, notice, error));
});
return;
}
const state = loadState(parent, req);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(renderAdmin(getUsername(user), state, '', ''));
}
function readBody(req, done) {
let body = '';
req.on('data', function(chunk) {
body += chunk;
if (body.length > 262144) req.destroy(new Error('Request body too large.'));
});
req.on('end', function() {
try {
done(null, querystring.parse(body));
} catch (err) {
done(err);
}
});
req.on('error', done);
}
function handleAction(parent, data) {
const action = getPostedValue(data.action);
if (action === 'clone') return cloneProfile(parent, data);
if (action === 'save') return saveProfile(parent, data);
if (action === 'select') return selectActiveProfile(parent, data.profileId);
if (action === 'export') return exportProfile(parent, data.profileId);
return { error: 'Unknown Theme Studio action.' };
}
function cloneProfile(parent, data) {
const base = getProfileById(parent, String(data.sourceProfileId || DEFAULT_PROFILE_ID)) || loadDefaultTheme(parent);
const name = cleanProfileName(data.newProfileName || 'Christ IT Theme');
const id = uniqueProfileId(parent, slugify(name));
const clone = JSON.parse(JSON.stringify(base));
clone.id = id;
clone.name = name;
clone.locked = false;
clone.capturedAt = new Date().toISOString().slice(0, 10);
clone.source = `Cloned from ${base.name || base.id || DEFAULT_PROFILE_ID}`;
clone.description = `Editable theme profile cloned from ${base.name || 'MeshCentral Default'}.`;
clone.notes = ['Editable GracePress Theme Studio profile.'];
clone.branding = clone.branding || {};
clone.generated = clone.generated || {};
writeProfile(parent, clone);
writeActiveProfile(parent, id);
return { notice: `Created editable theme profile "${name}".` };
}
function saveProfile(parent, data) {
const profileId = String(data.profileId || '');
const profile = getProfileById(parent, profileId);
if (!profile) return { error: 'Selected profile could not be found.' };
if (profile.locked) return { error: 'The default MeshCentral profile is locked. Clone it before editing.' };
profile.name = cleanProfileName(data.profileName || profile.name || profile.id);
profile.description = String(data.profileDescription || profile.description || '');
profile.updatedAt = new Date().toISOString();
profile.variables = profile.variables || {};
getEditableFields().forEach(function(field) {
const formKey = formFieldName(field.key);
if (Object.prototype.hasOwnProperty.call(data, formKey)) {
profile.variables[field.key] = normalizeFieldValue(field, data[formKey]);
}
});
writeProfile(parent, profile);
writeActiveProfile(parent, profile.id);
return { notice: `Saved "${profile.name}".` };
}
function selectActiveProfile(parent, profileId) {
const profile = getProfileById(parent, String(profileId || ''));
if (!profile) return { error: 'Selected profile could not be found.' };
writeActiveProfile(parent, profile.id);
return { notice: `Selected "${profile.name}" as the active editing profile.` };
}
function exportProfile(parent, profileId) {
const profile = getProfileById(parent, String(profileId || ''));
if (!profile) return { error: 'Selected profile could not be found.' };
const exportDir = path.join(getProfileDir(parent), 'exports');
fs.mkdirSync(exportDir, { recursive: true });
const file = path.join(exportDir, `${safeFileName(profile.id)}-${Date.now()}.json`);
fs.writeFileSync(file, JSON.stringify(profile, null, 2) + '\n', 'utf8');
return { notice: `Exported "${profile.name}" to ${file}.` };
}
function ensureProfileStore(parent) {
const dir = getProfileDir(parent);
fs.mkdirSync(dir, { recursive: true });
const defaultFile = path.join(dir, `${DEFAULT_PROFILE_ID}.json`);
if (!fs.existsSync(defaultFile)) {
writeProfile(parent, loadDefaultTheme(parent));
}
if (!fs.existsSync(path.join(dir, ACTIVE_FILE))) {
writeActiveProfile(parent, DEFAULT_PROFILE_ID);
}
}
function loadState(parent, req) {
ensureProfileStore(parent);
const profiles = loadProfiles(parent);
const requestedId = getRequestProfileId(req);
const activeId = requestedId || readActiveProfile(parent) || DEFAULT_PROFILE_ID;
const active = profiles.find(function(profile) { return profile.id === activeId; }) || profiles[0] || loadDefaultTheme(parent);
return {
profiles,
active,
activeId: active.id,
storagePath: getProfileDir(parent),
};
}
function getRequestProfileId(req) {
if (!req || !req.url || req.url.indexOf('?') === -1) return '';
const query = querystring.parse(req.url.split('?').slice(1).join('?'));
return String(query.profileId || '');
}
function loadProfiles(parent) {
const dir = getProfileDir(parent);
const files = fs.readdirSync(dir)
.filter(function(file) { return file.endsWith('.json') && file !== ACTIVE_FILE; })
.sort();
const profiles = files.map(function(file) {
try {
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
} catch (err) {
return null;
}
}).filter(Boolean);
profiles.sort(function(left, right) {
if (left.id === DEFAULT_PROFILE_ID) return -1;
if (right.id === DEFAULT_PROFILE_ID) return 1;
return String(left.name || left.id).localeCompare(String(right.name || right.id));
});
return profiles.length ? profiles : [loadDefaultTheme(parent)];
}
function getProfileById(parent, profileId) {
return loadProfiles(parent).find(function(profile) { return profile.id === profileId; }) || null;
}
function getProfileDir(parent) {
return path.join(__dirname, PROFILE_DIR);
}
function readActiveProfile(parent) {
try {
return JSON.parse(fs.readFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), 'utf8')).activeProfileId || DEFAULT_PROFILE_ID;
} catch (err) {
return DEFAULT_PROFILE_ID;
}
}
function writeActiveProfile(parent, profileId) {
fs.writeFileSync(path.join(getProfileDir(parent), ACTIVE_FILE), JSON.stringify({
activeProfileId: profileId || DEFAULT_PROFILE_ID,
selectedAt: new Date().toISOString(),
applied: false,
}, null, 2) + '\n', 'utf8');
}
function writeProfile(parent, profile) {
fs.mkdirSync(getProfileDir(parent), { recursive: true });
fs.writeFileSync(path.join(getProfileDir(parent), `${safeFileName(profile.id)}.json`), JSON.stringify(profile, null, 2) + '\n', 'utf8');
}
function loadDefaultTheme(parent) {
const candidates = [
path.join(__dirname, 'default-theme.json'),
].filter(Boolean);
for (const file of candidates) {
try {
if (fs.existsSync(file)) {
const theme = JSON.parse(fs.readFileSync(file, 'utf8'));
theme.id = DEFAULT_PROFILE_ID;
theme.name = theme.name || 'MeshCentral Default';
theme.locked = true;
theme.variables = withProfileDefaults(theme.variables || {});
return theme;
}
} catch (err) {
return fallbackDefaultTheme(file, err.message);
}
}
return fallbackDefaultTheme('', '');
}
function fallbackDefaultTheme(source, error) {
return {
schema: 1,
id: DEFAULT_PROFILE_ID,
name: 'MeshCentral Default',
description: error ? 'Default theme profile could not be loaded.' : 'Default theme profile is missing from this plugin package.',
locked: true,
capturedAt: '',
source,
variables: withProfileDefaults({}),
error,
};
}
function withProfileDefaults(variables) {
const defaults = {
'brand.logoUrl': '',
'brand.footerText': 'Terms & Privacy',
'icons.packPath': '',
'advanced.customCss': '',
};
return Object.assign({}, defaults, variables);
}
function renderAdmin(username, state, notice, error) {
const profile = state.active || loadDefaultTheme();
const variables = withProfileDefaults(profile.variables || {});
const locked = !!profile.locked;
const profiles = state.profiles || [];
const rawGroups = groupVariables(variables);
const rawRows = renderRawInventory(rawGroups);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${escapeHtml(PLUGIN.name)}</title>
<style>
:root {
--gp-bg: #f5f7fa;
--gp-panel: #ffffff;
--gp-ink: #1f2937;
--gp-muted: #667085;
--gp-line: #d8dee8;
--gp-accent: #0f5e9c;
--gp-accent-2: #087f5b;
--gp-warn: #b45309;
--gp-danger: #b42318;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 24px; background: var(--gp-bg); color: var(--gp-ink); font-family: Arial, Helvetica, sans-serif; }
.wrap { max-width: 1180px; }
.hero { border-left: 5px solid var(--gp-accent); padding: 0 0 0 16px; margin-bottom: 18px; }
h1 { margin: 0 0 6px; font-size: 24px; }
h2 { margin: 0 0 12px; font-size: 17px; }
h3 { margin: 0 0 8px; font-size: 15px; }
p { line-height: 1.45; }
.muted { color: var(--gp-muted); }
.grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 10px; margin: 16px 0; }
.metric, .panel { background: var(--gp-panel); border: 1px solid var(--gp-line); border-radius: 6px; padding: 14px; }
.metric .label { color: var(--gp-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
.metric .value { font-size: 18px; font-weight: 700; margin-top: 5px; }
.panels { display: grid; grid-template-columns: 1fr; gap: 14px; }
.profilebar { display: grid; grid-template-columns: minmax(260px, 1fr) auto auto; gap: 10px; align-items: end; }
.notice, .error { border-radius: 5px; padding: 9px 11px; margin: 0 0 12px; }
.notice { border: 1px solid #9bd7bd; background: #edfdf5; color: #087f5b; }
.error { border: 1px solid #f2aca8; background: #fff1f0; color: var(--gp-danger); }
label { display: block; color: #374151; font-weight: 700; font-size: 12px; margin: 0 0 5px; text-transform: uppercase; }
input, select, textarea { width: 100%; border: 1px solid #bfc7d4; border-radius: 5px; padding: 8px; font: inherit; color: #111827; background: #fff; }
input[type="color"] { min-height: 38px; padding: 3px; }
textarea { min-height: 92px; resize: vertical; }
input[readonly], textarea[readonly], select:disabled { background: #f3f5f8; color: #6b7280; }
.section-grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 12px; }
.field { margin-bottom: 10px; }
.field.color { display: grid; grid-template-columns: 54px 1fr; gap: 8px; align-items: end; }
.field.color label { grid-column: 1 / -1; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
button { border: 1px solid var(--gp-line); background: #eef2f7; color: #111827; border-radius: 5px; padding: 8px 11px; cursor: pointer; }
button.primary { background: var(--gp-accent); border-color: var(--gp-accent); color: #fff; }
button:disabled { opacity: .55; cursor: not-allowed; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border-top: 1px solid var(--gp-line); padding: 9px 10px; text-align: left; vertical-align: middle; }
th { background: #f0f3f8; color: #374151; font-size: 12px; text-transform: uppercase; }
code { background: #eef2f7; padding: 2px 5px; border-radius: 3px; }
.chip { display: inline-block; border: 1px solid var(--gp-line); background: #f8fafc; border-radius: 999px; padding: 3px 8px; font-size: 12px; color: #334155; }
.chip.ok { border-color: #9bd7bd; color: var(--gp-accent-2); background: #edfdf5; }
.chip.warn { border-color: #f2c98d; color: var(--gp-warn); background: #fff7ed; }
.swatch { display: inline-block; width: 18px; height: 18px; border: 1px solid #9ca3af; border-radius: 3px; vertical-align: middle; margin-right: 8px; background: var(--swatch); }
.raw { max-height: 360px; overflow: auto; border: 1px solid var(--gp-line); border-radius: 5px; }
@media (max-width: 840px) { .grid, .section-grid, .profilebar { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<h1>${escapeHtml(PLUGIN.name)}</h1>
<div class="muted">Profile editor baseline for MeshCentral UI theming. Logged in as ${escapeHtml(username)}.</div>
</div>
${notice ? `<div class="notice">${escapeHtml(notice)}</div>` : ''}
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
<div class="grid">
<div class="metric"><div class="label">Plugin</div><div class="value">${escapeHtml(PLUGIN.version)}</div></div>
<div class="metric"><div class="label">Profile</div><div class="value">${escapeHtml(profile.name || '')}</div></div>
<div class="metric"><div class="label">Variables</div><div class="value">${Object.keys(variables).length}</div></div>
<div class="metric"><div class="label">Mode</div><div class="value">${locked ? '<span class="chip ok">Locked default</span>' : '<span class="chip warn">Editable</span>'}</div></div>
</div>
<div class="panels">
<section class="panel">
<form method="post" class="profilebar">
<input type="hidden" name="action" value="select">
<div>
<label for="profileId">Theme profile</label>
<select id="profileId" name="profileId">${renderProfileOptions(profiles, profile.id)}</select>
</div>
<button class="primary" type="submit">Load Profile</button>
<button type="submit" name="action" value="export">Export JSON</button>
</form>
<form method="post" class="actions">
<input type="hidden" name="action" value="clone">
<input type="hidden" name="sourceProfileId" value="${escapeAttr(profile.id)}">
<input name="newProfileName" value="${escapeAttr(suggestCloneName(profile.name))}" aria-label="New profile name">
<button class="primary" type="submit">Clone Current Profile</button>
</form>
</section>
<form method="post" class="panel">
<input type="hidden" name="action" value="save">
<input type="hidden" name="profileId" value="${escapeAttr(profile.id)}">
<h2>Profile Editor</h2>
<p class="muted">${locked ? 'This is the locked MeshCentral Default profile. Clone it to create an editable theme.' : 'Edit values, save the profile, then export or apply after the global CSS hook is built.'}</p>
<div class="section-grid">
<div class="field">
<label for="profileName">Profile name</label>
<input id="profileName" name="profileName" value="${escapeAttr(profile.name || '')}" ${locked ? 'readonly' : ''}>
</div>
<div class="field">
<label for="profileDescription">Description</label>
<input id="profileDescription" name="profileDescription" value="${escapeAttr(profile.description || '')}" ${locked ? 'readonly' : ''}>
</div>
</div>
${CONTROL_GROUPS.map(function(group) { return renderControlGroup(group, variables, locked); }).join('')}
<div class="actions">
<button class="primary" type="submit" ${locked ? 'disabled' : ''}>Save Profile</button>
<button type="button" disabled>Apply Theme</button>
<button type="button" disabled>Install Startup CSS Hook</button>
</div>
</form>
<section class="panel">
<h2>Implementation Boundary</h2>
<p class="muted">Theme Studio now saves editable profiles. Apply is intentionally disabled until the generated CSS hook, startup reapply behavior, and rollback marker are implemented.</p>
<table>
<tbody>
<tr><th>ID</th><td><code>${escapeHtml(profile.id || '')}</code></td></tr>
<tr><th>Storage</th><td><code>${escapeHtml(state.storagePath || '')}</code></td></tr>
<tr><th>Source</th><td>${escapeHtml(profile.source || '')}</td></tr>
<tr><th>Updated</th><td>${escapeHtml(profile.updatedAt || profile.capturedAt || '')}</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Raw Variable Inventory</h2>
<p class="muted">Kept for audit and future mapping work. The editor above is the primary workflow.</p>
<div class="raw">${rawRows || '<p class="muted">No variables were found.</p>'}</div>
</section>
</div>
</div>
</body>
</html>`;
}
function renderProfileOptions(profiles, activeId) {
return profiles.map(function(profile) {
const label = `${profile.name || profile.id}${profile.locked ? ' (locked)' : ''}`;
return `<option value="${escapeAttr(profile.id)}" ${profile.id === activeId ? 'selected' : ''}>${escapeHtml(label)}</option>`;
}).join('');
}
function renderControlGroup(group, variables, locked) {
const fields = group.fields.map(function(field) {
return renderField(field, variables[field.key], locked);
}).join('');
return `<section>
<h3>${escapeHtml(group.label)}</h3>
<p class="muted">${escapeHtml(group.description)}</p>
<div class="section-grid">${fields}</div>
</section>`;
}
function renderField(field, value, locked) {
const name = formFieldName(field.key);
const id = `field-${name}`;
const current = value === undefined || value === null ? '' : String(value);
const disabled = locked ? 'disabled' : '';
const readonly = locked ? 'readonly' : '';
const fieldClass = field.type === 'color' ? 'field color' : 'field';
let control = '';
if (field.type === 'select') {
control = `<select id="${escapeAttr(id)}" name="${escapeAttr(name)}" ${disabled}>${renderSelectOptions(field.options || [], current)}</select>`;
} else if (field.type === 'textarea') {
control = `<textarea id="${escapeAttr(id)}" name="${escapeAttr(name)}" ${readonly}>${escapeHtml(current)}</textarea>`;
} else if (field.type === 'color') {
const color = isHexColor(current) ? current : '#000000';
control = `<input type="color" id="${escapeAttr(id)}-color" name="${escapeAttr(name)}" value="${escapeAttr(color)}" ${disabled}><input type="text" value="${escapeAttr(current)}" ${readonly} aria-label="${escapeAttr(field.label)} text value">`;
} else {
control = `<input id="${escapeAttr(id)}" name="${escapeAttr(name)}" value="${escapeAttr(current)}" placeholder="${escapeAttr(field.placeholder || '')}" ${readonly}>`;
}
return `<div class="${fieldClass}"><label for="${escapeAttr(id)}">${escapeHtml(field.label)}</label>${control}</div>`;
}
function renderSelectOptions(options, current) {
const seen = new Set();
const all = options.slice();
if (current && all.indexOf(current) === -1) all.unshift(current);
return all.filter(function(option) {
if (seen.has(option)) return false;
seen.add(option);
return true;
}).map(function(option) {
return `<option value="${escapeAttr(option)}" ${option === current ? 'selected' : ''}>${escapeHtml(option)}</option>`;
}).join('');
}
function renderRawInventory(groups) {
const order = ['brand', 'font', 'layout', 'color', 'button', 'table', 'icons', 'effects', 'advanced', 'other'];
return order.filter(function(group) { return groups[group] && groups[group].length; }).map(function(group) {
const body = groups[group].map(function(row) {
return `<tr><td><code>${escapeHtml(row.key)}</code></td><td>${renderValue(row.value)}</td></tr>`;
}).join('');
return `<h3>${escapeHtml(titleCase(group))}</h3><table><thead><tr><th>Variable</th><th>Value</th></tr></thead><tbody>${body}</tbody></table>`;
}).join('');
}
function getEditableFields() {
return CONTROL_GROUPS.reduce(function(fields, group) {
return fields.concat(group.fields);
}, []);
}
function groupVariables(variables) {
const groups = {};
Object.keys(variables).sort().forEach(function(key) {
const dot = key.indexOf('.');
const group = dot > 0 ? key.substring(0, dot) : 'other';
if (!groups[group]) groups[group] = [];
groups[group].push({ key, value: variables[key] });
});
return groups;
}
function renderValue(value) {
const text = String(value);
const color = isColorValue(text) ? `<span class="swatch" style="--swatch:${escapeAttr(text)}"></span>` : '';
return `${color}<code>${escapeHtml(text)}</code>`;
}
function normalizeFieldValue(field, value) {
value = getPostedValue(value);
value = String(value || '').trim();
if (field.type === 'color' && !isHexColor(value)) return '#000000';
return value;
}
function getPostedValue(value) {
if (Array.isArray(value)) return String(value[value.length - 1] || '');
return String(value || '');
}
function formFieldName(key) {
return `var_${String(key).replace(/[^a-zA-Z0-9]/g, '_')}`;
}
function uniqueProfileId(parent, base) {
base = base || 'theme';
const existing = new Set(loadProfiles(parent).map(function(profile) { return profile.id; }));
if (!existing.has(base)) return base;
let index = 2;
while (existing.has(`${base}-${index}`)) index += 1;
return `${base}-${index}`;
}
function cleanProfileName(value) {
const name = String(value || '').trim().replace(/\s+/g, ' ');
return name || 'Untitled Theme';
}
function slugify(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'theme';
}
function safeFileName(value) {
return String(value || 'theme').replace(/[^a-zA-Z0-9._-]/g, '_');
}
function suggestCloneName(name) {
if (!name || name === 'MeshCentral Default') return 'Christ IT Theme';
return `${name} Copy`;
}
function getUsername(user) {
return (user && (user.name || user._id)) ? (user.name || user._id) : 'MeshCentral admin';
}
function isHexColor(value) {
return /^#([0-9a-f]{6})$/i.test(String(value || ''));
}
function isColorValue(value) {
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value) || /^rgba?\(/i.test(value);
}
function titleCase(value) {
return String(value).replace(/[-_]/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function(c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, '&#96;');
}