diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 5c883601..961f6598 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 = '
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 || {}; @@ -2896,821 +2292,6 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde 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'}
-${description}
` : ''} -${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 += ` -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)'}
-${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'} -
- ` : ''} -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 += ` - - `; - - // 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 - }); - 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 += ` -${action.description || ''}
-nano ~/LEDMatrix/config/config_secrets.json"github": {
- "api_token": "ghp_your_token_here"
-}
- sudo systemctl restart ledmatrix-web- - Note: Your token is stored locally and never shared. It's only used to authenticate API requests to GitHub. -
-