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' }, cron_trigger: { titleKey: 'flow.node.cronTrigger', category: 'trigger' }, stable_for: { titleKey: 'flow.node.stableFor', category: 'timeop' }, state_duration: { titleKey: 'flow.node.stateDuration', category: 'timeop' }, on_change: { titleKey: 'flow.node.onChange', category: 'timeop' }, rate_limit: { titleKey: 'flow.node.rateLimit', category: 'timeop' }, delay: { titleKey: 'flow.node.delay', category: 'timeop' }, rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' }, oscillates: { titleKey: 'flow.node.oscillates', category: 'sensor' }, 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' }, device_feature_action: { titleKey: 'flow.node.greeFeature', category: 'action' }, group_action: { titleKey: 'flow.node.group', category: 'action' }, ha_service_action: { titleKey: 'flow.node.haServiceAction', category: 'haaction' }, }); function newFlowId(prefix = 'node') { if (crypto?.randomUUID) return `${prefix}-${crypto.randomUUID()}`; return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function sharedFlowInputRequiresComparison(kind) { return ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'house_mode', 'device_state', 'zone_state', 'group_state'].includes(kind); } function sharedFlowInputLouverAxis(item) { if (item?.kind !== 'device_state') return null; if (item.config?.field === 'swing_vertical') return 'vertical'; if (item.config?.field === 'swing_horizontal') return 'horizontal'; return null; } function sharedFlowReferenceComparisonDefaults(item) { const kind = item?.kind || ''; if (['outdoor_temperature', 'device_temperature', 'zone_temperature'].includes(kind)) return { operator: 'lt', value: 20 }; if (kind === 'ha_numeric') return { operator: 'lt', value: 0 }; if (kind === 'ha_state') return { operator: 'eq', value: 'on' }; if (kind === 'ha_attribute') return { operator: 'eq', value: '' }; if (kind === 'house_mode') return { operator: 'eq', value: 'cool' }; if (sharedFlowInputLouverAxis(item)) return { operator: 'eq', value: 0 }; if (kind === 'device_state') return { operator: 'eq', value: 'true' }; if (kind === 'zone_state') return { operator: 'eq', value: 'true' }; if (kind === 'group_state') return { operator: 'eq', value: 'true' }; return {}; } function sharedFlowReferenceDefaultConfig(item) { const config = { input_id: item?.id || '' }; if (item && sharedFlowInputRequiresComparison(item.kind)) Object.assign(config, sharedFlowReferenceComparisonDefaults(item)); return config; } 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 === 'cron_trigger') return { expression: '*/5 * * * *' }; if (kind === 'stable_for') return { seconds: 180 }; if (kind === 'state_duration') return { min_seconds: 60, max_seconds: 600 }; if (kind === 'on_change') return { mode: 'result' }; if (kind === 'rate_limit') return { max_count: 3, period_seconds: 3600 }; if (kind === 'delay') return { seconds: 30 }; if (kind === 'rolling_stat') return { source: 'outdoor_temperature', statistic: 'mean', window_seconds: 300, operator: 'lt', value: 20, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' }; if (kind === 'oscillates') return { source: 'outdoor_temperature', window_seconds: 300, min_span: 1.0, min_direction_changes: 2, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' }; 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 sharedFlowReferenceDefaultConfig(app.flowSharedInputs?.[0]); if (kind === 'zone_thermostat') return { zone_id: app.zones[0]?.id || '', preset: 'comfort', setpoint: 21, mode: 'auto', power: null, swing_vertical: null, swing_horizontal: null, 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 === 'device_feature_action') return { device_id: app.devices[0]?.id || '', feature: 'light', value: true, cooldown_seconds: 60 }; if (kind === 'group_action') return { group_id: app.groups[0]?.id || '', power: true, mode: '', preset: '', setpoint: 21, cooldown_seconds: 60 }; if (kind === 'ha_service_action') return { domain: 'switch', service: 'turn_on', entity_id: '', data: {}, 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)}${flow.draft ? ` ${esc(tr('flow.draft'))}` : ''}

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

${flow.draft ? `` : ``}
${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, draft: false, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [], }; } function flowCurrentName() { const input = $('#flowName'); if (input && !input.hidden) return input.value.trim(); return String(app.flowDraft?.name || '').trim(); } function renderFlowNameMode() { const input = $('#flowName'), view = $('#flowNameView'), text = $('#flowNameText'); if (!input || !view || !text || !app.flowDraft) return; const name = String(app.flowDraft.name || '').trim(); input.value = name; text.textContent = name || tr('flow.newDefaultName'); const editing = app.flowNameEditing === true; input.hidden = !editing; view.hidden = editing; } function editFlowName({ select = true } = {}) { if (!app.flowDraft) return; app.flowNameEditing = true; renderFlowNameMode(); const input = $('#flowName'); requestAnimationFrame(() => { input?.focus(); if (select) input?.select(); }); } function commitFlowName({ keepEditingOnEmpty = true } = {}) { if (!app.flowDraft) return false; const input = $('#flowName'); if (!input) return false; const name = input.value.trim(); if (!name) { if (keepEditingOnEmpty) { app.flowNameEditing = true; input.focus(); } return false; } if (app.flowDraft.name !== name) { app.flowDraft.name = name; app.flowDirty = true; flowHistoryCommit(); } app.flowNameEditing = false; renderFlowNameMode(); return true; } 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; app.flowZoom = 1; app.flowNameEditing = !flow; renderFlowNameMode(); $('#flowEnabled').checked = app.flowDraft.enabled !== false; $('#flowEditor').hidden = false; document.body.classList.add('flow-editor-open'); $('#flowNaturalPreview')?.classList.remove('is-expanded'); $('#flowNaturalPreview .flow-preview-toggle')?.setAttribute('aria-expanded', 'false'); renderFlowEditor(); flowHistoryReset(); requestAnimationFrame(() => { if (window.matchMedia('(max-width: 760px)').matches && app.flowDraft?.nodes?.length) fitFlowToView({ maxZoom: .9 }); }); startFlowSharedInputValueRefresh(); if (push) updateBrowserUrl(`/flows/${flow?.id || 'new'}`); } function closeFlowEditor({ push = true, force = false } = {}) { if (!force && app.flowDirty && !confirm(tr('confirm.discardChanges'))) return false; stopFlowSharedInputValueRefresh(); const mobileActions = $('#flowEditorActionsDialog'); if (mobileActions?.open) mobileActions.close(); const blockDialog = $('#flowBlockDialog'); if (blockDialog?.open) blockDialog.close(); $('#flowEditor').hidden = true; document.body.classList.remove('flow-editor-open'); app.flowDraft = null; app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false; app.flowNameEditing = false; flowHistoryClear(); hideFlowContextMenu(); clearFlowEditorNotice(); if (push) updateBrowserUrl('/flows'); return true; } function flowSharedInputValueSignature(item) { return `${item?.kind || ''}:${JSON.stringify(item?.config || {})}`; } function flowSharedInputLocalObservation(item) { if (!item) return { hasValue: false, value: null }; const c = item.config || {}; if (item.kind === 'outdoor_temperature') return { hasValue: app.outdoorTemperature !== null && app.outdoorTemperature !== undefined && Number.isFinite(Number(app.outdoorTemperature)), value: app.outdoorTemperature }; if (item.kind === 'device_temperature') { const value = app.devices.find(device => device.id === c.device_id)?.current_temperature; return { hasValue: value !== null && value !== undefined && Number.isFinite(Number(value)), value }; } if (item.kind === 'zone_temperature') { const value = app.zones.find(zone => zone.id === c.zone_id)?.current_temperature; return { hasValue: value !== null && value !== undefined && Number.isFinite(Number(value)), value }; } if (item.kind === 'house_mode') { const value = app.settings?.house_mode; return { hasValue: value !== null && value !== undefined && value !== '', value }; } if (item.kind === 'device_state') { const device = app.devices.find(value => value.id === c.device_id), value = device?.[c.field]; return { hasValue: value !== undefined && value !== null, value }; } if (item.kind === 'zone_state') { const zone = app.zones.find(value => value.id === c.zone_id), value = zone?.[c.field]; return { hasValue: value !== undefined && value !== null, value }; } if (item.kind === 'group_state') { const group = app.groups.find(value => value.id === c.group_id), value = group?.[c.field || 'power_enabled']; return { hasValue: value !== undefined && value !== null, value }; } if (item.kind === 'night_mode') { const value = app.controlPlan?.night_mode_active; return { hasValue: typeof value === 'boolean', value }; } if (item.kind === 'constant') return { hasValue: true, value: c.value }; return null; } function flowSharedInputObservation(item) { const local = flowSharedInputLocalObservation(item); if (local) return local; const cached = app.flowSharedInputValueCache?.[item?.id]; return cached?.signature === flowSharedInputValueSignature(item) ? cached : { hasValue: false, value: null }; } function flowSharedInputCurrentText(item, observation = flowSharedInputObservation(item)) { if (!observation?.hasValue) return '—'; const value = observation.value; const louverAxis = sharedFlowInputLouverAxis(item); if (louverAxis) return louverPositionLabel(louverAxis, value); if (['outdoor_temperature', 'device_temperature', 'zone_temperature'].includes(item.kind)) return fmtTemp(value); if (item.kind === 'ha_numeric') { const numeric = Number(value); if (!Number.isFinite(numeric)) return '—'; const unit = String(observation.unit || '').trim(); return `${Number.isInteger(numeric) ? numeric : Number(numeric.toFixed(2))}${unit ? ` ${unit}` : ''}`; } if (item.kind === 'house_mode') return houseModeLabel(String(value)); if (typeof value === 'boolean') return value ? tr('common.yes') : tr('common.no'); if (value === null || value === undefined || value === '') return '—'; return String(value); } function renderFlowSharedInputCurrentValues() { if (!app.flowDraft) return; $$('[data-flow-shared-current]').forEach(host => { const item = (app.flowSharedInputs || []).find(value => value.id === host.dataset.flowSharedCurrent); const text = item ? flowSharedInputCurrentText(item) : '—'; host.title = text; host.innerHTML = `${esc(tr('flow.sharedInputTestCurrent'))}${esc(text)}`; }); } async function loadFlowSharedInputHaValue(item) { if (!item || !['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available'].includes(item.kind)) return; const signature = flowSharedInputValueSignature(item), cached = app.flowSharedInputValueCache?.[item.id]; if (cached?.signature === signature && Date.now() - Number(cached.fetchedAt || 0) < 15000) return; if (app.flowSharedInputValueRequests?.[item.id] === signature) return; app.flowSharedInputValueRequests[item.id] = signature; try { const entityId = String(item.config?.entity_id || '').trim(); if (!entityId) throw new Error('missing entity_id'); const result = await api('/api/integrations/home-assistant/entity', { method: 'POST', body: { entity_id: entityId } }); let value = result.state, hasValue = true; if (item.kind === 'ha_available') value = result.available === true; else if (item.kind === 'ha_attribute') { value = result.attributes?.[item.config?.attribute]; hasValue = value !== undefined; } else if (item.kind === 'ha_numeric') { value = Number(result.state); hasValue = Number.isFinite(value); } app.flowSharedInputValueCache[item.id] = { signature, fetchedAt: Date.now(), hasValue, value, unit: item.kind === 'ha_numeric' ? result.attributes?.unit_of_measurement : '', }; } catch (_) { app.flowSharedInputValueCache[item.id] = { signature, fetchedAt: Date.now(), hasValue: false, value: null }; } finally { if (app.flowSharedInputValueRequests?.[item.id] === signature) delete app.flowSharedInputValueRequests[item.id]; renderFlowSharedInputCurrentValues(); } } function refreshFlowSharedInputCurrentValues() { if (!app.flowDraft) return; renderFlowSharedInputCurrentValues(); const ids = new Set((app.flowDraft.nodes || []).filter(node => node.kind === 'shared_input').map(node => node.config?.input_id).filter(Boolean)); ids.forEach(id => { const item = (app.flowSharedInputs || []).find(value => value.id === id); if (item) loadFlowSharedInputHaValue(item); }); } function startFlowSharedInputValueRefresh() { stopFlowSharedInputValueRefresh(); refreshFlowSharedInputCurrentValues(); app.flowSharedInputValueTimer = setInterval(refreshFlowSharedInputCurrentValues, 5000); } function stopFlowSharedInputValueRefresh() { if (app.flowSharedInputValueTimer) clearInterval(app.flowSharedInputValueTimer); app.flowSharedInputValueTimer = null; } 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 === 'cron_trigger') return c.expression || '—'; if (node.kind === 'stable_for') return flowDurationLabel(c.seconds); if (node.kind === 'state_duration') return `${flowDurationLabel(c.min_seconds || 0)}–${c.max_seconds == null ? '∞' : flowDurationLabel(c.max_seconds)}`; if (node.kind === 'on_change') return c.mode === 'value' ? tr('flow.changeModeValue') : tr('flow.changeModeResult'); if (node.kind === 'rate_limit') return `${Number(c.max_count || 1)} × / ${flowDurationLabel(c.period_seconds || 3600)}`; if (node.kind === 'delay') return flowDurationLabel(c.seconds); if (node.kind === 'rolling_stat') return `${c.statistic === 'median' ? tr('flow.median') : tr('flow.mean')} · ${flowDurationLabel(c.window_seconds)} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'oscillates') return `${flowDurationLabel(c.window_seconds)} · Δ≥${Number(c.min_span ?? 1)} · ↕≥${Number(c.min_direction_changes ?? 2)}`; 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 ? haSensorLabel(c.entity_id) : 'entity_id'} ${c.operator === 'neq' ? '≠' : '='} ${c.value || '—'}`; if (node.kind === 'ha_numeric') return `${c.entity_id ? haSensorLabel(c.entity_id) : 'entity_id'} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'ha_attribute') return `${c.entity_id ? haSensorLabel(c.entity_id) : 'entity_id'}.${c.attribute || 'attribute'} ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`; if (node.kind === 'ha_available') return `${c.entity_id ? haSensorLabel(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') { const axis = c.field === 'swing_vertical' ? 'vertical' : c.field === 'swing_horizontal' ? 'horizontal' : null; const value = axis ? louverPositionLabel(axis, c.value) : (c.value ?? '—'); return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${deviceCommandFieldLabel(c.field || 'state')} ${flowOperatorLabel(c.operator)} ${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); if (!item) return tr('flow.sharedInputMissing'); if (sharedFlowInputRequiresComparison(item.kind)) { const axis = sharedFlowInputLouverAxis(item); const value = axis ? louverPositionLabel(axis, c.value) : (c.value ?? '—'); return c.operator ? `${item.name} · ${sharedFlowInputSourceSummary(item)} · ${flowOperatorLabel(c.operator)} ${value}` : `${item.name} · ${tr('flow.selectOperator')}`; } return `${item.name} · ${sharedFlowInputSourceSummary(item)}`; } 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); const power = c.power == null ? null : (c.power ? tr('common.on') : tr('common.off')); const mode = c.mode ? (c.mode === 'auto' ? 'Auto' : modeLabel(c.mode)) : null; const target = c.preset === 'custom' ? fmtTemp(c.setpoint) : zonePresetLabel(c.preset || 'comfort'); return [zone?.name || tr('common.noZone'), power, mode, target].filter(Boolean).join(' · '); } 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 === 'device_feature_action') { const d = app.devices.find(v => v.id === c.device_id); return `${d?.name || tr('common.noDevice')} · ${deviceCommandFieldLabel(c.feature || 'light')} → ${flowDeviceFeatureValueLabel(c.feature, c.value)}`; } 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')}`; } if (node.kind === 'ha_service_action') return `${c.domain || 'domain'}.${c.service || 'service'} · ${c.entity_id || 'entity_id'}`; return ''; } function flowNodeTitle(meta) { return meta?.titleKey ? tr(meta.titleKey) : (meta?.title || ''); } function flowOperatorLabel(op) { return ({ lt: '<', lte: '≤', gt: '>', gte: '≥', eq: '=', neq: '≠' })[op] || '<'; } function flowDurationLabel(seconds) { const value = Number(seconds || 0); return value >= 60 && value % 60 === 0 ? `${value / 60} min` : `${value} s`; } function flowNodeById(id) { return app.flowDraft?.nodes?.find(node => node.id === id); } function renderFlowEnabledLabel() { const input = $('#flowEnabled'), label = $('#flowEnabledLabel'); if (input && label) label.textContent = tr(input.checked ? 'flow.enabledState' : 'flow.disabledState'); } function renderFlowSaveStatus() { const status = $('#flowSaveStatus'); if (!status || !app.flowDraft) return; const isNew = !app.flowDraft.id; status.textContent = app.flowDirty ? tr('flow.unsavedChanges') : isNew ? tr('flow.notSavedYet') : tr('flow.savedState'); status.classList.toggle('is-dirty', app.flowDirty || isNew); } let flowEditorNoticeTimer = null; function clearFlowEditorNotice() { if (flowEditorNoticeTimer) clearTimeout(flowEditorNoticeTimer); flowEditorNoticeTimer = null; const host = $('#flowEditorNotice'); if (!host) return; host.classList.remove('is-visible', 'is-error'); host.textContent = ''; } function flowEditorNotice(message, { error = false, timeout = 1700 } = {}) { const host = $('#flowEditorNotice'); if (!host || !message) return; if (flowEditorNoticeTimer) clearTimeout(flowEditorNoticeTimer); host.textContent = message; host.classList.toggle('is-error', error); host.classList.add('is-visible'); flowEditorNoticeTimer = setTimeout(() => { host.classList.remove('is-visible', 'is-error'); flowEditorNoticeTimer = null; }, timeout); } function notifyFlowEditorAction(message, mode = 'toast') { if (!message || mode === false || mode === 'none') return; if (mode === 'inline') flowEditorNotice(message); else toast(message); } let flowHistory = []; let flowHistoryApplying = false; const FLOW_HISTORY_LIMIT = 100; function flowHistorySnapshot() { if (!app.flowDraft) return null; const enabled = $('#flowEnabled'); return { draft: JSON.parse(JSON.stringify(app.flowDraft)), enabled: enabled ? enabled.checked : app.flowDraft.enabled !== false, selectedIds: [...(app.flowSelectedNodeIds || [])], selectedId: app.flowSelectedNodeId || null, connectFrom: app.flowConnectFrom || null, dirty: app.flowDirty === true, }; } function flowHistoryKey(snapshot) { if (!snapshot) return ''; return JSON.stringify({ draft: snapshot.draft, enabled: snapshot.enabled, dirty: snapshot.dirty }); } function flowHistoryClear() { flowHistory = []; flowHistoryApplying = false; } function flowHistoryReset() { const snapshot = flowHistorySnapshot(); flowHistory = snapshot ? [snapshot] : []; flowHistoryApplying = false; } function flowHistoryCommit() { if (flowHistoryApplying || !app.flowDraft) return; const snapshot = flowHistorySnapshot(); if (!snapshot) return; const previous = flowHistory[flowHistory.length - 1]; if (previous && flowHistoryKey(previous) === flowHistoryKey(snapshot)) return; flowHistory.push(snapshot); if (flowHistory.length > FLOW_HISTORY_LIMIT) flowHistory.splice(0, flowHistory.length - FLOW_HISTORY_LIMIT); } function canUndoFlowEdit() { return flowHistory.length > 1; } function undoFlowEdit({ notify = 'inline' } = {}) { if (!app.flowDraft || !canUndoFlowEdit()) { if (notify === 'inline') flowEditorNotice(tr('flow.nothingToUndo')); return false; } flowHistoryApplying = true; flowHistory.pop(); const snapshot = flowHistory[flowHistory.length - 1]; app.flowDraft = JSON.parse(JSON.stringify(snapshot.draft)); const valid = new Set(app.flowDraft.nodes.map(node => node.id)); app.flowSelectedNodeIds = (snapshot.selectedIds || []).filter(id => valid.has(id)); app.flowSelectedNodeId = snapshot.selectedId && valid.has(snapshot.selectedId) ? snapshot.selectedId : (app.flowSelectedNodeIds[app.flowSelectedNodeIds.length - 1] || null); app.flowConnectFrom = snapshot.connectFrom && valid.has(snapshot.connectFrom) ? snapshot.connectFrom : null; app.flowDirty = snapshot.dirty === true; app.flowNameEditing = false; const enabled = $('#flowEnabled'); if (enabled) enabled.checked = snapshot.enabled !== false; renderFlowEditor(); flowHistoryApplying = false; notifyFlowEditorAction(tr('flow.undoDone'), notify); return true; } let flowContextPoint = null; let flowContextEdgeId = null; let flowBlockInsertPoint = null; function hideFlowContextMenu() { const menu = $('#flowContextMenu'); if (menu) menu.hidden = true; flowContextEdgeId = null; } function showFlowContextMenu(items, event) { const menu = $('#flowContextMenu'); if (!menu) return; menu.innerHTML = items.map(item => item.separator ? '' : ``).join(''); menu.hidden = false; menu.style.left = '0px'; menu.style.top = '0px'; const rect = menu.getBoundingClientRect(); const margin = 8; const left = Math.max(margin, Math.min(event.clientX, window.innerWidth - rect.width - margin)); const top = Math.max(margin, Math.min(event.clientY, window.innerHeight - rect.height - margin)); menu.style.left = `${left}px`; menu.style.top = `${top}px`; } function setFlowZoom(value, { render = true } = {}) { const next = clamp(Number(value) || 1, .1, 1.35); app.flowZoom = Math.round(next * 20) / 20; const canvas = $('#flowCanvas'); if (canvas) canvas.style.zoom = String(app.flowZoom); const label = $('#flowZoomLabel'); if (label) label.textContent = `${Math.round(app.flowZoom * 100)}%`; if (render) requestAnimationFrame(renderFlowEdges); } function fitFlowToView({ maxZoom = 1 } = {}) { const workspace = $('#flowWorkspace'); const nodes = app.flowDraft?.nodes || []; if (!workspace || !nodes.length) { setFlowZoom(1); if (workspace) workspace.scrollTo({ left: 0, top: 0, behavior: 'auto' }); return; } const compactViewport = window.matchMedia('(max-width: 900px), (max-height: 700px)').matches; const nodeWidth = 170, fallbackHeight = 96, pad = compactViewport ? 24 : 48; const bounds = nodes.map(node => { const element = $(`[data-flow-node="${CSS.escape(node.id)}"]`); return { left: Number.isFinite(Number(element?.offsetLeft)) ? Number(element.offsetLeft) : Number(node.x || 0), top: Number.isFinite(Number(element?.offsetTop)) ? Number(element.offsetTop) : Number(node.y || 0), width: Math.max(nodeWidth, Number(element?.offsetWidth || 0)), height: Math.max(fallbackHeight, Number(element?.offsetHeight || 0)), }; }); const minX = Math.min(...bounds.map(item => item.left)) - pad; const minY = Math.min(...bounds.map(item => item.top)) - pad; const maxX = Math.max(...bounds.map(item => item.left + item.width)) + pad; const maxY = Math.max(...bounds.map(item => item.top + item.height)) + pad; const contentW = Math.max(1, maxX - minX), contentH = Math.max(1, maxY - minY); const availableW = Math.max(80, workspace.clientWidth - (compactViewport ? 12 : 20)); const availableH = Math.max(80, workspace.clientHeight - (compactViewport ? 12 : 20)); const minZoom = compactViewport ? .1 : .2; const rawZoom = clamp(Math.min(availableW / contentW, availableH / contentH, maxZoom), minZoom, 1.35); const zoom = Math.max(minZoom, Math.floor(rawZoom * 20) / 20); setFlowZoom(zoom); requestAnimationFrame(() => requestAnimationFrame(() => { const centerX = ((minX + maxX) / 2) * app.flowZoom; const centerY = ((minY + maxY) / 2) * app.flowZoom; workspace.scrollTo({ left: Math.max(0, centerX - workspace.clientWidth / 2), top: Math.max(0, centerY - workspace.clientHeight / 2), behavior: 'auto', }); renderFlowEdges(); })); } function renderFlowBlockLibrary(filter = '') { const host = $('#flowBlockLibrary'); if (!host) return; const query = String(filter || '').trim().toLocaleLowerCase(locale()); const groups = $$('.flow-palette .flow-palette-group'); host.innerHTML = groups.map(group => { const title = group.querySelector(':scope > span')?.textContent?.trim() || ''; const buttons = $$('[data-flow-add]', group).filter(button => !query || `${title} ${button.textContent}`.toLocaleLowerCase(locale()).includes(query)); if (!buttons.length) return ''; const categoryClass = [...group.classList].find(name => name.startsWith('block-category-')) || ''; return `

${esc(title)}

${buttons.map(button => ``).join('')}
`; }).join('') || `
${esc(tr('flow.noBlocksFound'))}${esc(tr('flow.noBlocksFoundHint'))}
`; } function openFlowBlockLibrary({ point = null } = {}) { flowBlockInsertPoint = point ? { x: Number(point.x || 0), y: Number(point.y || 0) } : null; const search = $('#flowBlockSearch'); if (search) search.value = ''; renderFlowBlockLibrary(''); $('#flowBlockDialog')?.showModal(); requestAnimationFrame(() => search?.focus()); } 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))}${node.kind === 'shared_input' && node.config?.input_id ? `
${esc(tr('flow.sharedInputTestCurrent'))}
` : ''}
`; }).join(''); $('#flowEmptyHint').hidden = draft.nodes.length > 0; const selectedCount = (app.flowSelectedNodeIds || []).length; const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = selectedCount ? tr('flow.selectedCount', { count: selectedCount }) : ''; const duplicateButton = $('[data-action="flow-duplicate-selection"]'); if (duplicateButton) duplicateButton.disabled = !selectedCount; renderFlowNameMode(); renderFlowEnabledLabel(); setFlowZoom(app.flowZoom || 1, { render: false }); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); renderFlowSaveStatus(); refreshFlowSharedInputCurrentValues(); const status = $('#flowCompileStatus'); status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 }); status.classList.toggle('flow-draft-badge', draft.draft === true); } function renderFlowEdges() { const svg = $('#flowEdges'), workspace = $('#flowWorkspace'); if (!svg || !workspace || !app.flowDraft) return; const rect = workspace.getBoundingClientRect(), zoom = app.flowZoom || 1; svg.setAttribute('viewBox', '0 0 2400 1500'); 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) / zoom - 2, y1 = (a.top - rect.top + workspace.scrollTop + a.height / 2) / zoom; const x2 = (b.left - rect.left + workspace.scrollLeft) / zoom + 2, y2 = (b.top - rect.top + workspace.scrollTop + b.height / 2) / zoom; 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 sharedFlowReferenceComparisonFields(item, config) { if (!item || !sharedFlowInputRequiresComparison(item.kind)) return ''; const defaults = sharedFlowReferenceComparisonDefaults(item); const selected = config.operator || ''; const louverAxis = sharedFlowInputLouverAxis(item); const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(item.kind); const fullOperators = numeric || item.kind === 'ha_attribute'; const operatorOptions = `${!selected ? `` : ''}${fullOperators ? flowOperatorOptions(selected) : [['eq', '='], ['neq', '≠']].map(([value, label]) => ``).join('')}`; const value = config.value ?? defaults.value ?? ''; let valueField = ``; if (louverAxis) valueField = ``; else if (numeric) valueField = ``; else if (item.kind === 'house_mode') valueField = ``; return `

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

`; } function renderFlowInspector() { const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return; host.classList.toggle('has-selection', Boolean(node)); if (!node) { host.classList.remove('is-expanded'); 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 === 'cron_trigger') fields = `

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

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

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

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

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

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

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

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

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

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

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

`; else if (node.kind === 'rolling_stat') { const source = c.source || 'outdoor_temperature'; fields = `${source === 'device_temperature' ? `` : ''}${source === 'zone_temperature' ? `` : ''}${source === 'ha_numeric' ? `` : ''}
${flowComparisonFields(c, false)}`; } else if (node.kind === 'oscillates') { const source = c.source || 'outdoor_temperature'; fields = `${source === 'device_temperature' ? `` : ''}${source === 'zone_temperature' ? `` : ''}${source === 'ha_numeric' ? `` : ''}

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

`; } 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 = `${['swing_vertical', 'swing_horizontal'].includes(c.field) ? flowLouverComparisonFields(c, c.field === 'swing_horizontal' ? 'horizontal' : 'vertical') : 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') { const item = (app.flowSharedInputs || []).find(value => value.id === c.input_id) || app.flowSharedInputs?.[0]; fields = (app.flowSharedInputs || []).length ? `

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

${sharedFlowReferenceComparisonFields(item, c)}` : `
${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 = `
${esc(tr('flow.thermostatDeviceOptions'))}
${flowOptionalLouverField(c, 'swing_vertical', 'vertical')}${flowOptionalLouverField(c, 'swing_horizontal', 'horizontal')}

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

`; else if (node.kind === 'device_action') fields = `${flowActionFields(c, false)}`; else if (node.kind === 'device_feature_action') fields = `${flowDeviceFeatureValueField(c)}

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

`; else if (node.kind === 'group_action') fields = `${flowActionFields(c, true)}`; else if (node.kind === 'ha_service_action') fields = `

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

`; 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 flowLouverComparisonFields(c, axis) { const options = [['eq', '='], ['neq', '≠']].map(([v, l]) => ``).join(''); const value = Number.isInteger(Number(c.value)) ? Number(c.value) : 0; return `
`; } function flowComparisonFields(c, temperature = true) { return `
`; } const FLOW_DEVICE_FEATURES = Object.freeze(['power', 'mode', 'target_temperature', 'fan_speed', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep']); const FLOW_DEVICE_BOOL_FEATURES = Object.freeze(['power', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep']); function flowDeviceFeatureDefaultValue(feature) { if (FLOW_DEVICE_BOOL_FEATURES.includes(feature)) return true; if (feature === 'mode') return 'auto'; if (feature === 'target_temperature') return 22; if (feature === 'fan_speed') return 0; if (['swing_vertical', 'swing_horizontal'].includes(feature)) return 1; return true; } function flowDeviceFeatureOptions(selected) { return FLOW_DEVICE_FEATURES.map(feature => ``).join(''); } function flowDeviceFeatureValueLabel(feature, value) { if (FLOW_DEVICE_BOOL_FEATURES.includes(feature)) return value === false ? tr('common.off') : tr('common.on'); if (feature === 'mode') return modeLabel(value || 'auto'); if (feature === 'target_temperature') return fmtTemp(value); if (feature === 'fan_speed') return fanLabel(Number(value)); if (feature === 'swing_vertical') return louverPositionLabel('vertical', value); if (feature === 'swing_horizontal') return louverPositionLabel('horizontal', value); return value ?? '—'; } function flowDeviceFeatureValueField(c) { const feature = c.feature || 'light'; const value = c.value ?? flowDeviceFeatureDefaultValue(feature); if (FLOW_DEVICE_BOOL_FEATURES.includes(feature)) return ``; if (feature === 'mode') return ``; if (feature === 'target_temperature') return ``; if (feature === 'fan_speed') return ``; if (feature === 'swing_vertical') return ``; if (feature === 'swing_horizontal') return ``; return ''; } function flowOptionalBoolField(c, key) { return ``; } function flowOptionalLouverField(c, key, axis) { 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'))}
${flowOptionalLouverField(c, 'swing_vertical', 'vertical')}${flowOptionalLouverField(c, 'swing_horizontal', '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 => ['action', 'haaction'].includes(FLOW_NODE_META[node.kind]?.category)); 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 flowCronHumanSummary(expression) { const raw = String(expression || '').trim(); const parts = raw.split(/\s+/); if (parts.length !== 5) return tr('flow.cronCustom', { expression: raw || '—' }); const [minute, hour, day, month, weekday] = parts; const allDays = day === '*' && month === '*' && weekday === '*'; if (minute === '*' && hour === '*' && allDays) return tr('flow.cronEveryMinute'); const everyMinutes = minute.match(/^\*\/(\d+)$/); if (everyMinutes && hour === '*' && allDays) return tr('flow.cronEveryMinutes', { minutes: everyMinutes[1] }); if (/^\d{1,2}$/.test(minute) && hour === '*' && allDays) return tr('flow.cronHourlyAt', { minute: String(Number(minute)).padStart(2, '0') }); if (/^\d{1,2}$/.test(minute) && /^\d{1,2}$/.test(hour) && allDays) return tr('flow.cronDailyAt', { time: `${String(Number(hour)).padStart(2, '0')}:${String(Number(minute)).padStart(2, '0')}` }); return tr('flow.cronCustom', { expression: raw }); } function renderFlowRuntimeInfo() { const target = $('#flowRuntimeSummary'); const container = $('#flowRuntimeInfo'); if (!target || !app.flowDraft) return; const seconds = Math.max(2, Number(app.settings?.zone_interval_seconds || 5)); const actions = app.flowDraft.nodes.filter(node => ['action', 'haaction'].includes(FLOW_NODE_META[node.kind]?.category)); if (!actions.length) { target.textContent = tr('flow.runtimeNoActions', { seconds }); if (container) container.title = tr('flow.runtimeHint'); return; } const cronExpressions = new Set(); let hasActionWithoutCron = false; actions.forEach(action => { const crons = flowUpstreamNodes(action.id).filter(node => node.kind === 'cron_trigger'); if (!crons.length) hasActionWithoutCron = true; crons.forEach(node => cronExpressions.add(String(node.config?.expression || '').trim())); }); if (!cronExpressions.size) { target.textContent = tr('flow.runtimeWithoutCron', { seconds }); } else if (!hasActionWithoutCron && cronExpressions.size === 1) { target.textContent = tr('flow.runtimeWithCron', { seconds, cron: flowCronHumanSummary([...cronExpressions][0]) }); } else if (!hasActionWithoutCron) { target.textContent = tr('flow.runtimeWithMultipleCrons', { seconds, count: cronExpressions.size }); } else { target.textContent = tr('flow.runtimeMixed', { seconds, count: cronExpressions.size }); } if (container) container.title = tr('flow.runtimeHint'); } 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, { point = flowBlockInsertPoint } = {}) { if (!app.flowDraft || !FLOW_NODE_META[kind]) return; const count = app.flowDraft.nodes.length; const workspace = $('#flowWorkspace'); const viewportX = workspace ? (workspace.scrollLeft / (app.flowZoom || 1)) + 48 : 80; const viewportY = workspace ? (workspace.scrollTop / (app.flowZoom || 1)) + 54 : 70; const baseX = point ? Number(point.x || 0) : viewportX + (count % 3) * 24; const baseY = point ? Number(point.y || 0) : viewportY + (count % 3) * 24; const node = { id: newFlowId('node'), kind, x: Math.max(36, baseX), y: Math.max(36, baseY), config: flowDefaultConfig(kind) }; flowBlockInsertPoint = null; app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor(); flowHistoryCommit(); requestAnimationFrame(() => $(`[data-flow-node="${CSS.escape(node.id)}"]`)?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' })); } 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(); flowHistoryCommit(); } 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(); flowHistoryCommit(); } function removeFlowEdge(id) { if (!app.flowDraft) return; app.flowDraft.edges = app.flowDraft.edges.filter(edge => edge.id !== id); app.flowDirty = true; renderFlowEditor(); flowHistoryCommit(); } function flowSourcePayload() { if (!app.flowDraft) return null; return { name: flowCurrentName(), enabled: $('#flowEnabled')?.checked !== false, draft: app.flowDraft.draft === true, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes || [], edges: app.flowDraft.edges || [], }; } function flowExportSharedInputs() { if (!app.flowDraft) return []; const ids = new Set((app.flowDraft.nodes || []) .filter(node => node.kind === 'shared_input') .map(node => node.config?.input_id) .filter(Boolean)); return (app.flowSharedInputs || []) .filter(item => ids.has(item.id)) .map(item => JSON.parse(JSON.stringify(item))); } 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); } function flowExportFilename(name) { const safe = String(name || 'flow').replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'flow'; return `${safe}.flow.json`; } async function exportFlowById(id) { if (!id) return; try { const flow = app.flows.find(item => item.id === id); const envelope = await api(`/api/flows/${encodeURIComponent(id)}/export`); downloadJsonFile(flowExportFilename(flow?.name || envelope?.flow?.name), envelope); toast(tr('flow.exported')); } catch (error) { toast(error.message, true); } } 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(), shared_inputs: flowExportSharedInputs(), flow: flowSourcePayload() }; downloadJsonFile(flowExportFilename(flowSourcePayload()?.name), 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[app.defaultLanguage] || 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(tr('flow.invalidPresetFilename')); 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(tr('flow.invalidPresetFile', { filename })); 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', 'ha_service_action'].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 = (count, available, key) => { if (!count) return; const label = tr(key, { count }); requirements.push({ label, ok: available >= count }); if (available < count) missing.push(label); }; addCount(placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone'); addCount(placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice'); addCount(placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup'); addCount(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 ? uiIcon('check') : '!'} ${esc(item.label)}`).join('') : `${uiIcon('check')} ${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'))}${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.presetNotFound'), 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 || flowCurrentName() === tr('flow.newDefaultName')) { app.flowDraft.name = flowPresetText(preset.name) || preset.id; if (app.flowNameEditing) $('#flowName').value = app.flowDraft.name; renderFlowNameMode(); } markFlowPresetRecent(preset.id); $('#flowTemplateDialog')?.close(); renderFlowEditor(); flowHistoryCommit(); } 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 flowSimulationLouverAxis(node) { if (node?.kind === 'device_state') { if (node.config?.field === 'swing_vertical') return 'vertical'; if (node.config?.field === 'swing_horizontal') return 'horizontal'; } if (node?.kind === 'shared_input') { const item = (app.flowSharedInputs || []).find(value => value.id === node.config?.input_id); return sharedFlowInputLouverAxis(item); } return null; } 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 louverAxis = flowSimulationLouverAxis(node); const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind); const control = louverAxis ? `` : ``; 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 && (flowSimulationLouverAxis(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 => `
${uiIcon(item.matched ? 'check' : 'close')} ${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 persistFlowDraft(body) { return api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body }); } async function applySavedFlow(saved, messageKey, { notify = 'inline' } = {}) { 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; app.flowNameEditing = false; $('#flowEnabled').checked = saved.enabled === true; renderFlows(); renderFlowEditor(); flowHistoryReset(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); notifyFlowEditorAction(tr(messageKey), notify); } async function saveFlow({ notify = 'inline' } = {}) { if (!app.flowDraft) return; if (app.flowNameEditing && !commitFlowName()) return toast(tr('flow.nameRequired'), true); const name = flowCurrentName(); if (!name) return toast(tr('flow.nameRequired'), true); const body = { name, enabled: $('#flowEnabled').checked, draft: false, 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 persistFlowDraft(body); await applySavedFlow(saved, 'flow.saved', { notify }); } catch (error) { if (error.status !== 400) return toast(error.message, true); const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason: error.message })); if (!saveDraft) return toast(error.message, true); try { const saved = await persistFlowDraft({ ...body, enabled: false, draft: true }); await applySavedFlow(saved, 'flow.savedAsDraft', { notify }); } catch (draftError) { toast(draftError.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; if (flow.draft) return toast(tr('flow.draftCannotEnable'), true); 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, draft: false, 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); } let flowClipboard = null; let flowClipboardPasteCount = 0; function copySelectedFlowNodes({ notify = 'inline' } = {}) { if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return false; const selected = new Set(app.flowSelectedNodeIds); const nodes = app.flowDraft.nodes .filter(node => selected.has(node.id)) .map(node => ({ ...node, config: JSON.parse(JSON.stringify(node.config || {})) })); const edges = app.flowDraft.edges .filter(edge => selected.has(edge.from) && selected.has(edge.to)) .map(edge => ({ ...edge })); if (!nodes.length) return false; flowClipboard = { nodes, edges }; flowClipboardPasteCount = 0; notifyFlowEditorAction(tr('flow.blocksCopied', { count: nodes.length }), notify); return true; } function pasteFlowNodes({ notify = 'inline', point = null } = {}) { if (!app.flowDraft || !flowClipboard?.nodes?.length) return false; flowClipboardPasteCount += 1; const offset = 36 * flowClipboardPasteCount; const minX = Math.min(...flowClipboard.nodes.map(node => Number(node.x || 0))); const minY = Math.min(...flowClipboard.nodes.map(node => Number(node.y || 0))); const dx = point ? Number(point.x || 0) - minX : offset; const dy = point ? Number(point.y || 0) - minY : offset; const idMap = new Map(flowClipboard.nodes.map(node => [node.id, newFlowId('node')])); const copies = flowClipboard.nodes.map(node => ({ ...node, id: idMap.get(node.id), config: JSON.parse(JSON.stringify(node.config || {})), x: Math.max(12, Number(node.x || 0) + dx), y: Math.max(12, Number(node.y || 0) + dy), })); const copiedEdges = flowClipboard.edges.map(edge => ({ ...edge, id: newFlowId('edge'), from: idMap.get(edge.from), to: idMap.get(edge.to), })); app.flowDraft.nodes.push(...copies); app.flowDraft.edges.push(...copiedEdges); app.flowSelectedNodeIds = copies.map(node => node.id); app.flowSelectedNodeId = copies[copies.length - 1]?.id || null; app.flowDirty = true; renderFlowEditor(); flowHistoryCommit(); notifyFlowEditorAction(tr('flow.blocksPasted', { count: copies.length }), notify); return true; } function duplicateSelectedFlowNodes({ notify = 'inline' } = {}) { const count = (app.flowSelectedNodeIds || []).length; if (!count || !copySelectedFlowNodes({ notify: false })) return false; if (!pasteFlowNodes({ notify: false })) return false; notifyFlowEditorAction(tr('flow.blocksDuplicated', { count }), notify); return true; } function cutSelectedFlowNodes({ notify = 'inline' } = {}) { const count = (app.flowSelectedNodeIds || []).length; if (!count || !copySelectedFlowNodes({ notify: false })) return false; removeSelectedFlowNodes(); notifyFlowEditorAction(tr('flow.blocksCut', { count }), notify); return true; } 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(); flowHistoryCommit(); } function nudgeSelectedFlowNodes(dx, dy) { if (!app.flowDraft || !(app.flowSelectedNodeIds || []).length) return false; const selected = new Set(app.flowSelectedNodeIds); app.flowDraft.nodes.forEach(node => { if (!selected.has(node.id)) return; node.x = Math.max(12, Number(node.x || 0) + dx); node.y = Math.max(12, Number(node.y || 0) + dy); }); app.flowDirty = true; renderFlowEditor(); flowHistoryCommit(); return true; } function openFlowShortcuts() { const dialog = $('#flowShortcutDialog'); if (dialog && !dialog.open) dialog.showModal(); } 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 (node.kind === 'shared_input' && key === 'input_id') { const item = (app.flowSharedInputs || []).find(value => value.id === input.value); node.config = sharedFlowReferenceDefaultConfig(item); } else if (node.kind === 'shared_input' && key === 'operator') { node.config.operator = input.value; if (node.config.value == null) { const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id); node.config.value = sharedFlowReferenceComparisonDefaults(item).value ?? ''; } } else if (node.kind === 'shared_input' && key === 'value') { const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id); const numeric = Boolean(sharedFlowInputLouverAxis(item)) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(item?.kind); node.config.value = numeric ? (input.value === '' ? null : Number(input.value)) : input.value; } else if (node.kind === 'device_feature_action' && key === 'feature') { node.config.feature = input.value; node.config.value = flowDeviceFeatureDefaultValue(input.value); } else if (node.kind === 'device_feature_action' && key === 'value') { if (FLOW_DEVICE_BOOL_FEATURES.includes(node.config.feature)) node.config.value = input.value === 'true'; else if (['target_temperature', 'fan_speed', 'swing_vertical', 'swing_horizontal'].includes(node.config.feature)) node.config.value = Number(input.value); else node.config.value = input.value; } else if (node.kind === 'device_state' && key === 'field') { const wasLouver = ['swing_vertical', 'swing_horizontal'].includes(node.config.field); node.config.field = input.value; if (['swing_vertical', 'swing_horizontal'].includes(input.value)) node.config.value = 0; else if (wasLouver) node.config.value = ''; } else if (node.kind === 'device_state' && key === 'value' && ['swing_vertical', 'swing_horizontal'].includes(node.config.field)) node.config.value = Number(input.value); else if (['swing_vertical', 'swing_horizontal'].includes(key)) node.config[key] = input.value === '' ? null : Number(input.value); else if (['power', '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 (key === 'data_json' && node.kind === 'ha_service_action') { try { node.config.data = JSON.parse(input.value || '{}'); } catch { toast(tr('flow.invalidJson'), true); return; } } else if (key === 'seconds' && ['stable_for', 'delay'].includes(node.kind)) node.config.seconds = Math.max(1, Number(input.value || 1)); else if (key === 'min_seconds' && node.kind === 'state_duration') node.config.min_seconds = Math.max(0, Number(input.value || 0)); else if (key === 'max_seconds' && node.kind === 'state_duration') node.config.max_seconds = input.value === '' ? null : Math.max(0, Number(input.value)); else if (key === 'max_count' && node.kind === 'rate_limit') node.config.max_count = Math.max(1, Math.floor(Number(input.value || 1))); else if (key === 'period_seconds' && node.kind === 'rate_limit') node.config.period_seconds = Math.max(1, Math.floor(Number(input.value || 1))); else if (key === 'window_seconds' && ['rolling_stat', 'oscillates'].includes(node.kind)) node.config.window_seconds = Math.max(10, Number(input.value || 10)); else if (key === 'value' && node.kind === 'rolling_stat') node.config.value = Number(input.value || 0); else if (key === 'min_span' && node.kind === 'oscillates') node.config.min_span = Math.max(0.001, Number(input.value || 0.001)); else if (key === 'min_direction_changes' && node.kind === 'oscillates') node.config.min_direction_changes = Math.max(1, Math.floor(Number(input.value || 1))); else if (['value', 'setpoint', 'target_temperature', 'cooldown_seconds', 'fan_speed'].includes(key) && ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'zone_thermostat', 'device_action', 'device_feature_action', 'group_action', 'ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value); else node.config[key] = input.value; app.flowDirty = true; renderFlowEditor(); flowHistoryCommit(); } let flowDrag = null; let flowMarquee = null; function flowCanvasPoint(event) { const workspace = $('#flowWorkspace'); if (!workspace) return { x: 0, y: 0 }; const rect = workspace.getBoundingClientRect(); const zoom = app.flowZoom || 1; return { x: (event.clientX - rect.left + workspace.scrollLeft) / zoom, y: (event.clientY - rect.top + workspace.scrollTop) / zoom, }; } function updateFlowMarqueeSelection(event) { if (!flowMarquee || !app.flowDraft) return; const current = flowCanvasPoint(event); const left = Math.min(flowMarquee.start.x, current.x); const top = Math.min(flowMarquee.start.y, current.y); const right = Math.max(flowMarquee.start.x, current.x); const bottom = Math.max(flowMarquee.start.y, current.y); const marquee = $('#flowSelectionMarquee'); if (marquee) { marquee.hidden = false; marquee.style.left = `${left}px`; marquee.style.top = `${top}px`; marquee.style.width = `${right - left}px`; marquee.style.height = `${bottom - top}px`; } const hits = app.flowDraft.nodes.filter(node => { const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); const nodeLeft = Number(node.x || 0), nodeTop = Number(node.y || 0); const nodeRight = nodeLeft + Number(el?.offsetWidth || 170); const nodeBottom = nodeTop + Number(el?.offsetHeight || 96); return nodeRight >= left && nodeLeft <= right && nodeBottom >= top && nodeTop <= bottom; }).map(node => node.id); const selected = [...new Set([...flowMarquee.baseSelection, ...hits])]; app.flowSelectedNodeIds = selected; app.flowSelectedNodeId = selected[selected.length - 1] || null; $$('[data-flow-node]').forEach(el => el.classList.toggle('selected', selected.includes(el.dataset.flowNode))); const count = $('#flowSelectionCount'); if (count) count.textContent = selected.length ? tr('flow.selectedCount', { count: selected.length }) : ''; } document.addEventListener('pointerdown', event => { if ($('#flowEditor')?.hidden) return; if (event.button !== 0) return; const nodeEl = event.target.closest?.('[data-flow-node]'); if (!nodeEl) { const workspace = event.target.closest?.('#flowWorkspace'); if (!workspace || event.target.closest?.('button,input,select,label,[data-flow-edge]')) return; const additive = event.shiftKey || event.ctrlKey || event.metaKey; flowMarquee = { start: flowCanvasPoint(event), clientX: event.clientX, clientY: event.clientY, baseSelection: additive ? [...(app.flowSelectedNodeIds || [])] : [], additive, moved: false, pointerId: event.pointerId, }; workspace.setPointerCapture?.(event.pointerId); const marquee = $('#flowSelectionMarquee'); if (marquee) marquee.hidden = true; event.preventDefault(); 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, moved: false }; nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault(); }); document.addEventListener('pointermove', event => { if (flowMarquee && app.flowDraft) { flowMarquee.moved = flowMarquee.moved || Math.abs(event.clientX - flowMarquee.clientX) > 3 || Math.abs(event.clientY - flowMarquee.clientY) > 3; if (flowMarquee.moved) updateFlowMarqueeSelection(event); event.preventDefault(); return; } if (!flowDrag || !app.flowDraft) return; let dx = (event.clientX - flowDrag.x) / (app.flowZoom || 1), dy = (event.clientY - flowDrag.y) / (app.flowZoom || 1); flowDrag.moved = flowDrag.moved || Math.abs(dx) > .5 || Math.abs(dy) > .5; 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; renderFlowSaveStatus(); renderFlowEdges(); }); document.addEventListener('pointerup', event => { if (flowMarquee) { if (!flowMarquee.moved) { app.flowSelectedNodeIds = flowMarquee.additive ? [...flowMarquee.baseSelection] : []; app.flowSelectedNodeId = app.flowSelectedNodeIds[app.flowSelectedNodeIds.length - 1] || null; } const workspace = $('#flowWorkspace'); workspace?.releasePointerCapture?.(flowMarquee.pointerId); const marquee = $('#flowSelectionMarquee'); if (marquee) marquee.hidden = true; flowMarquee = null; renderFlowEditor(); event.preventDefault(); return; } if (flowDrag?.moved) flowHistoryCommit(); flowDrag = null; }); document.addEventListener('contextmenu', event => { if ($('#flowEditor')?.hidden || !app.flowDraft) return; const workspace = event.target.closest?.('#flowWorkspace'); if (!workspace || event.target.closest?.('#flowContextMenu,input,select,textarea,[contenteditable="true"]')) return; event.preventDefault(); flowContextPoint = flowCanvasPoint(event); flowContextEdgeId = null; const nodeEl = event.target.closest?.('[data-flow-node]'); const edgeEl = event.target.closest?.('[data-flow-edge]'); if (edgeEl) { flowContextEdgeId = edgeEl.dataset.flowEdge; showFlowContextMenu([{ action: 'delete-edge', label: tr('flow.contextDeleteConnection') }], event); return; } if (nodeEl) { const id = nodeEl.dataset.flowNode; if (!(app.flowSelectedNodeIds || []).includes(id)) { app.flowSelectedNodeIds = [id]; app.flowSelectedNodeId = id; renderFlowEditor(); } const count = (app.flowSelectedNodeIds || []).length; showFlowContextMenu([ { action: 'copy', label: tr('flow.contextCopy', { count }) }, { action: 'cut', label: tr('flow.contextCut', { count }) }, { action: 'duplicate', label: tr('flow.contextDuplicate', { count }) }, { action: 'delete', label: tr('flow.contextDelete', { count }) }, { separator: true }, { action: 'paste', label: tr('flow.contextPaste'), disabled: !flowClipboard?.nodes?.length }, { action: 'add', label: tr('flow.contextAddBlock') }, ], event); return; } showFlowContextMenu([ { action: 'undo', label: tr('flow.contextUndo'), disabled: !canUndoFlowEdit() }, { action: 'add', label: tr('flow.contextAddBlock') }, { action: 'paste', label: tr('flow.contextPasteHere'), disabled: !flowClipboard?.nodes?.length }, { separator: true }, { action: 'select-all', label: tr('flow.selectAll'), disabled: !(app.flowDraft.nodes || []).length }, { action: 'fit', label: tr('flow.fitView'), disabled: !(app.flowDraft.nodes || []).length }, ], event); }); document.addEventListener('keydown', event => { const flowCard = event.target.closest?.('[data-flow-card-id]'); if (flowCard && event.target === flowCard && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openFlowEditor(flowCard.dataset.flowCardId); return; } if ($('#flowEditor')?.hidden || !app.flowDraft) return; if (event.target.closest?.('input,select,textarea,[contenteditable="true"]')) return; if (event.target.closest?.('dialog[open]') || document.querySelector('dialog[open]')) return; const modifier = event.ctrlKey || event.metaKey; const key = event.key.toLowerCase(); if (modifier && key === 's') { event.preventDefault(); saveFlow({ notify: 'inline' }); return; } if (modifier && key === 'k') { event.preventDefault(); openFlowBlockLibrary(); return; } if (modifier && key === 'z' && !event.shiftKey) { event.preventDefault(); undoFlowEdit({ notify: 'inline' }); return; } if (modifier && key === 'a') { event.preventDefault(); selectAllFlowNodes(); return; } if (modifier && key === 'c') { event.preventDefault(); copySelectedFlowNodes({ notify: 'inline' }); return; } if (modifier && key === 'x') { event.preventDefault(); cutSelectedFlowNodes({ notify: 'inline' }); return; } if (modifier && key === 'v') { event.preventDefault(); pasteFlowNodes({ notify: 'inline' }); return; } if (modifier && key === 'd') { event.preventDefault(); duplicateSelectedFlowNodes({ notify: 'inline' }); return; } if (modifier && event.key === '0') { event.preventDefault(); fitFlowToView(); return; } if (modifier && (event.key === '=' || event.key === '+')) { event.preventDefault(); setFlowZoom((app.flowZoom || 1) + .1); return; } if (modifier && event.key === '-') { event.preventDefault(); setFlowZoom((app.flowZoom || 1) - .1); return; } if (!modifier && !event.altKey && (key === 'a' || event.key === 'Insert')) { event.preventDefault(); openFlowBlockLibrary(); return; } if (!modifier && event.key === '?') { event.preventDefault(); openFlowShortcuts(); return; } if (event.key === 'Escape' && !$('#flowContextMenu')?.hidden) { event.preventDefault(); hideFlowContextMenu(); return; } if (event.key === 'Escape' && ((app.flowSelectedNodeIds || []).length || app.flowConnectFrom)) { event.preventDefault(); app.flowConnectFrom = null; clearFlowSelection(); return; } if ((event.key === 'Delete' || event.key === 'Backspace') && (app.flowSelectedNodeIds || []).length) { event.preventDefault(); removeSelectedFlowNodes(); return; } if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key) && (app.flowSelectedNodeIds || []).length) { event.preventDefault(); const step = event.shiftKey ? 1 : 10; const dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0; const dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0; nudgeSelectedFlowNodes(dx, dy); } }); document.addEventListener('click', event => { const contextAction = event.target.closest?.('[data-flow-context-action]'); if (contextAction) { const action = contextAction.dataset.flowContextAction; const point = flowContextPoint ? { ...flowContextPoint } : null; const edgeId = flowContextEdgeId; hideFlowContextMenu(); if (action === 'copy') copySelectedFlowNodes({ notify: 'inline' }); else if (action === 'cut') cutSelectedFlowNodes({ notify: 'inline' }); else if (action === 'duplicate') duplicateSelectedFlowNodes({ notify: 'inline' }); else if (action === 'delete') removeSelectedFlowNodes(); else if (action === 'paste') pasteFlowNodes({ notify: 'inline', point }); else if (action === 'add') openFlowBlockLibrary({ point }); else if (action === 'select-all') selectAllFlowNodes(); else if (action === 'fit') fitFlowToView(); else if (action === 'undo') undoFlowEdit({ notify: 'inline' }); else if (action === 'delete-edge' && edgeId) removeFlowEdge(edgeId); return; } if (!event.target.closest?.('#flowContextMenu')) hideFlowContextMenu(); const flowCard = event.target.closest?.('[data-flow-card-id]'); if (flowCard && !event.target.closest?.('button,summary,details,input,select,textarea,a,label')) { openFlowEditor(flowCard.dataset.flowCardId); return; } 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) { const dialog = add.closest?.('#flowBlockDialog'); if (!dialog) flowBlockInsertPoint = null; addFlowNode(add.dataset.flowAdd); if (dialog?.open) dialog.close(); 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; const mobileActionsDialog = actionButton?.closest?.('#flowEditorActionsDialog'); if (mobileActionsDialog?.open && action !== 'flow-mobile-actions') mobileActionsDialog.close(); 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 === 'export-flow-by-id') exportFlowById(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 === 'flow-mobile-actions') $('#flowEditorActionsDialog')?.showModal(); else if (action === 'flow-add-block') openFlowBlockLibrary({ point: null }); else if (action === 'flow-zoom-out') setFlowZoom((app.flowZoom || 1) - .1); else if (action === 'flow-zoom-in') setFlowZoom((app.flowZoom || 1) + .1); else if (action === 'flow-fit') fitFlowToView(); else if (action === 'flow-preview-toggle') { const preview = $('#flowNaturalPreview'); const expanded = preview?.classList.toggle('is-expanded') || false; actionButton.setAttribute('aria-expanded', String(expanded)); } else if (action === 'flow-toggle-inspector') $('#flowInspector')?.classList.toggle('is-expanded'); 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-duplicate-selection') duplicateSelectedFlowNodes(); else if (action === 'flow-clear-selection') clearFlowSelection(); else if (action === 'flow-shortcuts') openFlowShortcuts(); else if (action === 'edit-flow-name') editFlowName(); 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); }); $('#flowBlockSearch')?.addEventListener('input', event => renderFlowBlockLibrary(event.target.value)); $('#flowBlockSearch')?.addEventListener('keydown', event => { if (event.key !== 'Enter') return; const first = $('#flowBlockLibrary [data-flow-add]'); if (!first) return; event.preventDefault(); first.click(); }); $('#flowName')?.addEventListener('input', () => { if (app.flowDraft) { app.flowDirty = true; renderFlowSaveStatus(); } }); $('#flowName')?.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); commitFlowName(); } else if (event.key === 'Escape') { event.preventDefault(); $('#flowName').value = app.flowDraft?.name || ''; app.flowNameEditing = false; renderFlowNameMode(); } }); $('#flowName')?.addEventListener('blur', () => { if (!app.flowDraft || !app.flowNameEditing) return; if ($('#flowName').value.trim()) commitFlowName(); }); $('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) { app.flowDirty = true; renderFlowEnabledLabel(); renderFlowSaveStatus(); flowHistoryCommit(); } }); window.addEventListener('resize', () => { hideFlowContextMenu(); if (!$('#flowEditor')?.hidden) renderFlowEdges(); }); $('#flowWorkspace')?.addEventListener('scroll', hideFlowContextMenu, { passive: true });