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 });