Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/automations.js
alexei.dolgolyov 30fa107ef7 Add tags to all entity types with chip-based input and autocomplete
- Add `tags: List[str]` field to all 13 entity types (devices, output targets,
  CSS sources, picture sources, audio sources, value sources, sync clocks,
  automations, scene presets, capture/audio/PP/pattern templates)
- Update all stores, schemas, and route handlers for tag CRUD
- Add GET /api/v1/tags endpoint aggregating unique tags across all stores
- Create TagInput component with chip display, autocomplete dropdown,
  keyboard navigation, and API-backed suggestions
- Display tag chips on all entity cards (searchable via existing text filter)
- Add tag input to all 14 editor modals with dirty check support
- Add CSS styles and i18n keys (en/ru/zh) for tag UI
- Also includes code review fixes: thread safety, perf, store dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:20:19 +03:00

771 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Automations — automation cards, editor, condition builder, process picker, scene selector.
*/
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE } from '../core/icons.js';
import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
import { attachProcessPicker } from '../core/process-picker.js';
import { csScenes, createSceneCard } from './scene-presets.js';
let _automationTagsInput = null;
class AutomationEditorModal extends Modal {
constructor() { super('automation-editor-modal'); }
onForceClose() {
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
}
snapshotValues() {
return {
name: document.getElementById('automation-editor-name').value,
enabled: document.getElementById('automation-editor-enabled').checked.toString(),
logic: document.getElementById('automation-editor-logic').value,
conditions: JSON.stringify(getAutomationEditorConditions()),
scenePresetId: document.getElementById('automation-scene-id').value,
deactivationMode: document.getElementById('automation-deactivation-mode').value,
deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value,
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
};
}
}
const automationModal = new AutomationEditorModal();
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id' });
/* ── Condition logic IconSelect ───────────────────────────────── */
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _conditionLogicIconSelect = null;
function _ensureConditionLogicIconSelect() {
const sel = document.getElementById('automation-editor-logic');
if (!sel) return;
const items = [
{ value: 'or', icon: _icon(P.zap), label: t('automations.condition_logic.or'), desc: t('automations.condition_logic.or.desc') },
{ value: 'and', icon: _icon(P.link), label: t('automations.condition_logic.and'), desc: t('automations.condition_logic.and.desc') },
];
if (_conditionLogicIconSelect) { _conditionLogicIconSelect.updateItems(items); return; }
_conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
// Re-render automations when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') loadAutomations();
});
// React to real-time automation state changes from global events WS
document.addEventListener('server:automation_state_changed', () => {
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') {
loadAutomations();
}
});
export async function loadAutomations() {
if (_automationsLoading) return;
set_automationsLoading(true);
const container = document.getElementById('automations-content');
if (!container) { set_automationsLoading(false); return; }
if (!csAutomations.isMounted()) setTabRefreshing('automations-content', true);
try {
const [automations, scenes] = await Promise.all([
automationsCacheObj.fetch(),
scenePresetsCache.fetch(),
]);
const sceneMap = new Map(scenes.map(s => [s.id, s]));
const activeCount = automations.filter(a => a.is_active).length;
updateTabBadge('automations', activeCount);
renderAutomations(automations, sceneMap);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load automations:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`;
} finally {
set_automationsLoading(false);
setTabRefreshing('automations-content', false);
}
}
export function expandAllAutomationSections() {
CardSection.expandAll([csAutomations, csScenes]);
}
export function collapseAllAutomationSections() {
CardSection.collapseAll([csAutomations, csScenes]);
}
function renderAutomations(automations, sceneMap) {
const container = document.getElementById('automations-content');
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
if (csAutomations.isMounted()) {
csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems);
} else {
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllAutomationSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllAutomationSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems);
csAutomations.bind();
csScenes.bind();
// Localize data-i18n elements within the container
container.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
}
}
function createAutomationCard(automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
let condPills = '';
if (automation.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
} else {
const parts = automation.conditions.map(c => {
if (c.condition_type === 'always') {
return `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`;
}
if (c.condition_type === 'startup') {
return `<span class="stream-card-prop">${ICON_START} ${t('automations.condition.startup')}</span>`;
}
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
}
if (c.condition_type === 'time_of_day') {
return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`;
}
if (c.condition_type === 'system_idle') {
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
}
if (c.condition_type === 'display_state') {
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
}
if (c.condition_type === 'mqtt') {
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
}
if (c.condition_type === 'webhook') {
return `<span class="stream-card-prop">&#x1F517; ${t('automations.condition.webhook')}</span>`;
}
return `<span class="stream-card-prop">${c.condition_type}</span>`;
});
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
}
// Scene info
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888';
// Deactivation mode label
let deactivationLabel = '';
if (automation.deactivation_mode === 'revert') {
deactivationLabel = t('automations.deactivation_mode.revert');
} else if (automation.deactivation_mode === 'fallback_scene') {
const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null;
deactivationLabel = fallback ? `${t('automations.deactivation_mode.fallback_scene')}: ${escapeHtml(fallback.name)}` : t('automations.deactivation_mode.fallback_scene');
}
let lastActivityMeta = '';
if (automation.last_activated_at) {
const ts = new Date(automation.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('automations.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
}
return wrapCard({
dataAttr: 'data-automation-id',
id: automation.id,
classes: !automation.enabled ? 'automation-status-disabled' : '',
removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title">
${escapeHtml(automation.name)}
<span class="badge badge-automation-${statusClass}">${statusText}</span>
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${automation.condition_logic === 'and' ? t('automations.logic.all') : t('automations.logic.any')}</span>
<span class="card-meta">${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationLabel ? `<span class="card-meta">${deactivationLabel}</span>` : ''}
${lastActivityMeta}
</div>
<div class="stream-card-props">${condPills}</div>
${renderTagChips(automation.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
<button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${automation.enabled ? ICON_PAUSE : ICON_START}
</button>`,
});
}
export async function openAutomationEditor(automationId, cloneData) {
const modal = document.getElementById('automation-editor-modal');
const titleEl = document.getElementById('automation-editor-title');
const idInput = document.getElementById('automation-editor-id');
const nameInput = document.getElementById('automation-editor-name');
const enabledInput = document.getElementById('automation-editor-enabled');
const logicSelect = document.getElementById('automation-editor-logic');
const condList = document.getElementById('automation-conditions-list');
const errorEl = document.getElementById('automation-editor-error');
errorEl.style.display = 'none';
condList.innerHTML = '';
_ensureConditionLogicIconSelect();
_ensureDeactivationModeIconSelect();
// Fetch scenes for selector
try {
await scenePresetsCache.fetch();
} catch { /* use cached */ }
// Reset deactivation mode
document.getElementById('automation-deactivation-mode').value = 'none';
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
document.getElementById('automation-fallback-scene-group').style.display = 'none';
let _editorTags = [];
if (automationId) {
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
try {
const resp = await fetchWithAuth(`/automations/${automationId}`);
if (!resp.ok) throw new Error('Failed to load automation');
const automation = await resp.json();
idInput.value = automation.id;
nameInput.value = automation.name;
enabledInput.checked = automation.enabled;
logicSelect.value = automation.condition_logic;
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(automation.condition_logic);
for (const c of automation.conditions) {
addAutomationConditionRow(c);
}
// Scene selector
_initSceneSelector('automation-scene-id', automation.scene_preset_id);
// Deactivation mode
const deactMode = automation.deactivation_mode || 'none';
document.getElementById('automation-deactivation-mode').value = deactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
_editorTags = automation.tags || [];
} catch (e) {
showToast(e.message, 'error');
return;
}
} else if (cloneData) {
// Clone mode — create with prefilled data
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = '';
nameInput.value = (cloneData.name || '') + ' (Copy)';
enabledInput.checked = cloneData.enabled !== false;
logicSelect.value = cloneData.condition_logic || 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(cloneData.condition_logic || 'or');
// Clone conditions (strip webhook tokens — they must be unique)
for (const c of (cloneData.conditions || [])) {
const clonedCond = { ...c };
if (clonedCond.condition_type === 'webhook') delete clonedCond.token;
addAutomationConditionRow(clonedCond);
}
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
const cloneDeactMode = cloneData.deactivation_mode || 'none';
document.getElementById('automation-deactivation-mode').value = cloneDeactMode;
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
_editorTags = cloneData.tags || [];
} else {
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = '';
nameInput.value = '';
enabledInput.checked = true;
logicSelect.value = 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue('or');
_initSceneSelector('automation-scene-id', null);
_initSceneSelector('automation-fallback-scene-id', null);
}
// Wire up deactivation mode change
document.getElementById('automation-deactivation-mode').onchange = _onDeactivationModeChange;
automationModal.open();
modal.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
});
// Tags
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
_automationTagsInput = new TagInput(document.getElementById('automation-tags-container'), { placeholder: t('tags.placeholder') });
_automationTagsInput.setValue(_editorTags);
automationModal.snapshot();
}
function _onDeactivationModeChange() {
const mode = document.getElementById('automation-deactivation-mode').value;
document.getElementById('automation-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none';
}
export async function closeAutomationEditorModal() {
await automationModal.close();
}
// ===== Scene selector (EntitySelect) =====
let _sceneEntitySelect = null;
let _fallbackSceneEntitySelect = null;
function _getSceneItems() {
return (scenePresetsCache.data || []).map(s => ({
value: s.id,
label: s.name,
icon: `<span class="scene-color-dot" style="background:${s.color || '#4fc3f7'}"></span>`,
}));
}
function _initSceneSelector(selectId, selectedId) {
const sel = document.getElementById(selectId);
// Populate <select> with scene options
sel.innerHTML = (scenePresetsCache.data || []).map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
sel.value = selectedId || '';
// Determine which EntitySelect slot to use
const isMain = selectId === 'automation-scene-id';
const existing = isMain ? _sceneEntitySelect : _fallbackSceneEntitySelect;
if (existing) existing.destroy();
const es = new EntitySelect({
target: sel,
getItems: _getSceneItems,
placeholder: t('automations.scene.search_placeholder'),
allowNone: true,
noneLabel: t('automations.scene.none_selected'),
});
if (isMain) _sceneEntitySelect = es; else _fallbackSceneEntitySelect = es;
}
// ===== Deactivation mode IconSelect =====
const DEACT_MODE_KEYS = ['none', 'revert', 'fallback_scene'];
const DEACT_MODE_ICONS = {
none: P.pause, revert: P.undo2, fallback_scene: P.sparkles,
};
let _deactivationModeIconSelect = null;
function _ensureDeactivationModeIconSelect() {
const sel = document.getElementById('automation-deactivation-mode');
if (!sel || _deactivationModeIconSelect) return;
const items = DEACT_MODE_KEYS.map(k => ({
value: k,
icon: _icon(DEACT_MODE_ICONS[k]),
label: t(`automations.deactivation_mode.${k}`),
desc: t(`automations.deactivation_mode.${k}.desc`),
}));
_deactivationModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
// ===== Condition editor =====
export function addAutomationCondition() {
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
}
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook'];
const CONDITION_TYPE_ICONS = {
always: P.refreshCw, startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe,
};
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
const MATCH_TYPE_ICONS = {
running: P.play, topmost: P.monitor, topmost_fullscreen: P.tv, fullscreen: P.square,
};
function _buildMatchTypeItems() {
return MATCH_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(MATCH_TYPE_ICONS[k]),
label: t(`automations.condition.application.match_type.${k}`),
desc: t(`automations.condition.application.match_type.${k}.desc`),
}));
}
function _buildConditionTypeItems() {
return CONDITION_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(CONDITION_TYPE_ICONS[k]),
label: t(`automations.condition.${k}`),
desc: t(`automations.condition.${k}.desc`),
}));
}
function addAutomationConditionRow(condition) {
const list = document.getElementById('automation-conditions-list');
const row = document.createElement('div');
row.className = 'automation-condition-row';
const condType = condition.condition_type || 'application';
row.innerHTML = `
<div class="condition-header">
<select class="condition-type-select">
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">&#x2715;</button>
</div>
<div class="condition-fields-container"></div>
`;
const typeSelect = row.querySelector('.condition-type-select');
const container = row.querySelector('.condition-fields-container');
// Attach IconSelect to the condition type dropdown
const condIconSelect = new IconSelect({
target: typeSelect,
items: _buildConditionTypeItems(),
columns: 4,
});
function renderFields(type, data) {
if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
return;
}
if (type === 'startup') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.startup.hint')}</small>`;
return;
}
if (type === 'time_of_day') {
const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.time_of_day.start_time')}</label>
<input type="time" class="condition-start-time" value="${startTime}">
</div>
<div class="condition-field">
<label>${t('automations.condition.time_of_day.end_time')}</label>
<input type="time" class="condition-end-time" value="${endTime}">
</div>
<small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
</div>`;
return;
}
if (type === 'system_idle') {
const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true;
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.system_idle.idle_minutes')}</label>
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
</div>
<div class="condition-field">
<label>${t('automations.condition.system_idle.mode')}</label>
<select class="condition-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_active')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'display_state') {
const dState = data.state || 'on';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.display_state.state')}</label>
<select class="condition-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.condition.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.condition.display_state.off')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'mqtt') {
const topic = data.topic || '';
const payload = data.payload || '';
const matchMode = data.match_mode || 'exact';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.mqtt.topic')}</label>
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.payload')}</label>
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.match_mode')}</label>
<select class="condition-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
return;
}
if (type === 'webhook') {
if (data.token) {
const webhookUrl = window.location.origin + '/api/v1/webhooks/' + data.token;
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.webhook.url')}</label>
<div class="webhook-url-row">
<input type="text" class="condition-webhook-url" value="${escapeHtml(webhookUrl)}" readonly>
<button type="button" class="btn btn-secondary btn-webhook-copy" onclick="copyWebhookUrl(this)">${t('automations.condition.webhook.copy')}</button>
</div>
</div>
<input type="hidden" class="condition-webhook-token" value="${escapeHtml(data.token)}">
</div>`;
} else {
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<p class="webhook-save-hint">${t('automations.condition.webhook.save_first')}</p>
</div>`;
}
return;
}
const appsValue = (data.apps || []).join('\n');
const matchType = data.match_type || 'running';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.fullscreen')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label>${t('automations.condition.application.apps')}</label>
<button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<div class="process-picker" style="display:none">
<input type="text" class="process-picker-search" placeholder="${t('automations.condition.application.search')}" autocomplete="off">
<div class="process-picker-list"></div>
</div>
</div>
</div>
`;
const textarea = container.querySelector('.condition-apps');
attachProcessPicker(container, textarea);
// Attach IconSelect to match type
const matchSel = container.querySelector('.condition-match-type');
if (matchSel) {
new IconSelect({
target: matchSel,
items: _buildMatchTypeItems(),
columns: 2,
});
}
}
renderFields(condType, condition);
typeSelect.addEventListener('change', () => {
renderFields(typeSelect.value, {});
});
list.appendChild(row);
}
function getAutomationEditorConditions() {
const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row');
const conditions = [];
rows.forEach(row => {
const typeSelect = row.querySelector('.condition-type-select');
const condType = typeSelect ? typeSelect.value : 'application';
if (condType === 'always') {
conditions.push({ condition_type: 'always' });
} else if (condType === 'startup') {
conditions.push({ condition_type: 'startup' });
} else if (condType === 'time_of_day') {
conditions.push({
condition_type: 'time_of_day',
start_time: row.querySelector('.condition-start-time').value || '00:00',
end_time: row.querySelector('.condition-end-time').value || '23:59',
});
} else if (condType === 'system_idle') {
conditions.push({
condition_type: 'system_idle',
idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5,
when_idle: row.querySelector('.condition-when-idle').value === 'true',
});
} else if (condType === 'display_state') {
conditions.push({
condition_type: 'display_state',
state: row.querySelector('.condition-display-state').value || 'on',
});
} else if (condType === 'mqtt') {
conditions.push({
condition_type: 'mqtt',
topic: row.querySelector('.condition-mqtt-topic').value.trim(),
payload: row.querySelector('.condition-mqtt-payload').value,
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact',
});
} else if (condType === 'webhook') {
const tokenInput = row.querySelector('.condition-webhook-token');
const cond = { condition_type: 'webhook' };
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
conditions.push(cond);
} else {
const matchType = row.querySelector('.condition-match-type').value;
const appsText = row.querySelector('.condition-apps').value.trim();
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
conditions.push({ condition_type: 'application', apps, match_type: matchType });
}
});
return conditions;
}
export async function saveAutomationEditor() {
const idInput = document.getElementById('automation-editor-id');
const nameInput = document.getElementById('automation-editor-name');
const enabledInput = document.getElementById('automation-editor-enabled');
const logicSelect = document.getElementById('automation-editor-logic');
const name = nameInput.value.trim();
if (!name) {
automationModal.showError(t('automations.error.name_required'));
return;
}
const body = {
name,
enabled: enabledInput.checked,
condition_logic: logicSelect.value,
conditions: getAutomationEditorConditions(),
scene_preset_id: document.getElementById('automation-scene-id').value || null,
deactivation_mode: document.getElementById('automation-deactivation-mode').value,
deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').value || null,
tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
};
const automationId = idInput.value;
const isEdit = !!automationId;
try {
const url = isEdit ? `/automations/${automationId}` : '/automations';
const resp = await fetchWithAuth(url, {
method: isEdit ? 'PUT' : 'POST',
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save automation');
}
automationModal.forceClose();
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
loadAutomations();
} catch (e) {
if (e.isAuth) return;
automationModal.showError(e.message);
}
}
export async function toggleAutomationEnabled(automationId, enable) {
try {
const action = enable ? 'enable' : 'disable';
const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, {
method: 'POST',
});
if (!resp.ok) throw new Error(`Failed to ${action} automation`);
loadAutomations();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export function copyWebhookUrl(btn) {
const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url');
navigator.clipboard.writeText(input.value).then(() => {
const orig = btn.textContent;
btn.textContent = t('automations.condition.webhook.copied');
setTimeout(() => { btn.textContent = orig; }, 1500);
});
}
export async function cloneAutomation(automationId) {
try {
const resp = await fetchWithAuth(`/automations/${automationId}`);
if (!resp.ok) throw new Error('Failed to load automation');
const automation = await resp.json();
openAutomationEditor(null, automation);
} catch (e) {
if (e.isAuth) return;
showToast(t('automations.error.clone_failed'), 'error');
}
}
export async function deleteAutomation(automationId, automationName) {
const msg = t('automations.delete.confirm').replace('{name}', automationName);
const confirmed = await showConfirm(msg);
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/automations/${automationId}`, {
method: 'DELETE',
});
if (!resp.ok) throw new Error('Failed to delete automation');
showToast(t('automations.deleted'), 'success');
loadAutomations();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}