Add "Always" condition type to profiles

- Add AlwaysCondition model and evaluation (always returns true)
- Add condition type selector (Always/Application) in profile editor
- Show condition type pill on profile cards
- Fix misleading empty-conditions text (was "never activate", actually always active)
- Add i18n keys for Always condition (en + ru)
- Add CSS for condition type selector and description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 14:38:25 +03:00
parent 466527bd4a
commit 701eac19e5
6 changed files with 101 additions and 38 deletions

View File

@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from typing import Dict, Optional, Set from typing import Dict, Optional, Set
from wled_controller.core.profiles.platform_detector import PlatformDetector from wled_controller.core.profiles.platform_detector import PlatformDetector
from wled_controller.storage.profile import ApplicationCondition, Condition, Profile from wled_controller.storage.profile import AlwaysCondition, ApplicationCondition, Condition, Profile
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -158,6 +158,8 @@ class ProfileEngine:
topmost_proc: Optional[str], topmost_fullscreen: bool, topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str], fullscreen_procs: Set[str],
) -> bool: ) -> bool:
if isinstance(condition, AlwaysCondition):
return True
if isinstance(condition, ApplicationCondition): if isinstance(condition, ApplicationCondition):
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs) return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
return False return False

View File

@@ -48,6 +48,22 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.condition-type-select {
font-weight: 600;
font-size: 0.9rem;
padding: 2px 6px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
}
.condition-always-desc {
display: block;
color: var(--text-muted);
font-size: 0.85rem;
}
.btn-remove-condition { .btn-remove-condition {
background: none; background: none;
border: none; border: none;

View File

@@ -92,6 +92,9 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`; condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`;
} else { } else {
const parts = profile.conditions.map(c => { const parts = profile.conditions.map(c => {
if (c.condition_type === 'always') {
return `<span class="stream-card-prop">✅ ${t('profiles.condition.always')}</span>`;
}
if (c.condition_type === 'application') { if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', '); const apps = (c.apps || []).join(', ');
const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running')); const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running'));
@@ -233,45 +236,64 @@ function addProfileConditionRow(condition) {
const list = document.getElementById('profile-conditions-list'); const list = document.getElementById('profile-conditions-list');
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'profile-condition-row'; row.className = 'profile-condition-row';
const condType = condition.condition_type || 'application';
const appsValue = (condition.apps || []).join('\n');
const matchType = condition.match_type || 'running';
row.innerHTML = ` row.innerHTML = `
<div class="condition-header"> <div class="condition-header">
<span class="condition-type-label">${t('profiles.condition.application')}</span> <select class="condition-type-select">
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option>
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option>
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">&#x2715;</button> <button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">&#x2715;</button>
</div> </div>
<div class="condition-fields"> <div class="condition-fields-container"></div>
<div class="condition-field">
<label data-i18n="profiles.condition.application.match_type">${t('profiles.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.fullscreen')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label data-i18n="profiles.condition.application.apps">${t('profiles.condition.application.apps')}</label>
<button type="button" class="btn-browse-apps" title="${t('profiles.condition.application.browse')}">${t('profiles.condition.application.browse')}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<div class="process-picker" style="display:none">
<input type="text" class="process-picker-search" placeholder="${t('profiles.condition.application.search')}" autocomplete="off">
<div class="process-picker-list"></div>
</div>
</div>
</div>
`; `;
const browseBtn = row.querySelector('.btn-browse-apps'); const typeSelect = row.querySelector('.condition-type-select');
const picker = row.querySelector('.process-picker'); const container = row.querySelector('.condition-fields-container');
browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
const searchInput = row.querySelector('.process-picker-search'); function renderFields(type, data) {
searchInput.addEventListener('input', () => filterProcessPicker(picker)); if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`;
return;
}
const appsValue = (data.apps || []).join('\n');
const matchType = data.match_type || 'running';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('profiles.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.fullscreen')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label>${t('profiles.condition.application.apps')}</label>
<button type="button" class="btn-browse-apps" title="${t('profiles.condition.application.browse')}">${t('profiles.condition.application.browse')}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<div class="process-picker" style="display:none">
<input type="text" class="process-picker-search" placeholder="${t('profiles.condition.application.search')}" autocomplete="off">
<div class="process-picker-list"></div>
</div>
</div>
</div>
`;
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, { apps: [], match_type: 'running' });
});
list.appendChild(row); list.appendChild(row);
} }
@@ -340,10 +362,16 @@ function getProfileEditorConditions() {
const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row'); const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row');
const conditions = []; const conditions = [];
rows.forEach(row => { rows.forEach(row => {
const matchType = row.querySelector('.condition-match-type').value; const typeSelect = row.querySelector('.condition-type-select');
const appsText = row.querySelector('.condition-apps').value.trim(); const condType = typeSelect ? typeSelect.value : 'application';
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; if (condType === 'always') {
conditions.push({ condition_type: 'application', apps, match_type: matchType }); conditions.push({ condition_type: 'always' });
} 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; return conditions;
} }

View File

@@ -519,7 +519,9 @@
"profiles.conditions": "Conditions:", "profiles.conditions": "Conditions:",
"profiles.conditions.hint": "Rules that determine when this profile activates", "profiles.conditions.hint": "Rules that determine when this profile activates",
"profiles.conditions.add": "Add Condition", "profiles.conditions.add": "Add Condition",
"profiles.conditions.empty": "No conditions \u2014 profile will never activate automatically", "profiles.conditions.empty": "No conditions \u2014 profile is always active when enabled",
"profiles.condition.always": "Always",
"profiles.condition.always.hint": "Profile activates immediately when enabled and stays active. Use this to auto-start targets on server startup.",
"profiles.condition.application": "Application", "profiles.condition.application": "Application",
"profiles.condition.application.apps": "Applications:", "profiles.condition.application.apps": "Applications:",
"profiles.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)", "profiles.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",

View File

@@ -519,7 +519,9 @@
"profiles.conditions": "Условия:", "profiles.conditions": "Условия:",
"profiles.conditions.hint": "Правила, определяющие когда профиль активируется", "profiles.conditions.hint": "Правила, определяющие когда профиль активируется",
"profiles.conditions.add": "Добавить условие", "profiles.conditions.add": "Добавить условие",
"profiles.conditions.empty": "Нет условий \u2014 профиль не активируется автоматически", "profiles.conditions.empty": "Нет условий \u2014 профиль всегда активен когда включён",
"profiles.condition.always": "Всегда",
"profiles.condition.always.hint": "Профиль активируется сразу при включении и остаётся активным. Используйте для автозапуска целей при старте сервера.",
"profiles.condition.application": "Приложение", "profiles.condition.application": "Приложение",
"profiles.condition.application.apps": "Приложения:", "profiles.condition.application.apps": "Приложения:",
"profiles.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)", "profiles.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",

View File

@@ -18,11 +18,24 @@ class Condition:
def from_dict(cls, data: dict) -> "Condition": def from_dict(cls, data: dict) -> "Condition":
"""Factory: dispatch to the correct subclass.""" """Factory: dispatch to the correct subclass."""
ct = data.get("condition_type", "") ct = data.get("condition_type", "")
if ct == "always":
return AlwaysCondition.from_dict(data)
if ct == "application": if ct == "application":
return ApplicationCondition.from_dict(data) return ApplicationCondition.from_dict(data)
raise ValueError(f"Unknown condition type: {ct}") raise ValueError(f"Unknown condition type: {ct}")
@dataclass
class AlwaysCondition(Condition):
"""Always-true condition — profile activates unconditionally when enabled."""
condition_type: str = "always"
@classmethod
def from_dict(cls, data: dict) -> "AlwaysCondition":
return cls()
@dataclass @dataclass
class ApplicationCondition(Condition): class ApplicationCondition(Condition):
"""Activate when specified applications are running or topmost.""" """Activate when specified applications are running or topmost."""