// ============================================================ // Scripts: CRUD, quick access, execution dialog // ============================================================ import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, 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; } export async function loadScripts() { try { const response = await fetch('/api/scripts/list', { headers: getAuthHeaders() }); if (response.ok) { setScripts(await response.json()); displayQuickAccess(); } } catch (error) { console.error('Error loading scripts:', error); } } let _quickAccessGen = 0; export async function displayQuickAccess() { const gen = ++_quickAccessGen; const grid = document.getElementById('scripts-grid'); const fragment = document.createDocumentFragment(); const hasScripts = scripts.length > 0; let hasLinks = false; scripts.forEach(script => { const button = document.createElement('button'); button.className = 'script-btn'; button.onclick = () => executeScript(script.name, button); if (script.icon) { const iconEl = document.createElement('div'); iconEl.className = 'script-icon'; iconEl.setAttribute('data-mdi-icon', script.icon); button.appendChild(iconEl); } const label = document.createElement('div'); label.className = 'script-label'; label.textContent = script.label || script.name; button.appendChild(label); if (script.description) { const description = document.createElement('div'); description.className = 'script-description'; description.textContent = script.description; button.appendChild(description); } fragment.appendChild(button); }); try { if (hasCredentials()) { const response = await fetch('/api/links/list', { headers: getAuthHeaders() }); if (gen !== _quickAccessGen) return; if (response.ok) { const links = await response.json(); hasLinks = links.length > 0; links.forEach(link => { const card = document.createElement('a'); card.className = 'script-btn link-card'; card.href = link.url; card.target = '_blank'; card.rel = 'noopener noreferrer'; if (link.icon) { const iconEl = document.createElement('div'); iconEl.className = 'script-icon'; iconEl.setAttribute('data-mdi-icon', link.icon); card.appendChild(iconEl); } const label = document.createElement('div'); label.className = 'script-label'; label.textContent = link.label || link.name; card.appendChild(label); if (link.description) { const desc = document.createElement('div'); desc.className = 'script-description'; desc.textContent = link.description; card.appendChild(desc); } fragment.appendChild(card); }); } } } catch (e) { if (gen !== _quickAccessGen) return; console.warn('Failed to load links for quick access:', e); } if (!hasScripts && !hasLinks) { const empty = document.createElement('div'); empty.className = 'scripts-empty empty-state-illustration'; empty.innerHTML = `

${t('quick_access.no_items')}

`; fragment.prepend(empty); } grid.innerHTML = ''; grid.appendChild(fragment); resolveMdiIcons(grid); } 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/${encodeURIComponent(scriptName)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ params }) }); const result = await response.json(); if (response.ok && result.success) { showToast(t('scripts.msg.executed', { name: scriptName }), 'success'); } else { showToast(result.detail || t('scripts.msg.execute_failed', { name: scriptName }), 'error'); } } catch (error) { console.error(`Error executing script ${scriptName}:`, error); 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 = ''; const boolSvgFalse = ''; 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); } } // ============================================================ // Script Management CRUD // ============================================================ let _loadScriptsPromise = null; export async function loadScriptsTable() { if (_loadScriptsPromise) return _loadScriptsPromise; _loadScriptsPromise = _loadScriptsTableImpl(); _loadScriptsPromise.finally(() => { _loadScriptsPromise = null; }); return _loadScriptsPromise; } async function _loadScriptsTableImpl() { const tbody = document.getElementById('scriptsTableBody'); try { const response = await fetch('/api/scripts/list', { headers: getAuthHeaders() }); if (!response.ok) { throw new Error('Failed to fetch scripts'); } const scriptsList = await response.json(); if (scriptsList.length === 0) { tbody.innerHTML = '

' + t('scripts.empty') + '

'; return; } tbody.innerHTML = scriptsList.map(script => ` ${script.icon ? `` : ''}${escapeHtml(script.name)} ${escapeHtml(script.label || script.name)} ${escapeHtml(script.command || 'N/A')} ${script.timeout}s
`).join(''); resolveMdiIcons(tbody); } catch (error) { console.error('Error loading scripts:', error); tbody.innerHTML = `${escapeHtml(t('scripts.msg.load_failed'))}`; } } export function showAddScriptDialog() { const dialog = document.getElementById('scriptDialog'); const form = document.getElementById('scriptForm'); const title = document.getElementById('dialogTitle'); form.reset(); document.getElementById('scriptOriginalName').value = ''; 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; document.body.classList.add('dialog-open'); dialog.showModal(); } export async function showEditScriptDialog(scriptName) { const dialog = document.getElementById('scriptDialog'); const title = document.getElementById('dialogTitle'); try { const response = await fetch('/api/scripts/list', { headers: getAuthHeaders() }); if (!response.ok) { throw new Error('Failed to fetch script details'); } const scriptsList = await response.json(); const script = scriptsList.find(s => s.name === scriptName); if (!script) { showToast(t('scripts.msg.not_found'), 'error'); return; } document.getElementById('scriptOriginalName').value = scriptName; document.getElementById('scriptIsEdit').value = 'true'; document.getElementById('scriptName').value = scriptName; document.getElementById('scriptName').disabled = true; document.getElementById('scriptLabel').value = script.label || ''; document.getElementById('scriptCommand').value = script.command || ''; document.getElementById('scriptDescription').value = script.description || ''; document.getElementById('scriptIcon').value = script.icon || ''; document.getElementById('scriptTimeout').value = script.timeout || 30; const preview = document.getElementById('scriptIconPreview'); if (script.icon) { fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; }); } else { 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; document.body.classList.add('dialog-open'); dialog.showModal(); } catch (error) { console.error('Error loading script for edit:', error); showToast(t('scripts.msg.load_failed'), 'error'); } } export async function closeScriptDialog() { if (scriptFormDirty) { if (!await showConfirm(t('scripts.confirm.unsaved'))) { return; } } const dialog = document.getElementById('scriptDialog'); scriptFormDirty = false; closeDialog(dialog); document.body.classList.remove('dialog-open'); } export async function saveScript(event) { event.preventDefault(); const submitBtn = event.target.querySelector('button[type="submit"]'); if (submitBtn) submitBtn.disabled = true; const isEdit = document.getElementById('scriptIsEdit').value === 'true'; const scriptName = isEdit ? document.getElementById('scriptOriginalName').value : document.getElementById('scriptName').value; const data = { command: document.getElementById('scriptCommand').value, label: document.getElementById('scriptLabel').value || null, description: document.getElementById('scriptDescription').value || '', icon: document.getElementById('scriptIcon').value || null, timeout: parseInt(document.getElementById('scriptTimeout').value) || 30, shell: true, parameters: _collectParameterDefinitions(), }; const encodedName = encodeURIComponent(scriptName); const endpoint = isEdit ? `/api/scripts/update/${encodedName}` : `/api/scripts/create/${encodedName}`; const method = isEdit ? 'PUT' : 'POST'; try { const response = await fetch(endpoint, { method, headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify(data) }); const result = await response.json(); if (response.ok && result.success) { showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success'); scriptFormDirty = false; closeScriptDialog(); } else { showToast(result.detail || t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error'); } } catch (error) { console.error('Error saving script:', error); showToast(t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error'); } finally { if (submitBtn) submitBtn.disabled = false; } } export async function deleteScriptConfirm(scriptName) { if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) { return; } try { const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, { method: 'DELETE', headers: getAuthHeaders() }); const result = await response.json(); if (response.ok && result.success) { showToast(t('scripts.msg.deleted'), 'success'); } else { showToast(result.detail || t('scripts.msg.delete_failed'), 'error'); } } catch (error) { console.error('Error deleting script:', error); showToast(t('scripts.msg.delete_failed'), 'error'); } } // ============================================================ // Execution Result Dialog (shared by scripts and callbacks) // ============================================================ export function closeExecutionDialog() { const dialog = document.getElementById('executionDialog'); closeDialog(dialog); document.body.classList.remove('dialog-open'); } function showExecutionResult(name, result, type = 'script') { const dialog = document.getElementById('executionDialog'); const title = document.getElementById('executionDialogTitle'); const statusDiv = document.getElementById('executionStatus'); const outputSection = document.getElementById('outputSection'); const errorSection = document.getElementById('errorSection'); const outputPre = document.getElementById('executionOutput'); const errorPre = document.getElementById('executionError'); title.textContent = `${t('execution.result')}: ${name}`; const success = result.success && result.exit_code === 0; const statusClass = success ? 'success' : 'error'; const statusText = t(success ? 'execution.success' : 'execution.failed'); statusDiv.innerHTML = `
${escapeHtml(statusText)}
${result.exit_code !== undefined ? result.exit_code : 'N/A'}
${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}
`; outputSection.style.display = 'block'; if (result.stdout && result.stdout.trim()) { outputPre.textContent = result.stdout; } else { outputPre.textContent = t('execution.no_output'); outputPre.style.fontStyle = 'italic'; outputPre.style.color = 'var(--text-secondary)'; } if (result.stderr && result.stderr.trim()) { errorSection.style.display = 'block'; errorPre.textContent = result.stderr; errorPre.style.fontStyle = 'normal'; errorPre.style.color = 'var(--error)'; } else if (!success && result.error) { errorSection.style.display = 'block'; errorPre.textContent = result.error; errorPre.style.fontStyle = 'normal'; errorPre.style.color = 'var(--error)'; } else { errorSection.style.display = 'none'; } dialog.showModal(); } 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'); title.textContent = `${t('execution.executing')}: ${scriptName}`; statusDiv.innerHTML = `
${escapeHtml(t('execution.running'))}
`; document.getElementById('outputSection').style.display = 'none'; document.getElementById('errorSection').style.display = 'none'; document.body.classList.add('dialog-open'); dialog.showModal(); try { const response = await fetch(`/api/scripts/execute/${scriptName}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ params }) }); const result = await response.json(); if (response.ok) { showExecutionResult(scriptName, result, 'script'); } else { showExecutionResult(scriptName, { success: false, exit_code: -1, error: result.detail || 'Execution failed', stderr: result.detail || 'Unknown error' }, 'script'); } } catch (error) { console.error(`Error executing script ${scriptName}:`, error); showExecutionResult(scriptName, { success: false, exit_code: -1, error: error.message, stderr: `Network error: ${error.message}` }, 'script'); } } // ============================================================ // 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 = `
`; // Enhance the type