/** * Profiles — profile cards, editor, condition builder, process picker. */ import { apiKey, _profilesCache, set_profilesCache, _profilesLoading, set_profilesLoading } from '../core/state.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { updateTabBadge } from './tabs.js'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO } from '../core/icons.js'; class ProfileEditorModal extends Modal { constructor() { super('profile-editor-modal'); } snapshotValues() { return { name: document.getElementById('profile-editor-name').value, enabled: document.getElementById('profile-editor-enabled').checked.toString(), logic: document.getElementById('profile-editor-logic').value, conditions: JSON.stringify(getProfileEditorConditions()), targets: JSON.stringify(getProfileEditorTargetIds()), }; } } const profileModal = new ProfileEditorModal(); const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()", keyAttr: 'data-profile-id' }); // Re-render profiles when language changes (only if tab is active) document.addEventListener('languageChanged', () => { if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') loadProfiles(); }); // React to real-time profile state changes from global events WS document.addEventListener('server:profile_state_changed', () => { if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') { loadProfiles(); } }); export async function loadProfiles() { if (_profilesLoading) return; set_profilesLoading(true); const container = document.getElementById('profiles-content'); if (!container) { set_profilesLoading(false); return; } setTabRefreshing('profiles-content', true); try { const [profilesResp, targetsResp] = await Promise.all([ fetchWithAuth('/profiles'), fetchWithAuth('/picture-targets'), ]); if (!profilesResp.ok) throw new Error('Failed to load profiles'); const data = await profilesResp.json(); const targetsData = targetsResp.ok ? await targetsResp.json() : { targets: [] }; const allTargets = targetsData.targets || []; // Batch fetch all target states in a single request const batchStatesResp = await fetchWithAuth('/picture-targets/batch/states'); const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; const runningTargetIds = new Set( allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id) ); set_profilesCache(data.profiles); const activeCount = data.profiles.filter(p => p.is_active).length; updateTabBadge('profiles', activeCount); renderProfiles(data.profiles, runningTargetIds); } catch (error) { if (error.isAuth) return; console.error('Failed to load profiles:', error); container.innerHTML = `

${error.message}

`; } finally { set_profilesLoading(false); setTabRefreshing('profiles-content', false); } } export function expandAllProfileSections() { CardSection.expandAll([csProfiles]); } export function collapseAllProfileSections() { CardSection.collapseAll([csProfiles]); } function renderProfiles(profiles, runningTargetIds = new Set()) { const container = document.getElementById('profiles-content'); const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) }))); const toolbar = `
`; container.innerHTML = toolbar + csProfiles.render(items); csProfiles.bind(); // Localize data-i18n elements within the profiles container only // (calling global updateAllText() would trigger loadProfiles() again → infinite loop) container.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); } function createProfileCard(profile, runningTargetIds = new Set()) { const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive'; const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive'); let condPills = ''; if (profile.conditions.length === 0) { condPills = `${t('profiles.conditions.empty')}`; } else { const parts = profile.conditions.map(c => { if (c.condition_type === 'always') { return `${ICON_OK} ${t('profiles.condition.always')}`; } if (c.condition_type === 'application') { const apps = (c.apps || []).join(', '); const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running')); return `${t('profiles.condition.application')}: ${apps} (${matchLabel})`; } if (c.condition_type === 'time_of_day') { return `${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`; } if (c.condition_type === 'system_idle') { const mode = c.when_idle !== false ? t('profiles.condition.system_idle.when_idle') : t('profiles.condition.system_idle.when_active'); return `${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})`; } if (c.condition_type === 'display_state') { const stateLabel = t('profiles.condition.display_state.' + (c.state || 'on')); return `${ICON_MONITOR} ${t('profiles.condition.display_state')}: ${stateLabel}`; } if (c.condition_type === 'mqtt') { return `${ICON_RADIO} ${t('profiles.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}`; } return `${c.condition_type}`; }); const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or'); condPills = parts.join(`${logicLabel}`); } const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`; let lastActivityMeta = ''; if (profile.last_activated_at) { const ts = new Date(profile.last_activated_at); lastActivityMeta = `${ICON_CLOCK} ${ts.toLocaleString()}`; } return `
${escapeHtml(profile.name)} ${statusText}
${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')} ${ICON_TARGET} ${targetCountText} ${lastActivityMeta}
${condPills}
${profile.target_ids.length > 0 ? (() => { const anyRunning = profile.target_ids.some(id => runningTargetIds.has(id)); return ``; })() : ''}
`; } export async function openProfileEditor(profileId) { const modal = document.getElementById('profile-editor-modal'); const titleEl = document.getElementById('profile-editor-title'); const idInput = document.getElementById('profile-editor-id'); const nameInput = document.getElementById('profile-editor-name'); const enabledInput = document.getElementById('profile-editor-enabled'); const logicSelect = document.getElementById('profile-editor-logic'); const condList = document.getElementById('profile-conditions-list'); const errorEl = document.getElementById('profile-editor-error'); errorEl.style.display = 'none'; condList.innerHTML = ''; await loadProfileTargetChecklist([]); if (profileId) { titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`; try { const resp = await fetchWithAuth(`/profiles/${profileId}`); if (!resp.ok) throw new Error('Failed to load profile'); const profile = await resp.json(); idInput.value = profile.id; nameInput.value = profile.name; enabledInput.checked = profile.enabled; logicSelect.value = profile.condition_logic; for (const c of profile.conditions) { addProfileConditionRow(c); } await loadProfileTargetChecklist(profile.target_ids); } catch (e) { showToast(e.message, 'error'); return; } } else { titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.add')}`; idInput.value = ''; nameInput.value = ''; enabledInput.checked = true; logicSelect.value = 'or'; } profileModal.open(); modal.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); }); profileModal.snapshot(); } export async function closeProfileEditorModal() { await profileModal.close(); } async function loadProfileTargetChecklist(selectedIds) { const container = document.getElementById('profile-targets-list'); try { const resp = await fetchWithAuth('/picture-targets'); if (!resp.ok) throw new Error('Failed to load targets'); const data = await resp.json(); const targets = data.targets || []; if (targets.length === 0) { container.innerHTML = `${t('profiles.targets.empty')}`; return; } container.innerHTML = targets.map(tgt => { const checked = selectedIds.includes(tgt.id) ? 'checked' : ''; return ``; }).join(''); } catch (e) { container.innerHTML = `${e.message}`; } } export function addProfileCondition() { addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' }); } function addProfileConditionRow(condition) { const list = document.getElementById('profile-conditions-list'); const row = document.createElement('div'); row.className = 'profile-condition-row'; const condType = condition.condition_type || 'application'; row.innerHTML = `
`; const typeSelect = row.querySelector('.condition-type-select'); const container = row.querySelector('.condition-fields-container'); function renderFields(type, data) { if (type === 'always') { container.innerHTML = `${t('profiles.condition.always.hint')}`; return; } if (type === 'time_of_day') { const startTime = data.start_time || '00:00'; const endTime = data.end_time || '23:59'; container.innerHTML = `
${t('profiles.condition.time_of_day.overnight_hint')}
`; return; } if (type === 'system_idle') { const idleMinutes = data.idle_minutes ?? 5; const whenIdle = data.when_idle ?? true; container.innerHTML = `
`; return; } if (type === 'display_state') { const dState = data.state || 'on'; container.innerHTML = `
`; return; } if (type === 'mqtt') { const topic = data.topic || ''; const payload = data.payload || ''; const matchMode = data.match_mode || 'exact'; container.innerHTML = `
`; return; } const appsValue = (data.apps || []).join('\n'); const matchType = data.match_type || 'running'; container.innerHTML = `
`; const browseBtn = container.querySelector('.btn-browse-apps'); const picker = container.querySelector('.process-picker'); browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row)); const searchInput = container.querySelector('.process-picker-search'); searchInput.addEventListener('input', () => filterProcessPicker(picker)); } renderFields(condType, condition); typeSelect.addEventListener('change', () => { renderFields(typeSelect.value, {}); }); list.appendChild(row); } async function toggleProcessPicker(picker, row) { if (picker.style.display !== 'none') { picker.style.display = 'none'; return; } const listEl = picker.querySelector('.process-picker-list'); const searchEl = picker.querySelector('.process-picker-search'); searchEl.value = ''; listEl.innerHTML = `
${t('common.loading')}
`; picker.style.display = ''; try { const resp = await fetchWithAuth('/system/processes'); if (!resp.ok) throw new Error('Failed to fetch processes'); const data = await resp.json(); const textarea = row.querySelector('.condition-apps'); const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean)); picker._processes = data.processes; picker._existing = existing; renderProcessPicker(picker, data.processes, existing); searchEl.focus(); } catch (e) { listEl.innerHTML = `
${e.message}
`; } } function renderProcessPicker(picker, processes, existing) { const listEl = picker.querySelector('.process-picker-list'); if (processes.length === 0) { listEl.innerHTML = `
${t('profiles.condition.application.no_processes')}
`; return; } listEl.innerHTML = processes.map(p => { const added = existing.has(p.toLowerCase()); return `
${escapeHtml(p)}${added ? ' ✓' : ''}
`; }).join(''); listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { item.addEventListener('click', () => { const proc = item.dataset.process; const row = picker.closest('.profile-condition-row'); const textarea = row.querySelector('.condition-apps'); const current = textarea.value.trim(); textarea.value = current ? current + '\n' + proc : proc; item.classList.add('added'); item.textContent = proc + ' ✓'; picker._existing.add(proc.toLowerCase()); }); }); } function filterProcessPicker(picker) { const query = picker.querySelector('.process-picker-search').value.toLowerCase(); const filtered = (picker._processes || []).filter(p => p.includes(query)); renderProcessPicker(picker, filtered, picker._existing || new Set()); } function getProfileEditorConditions() { const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row'); const conditions = []; rows.forEach(row => { const typeSelect = row.querySelector('.condition-type-select'); const condType = typeSelect ? typeSelect.value : 'application'; if (condType === 'always') { conditions.push({ condition_type: 'always' }); } else if (condType === 'time_of_day') { conditions.push({ condition_type: 'time_of_day', start_time: row.querySelector('.condition-start-time').value || '00:00', end_time: row.querySelector('.condition-end-time').value || '23:59', }); } else if (condType === 'system_idle') { conditions.push({ condition_type: 'system_idle', idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5, when_idle: row.querySelector('.condition-when-idle').value === 'true', }); } else if (condType === 'display_state') { conditions.push({ condition_type: 'display_state', state: row.querySelector('.condition-display-state').value || 'on', }); } else if (condType === 'mqtt') { conditions.push({ condition_type: 'mqtt', topic: row.querySelector('.condition-mqtt-topic').value.trim(), payload: row.querySelector('.condition-mqtt-payload').value, match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact', }); } else { const matchType = row.querySelector('.condition-match-type').value; const appsText = row.querySelector('.condition-apps').value.trim(); const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; conditions.push({ condition_type: 'application', apps, match_type: matchType }); } }); return conditions; } function getProfileEditorTargetIds() { const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked'); return Array.from(checkboxes).map(cb => cb.value); } export async function saveProfileEditor() { const idInput = document.getElementById('profile-editor-id'); const nameInput = document.getElementById('profile-editor-name'); const enabledInput = document.getElementById('profile-editor-enabled'); const logicSelect = document.getElementById('profile-editor-logic'); const name = nameInput.value.trim(); if (!name) { profileModal.showError(t('profiles.error.name_required')); return; } const body = { name, enabled: enabledInput.checked, condition_logic: logicSelect.value, conditions: getProfileEditorConditions(), target_ids: getProfileEditorTargetIds(), }; const profileId = idInput.value; const isEdit = !!profileId; try { const url = isEdit ? `/profiles/${profileId}` : '/profiles'; const resp = await fetchWithAuth(url, { method: isEdit ? 'PUT' : 'POST', body: JSON.stringify(body), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || 'Failed to save profile'); } profileModal.forceClose(); showToast(isEdit ? t('profiles.updated') : t('profiles.created'), 'success'); loadProfiles(); } catch (e) { if (e.isAuth) return; profileModal.showError(e.message); } } export async function toggleProfileTargets(profileId) { try { const profileResp = await fetchWithAuth(`/profiles/${profileId}`); if (!profileResp.ok) throw new Error('Failed to load profile'); const profile = await profileResp.json(); // Batch fetch all target states to determine which are running const batchResp = await fetchWithAuth('/picture-targets/batch/states'); const allStates = batchResp.ok ? (await batchResp.json()).states : {}; const runningSet = new Set( profile.target_ids.filter(id => allStates[id]?.processing) ); const shouldStop = profile.target_ids.some(id => runningSet.has(id)); await Promise.all(profile.target_ids.map(id => fetchWithAuth(`/picture-targets/${id}/${shouldStop ? 'stop' : 'start'}`, { method: 'POST' }).catch(() => {}) )); loadProfiles(); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } } export async function toggleProfileEnabled(profileId, enable) { try { const action = enable ? 'enable' : 'disable'; const resp = await fetchWithAuth(`/profiles/${profileId}/${action}`, { method: 'POST', }); if (!resp.ok) throw new Error(`Failed to ${action} profile`); loadProfiles(); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } } export async function deleteProfile(profileId, profileName) { const msg = t('profiles.delete.confirm').replace('{name}', profileName); const confirmed = await showConfirm(msg); if (!confirmed) return; try { const resp = await fetchWithAuth(`/profiles/${profileId}`, { method: 'DELETE', }); if (!resp.ok) throw new Error('Failed to delete profile'); showToast(t('profiles.deleted'), 'success'); loadProfiles(); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } }