diff --git a/CHANGELOG.md b/CHANGELOG.md index f1acf71c..25ff75ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,15 @@ accepts both, but the store flags the old spelling as deprecated - `check_system_compatibility.sh` no longer reports installed packages as missing. - A network failure fetching GitHub repo info logs a warning, not an error. +- Web UI fixes: + - The Operation History plugin filter lists installed plugins (it showed one option, "plugins"). + - Ctrl/Cmd+S submits the active tab's visible form (with its validation) instead of the first form in the page; it does nothing inside a dialog or on a tab without a form. The Ctrl/Cmd+R override (the browser's own reload) and the textarea auto-resize (no textarea exists at load) are removed. + - Overview "Check Updates" asks for the same confirmation as "Update Code" and shows the server's message. Both, and the Tools tab's git pull, show the restart-pending banner when the update needs a restart. + - Tools tab actions and diagnostics show the server's error message; only a non-JSON error falls back to `HTTP `. + - An uninstalled plugin no longer reappears in the installed list: writes through `PluginAPI` clear its 5s GET cache, and Refresh and the post-uninstall reload bypass both list caches. + - Plugin widgets load from `/static/plugin-widgets/` only; the two other paths it tried have no route. + - The raw JSON editor escapes the parse error, and the slider widget escapes its value, min, max and step. + - Removed unused array-of-objects and key-value helpers from `plugins_manager.js` (about 640 lines, no callers) and a redundant `?v=` on its script tag. - Web UI and `src.common` fixes: - A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal. - Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it. diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index e683e6cd..77a607b3 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -229,31 +229,32 @@ document.addEventListener('DOMContentLoaded', function() { // SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in // js/app-shell.js — do not open EventSources for stats/display here. -// Auto-resize textareas -document.addEventListener('DOMContentLoaded', function() { - const textareas = document.querySelectorAll('textarea'); - textareas.forEach(textarea => { - textarea.addEventListener('input', function() { - this.style.height = 'auto'; - this.style.height = this.scrollHeight + 'px'; - }); - }); -}); - // Keyboard shortcuts document.addEventListener('keydown', function(e) { - // Ctrl/Cmd + R to refresh - if ((e.ctrlKey || e.metaKey) && e.key === 'r') { - e.preventDefault(); - location.reload(); - } - - // Ctrl/Cmd + S to save current form + // Ctrl/Cmd + S to save the form on the active tab: the first visible form + // in that tab's panel (#-content, or #plugin-config- for a plugin + // tab). The panel is looked up by activeTab rather than by visibility + // alone because during the x-transition between tabs both are visible. + // requestSubmit() runs validation and onsubmit guards like a real submit; + // with no visible form, do nothing. Inside a modal dialog the shortcut is + // the dialog's (json-file-manager saves its file on Ctrl+S), so the tab's + // form is left alone. if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); - const form = document.querySelector('form'); - if (form) { - form.dispatchEvent(new Event('submit')); + const active = document.activeElement; + if (active && active.closest && active.closest('[aria-modal="true"]')) { + return; + } + const app = typeof window.getApp === 'function' ? window.getApp() : null; + const tab = app && app.activeTab; + const panel = (tab && (document.getElementById(tab + '-content') || + document.getElementById('plugin-config-' + tab))) || + document.getElementById('tab-content'); + if (!panel) return; + const form = Array.from(panel.querySelectorAll('form')) + .find(f => f.getClientRects().length > 0); + if (form && typeof form.requestSubmit === 'function') { + form.requestSubmit(); } } }); diff --git a/web_interface/static/v3/js/plugins/api_client.js b/web_interface/static/v3/js/plugins/api_client.js index 5a30e016..23fee731 100644 --- a/web_interface/static/v3/js/plugins/api_client.js +++ b/web_interface/static/v3/js/plugins/api_client.js @@ -212,8 +212,16 @@ const PluginAPI = { // Use throttling for GET requests, immediate execution for POST/PUT/DELETE if (useThrottle && method === 'GET') { return await RequestThrottler.throttle(requestKey, makeRequest, 100); - } else { + } else if (method === 'GET') { return await makeRequest(); + } else { + // A write (install, uninstall, toggle, config save) can change any + // cached GET, e.g. the installed list; drop the cache once it lands. + try { + return await makeRequest(); + } finally { + RequestThrottler.clearCache(); + } } }, diff --git a/web_interface/static/v3/js/widgets/plugin-loader.js b/web_interface/static/v3/js/widgets/plugin-loader.js index bc3aa587..5b7ff822 100644 --- a/web_interface/static/v3/js/widgets/plugin-loader.js +++ b/web_interface/static/v3/js/widgets/plugin-loader.js @@ -33,11 +33,11 @@ return; } - // Try multiple possible paths for plugin widgets + // The one route that serves plugin widgets (serve_plugin_widget in + // blueprints/pages_v3.py); nothing is served under /plugins// or + // /static/plugins//, so trying those only added two failed imports. const possiblePaths = [ - `/static/plugin-widgets/${pluginId}/${widgetName}.js`, - `/plugins/${pluginId}/widgets/${widgetName}.js`, - `/static/plugins/${pluginId}/widgets/${widgetName}.js` + `/static/plugin-widgets/${pluginId}/${widgetName}.js` ]; let lastError = null; diff --git a/web_interface/static/v3/js/widgets/slider.js b/web_interface/static/v3/js/widgets/slider.js index 55ad5376..08d5a94f 100644 --- a/web_interface/static/v3/js/widgets/slider.js +++ b/web_interface/static/v3/js/widgets/slider.js @@ -85,7 +85,7 @@ html += `
- ${escapeHtml(prefix)}${currentValue}${escapeHtml(suffix)} + ${escapeHtml(prefix)}${escapeHtml(currentValue)}${escapeHtml(suffix)}
`; @@ -96,10 +96,10 @@ - ${escapeHtml(prefix)}${min}${escapeHtml(suffix)} - ${escapeHtml(prefix)}${max}${escapeHtml(suffix)} + ${escapeHtml(prefix)}${escapeHtml(min)}${escapeHtml(suffix)} + ${escapeHtml(prefix)}${escapeHtml(max)}${escapeHtml(suffix)} `; } diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 5af765f2..d7acc445 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -33,7 +33,7 @@ * togglePlugin, the GitHub token helpers, handleGitHubPluginInstall, * checkGitHubAuthStatus); then the plugin-manager IIFE (private state: * installedPlugins, the store cache, the on-demand poller); then the - * array-of-objects field helpers; then the Starlark IIFE. + * Starlark IIFE. * * The installed list is published only by renderInstalledPlugins: it sets * window.installedPlugins and dispatches one `pluginsUpdated` event, which @@ -44,9 +44,6 @@ * configurePlugin, togglePlugin, uninstallPlugin, installPlugin, * installFromCustomRegistry, removeSavedRepository, executePluginAction, * openOnDemandModal, requestOnDemandStop, loadOnDemandStatus, - * renderArrayObjectItem, addArrayObjectItem, removeArrayObjectItem, - * updateArrayObjectData, handleArrayObjectFileUpload, removeArrayObjectFile, - * removeKeyValuePair, updateKeyValuePairData, getSchemaProperty, * installStarlarkApp, installPixlet, the GitHub token functions, * window.installedPlugins and window.currentPluginConfig. */ @@ -923,6 +920,12 @@ function loadInstalledPlugins(forceRefresh = false) { debugLog('[FETCH] Loading installed plugins...'); + // A forced refresh must not be answered from PluginAPI's own GET cache + // (RequestThrottler, 5s) either, or it re-renders the list it replaces. + if (forceRefresh && window.PluginAPI && typeof window.PluginAPI.clearCache === 'function') { + window.PluginAPI.clearCache(); + } + // Use PluginAPI if available, otherwise fall back to direct fetch const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ? window.PluginAPI.getInstalledPlugins().then(plugins => { @@ -1883,516 +1886,6 @@ function closeOnDemandModalOnBackdrop(event) { } } -// Helper function to get the full property object from schema -// Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1") -function getSchemaProperty(schema, path) { - if (!schema || !schema.properties) return null; - - const parts = path.split('.'); - let current = schema.properties; - let i = 0; - - while (i < parts.length) { - let matched = false; - // Try progressively longer candidates, longest first - for (let j = parts.length; j > i; j--) { - const candidate = parts.slice(i, j).join('.'); - if (current && current[candidate]) { - if (j === parts.length) { - // Consumed all remaining parts — done - return current[candidate]; - } - if (current[candidate].properties) { - current = current[candidate].properties; - i = j; - matched = true; - break; - } else { - return null; // Can't navigate deeper - } - } - } - if (!matched) { - return null; - } - } - - return null; -} - -// "x-display": "hidden" (or an object of nothing but hidden children). -// Mirrors prop_is_hidden in plugin_config.html. -function isHiddenSchemaProp(schema) { - if (!schema || typeof schema !== 'object') return false; - if (schema['x-display'] === 'hidden') return true; - const children = schema.properties; - if (children && typeof children === 'object') { - const values = Object.values(children); - return values.length > 0 && values.every(isHiddenSchemaProp); - } - return false; -} - -// Helper function to render a single item in an array of objects -function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) { - const item = itemValue || {}; - const itemId = `${escapeAttribute(fieldId)}_item_${index}`; - // Store original item data in data attribute to preserve non-editable properties after reindexing - const itemDataJson = JSON.stringify(item); - const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson))); - let html = `
`; - - // Render each property of the object - const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties); - propertyOrder.forEach(propKey => { - if (!itemProperties[propKey]) return; - // "x-display": "hidden": no control. The stored value survives through - // data-item-data, which updateArrayObjectData overlays edits onto. - if (isHiddenSchemaProp(itemProperties[propKey])) return; - - const propSchema = itemProperties[propKey]; - const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default; - const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - const propDescription = propSchema.description || ''; - const propFullKey = `${fullKey}[${index}].${propKey}`; - - html += `
`; - - // Handle file-upload widget (for logo field) - if (propSchema['x-widget'] === 'file-upload') { - html += ``; - if (propDescription) { - html += `

${escapeHtml(propDescription)}

`; - } - const uploadConfig = propSchema['x-upload-config'] || {}; - // Derive pluginId strictly from uploadConfig or currentPluginConfig, no hard-coded fallback - const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || null; - const logoValue = propValue || {}; - // Use base64 encoding for JSON in data attributes to safely handle all characters - const logoDataJson = logoValue && Object.keys(logoValue).length > 0 ? JSON.stringify(logoValue) : ''; - const logoDataBase64 = logoDataJson ? btoa(unescape(encodeURIComponent(logoDataJson))) : ''; - const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp']; - const maxSizeMB = uploadConfig.max_size_mb || 5; - const pluginIdParam = pluginId ? `'${escapeAttribute(pluginId)}'` : 'null'; - const uploadConfigJson = JSON.stringify({ allowed_types: allowedTypes, max_size_mb: maxSizeMB }); - const uploadConfigBase64 = btoa(unescape(encodeURIComponent(uploadConfigJson))); - - html += ` -
- - - `; - - if (logoValue.path) { - html += ` -
- Logo - -
- `; - } - - html += `
`; - } else if (propSchema.type === 'boolean') { - // Boolean checkbox - html += ` - - `; - } else { - // Regular text/string input - html += ` - - `; - if (propDescription) { - html += `

${escapeHtml(propDescription)}

`; - } - const placeholder = propSchema.format === 'uri' ? 'https://example.com/feed' : ''; - html += ` - - `; - } - - html += `
`; - }); - - // Use schema-driven label for remove button, fallback to generic "Remove item" - const removeLabel = itemsSchema['x-removeLabel'] || 'Remove item'; - html += ` - -
`; - - return html; -} - - -// Functions to handle patternProperties key-value pairs -window.removeKeyValuePair = function(fieldId, index) { - const pairsContainer = document.getElementById(fieldId + '_pairs'); - if (!pairsContainer) return; - - const pair = pairsContainer.querySelector(`.key-value-pair[data-index="${index}"]`); - if (pair) { - pair.remove(); - // Re-index remaining pairs - const remainingPairs = pairsContainer.querySelectorAll('.key-value-pair'); - remainingPairs.forEach((p, newIndex) => { - p.setAttribute('data-index', newIndex); - const keyInput = p.querySelector('[data-key-index]'); - const valueInput = p.querySelector('[data-value-index]'); - if (keyInput) { - keyInput.setAttribute('name', keyInput.getAttribute('name').replace(/\[key_\d+\]/, `[key_${newIndex}]`)); - keyInput.setAttribute('data-key-index', newIndex); - keyInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${keyInput.getAttribute('name').split('[')[0]}')`); - } - if (valueInput) { - valueInput.setAttribute('name', valueInput.getAttribute('name').replace(/\[value_\d+\]/, `[value_${newIndex}]`)); - valueInput.setAttribute('data-value-index', newIndex); - valueInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${valueInput.getAttribute('name').split('[')[0]}')`); - } - const removeButton = p.querySelector('button[onclick*="removeKeyValuePair"]'); - if (removeButton) { - removeButton.setAttribute('onclick', `removeKeyValuePair('${fieldId}', ${newIndex})`); - } - }); - const hiddenInput = pairsContainer.closest('.key-value-pairs-container').querySelector('input[type="hidden"]'); - if (hiddenInput) { - const hiddenName = hiddenInput.getAttribute('name').replace(/_data$/, ''); - updateKeyValuePairData(fieldId, hiddenName); - } - - // Update add button state - const addButton = pairsContainer.nextElementSibling; - if (addButton) { - const maxProperties = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]); - if (remainingPairs.length < maxProperties) { - addButton.disabled = false; - addButton.style.opacity = '1'; - addButton.style.cursor = 'pointer'; - } - } - } -}; - -window.updateKeyValuePairData = function(fieldId, fullKey) { - const pairsContainer = document.getElementById(fieldId + '_pairs'); - const hiddenInput = document.getElementById(fieldId + '_data'); - if (!pairsContainer || !hiddenInput) return; - - const pairs = {}; - const keyInputs = pairsContainer.querySelectorAll('[data-key-index]'); - const valueInputs = pairsContainer.querySelectorAll('[data-value-index]'); - - keyInputs.forEach((keyInput, idx) => { - const key = keyInput.value.trim(); - const valueInput = Array.from(valueInputs).find(v => v.getAttribute('data-value-index') === keyInput.getAttribute('data-key-index')); - if (key && valueInput) { - const value = valueInput.value.trim(); - if (value) { - pairs[key] = value; - } - } - }); - - hiddenInput.value = JSON.stringify(pairs); -}; - -window.updateArrayObjectData = function(fieldId) { - const itemsContainer = document.getElementById(fieldId + '_items'); - const hiddenInput = document.getElementById(fieldId + '_data'); - if (!itemsContainer || !hiddenInput) return; - - // Get existing items from hidden input to preserve non-editable properties - let existingItems = []; - try { - const existingData = hiddenInput.value.trim(); - if (existingData) { - existingItems = JSON.parse(existingData); - } - } catch (e) { - console.error('Error parsing existing items data:', e); - } - - const items = []; - const itemElements = itemsContainer.querySelectorAll('.array-object-item'); - - itemElements.forEach((itemEl, index) => { - // Start with original item data from data attribute to preserve non-editable properties - // This avoids index-based corruption after deletions/reindexing - let existingItem = {}; - const itemDataBase64 = itemEl.getAttribute('data-item-data'); - if (itemDataBase64) { - try { - const itemDataJson = decodeURIComponent(escape(atob(itemDataBase64))); - existingItem = JSON.parse(itemDataJson); - } catch (e) { - console.error('Error parsing item data from data attribute:', e); - // Fallback to index-based lookup if data attribute is missing/corrupt - if (index < existingItems.length && existingItems[index]) { - existingItem = existingItems[index]; - } - } - } else { - // Fallback to index-based lookup if data attribute is missing - if (index < existingItems.length && existingItems[index]) { - existingItem = existingItems[index]; - } - } - const item = Object.assign({}, existingItem); // Copy existing item - - // Get all text inputs in this item and overlay their values with type coercion - itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => { - const propKey = input.getAttribute('data-prop-key'); - if (propKey && propKey !== 'logo_file') { - let value = input.value.trim(); - - // Type coercion: check input type or data-prop-type attribute - const inputType = input.type; - const propType = input.getAttribute('data-prop-type'); - - if (inputType === 'number' || propType === 'number') { - // Use valueAsNumber if available, fallback to Number() - const numValue = input.valueAsNumber !== undefined && !isNaN(input.valueAsNumber) - ? input.valueAsNumber - : Number(value); - item[propKey] = isNaN(numValue) ? value : numValue; - } else if (propType === 'array' || input.getAttribute('data-prop-is-list') === 'true') { - // Try to parse as JSON array, fallback to comma splitting - try { - const parsed = JSON.parse(value); - item[propKey] = Array.isArray(parsed) ? parsed : value; - } catch (e) { - // Fallback to comma-splitting for arrays - item[propKey] = value ? value.split(',').map(v => v.trim()).filter(v => v) : []; - } - } else { - // String value - keep as-is - item[propKey] = value; - } - } - }); - // Handle checkboxes - itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { - const propKey = checkbox.getAttribute('data-prop-key'); - if (propKey) { - item[propKey] = checkbox.checked; - } - }); - // Handle file upload data (stored in data attributes, base64-encoded) - itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => { - const fileDataBase64 = fileEl.getAttribute('data-file-data'); - if (fileDataBase64) { - try { - // Decode base64-encoded JSON - const fileDataJson = decodeURIComponent(escape(atob(fileDataBase64))); - const data = JSON.parse(fileDataJson); - const propKey = fileEl.getAttribute('data-prop-key'); - if (propKey) { - item[propKey] = data; - } - } catch (e) { - console.error('Error parsing file data:', e); - } - } - }); - items.push(item); - - // Update data-item-data attribute with the merged item to keep it in sync - try { - const itemDataJson = JSON.stringify(item); - const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson))); - itemEl.setAttribute('data-item-data', itemDataBase64); - } catch (e) { - console.error('Error updating data-item-data attribute:', e); - } - }); - - hiddenInput.value = JSON.stringify(items); -}; - -window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, propKey, pluginId) { - const file = event.target.files[0]; - if (!file) return; - - // Derive item element from event instead of constructing ID (works after reindexing) - const itemEl = event.target.closest('.array-object-item'); - if (!itemEl) { - console.error('Array object item element not found'); - return; - } - - // Find file upload container within the item element, scoped to propKey - const fileUploadContainer = itemEl.querySelector(`.file-upload-widget-inline[data-prop-key="${propKey}"]`); - if (!fileUploadContainer) { - console.error('File upload container not found for propKey:', propKey); - return; - } - - // Get upload config from data attribute - let uploadConfig = { allowed_types: ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'], max_size_mb: 5 }; - const uploadConfigBase64 = fileUploadContainer.getAttribute('data-upload-config'); - if (uploadConfigBase64) { - try { - const uploadConfigJson = decodeURIComponent(escape(atob(uploadConfigBase64))); - uploadConfig = JSON.parse(uploadConfigJson); - } catch (e) { - console.error('Error parsing upload config from data attribute:', e); - } - } - - // Validate file type using uploadConfig - const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp']; - if (!allowedTypes.includes(file.type)) { - showNotification(`File ${file.name} is not a valid image type`, 'error'); - return; - } - - // Validate file size using uploadConfig - const maxSizeMB = uploadConfig.max_size_mb || 5; - if (file.size > maxSizeMB * 1024 * 1024) { - showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error'); - return; - } - - // Validate pluginId before upload (fail fast) - if (!pluginId || pluginId === 'null' || pluginId === 'undefined' || (typeof pluginId === 'string' && pluginId.trim() === '')) { - showNotification('Plugin ID is required for file upload', 'error'); - console.error('File upload failed: pluginId is required'); - return; - } - - // Upload file - const formData = new FormData(); - formData.append('plugin_id', pluginId); - formData.append('files', file); - - try { - const response = await fetch('/api/v3/plugins/assets/upload', { - method: 'POST', - body: formData - }); - - // Check response.ok before parsing JSON to avoid parsing errors on HTTP errors - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = `Upload failed: HTTP ${response.status}`; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.message || errorMessage; - } catch (e) { - // If response isn't JSON, use the text or status - if (errorText) { - errorMessage = `Upload failed: ${errorText}`; - } - } - showNotification(errorMessage, 'error'); - return; - } - - const data = await response.json(); - - if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) { - const uploadedFile = data.uploaded_files[0]; - - // Store file data in data-file-data attribute on the container (base64-encoded) - const fileDataJson = JSON.stringify(uploadedFile); - const fileDataBase64 = btoa(unescape(encodeURIComponent(fileDataJson))); - fileUploadContainer.setAttribute('data-file-data', fileDataBase64); - fileUploadContainer.setAttribute('data-prop-key', propKey); - - // Update the display to show the uploaded image - const existingImage = fileUploadContainer.querySelector('.uploaded-image-container'); - if (existingImage) { - existingImage.remove(); - } - - const imageContainer = document.createElement('div'); - imageContainer.className = 'mt-2 flex items-center space-x-2 uploaded-image-container'; - const escapedPath = escapeAttribute(uploadedFile.path.replace(/^\/+/, '')); - const escapedFieldId = escapeAttribute(fieldId); - const escapedPropKey = escapeAttribute(propKey); - // Get current item index from data-index attribute for remove button - const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex; - imageContainer.innerHTML = ` - Logo - - `; - fileUploadContainer.appendChild(imageContainer); - - // Update the hidden input with the new file data - updateArrayObjectData(fieldId); - - showNotification('Logo uploaded successfully', 'success'); - } else { - showNotification(`Upload failed: ${data.message || 'Unknown error'}`, 'error'); - } - } catch (error) { - console.error('Upload error:', error); - showNotification(`Upload error: ${error.message}`, 'error'); - } - - // Clear file input - event.target.value = ''; -}; - -window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) { - const itemId = `${fieldId}_item_${itemIndex}`; - const fileUploadContainer = document.querySelector(`#${itemId} .file-upload-widget-inline`); - if (!fileUploadContainer) { - console.error('File upload container not found'); - return; - } - - // Remove file data from data attribute - fileUploadContainer.removeAttribute('data-file-data'); - - // Remove the image display - const imageContainer = fileUploadContainer.querySelector('.uploaded-image-container'); - if (imageContainer) { - imageContainer.remove(); - } - - // Update the hidden input to remove the file data - updateArrayObjectData(fieldId); - - showNotification('Logo removed', 'success'); -}; - // Generic Plugin Action Handler window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) { debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam); @@ -2770,9 +2263,12 @@ function handleUninstallSuccess(pluginId) { renderInstalledPlugins(updatedPlugins); showNotification(`Plugin uninstalled successfully`, 'success'); - // Also refresh from server to ensure consistency + // Also refresh from server to ensure consistency. The uninstall went + // through a plain fetch, so both list caches still hold the plugin: + // refreshInstalledPlugins() bypasses them. + pluginLoadCache.invalidate(); setTimeout(() => { - loadInstalledPlugins(); + refreshInstalledPlugins(); }, 1000); } @@ -3978,10 +3474,6 @@ window.dismissGithubWarning = function() { } -// Used by the array-of-objects helpers below, which live outside this IIFE. -window.renderArrayObjectItem = renderArrayObjectItem; -window.getSchemaProperty = getSchemaProperty; - // Assigned here because the functions are IIFE-local; the load-time and // htmx:afterSettle wiring below, outside the IIFE, reaches them through window. window.attachInstallButtonHandler = attachInstallButtonHandler; @@ -3989,134 +3481,6 @@ window.setupGitHubInstallHandlers = setupGitHubInstallHandlers; })(); // End IIFE -// Array-of-objects config fields: add and remove items (the inline onclick -// handlers that renderArrayObjectItem writes call these). -if (typeof window !== 'undefined') { - window.addArrayObjectItem = function(fieldId, fullKey, maxItems) { - const itemsContainer = document.getElementById(fieldId + '_items'); - const hiddenInput = document.getElementById(fieldId + '_data'); - if (!itemsContainer || !hiddenInput) return; - - const currentItems = itemsContainer.querySelectorAll('.array-object-item'); - if (currentItems.length >= maxItems) { - alert(`Maximum ${maxItems} items allowed`); - return; - } - - // Get schema for item properties - ensure currentPluginConfig is available - // Try window.currentPluginConfig first (most reliable), then currentPluginConfig - const schema = (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema) || - (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema); - if (!schema) { - console.error('addArrayObjectItem: Schema not available. currentPluginConfig may not be set.'); - return; - } - - // Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds) - const arraySchema = window.getSchemaProperty(schema, fullKey); - if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) { - return; - } - - const itemsSchema = arraySchema.items; - if (!itemsSchema || !itemsSchema.properties) return; - - const newIndex = currentItems.length; - // renderArrayObjectItem is exported by the plugin-manager IIFE above. - const itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema); - itemsContainer.insertAdjacentHTML('beforeend', itemHtml); - window.updateArrayObjectData(fieldId); - - // Update add button state - const addButton = itemsContainer.nextElementSibling; - if (addButton && currentItems.length + 1 >= maxItems) { - addButton.disabled = true; - addButton.style.opacity = '0.5'; - addButton.style.cursor = 'not-allowed'; - } - }; - - window.removeArrayObjectItem = function(fieldId, index) { - const itemsContainer = document.getElementById(fieldId + '_items'); - if (!itemsContainer) return; - - const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`); - if (item) { - item.remove(); - // Re-index remaining items - // Use data-index for index storage - no need to encode index in onclick strings or IDs - const remainingItems = itemsContainer.querySelectorAll('.array-object-item'); - remainingItems.forEach((itemEl, newIndex) => { - itemEl.setAttribute('data-index', newIndex); - // Update all inputs within this item - only update index in array bracket notation - itemEl.querySelectorAll('input, select, textarea').forEach(input => { - const name = input.getAttribute('name'); - const id = input.id; - if (name) { - // Only replace index in bracket notation like [0], [1], etc. - // Match pattern: field_name[index] but not field_name123 - const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`); - input.setAttribute('name', newName); - } - if (id) { - // Only update index in specific patterns like _item_0, _item_1 - // Match pattern: _item_ but be careful not to break other numeric IDs - const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`); - input.id = newId; - } - }); - // The buttons' inline onclick handlers carry the item index, so - // rewrite it in each one. - itemEl.querySelectorAll('button[onclick]').forEach(button => { - const onclick = button.getAttribute('onclick'); - if (onclick) { - // Match patterns like: - // removeArrayObjectItem('fieldId', 0) - // handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId') - // removeArrayObjectFile('fieldId', 0, 'propKey') - // Only replace the numeric index parameter (second or third argument depending on function) - let newOnclick = onclick; - // For removeArrayObjectItem('fieldId', index) - second param - newOnclick = newOnclick.replace( - /removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g, - `removeArrayObjectItem('$1', ${newIndex})` - ); - // For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param - newOnclick = newOnclick.replace( - /handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g, - `handleArrayObjectFileUpload(event, '$1', ${newIndex},` - ); - // For removeArrayObjectFile('fieldId', index, ...) - second param - newOnclick = newOnclick.replace( - /removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g, - `removeArrayObjectFile('$1', ${newIndex},` - ); - button.setAttribute('onclick', newOnclick); - } - }); - }); - window.updateArrayObjectData(fieldId); - - // Update add button state - const addButton = itemsContainer.nextElementSibling; - if (addButton && addButton.getAttribute('onclick')) { - // Extract maxItems from onclick attribute more safely - // Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems) - const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/); - if (onclickMatch && onclickMatch[1]) { - const maxItems = parseInt(onclickMatch[1]); - if (remainingItems.length < maxItems) { - addButton.disabled = false; - addButton.style.opacity = '1'; - addButton.style.cursor = 'pointer'; - } - } - } - } - }; - -} - // Force initialization immediately when script loads (for HTMX swapped content) debugLog('Plugins script loaded, checking for elements...'); diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index c1accfa1..68cf3658 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -911,7 +911,7 @@ LEDDialog, the widgets and app-shell.js's updatePlugin at load time or when a card is clicked, so it must follow all of them. No other script defines the same globals. --> - +