feat: typed script parameters with validation and icon-grid selector
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Successful in 1m15s

- Add ScriptParameterConfig model (string, integer, float, boolean, select types)
- Server-side validation at both define-time and execute-time
- Parameters passed as SCRIPT_PARAM_* environment variables
- Web UI parameter editor in script create/edit dialog (add/remove/reorder)
- Icon-grid selector component (ported from wled-screen-controller)
- Replace audio device dropdown with icon-grid selector
- Replace callback event dropdown with icon-grid selector
- Localization for parameter UI (en, ru)
This commit is contained in:
2026-03-25 11:25:03 +03:00
parent 1c0a011342
commit 1410a8d2cb
12 changed files with 1211 additions and 26 deletions
+23
View File
@@ -26,6 +26,26 @@ class CallbackConfig(BaseModel):
shell: bool = Field(default=True, description="Run command in shell")
class ScriptParameterConfig(BaseModel):
"""Configuration for a script parameter."""
type: str = Field(
...,
description="Parameter type: string, integer, float, boolean, select",
pattern=r"^(string|integer|float|boolean|select)$",
)
description: str = Field(default="", description="Parameter description")
required: bool = Field(default=False, description="Whether the parameter is required")
default: Optional[str | int | float | bool] = Field(
default=None, description="Default value if not provided"
)
min: Optional[float] = Field(default=None, description="Minimum value (numeric types only)")
max: Optional[float] = Field(default=None, description="Maximum value (numeric types only)")
options: Optional[list[str]] = Field(
default=None, description="Allowed values (select type only)"
)
class ScriptConfig(BaseModel):
"""Configuration for a custom script."""
@@ -36,6 +56,9 @@ class ScriptConfig(BaseModel):
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: Optional[str] = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
parameters: dict[str, ScriptParameterConfig] = Field(
default_factory=dict, description="Named parameters with type and validation rules"
)
class LinkConfig(BaseModel):
+239 -16
View File
@@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import ScriptConfig, settings
from ..config import ScriptConfig, ScriptParameterConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
@@ -24,9 +24,11 @@ logger = logging.getLogger(__name__)
class ScriptExecuteRequest(BaseModel):
"""Request model for script execution with optional arguments."""
"""Request model for script execution with optional parameters."""
args: list[str] = Field(default_factory=list, description="Additional arguments")
params: dict[str, str | int | float | bool] = Field(
default_factory=dict, description="Named parameters (validated against script schema)"
)
class ScriptExecuteResponse(BaseModel):
@@ -41,6 +43,18 @@ class ScriptExecuteResponse(BaseModel):
execution_time: float | None = None
class ScriptParameterInfo(BaseModel):
"""Information about a script parameter."""
type: str
description: str = ""
required: bool = False
default: str | int | float | bool | None = None
min: float | None = None
max: float | None = None
options: list[str] | None = None
class ScriptInfo(BaseModel):
"""Information about an available script."""
@@ -50,6 +64,7 @@ class ScriptInfo(BaseModel):
description: str
icon: str | None = None
timeout: int
parameters: dict[str, ScriptParameterInfo] = Field(default_factory=dict)
@router.get("/list")
@@ -67,11 +82,126 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
description=config.description,
icon=config.icon,
timeout=config.timeout,
parameters={
pname: ScriptParameterInfo(**pconfig.model_dump())
for pname, pconfig in config.parameters.items()
},
)
for name, config in settings.scripts.items()
]
def _validate_params(
params: dict[str, str | int | float | bool],
param_defs: dict[str, ScriptParameterConfig],
) -> dict[str, str]:
"""Validate parameters against script schema and return env vars.
Args:
params: User-supplied parameter values.
param_defs: Parameter definitions from script config.
Returns:
Dict of environment variables (SCRIPT_PARAM_<NAME> -> str value).
Raises:
HTTPException: On validation failure.
"""
# Reject unknown parameters
unknown = set(params.keys()) - set(param_defs.keys())
if unknown:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown parameters: {', '.join(sorted(unknown))}",
)
env_vars: dict[str, str] = {}
for pname, pdef in param_defs.items():
value = params.get(pname)
# Apply default if missing
if value is None and pdef.default is not None:
value = pdef.default
# Check required
if value is None:
if pdef.required:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required parameter '{pname}' is missing",
)
continue
# Type validation and coercion
if pdef.type == "integer":
try:
value = int(value)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be an integer, got: {value!r}",
)
if pdef.min is not None and value < pdef.min:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}",
)
if pdef.max is not None and value > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}",
)
elif pdef.type == "float":
try:
value = float(value)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a number, got: {value!r}",
)
if pdef.min is not None and value < pdef.min:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}",
)
if pdef.max is not None and value > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}",
)
elif pdef.type == "boolean":
if isinstance(value, str):
if value.lower() in ("true", "1", "yes"):
value = True
elif value.lower() in ("false", "0", "no"):
value = False
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a boolean, got: {value!r}",
)
elif not isinstance(value, bool):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a boolean, got: {value!r}",
)
elif pdef.type == "select":
value = str(value)
if pdef.options and value not in pdef.options:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be one of {pdef.options}, got: {value!r}",
)
else:
# string — just convert to str
value = str(value)
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
return env_vars
@router.post("/execute/{script_name}")
async def execute_script(
script_name: str,
@@ -82,7 +212,7 @@ async def execute_script(
Args:
script_name: Name of the script to execute (must be defined in config)
request: Optional arguments to pass to the script
request: Optional parameters to pass to the script
Returns:
Execution result including stdout, stderr, and exit code
@@ -94,26 +224,24 @@ async def execute_script(
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
)
script_config = settings.scripts[script_name]
args = request.args if request else []
params = request.params if request else {}
# Validate parameters and build env vars
extra_env = _validate_params(params, script_config.parameters)
logger.info(f"Executing script: {script_name}")
try:
# Build command
command = script_config.command
if args:
# Append arguments to command
command = f"{command} {' '.join(args)}"
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
_script_executor,
lambda: _run_script(
command=command,
command=script_config.command,
timeout=script_config.timeout,
shell=script_config.shell,
working_dir=script_config.working_dir,
extra_env=extra_env,
),
)
@@ -140,6 +268,7 @@ def _run_script(
timeout: int,
shell: bool,
working_dir: str | None,
extra_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Run a script synchronously.
@@ -148,11 +277,16 @@ def _run_script(
timeout: Timeout in seconds
shell: Whether to run in shell
working_dir: Working directory
extra_env: Additional environment variables (e.g. SCRIPT_PARAM_*)
Returns:
Dict with exit_code, stdout, stderr, execution_time
"""
start_time = time.time()
env = None
if extra_env:
import os
env = {**os.environ, **extra_env}
try:
result = subprocess.run(
command,
@@ -161,6 +295,7 @@ def _run_script(
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
execution_time = time.time() - start_time
return {
@@ -190,6 +325,24 @@ def _run_script(
# Script management endpoints
class ScriptParameterCreateRequest(BaseModel):
"""Request model for a script parameter definition."""
type: str = Field(
..., description="Parameter type: string, integer, float, boolean, select"
)
description: str = Field(default="", description="Parameter description")
required: bool = Field(default=False, description="Whether the parameter is required")
default: str | int | float | bool | None = Field(
default=None, description="Default value if not provided"
)
min: float | None = Field(default=None, description="Minimum value (numeric types only)")
max: float | None = Field(default=None, description="Maximum value (numeric types only)")
options: list[str] | None = Field(
default=None, description="Allowed values (select type only)"
)
class ScriptCreateRequest(BaseModel):
"""Request model for creating or updating a script."""
@@ -200,6 +353,60 @@ class ScriptCreateRequest(BaseModel):
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: str | None = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
parameters: dict[str, ScriptParameterCreateRequest] = Field(
default_factory=dict, description="Named parameters with type and validation rules"
)
def _validate_parameter_definitions(
parameters: dict[str, ScriptParameterCreateRequest],
) -> None:
"""Validate parameter definitions are well-formed.
Args:
parameters: Parameter definitions to validate.
Raises:
HTTPException: If any definition is invalid.
"""
valid_types = {"string", "integer", "float", "boolean", "select"}
for pname, pdef in parameters.items():
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", pname):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Parameter name '{pname}' must start with a letter"
" and contain only alphanumeric characters and underscores"
),
)
if pdef.type not in valid_types:
allowed = ", ".join(sorted(valid_types))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' has invalid type '{pdef.type}'. Must be one of: {allowed}",
)
if pdef.type == "select":
if not pdef.options or len(pdef.options) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' of type 'select' must have a non-empty 'options' list",
)
if pdef.type not in ("integer", "float"):
if pdef.min is not None or pdef.max is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}': 'min'/'max' are only valid for integer/float types",
)
if pdef.min is not None and pdef.max is not None and pdef.min > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}': 'min' ({pdef.min}) must be <= 'max' ({pdef.max})",
)
def _validate_script_name(name: str) -> None:
@@ -258,8 +465,16 @@ async def create_script(
detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.",
)
# Create script config
script_config = ScriptConfig(**request.model_dump())
# Validate parameter definitions
_validate_parameter_definitions(request.parameters)
# Build ScriptConfig with ScriptParameterConfig instances
data = request.model_dump()
data["parameters"] = {
pname: ScriptParameterConfig(**pdef)
for pname, pdef in data.get("parameters", {}).items()
}
script_config = ScriptConfig(**data)
# Add to config file and in-memory
try:
@@ -306,8 +521,16 @@ async def update_script(
detail=f"Script '{script_name}' not found. Use POST /api/scripts/create/{script_name} to create it.",
)
# Create updated script config
script_config = ScriptConfig(**request.model_dump())
# Validate parameter definitions
_validate_parameter_definitions(request.parameters)
# Build ScriptConfig with ScriptParameterConfig instances
data = request.model_dump()
data["parameters"] = {
pname: ScriptParameterConfig(**pdef)
for pname, pdef in data.get("parameters", {}).items()
}
script_config = ScriptConfig(**data)
# Update config file and in-memory
try:
+318
View File
@@ -1699,6 +1699,11 @@ dialog {
margin: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
animation: dialogIn 0.25s ease-out;
overflow: visible;
}
dialog form {
overflow: visible;
}
dialog.dialog-closing {
@@ -1871,6 +1876,319 @@ dialog.dialog-closing::backdrop {
background: var(--border);
}
/* Parameters editor (CRUD dialog) */
.params-section {
margin-top: 0.5rem;
}
.params-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.btn-small {
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: background 0.2s;
}
.btn-small:hover {
background: var(--border);
}
.param-row {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--bg-tertiary);
}
.param-row-header {
display: flex;
gap: 0.375rem;
align-items: center;
}
.param-row-header .param-name {
flex: 1;
min-width: 0;
}
.param-row-header .param-type {
width: 100px;
flex-shrink: 0;
}
.param-required-label {
display: flex !important;
align-items: center;
gap: 0.125rem;
margin-bottom: 0 !important;
cursor: pointer;
color: var(--accent);
font-weight: 700;
font-size: 1rem;
flex-shrink: 0;
}
.param-required-label input {
width: auto !important;
margin: 0 !important;
}
.param-remove-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0 0.25rem;
flex-shrink: 0;
transition: color 0.2s;
}
.param-remove-btn:hover {
color: var(--error);
}
.param-row-details {
margin-top: 0.375rem;
}
.param-row-details .param-description {
width: 100%;
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.param-row-extra {
display: flex;
gap: 0.375rem;
}
.param-row-extra input {
flex: 1;
min-width: 0;
}
.param-row-header input,
.param-row-header select,
.param-row-details input,
.param-row-extra input {
padding: 0.35rem 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
font-family: inherit;
font-size: 0.8rem;
}
.param-row-header input:focus,
.param-row-header select:focus,
.param-row-details input:focus,
.param-row-extra input:focus {
outline: none;
border-color: var(--accent);
}
/* Parameter hint in execution dialog */
.param-hint {
display: block;
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 0.125rem;
}
/* ── Icon Select ──────────────────────────────────────────── */
.icon-select-trigger {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.15s;
text-align: left;
font-family: inherit;
box-sizing: border-box;
}
/* Match dialog input height when inside a dialog */
.dialog-body .icon-select-trigger {
padding: 0.5rem;
margin-top: 0.25rem;
}
/* Compact trigger inside param rows */
.param-row-header .icon-select-trigger {
padding: 0.3rem 0.4rem;
margin-top: 0;
font-size: 0.75rem;
width: 110px;
flex-shrink: 0;
}
.icon-select-trigger:hover {
border-color: var(--accent);
}
.icon-select-trigger-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.icon-select-trigger-icon svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.param-row-header .icon-select-trigger-icon svg {
width: 14px;
height: 14px;
}
.icon-select-trigger-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-select-trigger-arrow {
flex-shrink: 0;
font-size: 0.8rem;
opacity: 0.5;
margin-left: auto;
}
.icon-select-popup {
position: absolute;
z-index: 100;
max-height: 260px;
overflow: hidden;
opacity: 0;
transform: translateY(-4px) scale(0.97);
transition: opacity 0.12s ease-out, transform 0.12s cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
box-sizing: border-box;
}
.icon-select-popup.open {
opacity: 1;
transform: translateY(0) scale(1);
overflow-y: auto;
pointer-events: auto;
}
.icon-select-grid {
display: grid;
grid-auto-rows: 1fr;
gap: 6px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
}
.icon-select-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 6px 4px;
border: 2px solid transparent;
border-radius: 6px;
background: var(--bg-tertiary);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
text-align: center;
}
.icon-select-cell:hover {
border-color: var(--accent);
transform: scale(1.03);
}
.icon-select-cell.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-tertiary));
}
.icon-select-cell-icon {
display: flex;
align-items: center;
justify-content: center;
}
.icon-select-cell-icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.icon-select-cell.active .icon-select-cell-icon svg {
fill: var(--accent);
}
.icon-select-cell-label {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
}
.icon-select-cell-desc {
font-size: 0.7rem;
opacity: 0.6;
line-height: 1.3;
}
/* Horizontal layout for single-column grids (e.g. audio device list) */
.icon-select-grid--horizontal .icon-select-cell {
flex-direction: row;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
text-align: left;
}
.icon-select-grid--horizontal .icon-select-cell-icon svg {
width: 16px;
height: 16px;
}
.icon-select-grid--horizontal .icon-select-cell-label {
font-size: 0.8rem;
}
@media (max-width: 480px) {
.icon-select-cell-desc { display: none; }
.icon-select-cell { padding: 6px 4px; }
.icon-select-grid { gap: 4px; padding: 4px; }
}
.empty-state {
text-align: center;
padding: 2rem;
+24
View File
@@ -465,6 +465,14 @@
<span data-i18n="scripts.field.timeout">Timeout (seconds)</span>
<input type="number" id="scriptTimeout" value="30" min="1" max="300">
</label>
<div class="params-section">
<div class="params-header">
<span data-i18n="scripts.field.parameters">Parameters</span>
<button type="button" class="btn-small" onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
</div>
<div id="scriptParamsContainer"></div>
</div>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
@@ -473,6 +481,22 @@
</form>
</dialog>
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
<dialog id="scriptParamsDialog">
<div class="dialog-header">
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
</div>
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
<div class="dialog-body">
<div id="scriptParamsInputs"></div>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
</div>
</form>
</dialog>
<!-- Add/Edit Callback Dialog -->
<dialog id="callbackDialog">
<div class="dialog-header">
+2
View File
@@ -40,6 +40,7 @@ import {
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
closeExecutionDialog, scriptFormDirty, setScriptFormDirty,
addParameterRow, closeScriptParamsDialog, submitScriptWithParams,
} from './scripts.js';
import {
@@ -106,6 +107,7 @@ Object.assign(window, {
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
closeExecutionDialog,
addParameterRow, closeScriptParamsDialog, submitScriptWithParams,
// Callbacks
showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog,
saveCallback, deleteCallbackConfirm,
+30
View File
@@ -3,10 +3,34 @@
// ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, getAuthHeaders, hasCredentials } from './core.js';
import { IconSelect } from './icon-select.js';
import { callbackEventIcons } from './icons.js';
export let callbackFormDirty = false;
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
let _callbackEventIconSelect = null;
function _ensureCallbackEventIconSelect() {
if (_callbackEventIconSelect) return;
const select = document.getElementById('callbackName');
if (!select) return;
const items = Object.entries(callbackEventIcons).map(([value, icon]) => ({
value,
icon,
label: value,
}));
_callbackEventIconSelect = new IconSelect({
target: select,
items,
columns: 3,
placeholder: t('callbacks.placeholder.event'),
onChange: () => { callbackFormDirty = true; },
});
}
let _loadCallbacksPromise = null;
export async function loadCallbacksTable() {
if (_loadCallbacksPromise) return _loadCallbacksPromise;
@@ -71,6 +95,9 @@ export function showAddCallbackDialog() {
document.getElementById('callbackName').disabled = false;
title.textContent = t('callbacks.dialog.add');
_ensureCallbackEventIconSelect();
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue('', false);
callbackFormDirty = false;
document.body.classList.add('dialog-open');
@@ -101,6 +128,9 @@ export async function showEditCallbackDialog(callbackName) {
document.getElementById('callbackIsEdit').value = 'true';
document.getElementById('callbackName').value = callbackName;
document.getElementById('callbackName').disabled = true;
_ensureCallbackEventIconSelect();
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue(callbackName, false);
document.getElementById('callbackCommand').value = callback.command;
document.getElementById('callbackTimeout').value = callback.timeout;
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
+160
View File
@@ -0,0 +1,160 @@
// ============================================================
// IconSelect: visual icon-grid selector (replaces <select>)
// Ported from wled-screen-controller (TypeScript → vanilla JS)
//
// Trigger replaces the <select> inline. Popup is absolutely
// positioned inside a wrapper that sits next to the trigger.
// Works inside <dialog showModal()> — dialog must have
// overflow: visible.
// ============================================================
const POPUP_CLASS = 'icon-select-popup';
let _globalListenerAdded = false;
export function closeAllIconSelects() {
document.querySelectorAll(`.${POPUP_CLASS}.open`).forEach(p => {
p.classList.remove('open');
});
}
function _ensureGlobalListener() {
if (_globalListenerAdded) return;
_globalListenerAdded = true;
document.addEventListener('click', (e) => {
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) {
closeAllIconSelects();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllIconSelects();
});
}
export class IconSelect {
constructor({ target, items, onChange, columns = 2, placeholder = '', horizontal = false }) {
_ensureGlobalListener();
this._select = target;
this._items = items;
this._onChange = onChange;
this._columns = columns;
this._placeholder = placeholder;
this._horizontal = horizontal;
// Hide native select
this._select.style.display = 'none';
// Trigger button (replaces select visually)
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'icon-select-trigger';
this._trigger.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
// Popup — absolutely positioned, appended to dialog (overflow:visible)
// or body, escaping any scrollable ancestors
this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS;
this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.innerHTML = this._buildGrid();
const portal = this._select.closest('dialog') || document.body;
portal.appendChild(this._popup);
this._bindCells();
this._syncTrigger();
}
_buildGrid() {
const cells = this._items.map(item =>
`<div class="icon-select-cell" data-value="${item.value}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>`
).join('');
const cls = 'icon-select-grid' + (this._horizontal ? ' icon-select-grid--horizontal' : '');
return `<div class="${cls}" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
}
_bindCells() {
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true);
this._popup.classList.remove('open');
});
});
}
_syncTrigger() {
const val = this._select.value;
const item = this._items.find(i => i.value === val);
if (item) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
`<span class="icon-select-trigger-label">${item.label}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
} else if (this._placeholder) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
}
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.classList.toggle('active', cell.dataset.value === val);
});
}
_positionPopup() {
// Get trigger position relative to the popup's offset parent
// (the dialog or body). Use getBoundingClientRect for both and
// compute the offset.
const triggerRect = this._trigger.getBoundingClientRect();
const parentRect = this._popup.offsetParent
? this._popup.offsetParent.getBoundingClientRect()
: { left: 0, top: 0 };
const relTop = triggerRect.bottom - parentRect.top;
const relLeft = triggerRect.left - parentRect.left;
const popupW = Math.max(triggerRect.width, 200);
this._popup.style.left = relLeft + 'px';
this._popup.style.top = (relTop + 4) + 'px';
this._popup.style.width = popupW + 'px';
}
_toggle() {
const wasOpen = this._popup.classList.contains('open');
closeAllIconSelects();
if (!wasOpen) {
this._positionPopup();
this._popup.classList.add('open');
}
}
setValue(value, fireChange = false) {
this._select.value = value;
this._syncTrigger();
if (fireChange) {
this._select.dispatchEvent(new Event('change', { bubbles: true }));
if (this._onChange) this._onChange(value);
}
}
updateItems(items) {
this._items = items;
this._popup.innerHTML = this._buildGrid();
this._bindCells();
this._syncTrigger();
}
destroy() {
this._trigger.remove();
this._popup.remove();
this._select.style.display = '';
}
}
+31
View File
@@ -0,0 +1,31 @@
// ============================================================
// SVG icon library for icon-select grids
// Simple inline SVGs (24x24 viewBox, fill="currentColor")
// ============================================================
const _svg = (path) =>
`<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">${path}</svg>`;
// Parameter types
export const paramTypeIcons = {
string: _svg('<path d="M3 7V5h18v2H3zm0 12v-2h12v2H3zm0-6v-2h18v2H3z"/>'),
integer: _svg('<path d="M4 17V7h2v4h3V7h2v10h-2v-4H6v4H4zm10-1h2v1h2v-4h-2v1h-2V9h6v8h-6v-1z"/>'),
float: _svg('<path d="M5 17V7h2v4h3V7h2v10H9v-4H7v4H5zm9.5 0v-2a1 1 0 1 1 0-2h1v-2h-1a3 3 0 0 0 0 6h1v2h-1zm3-6v2h1a1 1 0 1 1 0 2h-1v2h1a3 3 0 0 0 0-6h-1z"/>'),
boolean: _svg('<path d="M17 7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h10c2.76 0 5-2.24 5-5s-2.24-5-5-5zm0 8c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>'),
select: _svg('<path d="M3 5h18v2H3V5zm4 6h10v2H7v-2zm-4 6h18v2H3v-2z"/><path d="M7 7l5 5 5-5"/>'),
};
// Callback events
export const callbackEventIcons = {
on_play: _svg('<path d="M8 5v14l11-7z"/>'),
on_pause: _svg('<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>'),
on_stop: _svg('<path d="M6 6h12v12H6z"/>'),
on_next: _svg('<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>'),
on_previous: _svg('<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z"/>'),
on_volume: _svg('<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>'),
on_mute: _svg('<path d="M16.5 12A4.5 4.5 0 0 0 14 8.5v2.09l2.41 2.41c.06-.31.09-.65.09-1zM19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>'),
on_seek: _svg('<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>'),
on_turn_on: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
on_turn_off: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
on_toggle: _svg('<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>'),
};
+19
View File
@@ -13,6 +13,7 @@ import {
} from './core.js';
import { updateBackgroundColors } from './background.js';
import { loadDisplayMonitors } from './links.js';
import { IconSelect } from './icon-select.js';
// Tab management
export let activeTab = 'player';
@@ -422,6 +423,8 @@ function renderVisualizerFrame() {
}
// Audio device selection
let _audioDeviceIconSelect = null;
export async function loadAudioDevices() {
const section = document.getElementById('audioDeviceSection');
const select = document.getElementById('audioDeviceSelect');
@@ -466,6 +469,22 @@ export async function loadAudioDevices() {
}
}
// Enhance with icon grid
const audioSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5z"/></svg>';
const items = [
{ value: '', icon: audioSvg, label: t('settings.audio.auto') },
...devices.map(dev => ({ value: dev.name, icon: audioSvg, label: dev.name })),
];
if (_audioDeviceIconSelect) _audioDeviceIconSelect.destroy();
_audioDeviceIconSelect = new IconSelect({
target: select,
items,
columns: 1,
horizontal: true,
onChange: () => onAudioDeviceChanged(),
});
_audioDeviceIconSelect.setValue(select.value, false);
updateAudioDeviceStatus(status);
} catch (e) {
section.style.display = 'none';
+347 -10
View File
@@ -8,6 +8,8 @@ import {
scripts, setScripts,
getAuthHeaders, hasCredentials,
} from './core.js';
import { IconSelect } from './icon-select.js';
import { paramTypeIcons } from './icons.js';
export let scriptFormDirty = false;
export function setScriptFormDirty(value) { scriptFormDirty = value; }
@@ -119,28 +121,219 @@ export async function displayQuickAccess() {
resolveMdiIcons(grid);
}
async function executeScript(scriptName, buttonElement) {
buttonElement.classList.add('executing');
function _getScriptParams(scriptName) {
const script = scripts.find(s => s.name === scriptName);
return (script && script.parameters) ? script.parameters : {};
}
async function executeScript(scriptName, buttonElement) {
const paramDefs = _getScriptParams(scriptName);
if (Object.keys(paramDefs).length > 0) {
_showParamsInputDialog(scriptName, paramDefs, async (params) => {
buttonElement.classList.add('executing');
try {
await _doExecuteScript(scriptName, params);
} finally {
buttonElement.classList.remove('executing');
}
});
return;
}
buttonElement.classList.add('executing');
try {
await _doExecuteScript(scriptName, {});
} finally {
buttonElement.classList.remove('executing');
}
}
async function _doExecuteScript(scriptName, params) {
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ args: [] })
body: JSON.stringify({ params })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`${scriptName} executed successfully`, 'success');
showToast(t('scripts.msg.executed', { name: scriptName }), 'success');
} else {
showToast(`Failed to execute ${scriptName}`, 'error');
showToast(result.detail || t('scripts.msg.execute_failed', { name: scriptName }), 'error');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showToast(`Error executing ${scriptName}`, 'error');
} finally {
buttonElement.classList.remove('executing');
showToast(t('scripts.msg.execute_error', { name: scriptName }), 'error');
}
}
// ============================================================
// Script Parameters Input Dialog (execution-time)
// ============================================================
let _paramsCallback = null;
let _paramsScriptName = null;
let _paramsIconSelects = null;
function _showParamsInputDialog(scriptName, paramDefs, callback) {
_paramsCallback = callback;
_paramsScriptName = scriptName;
const dialog = document.getElementById('scriptParamsDialog');
const title = document.getElementById('scriptParamsDialogTitle');
const container = document.getElementById('scriptParamsInputs');
const script = scripts.find(s => s.name === scriptName);
title.textContent = script ? (script.label || scriptName) : scriptName;
container.innerHTML = '';
// Track IconSelect instances for cleanup
if (!_paramsIconSelects) _paramsIconSelects = [];
for (const [pname, pdef] of Object.entries(paramDefs)) {
const wrapper = document.createElement('label');
const labelText = document.createElement('span');
labelText.textContent = pname + (pdef.required ? ' *' : '');
wrapper.appendChild(labelText);
if (pdef.description) {
const hint = document.createElement('small');
hint.className = 'param-hint';
hint.textContent = pdef.description;
wrapper.appendChild(hint);
}
let input;
if (pdef.type === 'select' && pdef.options) {
input = document.createElement('select');
if (!pdef.required) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '—';
input.appendChild(opt);
}
for (const optVal of pdef.options) {
const opt = document.createElement('option');
opt.value = optVal;
opt.textContent = optVal;
if (pdef.default !== undefined && pdef.default !== null && String(pdef.default) === optVal) {
opt.selected = true;
}
input.appendChild(opt);
}
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
// Enhance with icon grid if few options
if (pdef.options.length <= 10) {
const selItems = pdef.options.map(o => ({ value: o, icon: '', label: o }));
const cols = Math.min(pdef.options.length, 4);
const isel = new IconSelect({ target: input, items: selItems, columns: cols });
_paramsIconSelects.push(isel);
}
} else if (pdef.type === 'boolean') {
const boolSvgTrue = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
const boolSvgFalse = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
input = document.createElement('select');
const optTrue = document.createElement('option');
optTrue.value = 'true';
optTrue.textContent = 'true';
const optFalse = document.createElement('option');
optFalse.value = 'false';
optFalse.textContent = 'false';
input.appendChild(optTrue);
input.appendChild(optFalse);
if (pdef.default !== undefined && pdef.default !== null) {
input.value = String(pdef.default);
}
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
// Enhance with icon grid
const isel = new IconSelect({
target: input,
items: [
{ value: 'true', icon: boolSvgTrue, label: 'True' },
{ value: 'false', icon: boolSvgFalse, label: 'False' },
],
columns: 2,
});
_paramsIconSelects.push(isel);
} else if (pdef.type === 'integer' || pdef.type === 'float') {
input = document.createElement('input');
input.type = 'number';
if (pdef.type === 'float') input.step = 'any';
if (pdef.min !== undefined && pdef.min !== null) input.min = pdef.min;
if (pdef.max !== undefined && pdef.max !== null) input.max = pdef.max;
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
} else {
input = document.createElement('input');
input.type = 'text';
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
}
container.appendChild(wrapper);
}
document.body.classList.add('dialog-open');
dialog.showModal();
}
export function closeScriptParamsDialog() {
const dialog = document.getElementById('scriptParamsDialog');
_paramsCallback = null;
_paramsScriptName = null;
// Destroy icon selects from execution dialog
if (_paramsIconSelects) {
_paramsIconSelects.forEach(isel => isel.destroy());
_paramsIconSelects = null;
}
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
export async function submitScriptWithParams(event) {
event.preventDefault();
const container = document.getElementById('scriptParamsInputs');
const inputs = container.querySelectorAll('[data-param-name]');
const params = {};
for (const input of inputs) {
const name = input.dataset.paramName;
const type = input.dataset.paramType;
let val = input.value;
if (val === '' && !input.required) continue;
if (val === '') continue;
if (type === 'integer') val = parseInt(val, 10);
else if (type === 'float') val = parseFloat(val);
else if (type === 'boolean') val = val === 'true';
params[name] = val;
}
const callback = _paramsCallback;
closeScriptParamsDialog();
if (callback) {
await callback(params);
}
}
@@ -214,6 +407,7 @@ export function showAddScriptDialog() {
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
document.getElementById('scriptIconPreview').innerHTML = '';
document.getElementById('scriptParamsContainer').innerHTML = '';
title.textContent = t('scripts.dialog.add');
scriptFormDirty = false;
@@ -260,6 +454,15 @@ export async function showEditScriptDialog(scriptName) {
preview.innerHTML = '';
}
// Populate parameters
const paramsContainer = document.getElementById('scriptParamsContainer');
paramsContainer.innerHTML = '';
if (script.parameters) {
for (const [pname, pdef] of Object.entries(script.parameters)) {
_addParameterRowWithData(pname, pdef);
}
}
title.textContent = t('scripts.dialog.edit');
scriptFormDirty = false;
@@ -301,7 +504,8 @@ export async function saveScript(event) {
description: document.getElementById('scriptDescription').value || '',
icon: document.getElementById('scriptIcon').value || null,
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
shell: true
shell: true,
parameters: _collectParameterDefinitions(),
};
const endpoint = isEdit ?
@@ -425,6 +629,15 @@ function showExecutionResult(name, result, type = 'script') {
}
export async function executeScriptDebug(scriptName) {
const paramDefs = _getScriptParams(scriptName);
if (Object.keys(paramDefs).length > 0) {
_showParamsInputDialog(scriptName, paramDefs, (params) => _executeScriptDebugWithParams(scriptName, params));
return;
}
await _executeScriptDebugWithParams(scriptName, {});
}
async function _executeScriptDebugWithParams(scriptName, params) {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
@@ -445,7 +658,7 @@ export async function executeScriptDebug(scriptName) {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ args: [] })
body: JSON.stringify({ params })
});
const result = await response.json();
@@ -471,6 +684,130 @@ export async function executeScriptDebug(scriptName) {
}
}
// ============================================================
// Parameter Definition Editor (CRUD dialog)
// ============================================================
const PARAM_TYPES = ['string', 'integer', 'float', 'boolean', 'select'];
export function addParameterRow() {
_addParameterRowWithData('', {});
scriptFormDirty = true;
}
const _paramTypeItems = PARAM_TYPES.map(pt => ({
value: pt,
icon: paramTypeIcons[pt] || '',
label: pt.charAt(0).toUpperCase() + pt.slice(1),
}));
function _addParameterRowWithData(name, def) {
const container = document.getElementById('scriptParamsContainer');
const row = document.createElement('div');
row.className = 'param-row';
row.innerHTML = `
<div class="param-row-header">
<input type="text" class="param-name" value="${escapeHtml(name)}"
placeholder="${t('scripts.params.name_placeholder')}" pattern="[a-zA-Z][a-zA-Z0-9_]*">
<select class="param-type">
${PARAM_TYPES.map(pt => `<option value="${pt}" ${def.type === pt ? 'selected' : ''}>${pt}</option>`).join('')}
</select>
<label class="param-required-label" title="${t('scripts.params.required')}">
<input type="checkbox" class="param-required" ${def.required ? 'checked' : ''}>
<span>*</span>
</label>
<button type="button" class="param-remove-btn" title="${t('scripts.params.remove')}">&times;</button>
</div>
<div class="param-row-details">
<input type="text" class="param-description" value="${escapeHtml(def.description || '')}"
placeholder="${t('scripts.params.description_placeholder')}">
<div class="param-row-extra">
<input type="text" class="param-default" value="${def.default !== undefined && def.default !== null ? escapeHtml(String(def.default)) : ''}"
placeholder="${t('scripts.params.default_placeholder')}">
<input type="text" class="param-min" value="${def.min !== undefined && def.min !== null ? def.min : ''}"
placeholder="Min" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
<input type="text" class="param-max" value="${def.max !== undefined && def.max !== null ? def.max : ''}"
placeholder="Max" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
<input type="text" class="param-options" value="${def.options ? def.options.join(', ') : ''}"
placeholder="${t('scripts.params.options_placeholder')}" style="display:${def.type === 'select' ? '' : 'none'}">
</div>
</div>
`;
// Enhance the type <select> with icon grid
const typeSelect = row.querySelector('.param-type');
const iconSelect = new IconSelect({
target: typeSelect,
items: _paramTypeItems,
columns: 5,
onChange: () => {
const isNumeric = typeSelect.value === 'integer' || typeSelect.value === 'float';
const isSelect = typeSelect.value === 'select';
row.querySelector('.param-min').style.display = isNumeric ? '' : 'none';
row.querySelector('.param-max').style.display = isNumeric ? '' : 'none';
row.querySelector('.param-options').style.display = isSelect ? '' : 'none';
scriptFormDirty = true;
},
});
row.querySelector('.param-remove-btn').addEventListener('click', () => {
iconSelect.destroy();
row.remove();
scriptFormDirty = true;
});
// Mark dirty on any input change
row.querySelectorAll('input').forEach(el => {
el.addEventListener('input', () => { scriptFormDirty = true; });
});
container.appendChild(row);
}
function _collectParameterDefinitions() {
const container = document.getElementById('scriptParamsContainer');
const rows = container.querySelectorAll('.param-row');
const params = {};
for (const row of rows) {
const name = row.querySelector('.param-name').value.trim();
if (!name) continue;
const type = row.querySelector('.param-type').value;
const def = { type };
const description = row.querySelector('.param-description').value.trim();
if (description) def.description = description;
if (row.querySelector('.param-required').checked) def.required = true;
const defaultVal = row.querySelector('.param-default').value.trim();
if (defaultVal !== '') {
if (type === 'integer') def.default = parseInt(defaultVal, 10);
else if (type === 'float') def.default = parseFloat(defaultVal);
else if (type === 'boolean') def.default = defaultVal.toLowerCase() === 'true';
else def.default = defaultVal;
}
if (type === 'integer' || type === 'float') {
const minVal = row.querySelector('.param-min').value.trim();
const maxVal = row.querySelector('.param-max').value.trim();
if (minVal !== '') def.min = parseFloat(minVal);
if (maxVal !== '') def.max = parseFloat(maxVal);
}
if (type === 'select') {
const optStr = row.querySelector('.param-options').value.trim();
if (optStr) def.options = optStr.split(',').map(s => s.trim()).filter(Boolean);
}
params[name] = def;
}
return params;
}
export async function executeCallbackDebug(callbackName) {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
+9
View File
@@ -74,6 +74,15 @@
"scripts.execution.error_output": "Error Output",
"scripts.execution.close": "Close",
"scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"scripts.field.parameters": "Parameters",
"scripts.params.add": "+ Add",
"scripts.params.remove": "Remove parameter",
"scripts.params.required": "Required",
"scripts.params.name_placeholder": "param_name",
"scripts.params.description_placeholder": "Parameter description",
"scripts.params.default_placeholder": "Default",
"scripts.params.options_placeholder": "option1, option2, ...",
"scripts.params.execute": "Execute",
"callbacks.management": "Callback Management",
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)",
"callbacks.add": "Add",
+9
View File
@@ -74,6 +74,15 @@
"scripts.execution.error_output": "Вывод ошибок",
"scripts.execution.close": "Закрыть",
"scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"scripts.field.parameters": "Параметры",
"scripts.params.add": "+ Добавить",
"scripts.params.remove": "Удалить параметр",
"scripts.params.required": "Обязательный",
"scripts.params.name_placeholder": "имя_параметра",
"scripts.params.description_placeholder": "Описание параметра",
"scripts.params.default_placeholder": "По умолчанию",
"scripts.params.options_placeholder": "вариант1, вариант2, ...",
"scripts.params.execute": "Выполнить",
"callbacks.management": "Управление Обратными Вызовами",
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
"callbacks.add": "Добавить",