/**
* 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 `
${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 = `
`;
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');
}
}