const FLOW_NODE_META = Object.freeze({ weekday: { titleKey: 'flow.node.weekday', category: 'time' }, time_range: { titleKey: 'flow.node.timeRange', category: 'time' }, date_range: { titleKey: 'flow.node.dateRange', category: 'time' }, outdoor_temperature: { titleKey: 'flow.node.outdoorTemperature', category: 'sensor' }, device_temperature: { titleKey: 'flow.node.deviceTemperature', category: 'sensor' }, zone_temperature: { titleKey: 'flow.node.zoneTemperature', category: 'sensor' }, ha_state: { titleKey: 'flow.node.haState', category: 'sensor' }, ha_numeric: { titleKey: 'flow.node.haNumeric', category: 'sensor' }, ha_attribute: { titleKey: 'flow.node.haAttribute', category: 'sensor' }, ha_available: { titleKey: 'flow.node.haAvailable', category: 'sensor' }, house_mode: { titleKey: 'flow.node.houseMode', category: 'sensor' }, device_state: { titleKey: 'flow.node.deviceState', category: 'sensor' }, zone_state: { titleKey: 'flow.node.zoneState', category: 'sensor' }, group_state: { titleKey: 'flow.node.groupState', category: 'sensor' }, night_mode: { titleKey: 'flow.node.nightMode', category: 'time' }, constant: { titleKey: 'flow.node.constant', category: 'sensor' }, shared_input: { titleKey: 'flow.node.sharedInput', category: 'sensor' }, logic_and: { titleKey: 'flow.node.and', category: 'logic' }, logic_or: { titleKey: 'flow.node.or', category: 'logic' }, logic_not: { titleKey: 'flow.node.not', category: 'logic' }, zone_thermostat: { titleKey: 'flow.node.thermostat', category: 'action' }, device_action: { titleKey: 'flow.node.greeDevice', category: 'action' }, group_action: { titleKey: 'flow.node.group', category: 'action' }, }); function newFlowId(prefix = 'node') { if (crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`; return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function flowDefaultConfig(kind) { if (kind === 'weekday') return { days: [1, 2, 3, 4, 5] }; if (kind === 'time_range') return { start: '06:00', end: '08:00' }; if (kind === 'date_range') { const today = new Date().toISOString().slice(0, 10); return { start: today, end: today }; } if (kind === 'outdoor_temperature') return { operator: 'lt', value: 5 }; if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '', operator: 'lt', value: 20 }; if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '', operator: 'lt', value: 20 }; if (kind === 'ha_state') return { entity_id: '', operator: 'eq', value: 'on' }; if (kind === 'ha_numeric') return { entity_id: '', operator: 'lt', value: 20 }; if (kind === 'ha_attribute') return { entity_id: '', attribute: '', operator: 'eq', value: '' }; if (kind === 'ha_available') return { entity_id: '' }; if (kind === 'house_mode') return { operator: 'eq', value: 'cool' }; if (kind === 'device_state') return { device_id: app.devices[0]?.id || '', field: 'online', operator: 'eq', value: 'true' }; if (kind === 'zone_state') return { zone_id: app.zones[0]?.id || '', field: 'demand', operator: 'eq', value: 'true' }; if (kind === 'group_state') return { group_id: app.groups[0]?.id || '', field: 'power_enabled', operator: 'eq', value: 'true' }; if (kind === 'night_mode') return {}; if (kind === 'constant') return { value: true }; if (kind === 'shared_input') return { input_id: app.flowSharedInputs?.[0]?.id || '' }; if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', cooldown_seconds: 60 }; if (kind === 'device_action') return { device_id: app.devices[0]?.id || '', power: true, mode: '', target_temperature: null, fan_speed: null, swing_vertical: null, swing_horizontal: null, quiet: null, turbo: null, light: null, air: null, xfan: null, health: null, sleep: null, cooldown_seconds: 60 }; if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 }; return {}; } function renderFlows() { const host = $('#flowList'); if (!host) return; const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length }); host.innerHTML = app.flows.length ? app.flows.map(flow => `

${esc(flow.name)}

${esc(flow.description || tr('flow.defaultDescription'))}

${esc(tr('flow.blocks'))}${flow.nodes?.length || 0}
${esc(tr('nav.schedules'))}${flow.compiled_schedule_ids?.length || 0}
${esc(tr('nav.automations'))}${flow.compiled_automation_ids?.length || 0}
`).join('') : `
${esc(tr('flow.emptyTitle'))}${esc(tr('flow.emptyText'))}
`; } function flowDraftFrom(flow) { return flow ? JSON.parse(JSON.stringify(flow)) : { id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [], }; } function openFlowEditor(id = '', { push = true } = {}) { const flow = id ? app.flows.find(item => item.id === id) : null; if (id && !flow) return toast(tr('flow.notFound'), true); app.flowDraft = flowDraftFrom(flow); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false; $('#flowName').value = app.flowDraft.name || ''; $('#flowEnabled').checked = app.flowDraft.enabled !== false; $('#flowEditor').hidden = false; document.body.classList.add('flow-editor-open'); renderFlowEditor(); if (push) updateBrowserUrl(`/flows/${flow?.id || 'new'}`); } function closeFlowEditor({ push = true, force = false } = {}) { if (!force && app.flowDirty && !confirm(tr('confirm.discardChanges'))) return false; $('#flowEditor').hidden = true; document.body.classList.remove('flow-editor-open'); app.flowDraft = null; app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false; if (push) updateBrowserUrl('/flows'); return true; } function flowNodeSummary(node) { const c = node.config || {}; if (node.kind === 'weekday') return (c.days || []).map(day => tr(`day.${day}`)).join(', ') || '—'; if (node.kind === 'time_range') return `${c.start || '—'}–${c.end || '—'}`; if (node.kind === 'date_range') return `${c.start || '—'} → ${c.end || '—'}`; if (node.kind === 'outdoor_temperature') return `${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`; if (node.kind === 'device_temperature') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`; if (node.kind === 'zone_temperature') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`; if (node.kind === 'ha_state') return `${c.entity_id || 'entity_id'} ${c.operator === 'neq' ? '≠' : '='} ${c.value || '—'}`; if (node.kind === 'ha_numeric') return `${c.entity_id || 'entity_id'} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'ha_attribute') return `${c.entity_id || 'entity_id'}.${c.attribute || 'attribute'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'ha_available') return `${c.entity_id || 'entity_id'} · ${tr('flow.available')}`; if (node.kind === 'house_mode') return `${tr('flow.houseMode')} ${flowOperatorLabel(c.operator)} ${c.value || '—'}`; if (node.kind === 'device_state') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'zone_state') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'group_state') return `${app.groups.find(g => g.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'night_mode') return tr('flow.nightModeActive'); if (node.kind === 'constant') return c.value === false ? tr('common.no') : tr('common.yes'); if (node.kind === 'shared_input') { const item = (app.flowSharedInputs || []).find(value => value.id === c.input_id); return item ? `${item.name} · ${sharedFlowInputSummary(item)}` : tr('flow.sharedInputMissing'); } if (node.kind === 'logic_and') return tr('flow.allConditions'); if (node.kind === 'logic_or') return tr('flow.anyCondition'); if (node.kind === 'logic_not') return tr('flow.invertCondition'); if (node.kind === 'zone_thermostat') { const zone = app.zones.find(z => z.id === c.zone_id); return `${zone?.name || tr('common.noZone')} · ${c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort')}`; } if (node.kind === 'device_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; } if (node.kind === 'group_action') { const g = app.groups.find(v => v.id === c.group_id); return `${g?.name || tr('groups.group')} · ${c.power == null ? tr('actions.noChange') : c.power ? tr('common.on') : tr('common.off')}`; } return ''; } function flowNodeTitle(meta) { return meta?.titleKey ? tr(meta.titleKey) : (meta?.title || ''); } function flowOperatorLabel(op) { return ({ lt: '<', lte: '≤', gt: '>', gte: '≥', eq: '=', neq: '≠' })[op] || '<'; } function flowNodeById(id) { return app.flowDraft?.nodes?.find(node => node.id === id); } function renderFlowEditor() { const draft = app.flowDraft; if (!draft) return; const nodesHost = $('#flowNodes'); nodesHost.innerHTML = draft.nodes.map(node => { const meta = FLOW_NODE_META[node.kind] || { title: node.kind, category: 'logic' }; return `
${esc(flowNodeTitle(meta))}
${esc(flowNodeSummary(node))}
`; }).join(''); $('#flowEmptyHint').hidden = draft.nodes.length > 0; const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : ''; renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); const status = $('#flowCompileStatus'); status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 }); } function renderFlowEdges() { const svg = $('#flowEdges'), workspace = $('#flowWorkspace'); if (!svg || !workspace || !app.flowDraft) return; const rect = workspace.getBoundingClientRect(); svg.setAttribute('viewBox', `0 0 ${Math.max(workspace.clientWidth, workspace.scrollWidth)} ${Math.max(workspace.clientHeight, workspace.scrollHeight)}`); svg.innerHTML = app.flowDraft.edges.map(edge => { const from = $(`[data-flow-node="${CSS.escape(edge.from)}"]`), to = $(`[data-flow-node="${CSS.escape(edge.to)}"]`); if (!from || !to) return ''; const a = from.getBoundingClientRect(), b = to.getBoundingClientRect(); const x1 = a.right - rect.left + workspace.scrollLeft - 2, y1 = a.top - rect.top + workspace.scrollTop + a.height / 2; const x2 = b.left - rect.left + workspace.scrollLeft + 2, y2 = b.top - rect.top + workspace.scrollTop + b.height / 2; const bend = Math.max(55, Math.abs(x2 - x1) * .45); return ``; }).join(''); } function flowSelectOptions(items, selected, nameFn = item => item.name) { return items.map(item => ``).join(''); } function flowOperatorOptions(selected) { return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([v,l]) => ``).join(''); } function renderFlowInspector() { const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return; if (!node) { host.innerHTML = `
${esc(tr('flow.selectBlock'))}${esc(tr('flow.selectBlockHint'))}
`; return; } const c = node.config || {}, meta = FLOW_NODE_META[node.kind] || { title: node.kind }; let fields = ''; if (node.kind === 'weekday') fields = `
${[1,2,3,4,5,6,7].map(day => ``).join('')}
`; else if (node.kind === 'time_range') fields = `
`; else if (node.kind === 'date_range') fields = `
`; else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c); else if (node.kind === 'device_temperature') fields = `${flowComparisonFields(c)}`; else if (node.kind === 'zone_temperature') fields = `${flowComparisonFields(c)}`; else if (node.kind === 'ha_state') fields = `
`; else if (node.kind === 'ha_numeric') fields = `${flowComparisonFields(c, false)}`; else if (node.kind === 'ha_attribute') fields = `${flowTextComparisonFields(c, true)}`; else if (node.kind === 'ha_available') fields = `

${esc(tr('flow.haAvailableHint'))}

`; else if (node.kind === 'house_mode') fields = `
`; else if (node.kind === 'device_state') fields = `${flowTextComparisonFields(c)}`; else if (node.kind === 'zone_state') fields = `${flowTextComparisonFields(c)}`; else if (node.kind === 'group_state') fields = `${flowTextComparisonFields(c)}`; else if (node.kind === 'night_mode') fields = `

${esc(tr('flow.nightModeHint'))}

`; else if (node.kind === 'constant') fields = ``; else if (node.kind === 'shared_input') fields = (app.flowSharedInputs || []).length ? `

${esc(tr('flow.sharedInputNodeHint'))}

` : `
${esc(tr('flow.sharedInputsEmpty'))}${esc(tr('flow.sharedInputNodeEmptyHint'))}
`; else if (node.kind === 'logic_and') fields = `

${esc(tr('flow.andHint'))}

`; else if (node.kind === 'logic_or') fields = `

${esc(tr('flow.orHint'))}

`; else if (node.kind === 'logic_not') fields = `

${esc(tr('flow.notHint'))}

`; else if (node.kind === 'zone_thermostat') fields = `
`; else if (node.kind === 'device_action') fields = `${flowActionFields(c, false)}`; else if (node.kind === 'group_action') fields = `${flowActionFields(c, true)}`; host.innerHTML = `
${esc(tr('flow.blockSettings'))}

${esc(flowNodeTitle(meta))}

${fields}
ID${esc(node.id)}
`; } function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq','='],['neq','≠']].map(([v,l]) => ``).join(''); return `
`; } function flowComparisonFields(c, temperature = true) { return `
`; } function flowOptionalBoolField(c, key) { return ``; } function flowActionFields(c, group) { const modes = group ? ['auto','house','heat','cool'] : ['auto','cool','dry','fan','heat']; const modeOptions = modes.map(v => ``).join(''); const base = `
`; const target = group ? `` : ``; const deviceOptions = group ? '' : `
${esc(tr('flow.deviceOptions'))}
${flowOptionalBoolField(c,'swing_vertical')}${flowOptionalBoolField(c,'swing_horizontal')}${flowOptionalBoolField(c,'quiet')}${flowOptionalBoolField(c,'turbo')}${flowOptionalBoolField(c,'light')}${flowOptionalBoolField(c,'air')}${flowOptionalBoolField(c,'xfan')}${flowOptionalBoolField(c,'health')}${flowOptionalBoolField(c,'sleep')}

${esc(tr('flow.deviceOptionsHint'))}

`; return `${base}${target}${deviceOptions}`; } function flowExpressionSummary(actionId) { if (!app.flowDraft) return ''; const byId = new Map(app.flowDraft.nodes.map(node => [node.id, node])); const incoming = id => app.flowDraft.edges.filter(edge => edge.to === id).map(edge => edge.from); const describe = (id, stack = new Set()) => { if (stack.has(id)) return '…'; const node = byId.get(id); if (!node) return ''; const next = new Set(stack); next.add(id); const parts = incoming(id).map(input => describe(input, next)).filter(Boolean); if (node.kind === 'logic_and') return `(${parts.join(` ${tr('flow.and')} `)})`; if (node.kind === 'logic_or') return `(${parts.join(` ${tr('flow.or')} `)})`; if (node.kind === 'logic_not') return `${tr('flow.not')} (${parts[0] || '—'})`; const own = flowNodeSummary(node); return parts.length ? `(${parts.join(` ${tr('flow.and')} `)}) ${tr('flow.and')} ${own}` : own; }; const roots = incoming(actionId).map(id => describe(id)).filter(Boolean); return roots.length ? roots.join(` ${tr('flow.and')} `) : tr('flow.always'); } function renderFlowInterpretation() { const target = $('#flowInterpretation'); if (!target || !app.flowDraft) return; const actions = app.flowDraft.nodes.filter(node => FLOW_NODE_META[node.kind]?.category === 'action'); if (!actions.length) { target.textContent = tr('flow.noActionsYet'); return; } target.textContent = actions.map(action => `${flowExpressionSummary(action.id)} → ${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · '); } function flowUpstreamNodes(id) { if (!app.flowDraft) return []; const byId = new Map(app.flowDraft.nodes.map(node => [node.id, node])); const seen = new Set(), stack = [id], out = []; while (stack.length) { const current = stack.pop(); app.flowDraft.edges.filter(edge => edge.to === current).forEach(edge => { if (seen.has(edge.from)) return; seen.add(edge.from); const node = byId.get(edge.from); if (node) { out.push(node); stack.push(node.id); } }); } return out; } function addFlowNode(kind) { if (!app.flowDraft || !FLOW_NODE_META[kind]) return; const count = app.flowDraft.nodes.length; const node = { id: newFlowId('node'), kind, x: 80 + (count % 5) * 190, y: 70 + Math.floor(count / 5) * 130, config: flowDefaultConfig(kind) }; app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor(); } function removeFlowNode(id) { if (!app.flowDraft) return; app.flowDraft.nodes = app.flowDraft.nodes.filter(node => node.id !== id); app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.from !== id && edge.to !== id); if (app.flowSelectedNodeId === id) app.flowSelectedNodeId = null; app.flowSelectedNodeIds = (app.flowSelectedNodeIds || []).filter(nodeId => nodeId !== id); if (app.flowConnectFrom === id) app.flowConnectFrom = null; app.flowDirty = true; renderFlowEditor(); } function connectFlowNodes(from, to) { if (!app.flowDraft || !from || !to || from === to) return; if (app.flowDraft.edges.some(edge => edge.from === from && edge.to === to)) return; app.flowDraft.edges.push({ id: newFlowId('edge'), from, to }); app.flowDirty = true; app.flowConnectFrom = null; renderFlowEditor(); } function removeFlowEdge(id) { if (!app.flowDraft) return; app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.id !== id); app.flowDirty = true; renderFlowEditor(); } function flowSourcePayload() { if (!app.flowDraft) return null; return { name: ($('#flowName')?.value || app.flowDraft.name || '').trim(), enabled: $('#flowEnabled')?.checked !== false, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes || [], edges: app.flowDraft.edges || [], }; } function downloadJsonFile(filename, data) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); } async function exportFlow() { if (!app.flowDraft) return; try { const envelope = app.flowDraft.id && !app.flowDirty ? await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/export`) : { format: 'gree-controller-flow', version: 1, exported_at: new Date().toISOString(), flow: flowSourcePayload() }; const safe = (flowSourcePayload()?.name || 'flow').replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'flow'; downloadJsonFile(`${safe}.flow.json`, envelope); toast(tr('flow.exported')); } catch (error) { toast(error.message, true); } } async function importFlowFile(file) { if (!file) return; try { const payload = JSON.parse(await file.text()); const saved = await api('/api/flows/import', { method: 'POST', body: payload }); const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved); renderFlows(); await loadBootstrap(); openFlowEditor(saved.id); toast(tr('flow.imported')); } catch (error) { toast(`${tr('flow.importFailed')}: ${error.message}`, true); } finally { const input = $('#flowImportFile'); if (input) input.value = ''; } } const FLOW_PRESET_CATEGORY_ORDER = ['comfort','energy','safety','night','reliability','home_assistant','advanced']; const FLOW_PRESET_FAVORITES_KEY = 'gree_controller_flow_preset_favorites'; const FLOW_PRESET_RECENT_KEY = 'gree_controller_flow_preset_recent'; let flowPresetLoadPromise = null; let flowPresetActiveCategory = ''; let flowPresetPreviewId = ''; let flowPresetSearch = ''; function flowPresetStoredIds(key) { try { const value = JSON.parse(localStorage.getItem(key) || '[]'); return Array.isArray(value) ? value.filter(item => typeof item === 'string') : []; } catch (_) { return []; } } function flowPresetFavoriteIds() { return new Set(flowPresetStoredIds(FLOW_PRESET_FAVORITES_KEY)); } function flowPresetRecentIds() { return flowPresetStoredIds(FLOW_PRESET_RECENT_KEY); } function saveFlowPresetIds(key, ids) { try { localStorage.setItem(key, JSON.stringify(ids)); } catch (_) {} } function toggleFlowPresetFavorite(id) { const ids = flowPresetFavoriteIds(); if (ids.has(id)) ids.delete(id); else ids.add(id); saveFlowPresetIds(FLOW_PRESET_FAVORITES_KEY, [...ids]); renderFlowPresetBrowser(flowPresetActiveCategory); } function markFlowPresetRecent(id) { const ids = flowPresetRecentIds().filter(value => value !== id); ids.unshift(id); saveFlowPresetIds(FLOW_PRESET_RECENT_KEY, ids.slice(0, 8)); } function flowPresetText(value) { if (typeof value === 'string') return value; if (!value || typeof value !== 'object') return ''; return value[app.language] || value.en || value.pl || Object.values(value).find(item => typeof item === 'string') || ''; } async function loadFlowPresets({ force = false } = {}) { if (!force && Array.isArray(app.flowPresets)) return app.flowPresets; if (!force && flowPresetLoadPromise) return flowPresetLoadPromise; flowPresetLoadPromise = (async () => { const indexResponse = await fetch(withBase('/presets/index.json'), { headers: { Accept: 'application/json' }, cache: 'no-store' }); if (!indexResponse.ok) throw new Error(`Presets HTTP ${indexResponse.status}`); const manifest = await indexResponse.json(); const entries = Array.isArray(manifest?.presets) ? manifest.presets : []; const presets = await Promise.all(entries.map(async entry => { const filename = String(entry?.file || ''); if (!filename || !/^[a-zA-Z0-9_-]+\.json$/.test(filename)) throw new Error('Invalid preset filename'); const response = await fetch(withBase(`/presets/${encodeURIComponent(filename)}`), { headers: { Accept: 'application/json' }, cache: 'no-store' }); if (!response.ok) throw new Error(`${filename}: HTTP ${response.status}`); const preset = await response.json(); if (!preset?.id || !preset?.flow || !Array.isArray(preset.flow.nodes) || !Array.isArray(preset.flow.edges)) throw new Error(`${filename}: invalid preset`); return preset; })); app.flowPresets = presets; return presets; })(); try { return await flowPresetLoadPromise; } finally { flowPresetLoadPromise = null; } } function materializeFlowPreset(preset) { const zone1 = app.zones[0]?.id || ''; const variables = { '$zone1': zone1, '$zone2': app.zones[1]?.id || zone1, '$device1': app.devices[0]?.id || '', '$group1': app.groups[0]?.id || '', '$shared1': app.flowSharedInputs?.[0]?.id || '', }; const replaceValue = value => { if (typeof value === 'string' && Object.prototype.hasOwnProperty.call(variables, value)) return variables[value]; if (Array.isArray(value)) return value.map(replaceValue); if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceValue(item)])); return value; }; const sourceNodes = Array.isArray(preset?.flow?.nodes) ? preset.flow.nodes : []; const sourceEdges = Array.isArray(preset?.flow?.edges) ? preset.flow.edges : []; const skipped = new Set(); const idMap = new Map(); const nodes = []; sourceNodes.forEach(source => { const originalConfig = source.config || {}; if (source.kind === 'group_state' && originalConfig.group_id === '$group1' && !variables.$group1) { skipped.add(source.id); return; } const node = { ...source, id: newFlowId('node'), config: replaceValue(originalConfig) }; if (source.kind === 'group_action' && originalConfig.group_id === '$group1' && !variables.$group1) { node.kind = 'zone_thermostat'; node.config = { zone_id: zone1, preset: node.config.preset || 'comfort', setpoint: Number(node.config.setpoint ?? 21), mode: ['heat','cool'].includes(node.config.mode) ? node.config.mode : 'auto', cooldown_seconds: Number(node.config.cooldown_seconds || 60), power: node.config.power ?? null, }; } idMap.set(source.id, node.id); nodes.push(node); }); const edges = sourceEdges .filter(edge => !skipped.has(edge.from) && !skipped.has(edge.to) && idMap.has(edge.from) && idMap.has(edge.to)) .map(edge => ({ id:newFlowId('edge'), from:idMap.get(edge.from), to:idMap.get(edge.to) })); return { nodes, edges }; } function flowPresetRequirements(preset) { const nodes = preset?.flow?.nodes || []; const placeholders = { zone:new Set(), device:new Set(), group:new Set(), shared:new Set() }; const visit = value => { if (typeof value === 'string') { const match = /^\$(zone|device|group|shared)(\d+)$/.exec(value); if (match) placeholders[match[1]].add(value); return; } if (Array.isArray(value)) value.forEach(visit); else if (value && typeof value === 'object') Object.values(value).forEach(visit); }; nodes.forEach(node => visit(node.config || {})); const hasHa = nodes.some(node => ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(node.kind)); const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))]; const requirements = []; const missing = []; const addCount = (kind, count, available, key) => { if (!count) return; const label = tr(key, { count }); requirements.push({ label, ok:available >= count }); if (available < count) missing.push(label); }; addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone'); addCount('device', placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice'); addCount('group', placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup'); addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared'); if (hasHa) { const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured); requirements.push({ label:tr('flow.templateRequiresHa'), ok:ready }); if (!ready) missing.push(tr('flow.templateRequiresHa')); } if (haEntities.length) requirements.push({ label:`${tr('flow.templateRequiresEntity')}: ${haEntities.join(', ')}`, ok:true, info:true }); return { requirements, missing }; } function flowPresetPreviewGraph(preset) { const nodes = preset?.flow?.nodes || [], edges = preset?.flow?.edges || []; if (!nodes.length) return ''; const byId = new Map(nodes.map(node => [node.id, node])); const xs = nodes.map(node => Number(node.x || 0)), ys = nodes.map(node => Number(node.y || 0)); const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys); const spanX = Math.max(1, maxX - minX), spanY = Math.max(1, maxY - minY); const point = node => ({ x:8 + ((Number(node.x || 0) - minX) / spanX) * 84, y:12 + ((Number(node.y || 0) - minY) / spanY) * 76 }); const lines = edges.map(edge => { const from = byId.get(edge.from), to = byId.get(edge.to); if (!from || !to) return ''; const a = point(from), b = point(to); return ``; }).join(''); const blocks = nodes.map(node => { const p = point(node), meta = FLOW_NODE_META[node.kind] || { title:node.kind, category:'logic' }; return `
${esc(flowNodeTitle(meta))}
`; }).join(''); return `
${blocks}
`; } function renderFlowPresetPreview(preset) { const host = $('#flowTemplatePreview'); if (!host) return; if (!preset) { host.innerHTML = `
${esc(tr('flow.templatePreview'))}${esc(tr('flow.templateSelectPreview'))}
`; return; } const req = flowPresetRequirements(preset); const favorite = flowPresetFavoriteIds().has(preset.id); const reqMarkup = req.requirements.length ? req.requirements.map(item => `${item.ok ? '✓' : '!' } ${esc(item.label)}`).join('') : `✓ ${esc(tr('flow.templateRequirementsReady'))}`; host.innerHTML = `
${esc(tr('flow.templatePreview'))}

${esc(flowPresetText(preset.name) || preset.id)}

${esc(flowPresetText(preset.description))}

${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}
${flowPresetPreviewGraph(preset)}
${esc(tr('flow.templateRequirements'))}
${reqMarkup}
${req.missing.length ? `

${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}

` : ''}
`; } function flowPresetCategoryLabel(key) { return tr(`flow.templateCategory.${key}`) === `flow.templateCategory.${key}` ? key : tr(`flow.templateCategory.${key}`); } function flowPresetCategoryHint(key) { const name = `flow.templateCategory.${key}.hint`, value = tr(name); return value === name ? '' : value; } function renderFlowPresetBrowser(category = '') { const tabs = $('#flowTemplateTabs'), host = $('#flowTemplateList'); if (!tabs || !host) return; const presets = Array.isArray(app.flowPresets) ? app.flowPresets : []; const byCategory = new Map(); presets.forEach(preset => { const key = preset.category || 'advanced'; if (!byCategory.has(key)) byCategory.set(key, []); byCategory.get(key).push(preset); }); const favorites = flowPresetFavoriteIds(); const recent = flowPresetRecentIds(); byCategory.set('favorites', presets.filter(preset => favorites.has(preset.id))); byCategory.set('recent', recent.map(id => presets.find(preset => preset.id === id)).filter(Boolean)); const normalCategories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...presets.map(preset => preset.category || 'advanced')])].filter(key => (byCategory.get(key) || []).length); const categories = ['favorites','recent', ...normalCategories]; if (!presets.length) { tabs.innerHTML = ''; host.innerHTML = ''; renderFlowPresetPreview(null); return; } flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : normalCategories[0]); tabs.innerHTML = categories.map(key => ``).join(''); const query = flowPresetSearch.trim().toLocaleLowerCase(locale()); const activeAll = byCategory.get(flowPresetActiveCategory) || []; const active = query ? activeAll.filter(preset => `${flowPresetText(preset.name)} ${flowPresetText(preset.description)} ${preset.id}`.toLocaleLowerCase(locale()).includes(query)) : activeAll; const emptyKey = query ? 'flow.templateNoResults' : flowPresetActiveCategory === 'favorites' ? 'flow.templateNoFavorites' : flowPresetActiveCategory === 'recent' ? 'flow.templateNoRecent' : 'flow.templateNoResults'; const cards = active.map(preset => { const isFavorite = favorites.has(preset.id), selected = preset.id === flowPresetPreviewId; return `
`; }).join(''); host.innerHTML = `
${esc(flowPresetCategoryLabel(flowPresetActiveCategory))}${esc(flowPresetCategoryHint(flowPresetActiveCategory))}
${cards ? `
${cards}
` : `
${esc(tr(emptyKey))}
`}
`; if (!active.some(preset => preset.id === flowPresetPreviewId)) flowPresetPreviewId = active[0]?.id || ''; renderFlowPresetPreview(presets.find(preset => preset.id === flowPresetPreviewId) || null); } async function openFlowTemplates() { if (!app.flowDraft) return; const host = $('#flowTemplateList'), tabs = $('#flowTemplateTabs'), search = $('#flowTemplateSearch'); if (!host) return; if (tabs) tabs.innerHTML = ''; if (search) { search.value = ''; flowPresetSearch = ''; } host.innerHTML = `

${esc(tr('common.loading'))}

`; renderFlowPresetPreview(null); $('#flowTemplateDialog')?.showModal(); try { await loadFlowPresets(); renderFlowPresetBrowser(flowPresetActiveCategory); } catch (error) { if (tabs) tabs.innerHTML = ''; host.innerHTML = `
${esc(tr('flow.templatesLoadFailed') || 'Unable to load presets')}${esc(error.message)}
`; } } function previewFlowTemplate(key) { const preset = (app.flowPresets || []).find(item => item.id === key); if (!preset) return; flowPresetPreviewId = preset.id; renderFlowPresetBrowser(flowPresetActiveCategory); } function applyFlowTemplate(key) { if (!app.flowDraft) return; const preset = (app.flowPresets || []).find(item => item.id === key); if (!preset) return toast(tr('flow.templatesLoadFailed') || 'Preset not found', true); if (app.flowDraft.nodes.length && !confirm(tr('flow.templateReplaceConfirm'))) return; const graph = materializeFlowPreset(preset); app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges; app.flowDraft.description = flowPresetText(preset.description); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true; if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = flowPresetText(preset.name) || preset.id; markFlowPresetRecent(preset.id); $('#flowTemplateDialog')?.close(); renderFlowEditor(); } function localDateTimeInputValue(date = new Date()) { const pad = value => String(value).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function flowSimulationOverrideNodes() { return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','ha_available','house_mode','device_state','zone_state','group_state','night_mode','shared_input'].includes(node.kind)); } function renderFlowSimulationOverrides() { const host = $('#flowSimulationOverrides'); if (!host) return; const nodes = flowSimulationOverrideNodes(); host.innerHTML = nodes.length ? nodes.map(node => { const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind; const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind); return ``; }).join('') : `

${esc(tr('flow.noSimulationOverrides'))}

`; } function collectFlowSimulationOverrides() { const result = {}; $$('[data-flow-sim-node]', $('#flowSimulationOverrides')).forEach(input => { if (input.value.trim() === '') return; const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim(); const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind; if (node && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind)) value = Number(value); else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true'; result[input.dataset.flowSimNode] = value; }); return result; } function openFlowDryRun() { if (!app.flowDraft) return; $('#flowTestTitle').textContent = tr('flow.dryRun'); $('#flowSimulationControls').hidden = false; $('#flowTestResults').innerHTML = ''; $('#flowSimulationAt').value = localDateTimeInputValue(); renderFlowSimulationOverrides(); $('#flowTestDialog')?.showModal(); } function renderFlowDryRunResult(result) { const host = $('#flowTestResults'); const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id; host.innerHTML = `
${esc(result.summary || tr('flow.dryRun'))}${esc(tr('flow.compiledPreview', result.compiled || { schedules:0, automations:0 }))}
${(result.actions || []).map(action => `
${esc(actionName(action.node_id))}${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}
${action.blocked_reason ? `

${esc(tr('flow.blockReason'))}: ${esc(action.blocked_reason)}

` : ''}
${(action.trace || []).map(item => `
${item.matched ? '✓' : '×'} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))}${esc(JSON.stringify(item.actual))}
`).join('')}
`).join('')}`; } async function runFlowDryRun() { if (!app.flowDraft) return; const input = $('#flowSimulationAt').value; const at = input ? new Date(input).toISOString() : new Date().toISOString(); $('#flowTestResults').innerHTML = `

${esc(tr('flow.runningSimulation'))}

`; try { const result = await api('/api/flows/simulate', { method:'POST', body:{ flow:flowSourcePayload(), flow_id:app.flowDraft.id || null, at, overrides:collectFlowSimulationOverrides(), log:true } }); renderFlowDryRunResult(result); } catch (error) { $('#flowTestResults').innerHTML = `
${esc(tr('flow.simulationFailed'))}${esc(error.message)}
`; } } function flowLogLevelLabel(level = '') { const key = { info:'flow.logLevelInfo', warn:'flow.logLevelWarn', error:'flow.logLevelError' }[String(level).toLowerCase()]; return key ? tr(key) : level; } function flowLogKindLabel(kind = '') { const key = { 'flow.created':'flow.logKindCreated', 'flow.updated':'flow.logKindUpdated', 'flow.deleted':'flow.logKindDeleted', 'flow.imported':'flow.logKindImported', 'flow.dry_run':'flow.logKindDryRun', 'flow.condition_error':'flow.logKindConditionError', 'flow.action_suppressed':'flow.logKindActionSuppressed', 'automation.fired':'flow.logKindActionFired', 'automation.error':'flow.logKindActionError', 'automation.blocked_by_zone':'flow.logKindBlockedZone', 'automation.blocked_by_manual_override':'flow.logKindBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logKindBlockedThermostat', 'automation.blocked_by_temporary_thermostat':'flow.logKindBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logKindBlockedThermostatOwner', 'automation.blocked_by_fresh_ownership':'flow.logKindBlockedOwnership', 'automation.conflict':'flow.logKindConflict', }[kind]; return key ? tr(key) : kind; } function flowLogMessage(event = {}) { const name = app.flowDraft?.name || tr('nav.flows'); const key = { 'flow.created':'flow.logMessageCreated', 'flow.updated':'flow.logMessageUpdated', 'flow.deleted':'flow.logMessageDeleted', 'flow.imported':'flow.logMessageImported', 'flow.dry_run':'flow.logMessageDryRun', 'flow.condition_error':'flow.logMessageConditionError', 'flow.action_suppressed':'flow.logMessageActionSuppressed', 'automation.fired':'flow.logMessageActionFired', 'automation.error':'flow.logMessageActionError', 'automation.blocked_by_zone':'flow.logMessageBlockedZone', 'automation.blocked_by_manual_override':'flow.logMessageBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logMessageBlockedThermostat', 'automation.blocked_by_temporary_thermostat':'flow.logMessageBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logMessageBlockedThermostatOwner', 'automation.blocked_by_fresh_ownership':'flow.logMessageBlockedOwnership', 'automation.conflict':'flow.logMessageConflict', }[event.kind]; return key ? tr(key, { name }) : (event.message || '—'); } async function openFlowLogs() { if (!app.flowDraft?.id) return toast(tr('flow.saveBeforeLogs'), true); $('#flowTestTitle').textContent = tr('flow.logs'); $('#flowSimulationControls').hidden = true; $('#flowTestResults').innerHTML = `

${esc(tr('common.loading'))}

`; $('#flowTestDialog')?.showModal(); try { const result = await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/logs?limit=150`); const events = result.events || []; $('#flowTestResults').innerHTML = events.length ? events.map(event => `
${esc(flowLogKindLabel(event.kind))}${esc(flowLogLevelLabel(event.level))}

${esc(flowLogMessage(event))}

${esc(new Date(event.timestamp).toLocaleString())}
`).join('') : `
${esc(tr('flow.noLogs'))}
`; } catch (error) { $('#flowTestResults').innerHTML = `
${esc(tr('flow.logsFailed'))}${esc(error.message)}
`; } } async function saveFlow() { if (!app.flowDraft) return; const name = $('#flowName').value.trim(); if (!name) return toast(tr('flow.nameRequired'), true); const body = { name, enabled: $('#flowEnabled').checked, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes, edges: app.flowDraft.edges }; if (app.flowDraft.id) body.expected_revision = Number(app.flowDraft.revision || 0); try { const saved = await api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body }); const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved); app.flowDraft = flowDraftFrom(saved); app.flowDirty = false; renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr('flow.saved')); } catch (error) { toast(error.message, true); } } async function deleteFlow(id) { const flow = app.flows.find(item => item.id === id); if (!flow) return; if (!confirm(tr('flow.deleteConfirm', { name: flow.name }))) return; try { await api(`/api/flows/${encodeURIComponent(id)}`, { method: 'DELETE' }); app.flows = app.flows.filter(item => item.id !== id); renderFlows(); await loadBootstrap(); toast(tr('flow.deleted')); } catch (error) { toast(error.message, true); } } async function toggleFlowEnabled(id, enabled) { const flow = app.flows.find(item => item.id === id); if (!flow) return; const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return; if (toggle) toggle.disabled = true; const body = { name:flow.name, enabled, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) }; try { const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method:'PUT', body }); const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved; renderFlows(); await loadBootstrap(); toast(enabled ? tr('flow.quickEnabled') : tr('flow.quickDisabled')); } catch (error) { renderFlows(); toast(error.message, true); } } function setFlowSelection(ids, primary = null) { if (!app.flowDraft) return; const valid = new Set(app.flowDraft.nodes.map(node => node.id)); app.flowSelectedNodeIds = [...new Set((ids || []).filter(id => valid.has(id)))]; app.flowSelectedNodeId = primary && valid.has(primary) ? primary : (app.flowSelectedNodeIds[app.flowSelectedNodeIds.length - 1] || null); renderFlowEditor(); } function selectAllFlowNodes() { if (!app.flowDraft) return; const lastNode = app.flowDraft.nodes[app.flowDraft.nodes.length - 1]; setFlowSelection(app.flowDraft.nodes.map(node => node.id), app.flowSelectedNodeId || lastNode?.id || null); } function clearFlowSelection() { setFlowSelection([], null); } function removeSelectedFlowNodes() { if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return; const selected = new Set(app.flowSelectedNodeIds); app.flowDraft.nodes = app.flowDraft.nodes.filter(node => !selected.has(node.id)); app.flowDraft.edges = app.flowDraft.edges.filter(edge => !selected.has(edge.from) && !selected.has(edge.to)); if (app.flowConnectFrom && selected.has(app.flowConnectFrom)) app.flowConnectFrom = null; app.flowSelectedNodeIds = []; app.flowSelectedNodeId = null; app.flowDirty = true; renderFlowEditor(); } function updateFlowConfig(input) { const node = flowNodeById(app.flowSelectedNodeId); if (!node) return; const key = input.dataset.flowConfig; if (!key) return; if (key === 'days') node.config.days = $$('[data-flow-config="days"]', $('#flowInspector')).filter(el => el.checked).map(el => Number(el.value)); else if (['power','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true'; else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true'; else if (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value); else node.config[key] = input.value; app.flowDirty = true; renderFlowEditor(); } let flowDrag = null; document.addEventListener('pointerdown', event => { if ($('#flowEditor')?.hidden) return; const nodeEl = event.target.closest?.('[data-flow-node]'); if (!nodeEl) { if (event.target.closest?.('#flowWorkspace') && !event.target.closest('button,input,select,label')) clearFlowSelection(); return; } if (event.target.closest('button,input,select,label')) return; const node = flowNodeById(nodeEl.dataset.flowNode); if (!node) return; const multiModifier = event.shiftKey || event.ctrlKey || event.metaKey; let selected = [...(app.flowSelectedNodeIds || [])]; if (multiModifier) { selected = selected.includes(node.id) ? selected.filter(id => id !== node.id) : [...selected, node.id]; if (!selected.includes(node.id)) { setFlowSelection(selected, selected[selected.length - 1] || null); event.preventDefault(); return; } } else if (!selected.includes(node.id)) selected = [node.id]; app.flowSelectedNodeIds = selected; app.flowSelectedNodeId = node.id; renderFlowEditor(); const starts = selected.map(id => flowNodeById(id)).filter(Boolean).map(item => ({ id:item.id, left:Number(item.x || 0), top:Number(item.y || 0) })); flowDrag = { x:event.clientX, y:event.clientY, starts }; nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault(); }); document.addEventListener('pointermove', event => { if (!flowDrag || !app.flowDraft) return; let dx = event.clientX - flowDrag.x, dy = event.clientY - flowDrag.y; const minLeft = Math.min(...flowDrag.starts.map(item => item.left)); const minTop = Math.min(...flowDrag.starts.map(item => item.top)); dx = Math.max(dx, 12 - minLeft); dy = Math.max(dy, 12 - minTop); flowDrag.starts.forEach(start => { const node = flowNodeById(start.id); if (!node) return; node.x = start.left + dx; node.y = start.top + dy; const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px`; } }); app.flowDirty = true; renderFlowEdges(); }); document.addEventListener('pointerup', () => { flowDrag = null; }); document.addEventListener('keydown', event => { if ($('#flowEditor')?.hidden || !app.flowDraft) return; if (event.target.closest?.('input,select,textarea,[contenteditable="true"]')) return; if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); selectAllFlowNodes(); return; } if ((event.key === 'Delete' || event.key === 'Backspace') && (app.flowSelectedNodeIds || []).length) { event.preventDefault(); removeSelectedFlowNodes(); } }); document.addEventListener('click', event => { const edge = event.target.closest?.('[data-flow-edge]'); if (edge) { removeFlowEdge(edge.dataset.flowEdge); return; } const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); return; } const templateCategory = event.target.closest?.('[data-flow-template-category]'); if (templateCategory) { renderFlowPresetBrowser(templateCategory.dataset.flowTemplateCategory); return; } const templateFavorite = event.target.closest?.('[data-flow-template-favorite]'); if (templateFavorite) { toggleFlowPresetFavorite(templateFavorite.dataset.flowTemplateFavorite); return; } const templatePreview = event.target.closest?.('[data-flow-template-preview]'); if (templatePreview) { previewFlowTemplate(templatePreview.dataset.flowTemplatePreview); return; } const templateUse = event.target.closest?.('[data-flow-template-use]'); if (templateUse) { applyFlowTemplate(templateUse.dataset.flowTemplateUse); return; } const remove = event.target.closest?.('[data-flow-remove]'); if (remove) { removeFlowNode(remove.dataset.flowRemove); return; } const output = event.target.closest?.('[data-flow-output]'); if (output) { app.flowConnectFrom = output.dataset.flowOutput; renderFlowEditor(); return; } const input = event.target.closest?.('[data-flow-input]'); if (input) { if (app.flowConnectFrom) connectFlowNodes(app.flowConnectFrom, input.dataset.flowInput); return; } const node = event.target.closest?.('[data-flow-node]'); if (node) { if (!(app.flowSelectedNodeIds || []).includes(node.dataset.flowNode)) app.flowSelectedNodeIds = [node.dataset.flowNode]; app.flowSelectedNodeId = node.dataset.flowNode; renderFlowEditor(); return; } const actionButton = event.target.closest?.('[data-action]'); const action = actionButton?.dataset.action; if (action === 'new-flow') openFlowEditor(); else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id); else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id); else if (action === 'toggle-flow-enabled') toggleFlowEnabled(actionButton.dataset.id, actionButton.dataset.value === 'true'); else if (action === 'save-flow') saveFlow(); else if (action === 'import-flow') $('#flowImportFile')?.click(); else if (action === 'export-flow') exportFlow(); else if (action === 'flow-templates') openFlowTemplates(); else if (action === 'flow-dry-run') openFlowDryRun(); else if (action === 'run-flow-dry-run') runFlowDryRun(); else if (action === 'flow-logs') openFlowLogs(); else if (action === 'flow-select-all') selectAllFlowNodes(); else if (action === 'flow-clear-selection') clearFlowSelection(); else if (action === 'flow-shared-input-settings') { if (!showView('homeassistant')) return; requestAnimationFrame(() => { const target = $('#flowSharedInputsSettings'); if (!target) return; target.scrollIntoView({ behavior:'smooth', block:'start' }); target.classList.add('is-linked-target'); setTimeout(() => target.classList.remove('is-linked-target'), 1800); }); } else if (action === 'close-flow-editor') closeFlowEditor(); }); document.addEventListener('change', event => { if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target); }); $('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0])); $('#flowTemplateSearch')?.addEventListener('input', event => { flowPresetSearch = event.target.value || ''; renderFlowPresetBrowser(flowPresetActiveCategory); }); $('#flowName')?.addEventListener('input', () => { if (app.flowDraft) app.flowDirty = true; }); $('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) app.flowDirty = true; }); window.addEventListener('resize', () => { if (!$('#flowEditor')?.hidden) renderFlowEdges(); });