Skip to content

Commit 7392167

Browse files
committed
Dev tools: add cleanup and robust YAML parsing
1 parent 964e0a9 commit 7392167

2 files changed

Lines changed: 125 additions & 388 deletions

File tree

custom_components/blueprint_studio/www/modules/dev-tools.js

Lines changed: 125 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ export function openDevTools(initialTab = 'actions') {
1212
if (existing) {
1313
const active = existing.querySelector('.bdt-tab-btn.active');
1414
if (active && active.dataset.tab === initialTab) {
15-
existing.remove();
15+
_destroyPanel(existing);
1616
return; // toggle off
1717
}
18-
existing.remove();
18+
_destroyPanel(existing);
1919
}
2020
_buildPanel(initialTab);
2121
}
@@ -24,7 +24,11 @@ export function openDevTools(initialTab = 'actions') {
2424

2525
function _buildPanel(activeTab) {
2626
const panel = document.createElement('div');
27+
const cleanupFns = [];
2728
panel.id = PANEL_ID;
29+
panel._bdtCleanup = () => {
30+
while (cleanupFns.length) cleanupFns.pop()();
31+
};
2832
panel.innerHTML = `
2933
<div class="bdt-header">
3034
<span class="material-icons bdt-header-icon">construction</span>
@@ -51,15 +55,17 @@ function _buildPanel(activeTab) {
5155
panel.querySelectorAll('.bdt-tab-btn').forEach(btn => {
5256
btn.addEventListener('click', () => _switchTab(panel, btn.dataset.tab));
5357
});
54-
panel.querySelector('.bdt-close').addEventListener('click', () => panel.remove());
58+
panel.querySelector('.bdt-close').addEventListener('click', () => _destroyPanel(panel));
5559

56-
const onKey = e => { if (e.key === 'Escape') { panel.remove(); document.removeEventListener('keydown', onKey); } };
60+
const onKey = e => { if (e.key === 'Escape') _destroyPanel(panel); };
5761
document.addEventListener('keydown', onKey);
62+
cleanupFns.push(() => document.removeEventListener('keydown', onKey));
5863

5964
_switchTab(panel, activeTab);
6065
_initActions(panel);
6166
_initTemplate(panel);
62-
_initStates(panel);
67+
const cleanupStates = _initStates(panel);
68+
if (cleanupStates) cleanupFns.push(cleanupStates);
6369
_initConfig(panel);
6470
}
6571

@@ -68,6 +74,12 @@ function _switchTab(panel, tab) {
6874
panel.querySelectorAll('.bdt-pane').forEach(p => p.classList.toggle('active', p.dataset.pane === tab));
6975
}
7076

77+
function _destroyPanel(panel) {
78+
if (!panel) return;
79+
if (typeof panel._bdtCleanup === 'function') panel._bdtCleanup();
80+
panel.remove();
81+
}
82+
7183
// ── Actions pane ──────────────────────────────────────────────────────────────
7284

7385
function _actionsPane() {
@@ -361,7 +373,7 @@ function _initActions(panel) {
361373
if (entityId) yaml += `target:\n entity_id: ${entityId}\n`;
362374
const dataFields = Object.entries(formData).filter(([k]) => k !== 'entity_id');
363375
if (dataFields.length) {
364-
yaml += `data:\n` + dataFields.map(([k, v]) => ` ${k}: ${v}`).join('\n') + '\n';
376+
yaml += `data:\n` + dataFields.map(([k, v]) => ` ${k}: ${_formatYamlScalar(v)}`).join('\n') + '\n';
365377
}
366378
yamlInput.value = yaml;
367379
}
@@ -400,6 +412,7 @@ function _initActions(panel) {
400412
const { action, data = {}, target = {} } = parsed;
401413
if (!action) { _showResult(yamlResult, false, 'Missing "action:" field'); return; }
402414
const [domain, service] = action.split('.');
415+
if (!domain || !service) { _showResult(yamlResult, false, 'Action must be in domain.service format'); return; }
403416
await _callAction(domain, service, data, target, yamlPerformBtn, yamlResult, 'Perform action');
404417
});
405418

@@ -545,7 +558,8 @@ function _initStates(panel) {
545558
e.entity_id.toLowerCase().includes(q) || (e.friendly_name || '').toLowerCase().includes(q));
546559
if (!filtered.length) { tbody.innerHTML = '<tr><td colspan="3" class="bdt-states-loading">No entities match.</td></tr>'; return; }
547560

548-
tbody.innerHTML = filtered.slice(0, 200).map(e => {
561+
const visible = filtered.slice(0, 200);
562+
tbody.innerHTML = visible.map(e => {
549563
const cls = e.state === 'on' ? 'bdt-state-on' : e.state === 'off' ? 'bdt-state-off' : 'bdt-state-other';
550564
const attrs = e.attributes || {};
551565
// Show a short summary: up to 2 key attributes excluding friendly_name/icon
@@ -580,7 +594,7 @@ function _initStates(panel) {
580594
row.addEventListener('click', () => {
581595
const next = row.nextElementSibling;
582596
if (next && next.classList.contains('bdt-attr-detail-row')) { next.remove(); return; }
583-
const entity = filtered[i];
597+
const entity = visible[i];
584598
const attrs = entity.attributes || {};
585599
const skip = new Set(['entity_picture']);
586600
const rows = Object.entries(attrs)
@@ -605,6 +619,8 @@ function _initStates(panel) {
605619
if (pane.classList.contains('active') && allEntities.length === 0) load();
606620
});
607621
observer.observe(pane, { attributes: true, attributeFilter: ['class'] });
622+
if (pane.classList.contains('active') && allEntities.length === 0) load();
623+
return () => observer.disconnect();
608624
}
609625

610626
// ── Config pane ───────────────────────────────────────────────────────────────
@@ -785,33 +801,114 @@ function _coerce(v) {
785801
}
786802

787803
/**
788-
* Parse a simple HA action YAML block into { action, data, target }.
789-
* Handles flat key:value and one level of nesting (data:, target:).
804+
* Parse the HA action YAML shape accepted by this panel into
805+
* { action, data, target }. It supports nested maps and simple lists under
806+
* data:/target: without trying to become a full YAML parser.
790807
*/
791808
function _parseActionYaml(text) {
792809
const result = { action: null, data: {}, target: {} };
793-
let currentSection = null;
794-
for (const raw of text.split('\n')) {
795-
const line = raw.trimEnd();
796-
if (!line.trim() || line.trim().startsWith('#')) continue;
810+
const stack = [{ indent: -1, value: result }];
811+
812+
const lines = text.split('\n');
813+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
814+
const raw = lines[lineIndex];
815+
const withoutComment = _stripYamlComment(raw);
816+
const line = withoutComment.trimEnd();
817+
if (!line.trim()) continue;
818+
797819
const indent = line.match(/^(\s*)/)[1].length;
798820
const trimmed = line.trim();
799-
if (indent === 0) {
800-
currentSection = null;
801-
const colonIdx = trimmed.indexOf(':');
802-
if (colonIdx === -1) continue;
803-
const key = trimmed.slice(0, colonIdx).trim();
804-
const val = trimmed.slice(colonIdx + 1).trim();
805-
if (key === 'action' || key === 'service') { result.action = val; }
806-
else if (key === 'data' || key === 'target') { currentSection = key; }
821+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
822+
const parent = stack[stack.length - 1].value;
823+
824+
if (trimmed.startsWith('- ')) {
825+
if (!Array.isArray(parent)) {
826+
throw new Error('List items must belong to a key such as entity_id:');
827+
}
828+
parent.push(_parseYamlValue(trimmed.slice(2).trim()));
829+
continue;
830+
}
831+
832+
const colonIdx = trimmed.indexOf(':');
833+
if (colonIdx === -1) throw new Error(`Invalid line: ${trimmed}`);
834+
835+
const key = trimmed.slice(0, colonIdx).trim();
836+
const val = trimmed.slice(colonIdx + 1).trim();
837+
if (!key) throw new Error(`Invalid line: ${trimmed}`);
838+
839+
if (indent === 0 && (key === 'action' || key === 'service')) {
840+
result.action = String(_parseYamlValue(val));
841+
continue;
842+
}
843+
if (indent === 0 && (key === 'data' || key === 'target')) {
844+
stack.push({ indent, value: result[key] });
845+
continue;
846+
}
847+
if (indent === 0) continue;
848+
849+
if (!parent || typeof parent !== 'object' || Array.isArray(parent)) {
850+
throw new Error(`Cannot assign "${key}" here`);
851+
}
852+
853+
if (val) {
854+
parent[key] = _parseYamlValue(val);
807855
} else {
808-
if (!currentSection) continue;
809-
const colonIdx = trimmed.indexOf(':');
810-
if (colonIdx === -1) continue;
811-
const key = trimmed.slice(0, colonIdx).trim();
812-
const val = trimmed.slice(colonIdx + 1).trim();
813-
if (val) result[currentSection][key] = _coerce(val);
856+
const nextLine = _nextContentLine(lines, lineIndex);
857+
parent[key] = nextLine && nextLine.trim().startsWith('- ') ? [] : {};
858+
stack.push({ indent, value: parent[key] });
814859
}
815860
}
816861
return result;
817862
}
863+
864+
function _nextContentLine(lines, currentIndex) {
865+
for (let i = currentIndex + 1; i < lines.length; i += 1) {
866+
const line = _stripYamlComment(lines[i]).trim();
867+
if (line) return line;
868+
}
869+
return '';
870+
}
871+
872+
function _stripYamlComment(line) {
873+
let inSingle = false;
874+
let inDouble = false;
875+
for (let i = 0; i < line.length; i += 1) {
876+
const char = line[i];
877+
const prev = line[i - 1];
878+
if (char === "'" && !inDouble) inSingle = !inSingle;
879+
else if (char === '"' && !inSingle && prev !== '\\') inDouble = !inDouble;
880+
else if (char === '#' && !inSingle && !inDouble && (i === 0 || /\s/.test(prev))) {
881+
return line.slice(0, i);
882+
}
883+
}
884+
return line;
885+
}
886+
887+
function _parseYamlValue(value) {
888+
if (value === '') return '';
889+
if (value.startsWith('[') && value.endsWith(']')) {
890+
const inner = value.slice(1, -1).trim();
891+
if (!inner) return [];
892+
return inner.split(',').map(item => _parseYamlValue(item.trim()));
893+
}
894+
if (value.startsWith('{') && value.endsWith('}')) {
895+
const inner = value.slice(1, -1).trim();
896+
if (!inner) return {};
897+
return inner.split(',').reduce((obj, pair) => {
898+
const colonIdx = pair.indexOf(':');
899+
if (colonIdx === -1) throw new Error(`Invalid inline map item: ${pair.trim()}`);
900+
const key = pair.slice(0, colonIdx).trim();
901+
obj[key] = _parseYamlValue(pair.slice(colonIdx + 1).trim());
902+
return obj;
903+
}, {});
904+
}
905+
return _coerce(value);
906+
}
907+
908+
function _formatYamlScalar(value) {
909+
if (Array.isArray(value)) return `[${value.map(_formatYamlScalar).join(', ')}]`;
910+
if (value && typeof value === 'object') {
911+
return `{${Object.entries(value).map(([k, v]) => `${k}: ${_formatYamlScalar(v)}`).join(', ')}}`;
912+
}
913+
return String(value);
914+
}

0 commit comments

Comments
 (0)