From d2b49c4ff2783a92d437fbc5858c981b85437696 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Thu, 16 Jul 2026 09:02:16 -0400 Subject: [PATCH 01/37] chore(web): remove dead legacy client-side plugin-config generator (~2,300 lines) Plugin config forms have been rendered server-side (plugin_config.html via GET /partials/plugin-config/) since the HTMX migration; the old client-side generator survived as unreachable code. Verified dead by call graph, not by naming: showPluginConfigModal and showGithubTokenInstructions have zero callers anywhere in templates or JS, and everything removed here is reachable only from those two roots. Removed: - plugins_manager.js: showPluginConfigModal, generatePluginConfigForm, generateFormFromSchema, generateFieldHtml, generateSimpleConfigForm, handlePluginConfigSubmit, the modal's JSON-editor view (initJsonEditor, switchPluginConfigView, syncFormToJson/JsonToForm, saveConfigFromJsonEditor, resetPluginConfigToDefaults, displayValidationErrors, closePluginConfigModal, savePluginConfiguration, currentPluginConfigState), their exclusive helpers (getSchemaPropertyType, escapeCssSelector, dotToNested, collectBooleanFields, normalizeFormDataForConfig, flattenConfig, loadCustomHtmlWidget), the orphaned-modal cleanup block, the modal's listener wiring, and the never-invoked showGithubTokenInstructions/closeInstructionsModal pair. - plugins.html: the #plugin-config-modal markup those functions drove. - base.html: the deprecated pluginConfigData() component and the window.PluginConfigHelpers shim (only ever called by pluginConfigData). Deliberately kept, verified still live: - renderArrayObjectItem, getSchemaProperty, escapeHtml/escapeAttribute (window-exposed for the top-level array-of-objects handlers the server-rendered form uses), toggleNestedSection, addKeyValuePair/ addArrayObjectItem families, executePluginAction, and window.currentPluginConfig = null init (file-upload.js and executePluginAction read it, optional-chained). - app()'s internal generateConfigForm/generateSimpleConfigForm methods in base.html: unreachable now but embedded in the live Alpine component; excising methods from a live object is deferred to keep this change zero-risk. Validation: every deletion seam inspected line-by-line; Jinja parse of both templates passes; repo-wide sweep confirms zero remaining references to any deleted function or element id (deleted ranges contained no Jinja tags). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- web_interface/static/v3/plugins_manager.js | 1956 +---------------- web_interface/templates/v3/base.html | 338 --- .../templates/v3/partials/plugins.html | 59 - 3 files changed, 32 insertions(+), 2321 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 5c8836017..961f6598d 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -391,22 +391,6 @@ window.togglePlugin = window.togglePlugin || function(pluginId, enabled) { }); }; -// Cleanup orphaned modals from previous executions to prevent duplicates when moving to body -try { - const existingModals = document.querySelectorAll('#plugin-config-modal'); - if (existingModals.length > 0) { - existingModals.forEach(el => { - // Only remove modals that were moved to body (orphaned from previous loads) - // The new modal in the current content should be inside a container, not direct body child - if (el.parentElement === document.body) { - console.log('[PLUGINS SCRIPT] Cleaning up orphaned plugin modal'); - el.remove(); - } - }); - } -} catch (e) { - console.warn('[PLUGINS SCRIPT] Error cleaning up modals:', e); -} // Track pending render data for when DOM isn't ready yet window.__pendingInstalledPlugins = window.__pendingInstalledPlugins || null; @@ -1064,7 +1048,6 @@ window.initPluginsPage = function() { const refreshBtn = document.getElementById('refresh-plugins-btn'); const updateAllBtn = document.getElementById('update-all-plugins-btn'); const restartBtn = document.getElementById('restart-display-btn'); - const closeBtn = document.getElementById('close-plugin-config'); const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal'); const cancelOnDemandBtn = document.getElementById('cancel-on-demand'); const onDemandForm = document.getElementById('on-demand-form'); @@ -1089,20 +1072,6 @@ window.initPluginsPage = function() { if (storePpEl) storePpEl.value = storeFilterState.perPage; setupStoreFilterListeners(); - if (closeBtn) { - closeBtn.replaceWith(closeBtn.cloneNode(true)); - document.getElementById('close-plugin-config').addEventListener('click', closePluginConfigModal); - - // View toggle buttons - document.getElementById('view-toggle-form')?.addEventListener('click', () => switchPluginConfigView('form')); - document.getElementById('view-toggle-json')?.addEventListener('click', () => switchPluginConfigView('json')); - - // Reset to defaults button - document.getElementById('reset-to-defaults-btn')?.addEventListener('click', resetPluginConfigToDefaults); - - // JSON editor save button - document.getElementById('save-json-config-btn')?.addEventListener('click', saveConfigFromJsonEditor); - } if (closeOnDemandModalBtn) { closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true)); document.getElementById('close-on-demand-modal').addEventListener('click', closeOnDemandModal); @@ -2165,136 +2134,6 @@ function closeOnDemandModalOnBackdrop(event) { // configurePlugin is already defined at the top of the script - no need to redefine -window.showPluginConfigModal = function(pluginId, config) { - const modal = document.getElementById('plugin-config-modal'); - const title = document.getElementById('plugin-config-title'); - const content = document.getElementById('plugin-config-content'); - - if (!modal) { - console.error('[DEBUG] Plugin config modal element not found'); - if (typeof showError === 'function') { - showError('Plugin configuration modal not found. Please refresh the page.'); - } else if (typeof showNotification === 'function') { - showNotification('Plugin configuration modal not found. Please refresh the page.', 'error'); - } - return; - } - - console.log('[DEBUG] ===== Opening plugin config modal ====='); - console.log('[DEBUG] Plugin ID:', pluginId); - console.log('[DEBUG] Config:', config); - - // Check if modal elements exist (already checked above, but double-check for safety) - if (!title) { - console.error('[DEBUG] Plugin config title element not found'); - if (typeof showError === 'function') { - showError('Plugin configuration title element not found.'); - } else if (typeof showNotification === 'function') { - showNotification('Plugin configuration title element not found.', 'error'); - } - return; - } - - if (!content) { - console.error('[DEBUG] Plugin config content element not found'); - if (typeof showError === 'function') { - showError('Plugin configuration content element not found.'); - } else if (typeof showNotification === 'function') { - showNotification('Plugin configuration content element not found.', 'error'); - } - return; - } - - // Initialize state - currentPluginConfigState.pluginId = pluginId; - currentPluginConfigState.config = config || {}; - currentPluginConfigState.jsonEditor = null; - - // Reset view to form - switchPluginConfigView('form'); - - // Hide validation errors - displayValidationErrors([]); - - title.textContent = `Configure ${pluginId}`; - - // Show loading state while form is generated - content.innerHTML = '
'; - - // Move modal to body to avoid z-index/overflow issues - if (modal.parentElement !== document.body) { - document.body.appendChild(modal); - } - - // Remove any inline display:none that might be in the HTML FIRST - // This is critical because the HTML template has style="display: none;" inline - // We need to remove it before setting new styles - let currentStyle = modal.getAttribute('style') || ''; - if (currentStyle.includes('display: none') || currentStyle.includes('display:none')) { - currentStyle = currentStyle.replace(/display:\s*none[;]?/gi, '').trim(); - // Clean up any double semicolons or trailing semicolons - currentStyle = currentStyle.replace(/;;+/g, ';').replace(/^;|;$/g, ''); - if (currentStyle) { - modal.setAttribute('style', currentStyle); - } else { - modal.removeAttribute('style'); - } - } - - // Show modal immediately - use important to override any other styles - // Also ensure visibility, opacity, and z-index are set correctly - modal.style.setProperty('display', 'flex', 'important'); - modal.style.setProperty('visibility', 'visible', 'important'); - modal.style.setProperty('opacity', '1', 'important'); - modal.style.setProperty('z-index', '9999', 'important'); - modal.style.setProperty('position', 'fixed', 'important'); - - // Ensure modal content is also visible - const modalContent = modal.querySelector('.modal-content'); - if (modalContent) { - modalContent.style.setProperty('display', 'block', 'important'); - modalContent.style.setProperty('visibility', 'visible', 'important'); - modalContent.style.setProperty('opacity', '1', 'important'); - } - - console.log('[DEBUG] Modal display set to flex'); - console.log('[DEBUG] Modal computed style:', window.getComputedStyle(modal).display); - console.log('[DEBUG] Modal z-index:', window.getComputedStyle(modal).zIndex); - console.log('[DEBUG] Modal visibility:', window.getComputedStyle(modal).visibility); - console.log('[DEBUG] Modal opacity:', window.getComputedStyle(modal).opacity); - console.log('[DEBUG] Modal in DOM:', document.body.contains(modal)); - console.log('[DEBUG] Modal parent:', modal.parentElement?.tagName); - console.log('[DEBUG] Modal rect:', modal.getBoundingClientRect()); - - // Load schema for validation - fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`) - .then(r => r.json()) - .then(schemaData => { - if (schemaData.status === 'success' && schemaData.data?.schema) { - currentPluginConfigState.schema = schemaData.data.schema; - } - }) - .catch(err => console.warn('Could not load schema:', err)); - - // Generate form asynchronously - generatePluginConfigForm(pluginId, config) - .then(formHtml => { - console.log('[DEBUG] Form generated, setting content. HTML length:', formHtml.length); - content.innerHTML = formHtml; - - // Attach form submit handler after form is inserted - const form = document.getElementById('plugin-config-form'); - if (form) { - form.addEventListener('submit', handlePluginConfigSubmit); - console.log('Form submit handler attached'); - } - - }) - .catch(error => { - console.error('Error generating config form:', error); - content.innerHTML = '

Error loading configuration form

'; - }); -} // Helper function to get the full property object from schema // Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1") @@ -2333,449 +2172,6 @@ function getSchemaProperty(schema, path) { return null; } -// Helper function to find property type in nested schema using dot notation -function getSchemaPropertyType(schema, path) { - const prop = getSchemaProperty(schema, path); - return prop; // Return the full property object (was returning just type, but callers expect object) -} - -// Helper function to escape CSS selector special characters -function escapeCssSelector(str) { - if (typeof str !== 'string') { - str = String(str); - } - // Use CSS.escape() when available (handles unicode, leading digits, and edge cases) - if (typeof CSS !== 'undefined' && CSS.escape) { - return CSS.escape(str); - } - // Fallback to regex-based escaping for older browsers - return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); -} - -// Helper function to convert dot notation to nested object -// Uses schema-aware greedy matching to preserve dotted keys (e.g., "eng.1") -function dotToNested(obj, schema) { - const result = {}; - - for (const key in obj) { - const parts = key.split('.'); - let current = result; - let currentSchema = (schema && schema.properties) ? schema.properties : null; - let i = 0; - - while (i < parts.length - 1) { - let matched = false; - if (currentSchema) { - // First, check if the full remaining tail is a leaf property - // (e.g., "eng.1" as a complete dotted key with no sub-properties) - const tailCandidate = parts.slice(i).join('.'); - if (tailCandidate in currentSchema) { - current[tailCandidate] = obj[key]; - matched = true; - i = parts.length; // consumed all parts - break; - } - // Try progressively longer candidates (longest first) to greedily - // match dotted property names like "eng.1" - for (let j = parts.length - 1; j > i; j--) { - const candidate = parts.slice(i, j).join('.'); - if (candidate in currentSchema) { - if (!current[candidate]) { - current[candidate] = {}; - } - current = current[candidate]; - const schemaProp = currentSchema[candidate]; - currentSchema = (schemaProp && schemaProp.properties) ? schemaProp.properties : null; - i = j; - matched = true; - break; - } - } - } - if (!matched) { - // No schema match or no schema — use single segment - const part = parts[i]; - if (!current[part]) { - current[part] = {}; - } - current = current[part]; - if (currentSchema) { - const schemaProp = currentSchema[part]; - currentSchema = (schemaProp && schemaProp.properties) ? schemaProp.properties : null; - } else { - currentSchema = null; - } - i++; - } - } - - // Set the final key (remaining parts joined — may itself be dotted) - // Skip if tail-matching already consumed all parts and wrote the value - if (i < parts.length) { - const finalKey = parts.slice(i).join('.'); - current[finalKey] = obj[key]; - } - } - - return result; -} - -// Helper function to collect all boolean fields from schema (including nested) -function collectBooleanFields(schema, prefix = '') { - const boolFields = []; - - if (!schema || !schema.properties) return boolFields; - - Object.entries(schema.properties).forEach(([key, prop]) => { - const fullKey = prefix ? `${prefix}.${key}` : key; - - if (prop.type === 'boolean') { - boolFields.push(fullKey); - } else if (prop.type === 'object' && prop.properties) { - boolFields.push(...collectBooleanFields(prop, fullKey)); - } - }); - - return boolFields; -} - -/** - * Normalize FormData from a plugin config form into a nested config object. - * Handles _data JSON inputs, bracket-notation checkboxes, array-of-objects, - * file-upload widgets, proper checkbox DOM detection, unchecked boolean - * handling, and schema-aware dotted-key nesting. - * - * @param {HTMLFormElement} form - The form element (needed for checkbox DOM detection) - * @param {Object|null} schema - The plugin's JSON Schema - * @returns {Object} Nested config object ready for saving - */ -function normalizeFormDataForConfig(form, schema) { - const formData = new FormData(form); - const flatConfig = {}; - - for (const [key, value] of formData.entries()) { - // Check if this is a patternProperties or array-of-objects hidden input (contains JSON data) - // Only match keys ending with '_data' to avoid false positives like 'meta_data_field' - if (key.endsWith('_data')) { - try { - const baseKey = key.replace(/_data$/, ''); - const jsonValue = JSON.parse(value); - // Handle both objects (patternProperties) and arrays (array-of-objects) - // Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript) - if (jsonValue !== null && typeof jsonValue === 'object') { - flatConfig[baseKey] = jsonValue; - continue; // Skip normal processing for JSON data fields - } - } catch (e) { - // Not valid JSON, continue with normal processing - } - } - - // Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input) - // Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data - if (key.endsWith('[]')) { - continue; - } - - // Skip key_value pair inputs (they're handled by the hidden _data input) - if (key.includes('[key_') || key.includes('[value_')) { - continue; - } - - // Skip array-of-objects per-item inputs (they're handled by the hidden _data input) - // Pattern: feeds_item_0_name, feeds_item_1_url, etc. - if (key.includes('_item_') && /_item_\d+_/.test(key)) { - continue; - } - - // Try to get schema property - handle both dot notation and underscore notation - let propSchema = getSchemaPropertyType(schema, key); - let actualKey = key; - let actualValue = value; - - // If not found with dots, try converting underscores to dots (for nested fields) - if (!propSchema && key.includes('_')) { - const dotKey = key.replace(/_/g, '.'); - propSchema = getSchemaPropertyType(schema, dotKey); - if (propSchema) { - // Use the dot notation key for consistency - actualKey = dotKey; - actualValue = value; - } - } - - if (propSchema) { - const propType = propSchema.type; - - if (propType === 'array') { - // Check if this is a file upload widget (JSON array) - if (propSchema['x-widget'] === 'file-upload') { - // Try to parse as JSON first (for file uploads) - try { - // Handle HTML entity encoding (from hidden input) - let decodedValue = actualValue; - if (typeof actualValue === 'string') { - // Decode HTML entities if present - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = actualValue; - decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue; - } - - const jsonValue = JSON.parse(decodedValue); - if (Array.isArray(jsonValue)) { - flatConfig[actualKey] = jsonValue; - } else { - // Fallback to comma-separated - const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : []; - flatConfig[actualKey] = arrayValue; - } - } catch (e) { - // Not JSON, use comma-separated - const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : []; - flatConfig[actualKey] = arrayValue; - } - } else { - // Regular array: convert comma-separated string to array - const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : []; - flatConfig[actualKey] = arrayValue; - } - } else if (propType === 'integer') { - flatConfig[actualKey] = parseInt(actualValue, 10); - } else if (propType === 'number') { - flatConfig[actualKey] = parseFloat(actualValue); - } else if (propType === 'boolean') { - // Use querySelector to reliably find checkbox by name attribute - // Escape special CSS selector characters in the name - const escapedKey = escapeCssSelector(key); - const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); - - if (formElement) { - // Element found - use its checked state - flatConfig[actualKey] = formElement.checked; - } else { - // Element not found - normalize string booleans and check FormData value - // Checkboxes send "on" when checked, nothing when unchecked - if (typeof actualValue === 'string') { - const lowerValue = actualValue.toLowerCase().trim(); - if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { - flatConfig[actualKey] = true; - } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { - flatConfig[actualKey] = false; - } else { - flatConfig[actualKey] = true; - } - } else if (actualValue === undefined || actualValue === null) { - flatConfig[actualKey] = false; - } else { - flatConfig[actualKey] = Boolean(actualValue); - } - } - } else { - flatConfig[actualKey] = actualValue; - } - } else { - // No schema, try to infer type - // Check if value looks like a JSON string (starts with [ or {) - if (typeof actualValue === 'string' && (actualValue.trim().startsWith('[') || actualValue.trim().startsWith('{'))) { - try { - // Handle HTML entity encoding - let decodedValue = actualValue; - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = actualValue; - decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue; - - const parsed = JSON.parse(decodedValue); - flatConfig[actualKey] = parsed; - } catch (e) { - // Not valid JSON, save as string - flatConfig[actualKey] = actualValue; - } - } else { - // No schema - try to detect checkbox by finding the element - const escapedKey = escapeCssSelector(key); - const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); - - if (formElement && formElement.type === 'checkbox') { - flatConfig[actualKey] = formElement.checked; - } else { - if (typeof actualValue === 'string') { - const lowerValue = actualValue.toLowerCase().trim(); - if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { - flatConfig[actualKey] = true; - } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { - flatConfig[actualKey] = false; - } else { - flatConfig[actualKey] = actualValue; - } - } else { - flatConfig[actualKey] = actualValue; - } - } - } - } - } - - // Handle unchecked checkboxes (not in FormData) - including nested ones - if (schema && schema.properties) { - const allBoolFields = collectBooleanFields(schema); - allBoolFields.forEach(key => { - if (!(key in flatConfig)) { - flatConfig[key] = false; - } - }); - } - - // Convert dot notation to nested object - return dotToNested(flatConfig, schema); -} - -function handlePluginConfigSubmit(e) { - e.preventDefault(); - console.log('Form submitted'); - - if (!currentPluginConfig) { - showNotification('Plugin configuration not loaded', 'error'); - return; - } - - const pluginId = currentPluginConfig.pluginId; - const schema = currentPluginConfig.schema; - const form = e.target; - - // Fix invalid hidden fields before submission - // This prevents "invalid form control is not focusable" errors - const allInputs = form.querySelectorAll('input[type="number"]'); - allInputs.forEach(input => { - const min = parseFloat(input.getAttribute('min')); - const max = parseFloat(input.getAttribute('max')); - const value = parseFloat(input.value); - - if (!isNaN(value)) { - if (!isNaN(min) && value < min) { - input.value = min; - } else if (!isNaN(max) && value > max) { - input.value = max; - } - } - }); - - const config = normalizeFormDataForConfig(form, schema); - - console.log('Nested config to save:', config); - - // Save the configuration - fetch('/api/v3/plugins/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - plugin_id: pluginId, - config: config - }) - }) - .then(response => response.json()) - .then(data => { - if (data.status === 'success') { - // Hide validation errors on success - displayValidationErrors([]); - showNotification('Configuration saved successfully', 'success'); - closePluginConfigModal(); - loadInstalledPlugins(); // Refresh to show updated config - } else { - // Display validation errors if present - if (data.validation_errors && Array.isArray(data.validation_errors)) { - displayValidationErrors(data.validation_errors); - } - showNotification('Error saving configuration: ' + data.message, 'error'); - } - }) - .catch(error => { - console.error('Error saving plugin config:', error); - showNotification('Error saving configuration: ' + error.message, 'error'); - }); -} - -function generatePluginConfigForm(pluginId, config) { - console.log('[DEBUG] ===== Generating plugin config form ====='); - console.log('[DEBUG] Plugin ID:', pluginId); - // Load plugin schema and actions for dynamic form generation - const installedPluginsPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ? - window.PluginAPI.getInstalledPlugins().then(plugins => ({ status: 'success', data: { plugins: plugins } })) : - fetch(`/api/v3/plugins/installed`).then(r => r.json()); - - return Promise.all([ - fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()), - installedPluginsPromise - ]) - .then(([schemaData, pluginsData]) => { - console.log('[DEBUG] Schema data received:', schemaData.status); - - // Get plugin info including web_ui_actions - let pluginInfo = null; - if (pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) { - pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId); - console.log('[DEBUG] Plugin info found:', pluginInfo ? 'yes' : 'no'); - if (pluginInfo) { - console.log('[DEBUG] Plugin info keys:', Object.keys(pluginInfo)); - console.log('[DEBUG] web_ui_actions in pluginInfo:', 'web_ui_actions' in pluginInfo); - console.log('[DEBUG] web_ui_actions value:', pluginInfo.web_ui_actions); - } - } else { - console.log('[DEBUG] pluginsData status:', pluginsData.status); - } - const webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : []; - console.log('[DEBUG] Final webUiActions:', webUiActions, 'length:', webUiActions.length); - - if (schemaData.status === 'success' && schemaData.data.schema) { - console.log('[DEBUG] Schema has properties:', Object.keys(schemaData.data.schema.properties || {})); - // Store plugin ID, schema, and actions for form submission - currentPluginConfig = { - pluginId: pluginId, - schema: schemaData.data.schema, - webUiActions: webUiActions - }; - // Also assign to window for global access in template interpolations - window.currentPluginConfig = currentPluginConfig; - // Also update state - currentPluginConfigState.schema = schemaData.data.schema; - console.log('[DEBUG] Calling generateFormFromSchema...'); - return generateFormFromSchema(schemaData.data.schema, config, webUiActions); - } else { - // Fallback to simple form if no schema - currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: webUiActions }; - // Also assign to window for global access in template interpolations - window.currentPluginConfig = currentPluginConfig; - return generateSimpleConfigForm(config, webUiActions); - } - }) - .catch(error => { - console.error('Error loading schema:', error); - currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: [] }; - // Also assign to window for global access in template interpolations - window.currentPluginConfig = currentPluginConfig; - return generateSimpleConfigForm(config, []); - }); -} - -// Helper to flatten nested config for form display (converts {nfl: {enabled: true}} to {'nfl.enabled': true}) -function flattenConfig(obj, prefix = '') { - let result = {}; - - for (const key in obj) { - const value = obj[key]; - const fullKey = prefix ? `${prefix}.${key}` : key; - - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - // Recursively flatten nested objects - Object.assign(result, flattenConfig(value, fullKey)); - } else { - result[fullKey] = value; - } - } - - return result; -} - -// Generate field HTML for a single property (used recursively) // Helper function to render a single item in an array of objects function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) { const item = itemValue || {}; @@ -2850,867 +2246,52 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde 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; -} - -function generateFieldHtml(key, prop, value, prefix = '') { - const fullKey = prefix ? `${prefix}.${key}` : key; - const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - const description = prop.description || ''; - let html = ''; - - // Debug logging for categories field - if (key === 'categories') { - console.log(`[DEBUG] Processing categories field:`, { - type: prop.type, - hasAdditionalProperties: !!(prop.additionalProperties), - additionalPropertiesType: prop.additionalProperties?.type, - hasProperties: !!(prop.properties), - allKeys: Object.keys(prop) - }); - } - - // Handle patternProperties objects (dynamic key-value pairs like custom_feeds, feed_logo_map) - if (prop.type === 'object' && prop.patternProperties && !prop.properties) { - const fieldId = fullKey.replace(/\./g, '_'); - const currentValue = value || {}; - const patternProp = Object.values(prop.patternProperties)[0]; // Get the pattern property schema - const valueType = patternProp.type || 'string'; - const maxProperties = prop.maxProperties || 50; - const entries = Object.entries(currentValue); - - html += ` -
-
-

${description || 'Add key-value pairs'}

-
- `; - - // Render existing pairs - entries.forEach(([pairKey, pairValue], index) => { - html += ` -
- - - -
- `; - }); - - html += ` -
- - -
-
- `; - - return html; - } - - // Handle objects with additionalProperties (dynamic keys with object values, like categories) - // Must have additionalProperties, no top-level properties, and additionalProperties must be an object type - const hasAdditionalProperties = prop.type === 'object' && - (prop.properties === undefined || prop.properties === null) && // Explicitly exclude objects with properties (those use nested handler) - prop.additionalProperties && - typeof prop.additionalProperties === 'object' && - prop.additionalProperties !== null && - prop.additionalProperties.type === 'object' && - !prop.patternProperties; // Also exclude patternProperties objects - - // Debug logging for categories field specifically - if (key === 'categories') { - console.log(`[DEBUG] Categories field check:`, { - type: prop.type, - hasProperties: !!prop.properties, - hasAdditionalProperties: !!prop.additionalProperties, - additionalPropertiesType: prop.additionalProperties?.type, - additionalPropertiesIsObject: typeof prop.additionalProperties === 'object', - matchesCondition: hasAdditionalProperties, - allPropKeys: Object.keys(prop) - }); - } - - if (hasAdditionalProperties) { - const fieldId = fullKey.replace(/\./g, '_'); - const currentValue = value || {}; - const categorySchema = prop.additionalProperties; - const entries = Object.entries(currentValue); - - console.log(`[DEBUG] Rendering additionalProperties object for ${fullKey}:`, { - entries: entries.length, - keys: Object.keys(currentValue) - }); - - html += ` -
-
-

${label}

- ${description ? `

${description}

` : ''} -
- `; - - // Render each category - entries.forEach(([categoryKey, categoryValue]) => { - const categoryId = `${fieldId}_${categoryKey}`; - // Ensure categoryValue is an object - const catValue = typeof categoryValue === 'object' && categoryValue !== null ? categoryValue : {}; - const enabled = catValue.enabled !== undefined ? catValue.enabled : (categorySchema.properties?.enabled?.default !== undefined ? categorySchema.properties.enabled.default : true); - // Safely extract string values, ensuring they're strings - const dataFile = (typeof catValue.data_file === 'string' ? catValue.data_file : '') || ''; - const displayName = (typeof catValue.display_name === 'string' ? catValue.display_name : '') || categoryKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - - html += ` -
-
-
- -
- ${escapeHtml(categoryKey)} -
-
-
- - -
-
- - -
-
-
- `; - }); - - if (entries.length === 0) { - html += ` -
- - No categories configured. Use the File Manager below to add JSON files. -
- `; - } - - html += ` -
-
-
- `; - - return html; - } - - // Handle nested objects with known properties - if (prop.type === 'object' && prop.properties) { - const sectionId = `section-${fullKey.replace(/\./g, '-')}`; - const nestedConfig = value || {}; - const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - // Calculate nesting depth for better spacing - const nestingDepth = (fullKey.match(/\./g) || []).length; - const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4'; - - html += ` -
- - -
- `; - - // Add extra spacing after nested sections to prevent overlap with next section - html += `
`; - - return html; - } - - // Regular (non-nested) field - html += ` -
- - `; - - if (description) { - html += `

${description}

`; - } - - // Generate appropriate input based on type - if (prop.type === 'boolean') { - html += ` - - `; - } else if (prop.type === 'number' || prop.type === 'integer') { - const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : ''; - const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : ''; - const step = prop.type === 'integer' ? 'step="1"' : 'step="any"'; - - // Ensure value respects min/max constraints - let fieldValue = value !== undefined ? value : (prop.default !== undefined ? prop.default : ''); - if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) { - const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue; - if (!isNaN(numValue)) { - // Clamp value to min/max if constraints exist - if (prop.minimum !== undefined && numValue < prop.minimum) { - fieldValue = prop.minimum; - } else if (prop.maximum !== undefined && numValue > prop.maximum) { - fieldValue = prop.maximum; - } else { - fieldValue = numValue; - } - } - } - - // If still empty and we have a default, use it - if (fieldValue === '' && prop.default !== undefined) { - fieldValue = prop.default; - } - - html += ` - - `; - } else if (prop.type === 'array') { - // Check if this is an array of objects FIRST (before other checks) - if (prop.items && prop.items.type === 'object' && prop.items.properties) { - // Array of objects widget (like custom_feeds with name, url, enabled, logo) - console.log(`[DEBUG] ✅ Detected array-of-objects widget for ${fullKey}`); - const fieldId = fullKey.replace(/\./g, '_'); - const itemsSchema = prop.items; - const itemProperties = itemsSchema.properties || {}; - const maxItems = prop.maxItems || 50; - const currentItems = Array.isArray(value) ? value : []; - - html += ` -
-
- `; - - // Render existing items - currentItems.forEach((item, index) => { - html += renderArrayObjectItem(fieldId, fullKey, itemProperties, item, index, itemsSchema); - }); - - html += ` -
- - -
- `; - } else { - // Array - check for file upload widget first (to avoid breaking static-image plugin), - // then checkbox-group, then custom-feeds - const hasXWidget = prop.hasOwnProperty('x-widget'); - const xWidgetValue = prop['x-widget']; - const xWidgetValue2 = prop['x-widget'] || prop['x_widget'] || prop.xWidget; - - console.log(`[DEBUG] Array field ${fullKey}:`, { - type: prop.type, - hasItems: !!prop.items, - itemsType: prop.items?.type, - itemsHasProperties: !!prop.items?.properties, - hasXWidget: hasXWidget, - 'x-widget': xWidgetValue, - 'x-widget (alt)': xWidgetValue2, - 'x-upload-config': prop['x-upload-config'], - propKeys: Object.keys(prop), - value: value - }); - - // Check for file-upload widget FIRST (to avoid breaking static-image plugin) - if (xWidgetValue === 'file-upload' || xWidgetValue2 === 'file-upload') { - console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`); - const uploadConfig = prop['x-upload-config'] || {}; - const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image'; - const maxFiles = uploadConfig.max_files || 10; - const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json' - const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']); - const maxSizeMB = uploadConfig.max_size_mb || 5; - const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified - const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified - - const currentFiles = Array.isArray(value) ? value : []; - const fieldId = fullKey.replace(/\./g, '_'); - - html += ` -
- -
- - -

Drag and drop ${fileType === 'json' ? 'JSON files' : 'images'} here or click to browse

-

Max ${maxFiles} files, ${maxSizeMB}MB each ${fileType === 'json' ? '(JSON)' : '(PNG, JPG, GIF, BMP)'}

-
- - -
- ${currentFiles.map((file, idx) => { - const fileId = file.id || file.category_name || idx; - const fileName = file.original_filename || file.filename || (fileType === 'json' ? 'JSON File' : 'Image'); - const entryCount = file.entry_count ? `${file.entry_count} entries` : ''; - - return ` -
-
-
- ${fileType === 'json' ? ` -
- -
- ` : ` - ${fileName} - - `} -
-

${escapeHtml(fileName)}

-

${formatFileSize(file.size || 0)} • ${formatDate(file.uploaded_at)}

- ${entryCount ? `

${entryCount}

` : ''} - ${fileType === 'image' && file.schedule ? ` -

- ${file.schedule.enabled && file.schedule.mode !== 'always' ? (window.getScheduleSummary ? window.getScheduleSummary(file.schedule) : 'Scheduled') : 'Always shown'} -

- ` : ''} -
-
-
- ${fileType === 'image' ? ` - - ` : ''} - -
-
- ${fileType === 'image' ? ` - - ` : ''} -
- `; - }).join('')} -
- - - -
- `; - } else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') { - // Checkbox group widget for multi-select arrays with enum items - // Use _data hidden input pattern to serialize selected values correctly - console.log(`[DEBUG] ✅ Detected checkbox-group widget for ${fullKey} - rendering checkboxes`); - const arrayValue = Array.isArray(value) ? value : (prop.default || []); - const enumItems = prop.items && prop.items.enum ? prop.items.enum : []; - const xOptions = prop['x-options'] || {}; - const labels = xOptions.labels || {}; - const fieldId = fullKey.replace(/\./g, '_'); - - html += `
`; - enumItems.forEach((option) => { - const isChecked = arrayValue.includes(option); - const label = labels[option] || option.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - const checkboxId = `${fieldId}_${escapeHtml(option)}`; - html += ` - - `; - }); - html += `
`; - // Hidden input to store selected values as JSON array (like array-of-objects pattern) - html += ``; - // Sentinel hidden input with bracket notation to allow clearing array to [] when all unchecked - // This ensures the field is always submitted, even when all checkboxes are unchecked - html += ``; - } else if (xWidgetValue === 'custom-feeds' || xWidgetValue2 === 'custom-feeds') { - // Custom feeds widget - check schema validation first - const itemsSchema = prop.items || {}; - const itemProperties = itemsSchema.properties || {}; - if (!itemProperties.name || !itemProperties.url) { - // Schema doesn't match expected structure - fallback to regular array input - console.log(`[DEBUG] ⚠️ Custom feeds widget requires 'name' and 'url' properties for ${fullKey}, using regular array input`); - let arrayValue = ''; - if (value === null || value === undefined) { - arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : ''; - } else if (Array.isArray(value)) { - arrayValue = value.join(', '); - } else { - arrayValue = ''; - } - html += ` - -

Enter values separated by commas

- `; - } else { - // Custom feeds table interface - widget-specific implementation - // Note: This is handled by the template, but we include it here for consistency - // The template renders the custom feeds table, so JS-rendered forms should match - console.log(`[DEBUG] ✅ Detected custom-feeds widget for ${fullKey} - note: custom feeds table is typically rendered server-side`); - let arrayValue = ''; - if (value === null || value === undefined) { - arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : ''; - } else if (Array.isArray(value)) { - arrayValue = value.join(', '); - } else { - arrayValue = ''; - } - html += ` - -

Enter values separated by commas (custom feeds table rendered server-side)

- `; - } - } else { - // Regular array input (comma-separated) - console.log(`[DEBUG] ❌ No special widget detected for ${fullKey}, using regular array input`); - // Handle null/undefined values - use default if available - let arrayValue = ''; - if (value === null || value === undefined) { - arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : ''; - } else if (Array.isArray(value)) { - arrayValue = value.join(', '); - } else { - arrayValue = ''; - } - html += ` - -

Enter values separated by commas

- `; - } - } - } else if (prop.enum) { - html += ``; - } else if (prop['x-widget'] === 'json-file-manager') { - // Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions) - const widgetConfig = prop['x-widget-config'] || {}; - const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || ''; - const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_'); - - html += `
`; - - setTimeout(() => { - const mount = document.getElementById(`${safeFieldId}_jfm_mount`); - if (!mount) return; - // Destroy the previous instance for this mount only — leave other instances intact - window.__jfmInstances = window.__jfmInstances || {}; - const prev = window.__jfmInstances[safeFieldId]; - if (prev?._destroy) prev._destroy(); - if (typeof JsonFileManager !== 'undefined') { - window.__jfmInstances[safeFieldId] = new JsonFileManager(mount, widgetConfig, pluginId); - } else { - window.__jfmInstances[safeFieldId] = null; - mount.innerHTML = '

json-file-manager widget not loaded. Check base.html includes json-file-manager.js.

'; - } - }, 150); - } else if (prop['x-widget'] === 'custom-html') { - // Custom HTML widget - load HTML from plugin directory - const htmlFile = prop['x-html-file']; - const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || ''; - const fieldId = fullKey.replace(/\./g, '_'); - - console.log(`[Custom HTML Widget] Generating widget for ${fullKey}:`, { - htmlFile, - pluginId, - fieldId, - hasPluginId: !!pluginId - }); - - if (htmlFile && pluginId) { - html += ` -
-
- -

Loading file manager...

-
-
+ id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}" + data-prop-key="${escapeAttribute(propKey)}" + class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + ${propValue ? 'checked' : ''} + onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')"> + ${escapeHtml(propLabel)} + `; - - // Load HTML asynchronously - setTimeout(() => { - loadCustomHtmlWidget(fieldId, pluginId, htmlFile); - }, 100); } else { - console.error(`[Custom HTML Widget] Missing configuration for ${fullKey}:`, { - htmlFile, - pluginId, - currentPluginConfig: currentPluginConfig?.pluginId, - windowPluginConfig: window.currentPluginConfig?.pluginId - }); + // Regular text/string input html += ` -
- - Custom HTML widget configuration error: missing html-file or plugin-id -
htmlFile: ${htmlFile || 'missing'}, pluginId: ${pluginId || 'missing'} -
+ `; - } - } else if (prop.type === 'object') { - // Fallback for objects that don't match any special case - render as JSON textarea - console.warn(`[DEBUG] Object field ${fullKey} doesn't match any special handler, rendering as JSON textarea`); - const jsonValue = typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || '{}'); - html += ` - -

Edit as JSON object

- `; - } else { - // Check if this is a secret field - const isSecret = prop['x-secret'] === true; - const inputType = isSecret ? 'password' : 'text'; - const maxLength = prop.maxLength || ''; - const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : ''; - const secretClass = isSecret ? 'pr-10' : ''; - - html += ` -
- - `; - - if (isSecret) { + if (propDescription) { + html += `

${escapeHtml(propDescription)}

`; + } + const placeholder = propSchema.format === 'uri' ? 'https://example.com/feed' : ''; html += ` - + `; } html += `
`; - } + }); - html += `
`; + // Use schema-driven label for remove button, fallback to generic "Remove item" + const removeLabel = itemsSchema['x-removeLabel'] || 'Remove item'; + html += ` + + `; return html; } -// Load custom HTML widget from plugin directory -async function loadCustomHtmlWidget(fieldId, pluginId, htmlFile) { - try { - const container = document.getElementById(`${fieldId}_custom_html`); - if (!container) { - console.warn(`[Custom HTML Widget] Container not found: ${fieldId}_custom_html`); - return; - } - - // Fetch HTML from plugin static files endpoint - const response = await fetch(`/api/v3/plugins/${pluginId}/static/${htmlFile}`); - - if (!response.ok) { - throw new Error(`Failed to load custom HTML: ${response.statusText}`); - } - - const html = await response.text(); - - // Inject HTML into container - container.innerHTML = html; - - // Execute any script tags in the loaded HTML - const scripts = container.querySelectorAll('script'); - scripts.forEach(oldScript => { - const newScript = document.createElement('script'); - Array.from(oldScript.attributes).forEach(attr => { - newScript.setAttribute(attr.name, attr.value); - }); - newScript.appendChild(document.createTextNode(oldScript.innerHTML)); - oldScript.parentNode.replaceChild(newScript, oldScript); - }); - - console.log(`[Custom HTML Widget] Loaded ${htmlFile} for plugin ${pluginId}`); - } catch (error) { - console.error(`[Custom HTML Widget] Error loading ${htmlFile} for plugin ${pluginId}:`, error); - const container = document.getElementById(`${fieldId}_custom_html`); - if (container) { - container.innerHTML = ` -
- - Failed to load custom HTML: ${error.message} -
- `; - } - } -} - -function generateFormFromSchema(schema, config, webUiActions = []) { - console.log('[DEBUG] ===== generateFormFromSchema called ====='); - console.log('[DEBUG] Schema properties:', Object.keys(schema.properties || {})); - console.log('[DEBUG] Web UI Actions:', webUiActions.length); - let formHtml = '
'; - - if (schema.properties) { - // Get ordered properties if x-propertyOrder is defined - let propertyEntries = Object.entries(schema.properties); - if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) { - const order = schema['x-propertyOrder']; - const orderedEntries = []; - const unorderedEntries = []; - - // Separate ordered and unordered properties - propertyEntries.forEach(([key, prop]) => { - const index = order.indexOf(key); - if (index !== -1) { - orderedEntries[index] = [key, prop]; - } else { - unorderedEntries.push([key, prop]); - } - }); - - // Combine ordered entries (filter out undefined from sparse array) with unordered entries - propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries); - } - - propertyEntries.forEach(([key, prop]) => { - // Skip the 'enabled' property - it's managed separately via the header toggle - if (key === 'enabled') return; - - let value = config[key] !== undefined ? config[key] : prop.default; - - // Special handling: use uploaded_files from config if available (populated by backend from disk) - // No need to populate from categories here since backend does it - - formHtml += generateFieldHtml(key, prop, value); - }); - } - - // Add web UI actions section if plugin defines any - console.log('[DEBUG] webUiActions:', webUiActions, 'length:', webUiActions ? webUiActions.length : 0); - if (webUiActions && webUiActions.length > 0) { - console.log('[DEBUG] Rendering', webUiActions.length, 'actions'); - formHtml += ` -
-

Actions

-

${webUiActions[0].section_description || 'Perform actions for this plugin'}

- -
- `; - - webUiActions.forEach((action, index) => { - const actionId = `action-${action.id}-${index}`; - const statusId = `action-status-${action.id}-${index}`; - const bgColor = action.color || 'blue'; - - // Map color names to explicit Tailwind classes to ensure they're included - const colorMap = { - 'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' }, - 'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' }, - 'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' }, - 'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' }, - 'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' } - }; - - const colors = colorMap[bgColor] || colorMap['blue']; - - formHtml += ` -
-
-
-

- ${action.icon ? `` : ''}${action.title || action.id} -

-

${action.description || ''}

-
- -
- -
- `; - }); - - formHtml += ` -
-
- `; - } else { - console.log('[DEBUG] No webUiActions to render'); - } - - formHtml += ` -
- - -
-
- `; - - return Promise.resolve(formHtml); -} // Functions to handle patternProperties key-value pairs window.addKeyValuePair = function(fieldId, fullKey, maxProperties) { @@ -4339,361 +2920,6 @@ window.toggleNestedSection = function(sectionId, event) { } } -function generateSimpleConfigForm(config, webUiActions = []) { - console.log('[DEBUG] generateSimpleConfigForm - webUiActions:', webUiActions, 'length:', webUiActions ? webUiActions.length : 0); - let actionsHtml = ''; - if (webUiActions && webUiActions.length > 0) { - console.log('[DEBUG] Rendering', webUiActions.length, 'actions in simple form'); - actionsHtml = ` -
-

Actions

-
- `; - - // Map color names to explicit Tailwind classes - const colorMap = { - 'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' }, - 'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' }, - 'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' }, - 'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' }, - 'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' } - }; - - webUiActions.forEach((action, index) => { - const actionId = `action-${action.id}-${index}`; - const statusId = `action-status-${action.id}-${index}`; - const bgColor = action.color || 'blue'; - const colors = colorMap[bgColor] || colorMap['blue']; - - actionsHtml += ` -
-
-
-

- ${action.icon ? `` : ''}${action.title || action.id} -

-

${action.description || ''}

-
- -
- -
- `; - }); - actionsHtml += ` -
-
- `; - } - - return ` -
-
- - -
- ${actionsHtml} -
- - -
-
- `; -} - -// Plugin config modal state -let currentPluginConfigState = { - pluginId: null, - config: {}, - schema: null, - jsonEditor: null, - formData: {} -}; - -// Initialize JSON editor -async function initJsonEditor() { - const textarea = document.getElementById('plugin-config-json-editor'); - if (!textarea) return null; - - // Lazy load CodeMirror if needed - if (typeof CodeMirror === 'undefined') { - if (typeof window.loadCodeMirror === 'function') { - try { - await window.loadCodeMirror(); - } catch (error) { - console.error('Failed to load CodeMirror:', error); - showNotification('JSON editor not available. Please refresh the page.', 'error'); - return null; - } - } else { - console.error('CodeMirror not loaded and loadCodeMirror not available. Please refresh the page.'); - showNotification('JSON editor not available. Please refresh the page.', 'error'); - return null; - } - } - - if (currentPluginConfigState.jsonEditor) { - currentPluginConfigState.jsonEditor.toTextArea(); - currentPluginConfigState.jsonEditor = null; - } - - const editor = CodeMirror.fromTextArea(textarea, { - mode: 'application/json', - theme: 'monokai', - lineNumbers: true, - lineWrapping: true, - indentUnit: 2, - tabSize: 2, - autoCloseBrackets: true, - matchBrackets: true, - foldGutter: true, - gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] - }); - - // Validate JSON on change - editor.on('change', function() { - const value = editor.getValue(); - try { - JSON.parse(value); - editor.setOption('class', ''); - } catch (e) { - editor.setOption('class', 'cm-error'); - } - }); - - return editor; -} - -// Switch between form and JSON views -function switchPluginConfigView(view) { - const formView = document.getElementById('plugin-config-form-view'); - const jsonView = document.getElementById('plugin-config-json-view'); - const formBtn = document.getElementById('view-toggle-form'); - const jsonBtn = document.getElementById('view-toggle-json'); - - if (view === 'json') { - formView.classList.add('hidden'); - jsonView.classList.remove('hidden'); - formBtn.classList.remove('active', 'bg-blue-600', 'text-white'); - formBtn.classList.add('text-gray-700', 'hover:bg-gray-200'); - jsonBtn.classList.add('active', 'bg-blue-600', 'text-white'); - jsonBtn.classList.remove('text-gray-700', 'hover:bg-gray-200'); - - // Sync form data to JSON editor - syncFormToJson(); - - // Initialize editor if not already done - if (!currentPluginConfigState.jsonEditor) { - // Small delay to ensure textarea is visible, then load CodeMirror and initialize - setTimeout(async () => { - currentPluginConfigState.jsonEditor = await initJsonEditor(); - if (currentPluginConfigState.jsonEditor) { - const jsonText = JSON.stringify(currentPluginConfigState.config, null, 2); - currentPluginConfigState.jsonEditor.setValue(jsonText); - currentPluginConfigState.jsonEditor.refresh(); - } - }, 50); - } else { - // Update editor content if already initialized - const jsonText = JSON.stringify(currentPluginConfigState.config, null, 2); - currentPluginConfigState.jsonEditor.setValue(jsonText); - currentPluginConfigState.jsonEditor.refresh(); - } - } else { - jsonView.classList.add('hidden'); - formView.classList.remove('hidden'); - jsonBtn.classList.remove('active', 'bg-blue-600', 'text-white'); - jsonBtn.classList.add('text-gray-700', 'hover:bg-gray-200'); - formBtn.classList.add('active', 'bg-blue-600', 'text-white'); - formBtn.classList.remove('text-gray-700', 'hover:bg-gray-200'); - - // Sync JSON to form if JSON was edited - syncJsonToForm(); - } -} - -// Sync form data to JSON config -function syncFormToJson() { - const form = document.getElementById('plugin-config-form'); - if (!form) return; - - const schema = currentPluginConfigState.schema; - const config = normalizeFormDataForConfig(form, schema); - - // Deep merge with existing config to preserve nested structures - function deepMerge(target, source) { - for (const key in source) { - if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; - if (!Object.prototype.hasOwnProperty.call(source, key)) continue; - if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { - if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) { - target[key] = {}; - } - deepMerge(target[key], source[key]); - } else { - target[key] = source[key]; - } - } - return target; - } - - // Deep merge new form data into existing config - currentPluginConfigState.config = deepMerge( - JSON.parse(JSON.stringify(currentPluginConfigState.config)), // Deep clone - config - ); -} - -// Sync JSON editor content to form -function syncJsonToForm() { - if (!currentPluginConfigState.jsonEditor) return; - - try { - const jsonText = currentPluginConfigState.jsonEditor.getValue(); - const config = JSON.parse(jsonText); - currentPluginConfigState.config = config; - - // Update form fields (this is complex, so we'll reload the form) - // For now, just update the config state - form will be regenerated on next open - console.log('JSON synced to config state'); - } catch (e) { - console.error('Invalid JSON in editor:', e); - showNotification('Invalid JSON in editor. Please fix errors before switching views.', 'error'); - } -} - -// Reset plugin config to defaults -async function resetPluginConfigToDefaults() { - if (!currentPluginConfigState.pluginId) { - showNotification('No plugin selected', 'error'); - return; - } - - if (!confirm('Are you sure you want to reset this plugin configuration to defaults? This will replace all current settings.')) { - return; - } - - try { - const response = await fetch('/api/v3/plugins/config/reset', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - plugin_id: currentPluginConfigState.pluginId, - preserve_secrets: true - }) - }); - - const data = await response.json(); - - if (data.status === 'success') { - showNotification(data.message, 'success'); - - // Reload the config form with defaults - const newConfig = data.data?.config || {}; - currentPluginConfigState.config = newConfig; - - // Regenerate form - const content = document.getElementById('plugin-config-content'); - if (content) { - content.innerHTML = '
'; - generatePluginConfigForm(currentPluginConfigState.pluginId, newConfig) - .then(formHtml => { - content.innerHTML = formHtml; - const form = document.getElementById('plugin-config-form'); - if (form) { - form.addEventListener('submit', handlePluginConfigSubmit); - } - }); - } - - // Update JSON editor if it's visible - if (currentPluginConfigState.jsonEditor) { - const jsonText = JSON.stringify(newConfig, null, 2); - currentPluginConfigState.jsonEditor.setValue(jsonText); - } - } else { - showNotification(data.message || 'Failed to reset configuration', 'error'); - } - } catch (error) { - console.error('Error resetting config:', error); - showNotification('Error resetting configuration: ' + error.message, 'error'); - } -} - -// Display validation errors -function displayValidationErrors(errors) { - const errorContainer = document.getElementById('plugin-config-validation-errors'); - const errorList = document.getElementById('validation-errors-list'); - - if (!errorContainer || !errorList) return; - - if (errors && errors.length > 0) { - errorContainer.classList.remove('hidden'); - errorList.innerHTML = errors.map(error => `
  • ${escapeHtml(error)}
  • `).join(''); - } else { - errorContainer.classList.add('hidden'); - errorList.innerHTML = ''; - } -} - -// Save configuration from JSON editor -async function saveConfigFromJsonEditor() { - if (!currentPluginConfigState.jsonEditor || !currentPluginConfigState.pluginId) { - return; - } - - try { - const jsonText = currentPluginConfigState.jsonEditor.getValue(); - const config = JSON.parse(jsonText); - - // Update state - currentPluginConfigState.config = config; - - // Save the configuration (will handle validation errors) - savePluginConfiguration(currentPluginConfigState.pluginId, config); - } catch (e) { - console.error('Error saving JSON config:', e); - if (e instanceof SyntaxError) { - showNotification('Invalid JSON. Please fix syntax errors before saving.', 'error'); - displayValidationErrors([`JSON Syntax Error: ${e.message}`]); - } else { - showNotification('Error saving configuration: ' + e.message, 'error'); - } - } -} - -window.closePluginConfigModal = function() { - const modal = document.getElementById('plugin-config-modal'); - modal.style.display = 'none'; - - // Clean up JSON editor - if (currentPluginConfigState.jsonEditor) { - currentPluginConfigState.jsonEditor.toTextArea(); - currentPluginConfigState.jsonEditor = null; - } - - // Reset state - currentPluginConfig = null; - currentPluginConfigState.pluginId = null; - currentPluginConfigState.config = {}; - currentPluginConfigState.schema = null; - - // Hide validation errors - displayValidationErrors([]); - - console.log('Modal closed'); -} // Generic Plugin Action Handler window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) { @@ -6228,60 +4454,6 @@ function showError(message) { `; } -// Plugin configuration form submission is handled by handlePluginConfigSubmit -// which is attached directly to the form. The document-level listener has been removed -// to avoid duplicate submissions and to ensure proper handling of _data fields. - -function savePluginConfiguration(pluginId, config) { - // Update the plugin configuration in the backend - fetch('/api/v3/plugins/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plugin_id: pluginId, config }) - }) - .then(response => { - if (!response.ok) { - // Try to parse error response - return response.json().then(data => { - // Return error data with status - return { error: true, status: response.status, ...data }; - }).catch(() => { - // If JSON parsing fails, return generic error - return { - error: true, - status: response.status, - message: `Server error: ${response.status} ${response.statusText}` - }; - }); - } - return response.json(); - }) - .then(data => { - if (data.error || data.status !== 'success') { - // Display validation errors if present - if (data.validation_errors && Array.isArray(data.validation_errors)) { - displayValidationErrors(data.validation_errors); - } - let errorMessage = data.message || 'Error saving configuration'; - if (data.validation_errors && Array.isArray(data.validation_errors) && data.validation_errors.length > 0) { - errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n'); - } - showNotification(errorMessage, 'error'); - console.error('Config save failed:', data); - } else { - // Hide validation errors on success - displayValidationErrors([]); - showNotification(data.message || 'Configuration saved successfully', data.status); - closePluginConfigModal(); - // Refresh the installed plugins to update the UI - loadInstalledPlugins(); - } - }) - .catch(error => { - console.error('Error saving plugin config:', error); - showNotification('Error saving plugin configuration: ' + error.message, 'error'); - }); -} // Utility function to escape HTML function escapeHtml(text) { @@ -6636,70 +4808,6 @@ window.dismissGithubWarning = function() { } } -window.showGithubTokenInstructions = function() { - const instructions = ` -
    -

    How to Add a GitHub Token

    - -
    -
    -
    Step 1: Create a GitHub Token
    -
      -
    1. Click the "Create a GitHub Token" link above (or click here)
    2. -
    3. Give it a name like "LEDMatrix Plugin Manager"
    4. -
    5. No special scopes/permissions are needed for public repositories
    6. -
    7. Click "Generate token" at the bottom
    8. -
    9. Copy the generated token (it starts with "ghp_")
    10. -
    -
    - -
    -
    Step 2: Add Token to LEDMatrix
    -
      -
    1. SSH into your Raspberry Pi
    2. -
    3. Edit the secrets file: nano ~/LEDMatrix/config/config_secrets.json
    4. -
    5. Find the "github" section and add your token: -
      "github": {
      -  "api_token": "ghp_your_token_here"
      -}
      -
    6. -
    7. Save the file (Ctrl+O, Enter, Ctrl+X)
    8. -
    9. Restart the web service: sudo systemctl restart ledmatrix-web
    10. -
    -
    - -
    -

    - - Note: Your token is stored locally and never shared. It's only used to authenticate API requests to GitHub. -

    -
    -
    - -
    - -
    -
    - `; - - // Use the existing plugin config modal for instructions - const modal = document.getElementById('plugin-config-modal'); - const title = document.getElementById('plugin-config-title'); - const content = document.getElementById('plugin-config-content'); - - title.textContent = 'GitHub Token Setup'; - content.innerHTML = instructions; - modal.style.display = 'flex'; - console.log('GitHub instructions modal opened'); -} - -window.closeInstructionsModal = function() { - const modal = document.getElementById('plugin-config-modal'); - modal.style.display = 'none'; - console.log('Instructions modal closed'); -} // ==================== File Upload Functions ==================== // Note: handleFileDrop, handleFileSelect, and handleFiles are defined in diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 8ec96d183..fda97e281 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -1676,216 +1676,6 @@

    } }); - // ===== DEPRECATED: pluginConfigData ===== - // This function is no longer used - plugin configuration forms are now - // rendered server-side and loaded via HTMX. Kept for backwards compatibility. - // See: /v3/partials/plugin-config/ for the new implementation. - function pluginConfigData(plugin) { - if (!plugin) { - console.error('pluginConfigData called with undefined plugin'); - return { - plugin: { id: 'unknown', name: 'Unknown Plugin', enabled: false }, - loading: false, - config: {}, - schema: {}, - webUiActions: [], - onDemandRefreshing: false, - onDemandStopping: false - }; - } - return { - plugin: plugin, - loading: true, - config: {}, - schema: {}, - webUiActions: [], - onDemandRefreshing: false, - onDemandStopping: false, - get onDemandStore() { - if (window.Alpine && typeof Alpine.store === 'function' && Alpine.store('onDemand')) { - return Alpine.store('onDemand'); - } - return window.__onDemandStore || { loading: true, state: {}, service: {}, error: null, lastUpdated: null }; - }, - get isOnDemandLoading() { - const store = this.onDemandStore || {}; - return !!store.loading; - }, - get onDemandState() { - const store = this.onDemandStore || {}; - return store.state || {}; - }, - get onDemandService() { - const store = this.onDemandStore || {}; - return store.service || {}; - }, - get onDemandError() { - const store = this.onDemandStore || {}; - return store.error || null; - }, - get onDemandActive() { - const state = this.onDemandState; - return !!(state.active && state.plugin_id === plugin.id); - }, - resolvePluginName() { - return plugin.name || plugin.id; - }, - resolvePluginDisplayName(id) { - if (!id) { - return 'Another plugin'; - } - const list = window.installedPlugins || []; - const match = Array.isArray(list) ? list.find(p => p.id === id) : null; - return match ? (match.name || match.id) : id; - }, - formatDuration(value) { - if (value === undefined || value === null) { - return ''; - } - const total = Number(value); - if (Number.isNaN(total)) { - return ''; - } - const seconds = Math.max(0, Math.round(total)); - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes > 0) { - return `${minutes}m${remainingSeconds > 0 ? ` ${remainingSeconds}s` : ''}`; - } - return `${remainingSeconds}s`; - }, - get onDemandStatusText() { - if (this.isOnDemandLoading) { - return 'Loading on-demand status...'; - } - if (this.onDemandError) { - return `On-demand error: ${this.onDemandError}`; - } - const state = this.onDemandState; - if (state.active) { - const activeName = this.resolvePluginDisplayName(state.plugin_id); - if (state.plugin_id !== plugin.id) { - return `${activeName} is running on-demand.`; - } - const modeLabel = state.mode ? ` (${state.mode})` : ''; - const remaining = this.formatDuration(state.remaining); - const duration = this.formatDuration(state.duration); - let message = `${this.resolvePluginName()}${modeLabel} is running on-demand`; - if (remaining) { - message += ` — ${remaining} remaining`; - } else if (duration) { - message += ` — duration ${duration}`; - } else { - message += ' — until stopped'; - } - return message; - } - const lastEvent = state.last_event ? state.last_event.replace(/-/g, ' ') : null; - if (lastEvent && lastEvent !== 'cleared') { - return `No on-demand session active (last event: ${lastEvent})`; - } - return 'No on-demand session active.'; - }, - get onDemandStatusClass() { - if (this.isOnDemandLoading) return 'text-blue-600'; - if (this.onDemandError) return 'text-red-600'; - if (this.onDemandActive) return 'text-green-600'; - return 'text-blue-600'; - }, - get onDemandServiceText() { - if (this.isOnDemandLoading) { - return 'Checking display service status...'; - } - if (this.onDemandError) { - return 'Display service status unavailable.'; - } - if (this.onDemandService.active) { - return 'Display service is running.'; - } - const serviceError = this.onDemandService.stderr || this.onDemandService.error; - return serviceError ? `Display service inactive (${serviceError})` : 'Display service is not running.'; - }, - get onDemandServiceClass() { - if (this.isOnDemandLoading) return 'text-blue-500'; - if (this.onDemandError) return 'text-red-500'; - return this.onDemandService.active ? 'text-blue-500' : 'text-red-500'; - }, - get onDemandLastUpdated() { - const store = this.onDemandStore || {}; - if (!store.lastUpdated) { - return ''; - } - const deltaSeconds = Math.round((Date.now() - store.lastUpdated) / 1000); - if (deltaSeconds < 5) return 'Just now'; - if (deltaSeconds < 60) return `${deltaSeconds}s ago`; - const minutes = Math.round(deltaSeconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); - return `${hours}h ago`; - }, - get canStopOnDemand() { - if (this.isOnDemandLoading) return false; - if (this.onDemandError) return true; - return this.onDemandActive; - }, - get disableRunButton() { - return !plugin.enabled; - }, - get showEnableHint() { - return !plugin.enabled; - }, - notify(message, type = 'info') { - if (typeof showNotification === 'function') { - showNotification(message, type); - } - }, - refreshOnDemandStatus() { - if (typeof window.loadOnDemandStatus !== 'function') { - this.notify('On-demand status controls unavailable. Refresh the Plugin Manager tab.', 'error'); - return; - } - this.onDemandRefreshing = true; - Promise.resolve(window.loadOnDemandStatus(true)) - .finally(() => { - this.onDemandRefreshing = false; - }); - }, - runOnDemand() { - // Note: On-demand can work with disabled plugins - the backend will temporarily enable them - if (typeof window.openOnDemandModal === 'function') { - window.openOnDemandModal(plugin.id); - } else { - this.notify('On-demand modal unavailable. Refresh the Plugin Manager tab.', 'error'); - } - }, - stopOnDemandWithEvent(stopService = false) { - if (typeof window.requestOnDemandStop !== 'function') { - this.notify('Unable to stop on-demand mode. Refresh the Plugin Manager tab.', 'error'); - return; - } - this.onDemandStopping = true; - Promise.resolve(window.requestOnDemandStop({ stopService })) - .finally(() => { - this.onDemandStopping = false; - }); - }, - async loadPluginConfig(pluginId) { - // Use PluginConfigHelpers to load config directly into this component - if (window.PluginConfigHelpers) { - await window.PluginConfigHelpers.loadPluginConfig(pluginId, this); - this.loading = false; - return; - } - console.error('loadPluginConfig not available'); - this.loading = false; - } - // Note: generateConfigForm and savePluginConfig are now called via window.PluginConfigHelpers - // to avoid delegation recursion and ensure proper access to app component. - // See template usage: - // x-html="window.PluginConfigHelpers.generateConfigForm(...)" and - // x-on:submit.prevent="window.PluginConfigHelpers.savePluginConfig(...)" - }; - } // Alpine.js app function - full implementation function app() { @@ -3399,134 +3189,6 @@

    // Make app() available globally window.app = app; - // ===== DEPRECATED: Plugin Configuration Functions (Global Access) ===== - // These functions are no longer the primary method for loading plugin configs. - // Plugin configuration forms are now rendered server-side via HTMX. - // See: /v3/partials/plugin-config/ for the new implementation. - // Kept for backwards compatibility with any remaining client-side code. - window.PluginConfigHelpers = { - loadPluginConfig: async function(pluginId, componentContext) { - // This function can be called from inline components - // It loads config, schema, and updates the component context - if (!componentContext) { - console.error('loadPluginConfig requires component context'); - return; - } - - console.log('Loading config for plugin:', pluginId); - componentContext.loading = true; - - try { - // Load config, schema, and installed plugins (for web_ui_actions) in parallel - let configData, schemaData, pluginsData; - - if (window.PluginAPI && window.PluginAPI.batch) { - try { - const results = await window.PluginAPI.batch([ - {endpoint: `/plugins/config?plugin_id=${pluginId}`, method: 'GET'}, - {endpoint: `/plugins/schema?plugin_id=${pluginId}`, method: 'GET'}, - {endpoint: '/plugins/installed', method: 'GET'} - ]); - [configData, schemaData, pluginsData] = results; - } catch (batchError) { - console.error('Batch API request failed, falling back to individual requests:', batchError); - // Fall back to individual requests - const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([ - fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })), - fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })), - fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })) - ]); - configData = configResponse; - schemaData = schemaResponse; - pluginsData = pluginsResponse; - } - } else { - const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([ - fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })), - fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })), - fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })) - ]); - configData = configResponse; - schemaData = schemaResponse; - pluginsData = pluginsResponse; - } - - if (configData && configData.status === 'success') { - componentContext.config = configData.data; - } else { - console.warn('Config API returned non-success status:', configData); - // Set defaults if config failed to load - componentContext.config = { enabled: true, display_duration: 30 }; - } - - if (schemaData && schemaData.status === 'success') { - componentContext.schema = schemaData.data.schema || {}; - } else { - console.warn('Schema API returned non-success status:', schemaData); - // Set empty schema as fallback - componentContext.schema = {}; - } - - if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) { - const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId); - componentContext.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : []; - } else { - console.warn('Plugins API returned non-success status:', pluginsData); - componentContext.webUiActions = []; - } - - console.log('Loaded config, schema, and actions for', pluginId); - } catch (error) { - console.error('Error loading plugin config:', error); - componentContext.config = { enabled: true, display_duration: 30 }; - componentContext.schema = {}; - componentContext.webUiActions = []; - } finally { - componentContext.loading = false; - } - }, - - generateConfigForm: function(pluginId, config, schema, webUiActions, componentContext) { - // Try to get the app component - let appComponent = null; - if (window.Alpine) { - const appElement = document.querySelector('[x-data="app()"]'); - if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) { - appComponent = appElement._x_dataStack[0]; - } - } - - // If we have access to the app component, use its method - if (appComponent && typeof appComponent.generateConfigForm === 'function') { - return appComponent.generateConfigForm(pluginId, config, schema, webUiActions); - } - - // Fallback: return loading message if function not available - if (!pluginId || !config) { - return '
    Loading configuration...
    '; - } - return '
    Configuration form not available yet...
    '; - }, - - savePluginConfig: async function(pluginId, event, componentContext) { - // Try to get the app component - let appComponent = null; - if (window.Alpine) { - const appElement = document.querySelector('[x-data="app()"]'); - if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) { - appComponent = appElement._x_dataStack[0]; - } - } - - // If we have access to the app component, use its method - if (appComponent && typeof appComponent.savePluginConfig === 'function') { - return appComponent.savePluginConfig(pluginId, event); - } - - console.error('savePluginConfig not available'); - throw new Error('Save configuration method not available'); - } - }; // ===== Nested Section Toggle ===== window.toggleNestedSection = function(sectionId, event) { diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index cf6aa7268..ba7f0c887 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -466,65 +466,6 @@

    - - - -