/** * Profiles — profile cards, editor, condition builder, process picker. */ import { apiKey, _profilesCache, set_profilesCache } from '../core/state.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; const profileModal = new Modal('profile-editor-modal'); const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()" }); // 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() { const container = document.getElementById('profiles-content'); if (!container) return; 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); renderProfiles(data.profiles, runningTargetIds); } catch (error) { if (error.isAuth) return; console.error('Failed to load profiles:', error); container.innerHTML = `

${error.message}

`; } } function renderProfiles(profiles, runningTargetIds = new Set()) { const container = document.getElementById('profiles-content'); const cardsHtml = profiles.map(p => createProfileCard(p, runningTargetIds)).join(''); container.innerHTML = csProfiles.render(cardsHtml, profiles.length); 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 === '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})`; } 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 = `🕐 ${ts.toLocaleString()}`; } return `
${escapeHtml(profile.name)} ${statusText}
${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')} ⚡ ${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.textContent = 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.textContent = 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')); }); } export function closeProfileEditorModal() { profileModal.forceClose(); } 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 appsValue = (condition.apps || []).join('\n'); const matchType = condition.match_type || 'running'; row.innerHTML = `
${t('profiles.condition.application')}
`; const browseBtn = row.querySelector('.btn-browse-apps'); const picker = row.querySelector('.process-picker'); browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row)); const searchInput = row.querySelector('.process-picker-search'); searchInput.addEventListener('input', () => filterProcessPicker(picker)); 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 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'); } }