From 079598b2846b8aadb1aa1a219a823a77b894b427 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 3 Jan 2026 10:47:21 -0500 Subject: [PATCH 01/74] fix(plugins): Remove compatible_versions requirement from single plugin install Remove compatible_versions from required fields in install_from_url method to match install_plugin behavior. This allows installing plugins from URLs without manifest version requirements, consistent with store plugin installation. --- src/plugin_system/store_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index dc50cdc5d..aa8c32cbd 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -974,7 +974,7 @@ def install_from_url(self, repo_url: str, plugin_id: str = None, plugin_path: st } # Validate manifest has required fields - required_fields = ['id', 'name', 'class_name', 'compatible_versions'] + required_fields = ['id', 'name', 'class_name'] missing_fields = [field for field in required_fields if field not in manifest] if missing_fields: return { @@ -982,7 +982,7 @@ def install_from_url(self, repo_url: str, plugin_id: str = None, plugin_path: st 'error': f'Manifest missing required fields: {", ".join(missing_fields)}' } - # Validate version fields consistency + # Validate version fields consistency (warnings only, not required) validation_errors = self._validate_manifest_version_fields(manifest) if validation_errors: self.logger.warning(f"Manifest version field validation warnings for {plugin_id}: {', '.join(validation_errors)}") From 5241bbf89c72d51f474d5394e9e81ff557b5e07b Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 3 Jan 2026 18:06:51 -0500 Subject: [PATCH 02/74] fix(7-segment-clock): Update submodule with separator and spacing fixes --- plugins/7-segment-clock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/7-segment-clock b/plugins/7-segment-clock index 61a9c71d6..cf58d50b9 160000 --- a/plugins/7-segment-clock +++ b/plugins/7-segment-clock @@ -1 +1 @@ -Subproject commit 61a9c71d67cca4cd93a7fc1087c478207c59419c +Subproject commit cf58d50b9083d61ef30b279f90270f11b4e3df40 From 6b818730240aea5f52d93ba9e460276cbde02e94 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sun, 4 Jan 2026 16:52:19 -0500 Subject: [PATCH 03/74] fix(plugins): Add onchange handlers to existing custom feed inputs - Add onchange handlers to key and value inputs for existing patternProperties fields - Fixes bug where editing existing custom RSS feeds didn't save changes - Ensures hidden JSON input field is updated when users edit feed entries - Affects all plugins using patternProperties (custom_feeds, feed_logo_map, etc.) --- web_interface/static/v3/plugins_manager.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 353714e8f..59ccb49f9 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -2506,13 +2506,15 @@ function generateFieldHtml(key, prop, value, prefix = '') { value="${pairKey}" placeholder="Key" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" - data-key-index="${index}"> + data-key-index="${index}" + onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')"> + data-value-index="${index}" + onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')"> + `; + + if (logoValue.path) { + html += ` +
+ Logo + +
+ `; + } + + html += ``; + } else if (propSchema.type === 'boolean') { + // Boolean checkbox + html += ` + + `; + } else { + // Regular text/string input + html += ` + + `; + if (propDescription) { + html += `

${escapeHtml(propDescription)}

`; + } + html += ` + + `; + } + + html += ``; + }); + + 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()); @@ -2907,6 +3015,36 @@ function generateFieldHtml(key, prop, value, prefix = '') { `; }); html += ``; + } else 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 { // Regular array input console.log(`[DEBUG] ❌ NOT a file upload widget for ${fullKey}, using regular array input`); @@ -3296,6 +3434,153 @@ window.updateKeyValuePairData = function(fieldId, fullKey) { hiddenInput.value = JSON.stringify(pairs); }; +// Functions to handle array-of-objects +window.addArrayObjectItem = function(fieldId, fullKey, maxItems) { + const itemsContainer = document.getElementById(fieldId + '_items'); + const hiddenInput = document.getElementById(fieldId + '_data'); + if (!itemsContainer || !hiddenInput) return; + + const currentItems = itemsContainer.querySelectorAll('.array-object-item'); + if (currentItems.length >= maxItems) { + alert(`Maximum ${maxItems} items allowed`); + return; + } + + // Get schema for item properties from the hidden input's data attribute or currentPluginConfig + const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema); + if (!schema) return; + + // Navigate to the items schema + const keys = fullKey.split('.'); + let itemsSchema = schema.properties; + for (const key of keys) { + if (itemsSchema && itemsSchema[key]) { + itemsSchema = itemsSchema[key]; + if (itemsSchema.type === 'array' && itemsSchema.items) { + itemsSchema = itemsSchema.items; + break; + } + } + } + + if (!itemsSchema || !itemsSchema.properties) return; + + const newIndex = currentItems.length; + const itemHtml = renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema); + itemsContainer.insertAdjacentHTML('beforeend', itemHtml); + updateArrayObjectData(fieldId); + + // Update add button state + const addButton = itemsContainer.nextElementSibling; + if (addButton && currentItems.length + 1 >= maxItems) { + addButton.disabled = true; + addButton.style.opacity = '0.5'; + addButton.style.cursor = 'not-allowed'; + } +}; + +window.removeArrayObjectItem = function(fieldId, index) { + const itemsContainer = document.getElementById(fieldId + '_items'); + if (!itemsContainer) return; + + const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`); + if (item) { + item.remove(); + // Re-index remaining items + const remainingItems = itemsContainer.querySelectorAll('.array-object-item'); + remainingItems.forEach((itemEl, newIndex) => { + itemEl.setAttribute('data-index', newIndex); + // Update all inputs within this item - need to update name/id attributes + itemEl.querySelectorAll('input, select, textarea').forEach(input => { + const name = input.getAttribute('name') || input.id; + if (name) { + // Update name/id attribute with new index + const newName = name.replace(/\[\d+\]/, `[${newIndex}]`); + if (input.getAttribute('name')) input.setAttribute('name', newName); + if (input.id) input.id = input.id.replace(/\d+/, newIndex); + } + }); + // Update button onclick attributes + itemEl.querySelectorAll('button[onclick]').forEach(button => { + const onclick = button.getAttribute('onclick'); + if (onclick) { + button.setAttribute('onclick', onclick.replace(/\d+/, newIndex)); + } + }); + }); + updateArrayObjectData(fieldId); + + // Update add button state + const addButton = itemsContainer.nextElementSibling; + if (addButton) { + const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]); + if (remainingItems.length < maxItems) { + addButton.disabled = false; + addButton.style.opacity = '1'; + addButton.style.cursor = 'pointer'; + } + } + } +}; + +window.updateArrayObjectData = function(fieldId) { + const itemsContainer = document.getElementById(fieldId + '_items'); + const hiddenInput = document.getElementById(fieldId + '_data'); + if (!itemsContainer || !hiddenInput) return; + + const items = []; + const itemElements = itemsContainer.querySelectorAll('.array-object-item'); + + itemElements.forEach((itemEl, index) => { + const item = {}; + // Get all text inputs in this item + itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => { + const propKey = input.getAttribute('data-prop-key'); + if (propKey && propKey !== 'logo_file') { + item[propKey] = input.value.trim(); + } + }); + // Handle checkboxes + itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + const propKey = checkbox.getAttribute('data-prop-key'); + if (propKey) { + item[propKey] = checkbox.checked; + } + }); + // Handle file upload data (stored in data attributes) + itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => { + const fileData = fileEl.getAttribute('data-file-data'); + if (fileData) { + try { + const data = JSON.parse(fileData); + const propKey = fileEl.getAttribute('data-prop-key'); + if (propKey) { + item[propKey] = data; + } + } catch (e) { + console.error('Error parsing file data:', e); + } + } + }); + items.push(item); + }); + + hiddenInput.value = JSON.stringify(items); +}; + +window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) { + // TODO: Implement file upload handling for array object items + // This is a placeholder - file upload in nested objects needs special handling + console.log('File upload for array object item:', { fieldId, itemIndex, propKey, pluginId }); + updateArrayObjectData(fieldId); +}; + +window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) { + // TODO: Implement file removal for array object items + console.log('File removal for array object item:', { fieldId, itemIndex, propKey }); + updateArrayObjectData(fieldId); +}; + // Function to toggle nested sections window.toggleNestedSection = function(sectionId, event) { // Prevent event bubbling if event is provided From 668fadb7d5e0b9305d9f7400995222315988aa28 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sun, 4 Jan 2026 19:49:10 -0500 Subject: [PATCH 05/74] Update plugins_manager.js cache-busting version Update version parameter to force browser to load new JavaScript with array-of-objects widget support. --- web_interface/templates/v3/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index d36beca96..8908aa2a2 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4818,7 +4818,7 @@

- + `; + } + itemsContainer.insertAdjacentHTML('beforeend', itemHtml); + window.updateArrayObjectData(fieldId); + + // Update add button state + const addButton = itemsContainer.nextElementSibling; + if (addButton && currentItems.length + 1 >= maxItems) { + addButton.disabled = true; + addButton.style.opacity = '0.5'; + addButton.style.cursor = 'not-allowed'; + } + }; + + window.removeArrayObjectItem = function(fieldId, index) { + const itemsContainer = document.getElementById(fieldId + '_items'); + if (!itemsContainer) return; + + const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`); + if (item) { + item.remove(); + // Re-index remaining items + const remainingItems = itemsContainer.querySelectorAll('.array-object-item'); + remainingItems.forEach((itemEl, newIndex) => { + itemEl.setAttribute('data-index', newIndex); + // Update all inputs within this item - need to update name/id attributes + itemEl.querySelectorAll('input, select, textarea').forEach(input => { + const name = input.getAttribute('name') || input.id; + if (name) { + // Update name/id attribute with new index + const newName = name.replace(/\[\d+\]/, `[${newIndex}]`); + if (input.getAttribute('name')) input.setAttribute('name', newName); + if (input.id) input.id = input.id.replace(/\d+/, newIndex); + } + }); + // Update button onclick attributes + itemEl.querySelectorAll('button[onclick]').forEach(button => { + const onclick = button.getAttribute('onclick'); + if (onclick) { + button.setAttribute('onclick', onclick.replace(/\d+/, newIndex)); + } + }); + }); + window.updateArrayObjectData(fieldId); + + // Update add button state + const addButton = itemsContainer.nextElementSibling; + if (addButton) { + const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]); + if (remainingItems.length < maxItems) { + addButton.disabled = false; + addButton.style.opacity = '1'; + addButton.style.cursor = 'pointer'; + } + } + } + }; + + window.updateArrayObjectData = function(fieldId) { + const itemsContainer = document.getElementById(fieldId + '_items'); + const hiddenInput = document.getElementById(fieldId + '_data'); + if (!itemsContainer || !hiddenInput) return; + + const items = []; + const itemElements = itemsContainer.querySelectorAll('.array-object-item'); + + itemElements.forEach((itemEl, index) => { + const item = {}; + // Get all text inputs in this item + itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => { + const propKey = input.getAttribute('data-prop-key'); + if (propKey && propKey !== 'logo_file') { + item[propKey] = input.value.trim(); + } + }); + // Handle checkboxes + itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + const propKey = checkbox.getAttribute('data-prop-key'); + if (propKey) { + item[propKey] = checkbox.checked; + } + }); + // Handle file upload data (stored in data attributes) + itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => { + const fileData = fileEl.getAttribute('data-file-data'); + if (fileData) { + try { + const data = JSON.parse(fileData); + const propKey = fileEl.getAttribute('data-prop-key'); + if (propKey) { + item[propKey] = data; + } + } catch (e) { + console.error('Error parsing file data:', e); + } + } + }); + items.push(item); + }); + + hiddenInput.value = JSON.stringify(items); + }; + + window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) { + // TODO: Implement file upload handling for array object items + // This is a placeholder - file upload in nested objects needs special handling + console.log('File upload for array object item:', { fieldId, itemIndex, propKey, pluginId }); + window.updateArrayObjectData(fieldId); + }; + + window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) { + // TODO: Implement file removal for array object items + // This is a placeholder - file removal in nested objects needs special handling + console.log('File removal for array object item:', { fieldId, itemIndex, propKey }); + window.updateArrayObjectData(fieldId); + }; + + console.log('[ARRAY-OBJECTS] Functions defined on window:', { + addArrayObjectItem: typeof window.addArrayObjectItem, + removeArrayObjectItem: typeof window.removeArrayObjectItem, + updateArrayObjectData: typeof window.updateArrayObjectData, + handleArrayObjectFileUpload: typeof window.handleArrayObjectFileUpload, + removeArrayObjectFile: typeof window.removeArrayObjectFile + }); +} + // Make currentPluginConfig globally accessible (outside IIFE) window.currentPluginConfig = null; From e0fff2c3f24bcddc526812607be62d9bab1768a3 Mon Sep 17 00:00:00 2001 From: Chuck Date: Mon, 5 Jan 2026 14:38:13 -0500 Subject: [PATCH 23/74] Update cache version for syntax fix --- web_interface/templates/v3/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index cce893af1..ceeb6ca75 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4818,7 +4818,7 @@

- + - {% elif prop_schema.get('type') == 'boolean' %} - {# Boolean checkbox #} - - {% else %} - {# Regular text/string input #} - - {% if prop_description %} -

{{ prop_description }}

- {% endif %} - - {% endif %} - - {% endfor %} - -
- -
- - {% endfor %} - - - - - - - {% elif prop.get('x-widget') == 'file-upload' or prop.get('x_widget') == 'file-upload' %} + {% set x_widget = prop.get('x-widget') or prop.get('x_widget') %} + {% if x_widget == 'file-upload' %} {# File upload widget for arrays #} {% set upload_config = prop.get('x-upload-config') or {} %} {% set max_files = upload_config.get('max_files', 10) %} From cefe17bd5f1134d8d328ebf6f3477feae33bd9e5 Mon Sep 17 00:00:00 2001 From: Chuck Date: Mon, 5 Jan 2026 14:51:50 -0500 Subject: [PATCH 27/74] Add simple table interface for custom feeds - Replace complex array-of-objects widget with clean table - Table columns: Name, URL, Logo (upload), Enabled checkbox, Delete - Use dot notation for form field names (feeds.custom_feeds.0.name) - Add JavaScript functions for add/remove rows and logo upload - Fix file-upload detection order to prevent breaking static-image plugin --- web_interface/templates/v3/base.html | 34 ++++-- .../templates/v3/partials/plugin_config.html | 108 ++++++++++++++++-- 2 files changed, 122 insertions(+), 20 deletions(-) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 07520b1e3..e6a42387a 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4839,7 +4839,7 @@

newRow.innerHTML = ` id="${fieldId}_logo_${newIndex}" accept="image/png,image/jpeg,image/bmp" style="display: none;" - onchange="handleCustomFeedLogoUpload(event, '${fieldId}', ${newIndex}, 'ledmatrix-news')"> + onchange="handleCustomFeedLogoUpload(event, '${fieldId}', ${newIndex}, 'ledmatrix-news', '${fullKey}')"> Logo - - + + `; } diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 5a8bd851c..d5033a865 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -153,15 +153,105 @@ {% else %} - {# Regular array input (comma-separated) #} - {% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %} - -

Separate multiple values with commas

+ {# Check if it's an array of objects (like custom_feeds) - use simple table interface #} + {% set items_schema = prop.get('items') or {} %} + {% set is_array_of_objects = items_schema.get('type') == 'object' and items_schema.get('properties') %} + {% if is_array_of_objects %} + {# Simple table-based interface for custom feeds #} + {% set item_properties = items_schema.get('properties', {}) %} + {% set max_items = prop.get('maxItems', 50) %} + {% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %} + +
+ + + + + + + + + + + + {% for item in array_value %} + {% set item_index = loop.index0 %} + + + + + + + + {% endfor %} + +
NameURLLogoEnabledActions
+ + + + + {% set logo_value = item.get('logo') or {} %} + {% set logo_path = logo_value.get('path', '') %} +
+ + + {% if logo_path %} + Logo + + + {% else %} + No logo + {% endif %} +
+
+ + + +
+ +
+ {% else %} + {# Regular array input (comma-separated) #} + {% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %} + +

Separate multiple values with commas

+ {% endif %} {% endif %} {# Text input (default) #} From a8c262f3b02d87107322c487e5a88837b1d151b2 Mon Sep 17 00:00:00 2001 From: Chuck Date: Mon, 5 Jan 2026 14:57:42 -0500 Subject: [PATCH 28/74] Fix custom feeds table issues - Fix JavaScript error in removeCustomFeedRow (get tbody before removing row) - Improve array conversion logic to handle nested paths like feeds.custom_feeds - Add better error handling and debug logging for array conversion - Ensure dicts with numeric keys are properly converted to arrays before validation --- web_interface/blueprints/api_v3.py | 19 +++++++++++++++---- web_interface/templates/v3/base.html | 9 +++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index bd0cbb283..82f254d09 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -3486,9 +3486,9 @@ def fix_array_structures(config_dict, schema_props, prefix=''): # If it's a dict with numeric string keys, convert to array if isinstance(current_value, dict) and not isinstance(current_value, list): try: - keys = [k for k in current_value.keys()] - if all(k.isdigit() for k in keys): - sorted_keys = sorted(keys, key=int) + keys = list(current_value.keys()) + if keys and all(str(k).isdigit() for k in keys): + sorted_keys = sorted(keys, key=lambda x: int(str(x))) array_value = [current_value[k] for k in sorted_keys] # Convert array elements to correct types based on schema items_schema = prop_schema.get('items', {}) @@ -3509,7 +3509,10 @@ def fix_array_structures(config_dict, schema_props, prefix=''): array_value = converted_array config_dict[prop_key] = array_value current_value = array_value # Update for length check below - except (ValueError, KeyError, TypeError): + except (ValueError, KeyError, TypeError) as e: + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Failed to convert {prop_key} to array: {e}") pass # If it's an array, ensure correct types and check minItems @@ -3621,9 +3624,17 @@ def ensure_array_defaults(config_dict, schema_props, prefix=''): if schema and 'properties' in schema: # First, fix any dict structures that should be arrays + # This must be called BEFORE validation to convert dicts with numeric keys to arrays fix_array_structures(plugin_config, schema['properties']) # Then, ensure None arrays get defaults ensure_array_defaults(plugin_config, schema['properties']) + + # Debug: Log the structure after fixing + import logging + logger = logging.getLogger(__name__) + if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}): + custom_feeds = plugin_config['feeds']['custom_feeds'] + logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}") # Get schema manager instance (for JSON requests) schema_mgr = api_v3.schema_manager diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index e6a42387a..72a02d5e5 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4888,10 +4888,15 @@

function removeCustomFeedRow(button) { const row = button.closest('tr'); - if (row && confirm('Remove this feed?')) { + if (!row) return; + + if (confirm('Remove this feed?')) { + const tbody = row.parentElement; + if (!tbody) return; + row.remove(); + // Re-index remaining rows - const tbody = row.parentElement; const rows = tbody.querySelectorAll('.custom-feed-row'); rows.forEach((r, index) => { r.setAttribute('data-index', index); From a50afed7d1b51afdbbb900d7c8d27a3b86dda5c8 Mon Sep 17 00:00:00 2001 From: Chuck Date: Mon, 5 Jan 2026 15:05:13 -0500 Subject: [PATCH 29/74] Add fallback fix for feeds.custom_feeds dict-to-array conversion - Add explicit fallback conversion for feeds.custom_feeds if fix_array_structures misses it - This ensures the dict with numeric keys is converted to an array before validation - Logo field is already optional in schema (not in required array) --- web_interface/blueprints/api_v3.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 82f254d09..bad7e5b1c 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -3635,6 +3635,19 @@ def ensure_array_defaults(config_dict, schema_props, prefix=''): if 'feeds' in plugin_config and 'custom_feeds' in plugin_config.get('feeds', {}): custom_feeds = plugin_config['feeds']['custom_feeds'] logger.debug(f"After fix_array_structures: custom_feeds type={type(custom_feeds)}, value={custom_feeds}") + + # Force fix for feeds.custom_feeds if it's still a dict (fallback) + if 'feeds' in plugin_config: + feeds_config = plugin_config.get('feeds', {}) + if 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict): + custom_feeds_dict = feeds_config['custom_feeds'] + # Check if all keys are numeric + keys = list(custom_feeds_dict.keys()) + if keys and all(str(k).isdigit() for k in keys): + # Convert to array + sorted_keys = sorted(keys, key=lambda x: int(str(x))) + feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys] + logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items") # Get schema manager instance (for JSON requests) schema_mgr = api_v3.schema_manager From a9a42812ac694098ac71a210b47c3de41f150899 Mon Sep 17 00:00:00 2001 From: Chuck Date: Tue, 6 Jan 2026 17:51:26 -0500 Subject: [PATCH 30/74] feat(web): Add checkbox-group widget support for plugin config arrays Add server-side rendering support for checkbox-group widget in plugin configuration forms. This allows plugins to use checkboxes for multi-select array fields instead of comma-separated text inputs. The implementation: - Checks for x-widget: 'checkbox-group' in schema - Renders checkboxes for each enum item in items.enum - Supports custom labels via x-options.labels - Works with any plugin that follows the pattern Already used by: - ledmatrix-news plugin (enabled_feeds) - odds-ticker plugin (enabled_leagues) --- .../templates/v3/partials/plugin_config.html | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index d5033a865..937d1fd72 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -58,7 +58,7 @@ {% if field_type == 'integer' %}step="1"{% else %}step="any"{% endif %} class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500"> - {# Array - check for file upload widget first (to avoid breaking static-image plugin), then array of objects #} + {# Array - check for file upload widget first (to avoid breaking static-image plugin), then checkbox-group, then array of objects #} {% elif field_type == 'array' %} {% set x_widget = prop.get('x-widget') or prop.get('x_widget') %} {% if x_widget == 'file-upload' %} @@ -152,6 +152,30 @@ + {% elif x_widget == 'checkbox-group' %} + {# Checkbox group widget for multi-select arrays with enum items #} + {% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %} + {% set items_schema = prop.get('items') or {} %} + {% set enum_items = items_schema.get('enum') or [] %} + {% set x_options = prop.get('x-options') or {} %} + {% set labels = x_options.get('labels') or {} %} + +
+ {% for option in enum_items %} + {% set is_checked = option in array_value %} + {% set option_label = labels.get(option, option|replace('_', ' ')|title) %} + {% set checkbox_id = (field_id ~ '_' ~ option)|replace('.', '_')|replace(' ', '_') %} + + {% endfor %} +
{% else %} {# Check if it's an array of objects (like custom_feeds) - use simple table interface #} {% set items_schema = prop.get('items') or {} %} From 19b6fc243fb59e658ae3c14c4e0ee186d34bfd24 Mon Sep 17 00:00:00 2001 From: Chuck Date: Wed, 7 Jan 2026 21:47:38 -0500 Subject: [PATCH 31/74] feat(install): Add one-shot installation script - Create comprehensive one-shot installer with robust error handling - Includes network checks, disk space validation, and retry logic - Handles existing installations gracefully (idempotent) - Updates README with quick install command prominently featured - Manual installation instructions moved to collapsible section The script provides explicit error messages and never fails silently. All prerequisites are validated before starting installation. --- README.md | 33 ++- scripts/install/one-shot-install.sh | 378 ++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+), 4 deletions(-) create mode 100755 scripts/install/one-shot-install.sh diff --git a/README.md b/README.md index c033def1f..445fc4318 100644 --- a/README.md +++ b/README.md @@ -275,12 +275,36 @@ These are not required and you can probably rig up something basic with stuff yo # System Setup & Installation -1. Open PowerShell and ssh into your Raspberry Pi with ledpi@ledpi (or Username@Hostname) +## Quick Install (Recommended) + +SSH into your Raspberry Pi and paste this single command: + +```bash +curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | bash +``` + +This one-shot installer will automatically: +- Check system prerequisites (network, disk space, sudo access) +- Install required system packages (git, python3, build tools, etc.) +- Clone or update the LEDMatrix repository +- Run the complete first-time installation script + +The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. All errors are reported explicitly with actionable fixes. + +**Note:** The script is safe to run multiple times and will handle existing installations gracefully. + +
+ +Manual Installation (Alternative) + +If you prefer to install manually or the one-shot installer doesn't work for your setup: + +1. SSH into your Raspberry Pi: ```bash ssh ledpi@ledpi ``` -2. Update repositories, upgrade raspberry pi OS, install git +2. Update repositories, upgrade Raspberry Pi OS, and install prerequisites: ```bash sudo apt update && sudo apt upgrade -y sudo apt install -y git python3-pip cython3 build-essential python3-dev python3-pillow scons @@ -292,8 +316,7 @@ git clone https://github.com/ChuckBuilds/LEDMatrix.git cd LEDMatrix ``` -4. First-time installation (recommended) - +4. Run the first-time installation script: ```bash chmod +x first_time_install.sh sudo bash ./first_time_install.sh @@ -303,6 +326,8 @@ This single script installs services, dependencies, configures permissions and s
+ + ## Configuration diff --git a/scripts/install/one-shot-install.sh b/scripts/install/one-shot-install.sh new file mode 100755 index 000000000..8478d04fe --- /dev/null +++ b/scripts/install/one-shot-install.sh @@ -0,0 +1,378 @@ +#!/bin/bash + +# LED Matrix One-Shot Installation Script +# This script provides a single-command installation experience +# Usage: curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | bash + +set -Eeuo pipefail + +# Global state for error tracking +CURRENT_STEP="initialization" + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Error handler for explicit failures +on_error() { + local exit_code=$? + local line_no=${1:-unknown} + echo "" >&2 + echo -e "${RED}✗ ERROR: Installation failed at step: $CURRENT_STEP${NC}" >&2 + echo -e "${RED} Line: $line_no, Exit code: $exit_code${NC}" >&2 + echo "" >&2 + echo "Common fixes:" >&2 + echo " - Check internet connectivity: ping -c1 8.8.8.8" >&2 + echo " - Verify sudo access: sudo -v" >&2 + echo " - Check disk space: df -h /" >&2 + echo " - If APT lock error: sudo dpkg --configure -a" >&2 + echo " - Wait a few minutes and try again" >&2 + echo "" >&2 + echo "This script is safe to run multiple times. You can re-run it to continue." >&2 + exit "$exit_code" +} +trap 'on_error $LINENO' ERR + +# Helper functions for colored output +print_step() { + echo "" + echo -e "${BLUE}==========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}==========================================${NC}" + echo "" +} + +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" +} + +# Retry function for network operations +retry() { + local attempt=1 + local max_attempts=3 + local delay_seconds=5 + while true; do + if "$@"; then + return 0 + fi + local status=$? + if [ $attempt -ge $max_attempts ]; then + print_error "Command failed after $attempt attempts: $*" + return $status + fi + print_warning "Command failed (attempt $attempt/$max_attempts). Retrying in ${delay_seconds}s: $*" + attempt=$((attempt+1)) + sleep "$delay_seconds" + done +} + +# Check network connectivity +check_network() { + CURRENT_STEP="Network connectivity check" + print_step "Checking network connectivity..." + + if command -v ping >/dev/null 2>&1; then + if ping -c 1 -W 3 8.8.8.8 >/dev/null 2>&1; then + print_success "Internet connectivity confirmed (ping test)" + return 0 + fi + fi + + if command -v curl >/dev/null 2>&1; then + if curl -Is --max-time 5 http://deb.debian.org >/dev/null 2>&1; then + print_success "Internet connectivity confirmed (curl test)" + return 0 + fi + fi + + if command -v wget >/dev/null 2>&1; then + if wget --spider --timeout=5 http://deb.debian.org >/dev/null 2>&1; then + print_success "Internet connectivity confirmed (wget test)" + return 0 + fi + fi + + print_error "No internet connectivity detected" + echo "" + echo "Please ensure your Raspberry Pi is connected to the internet:" + echo " 1. Check WiFi/Ethernet connection" + echo " 2. Test manually: ping -c1 8.8.8.8" + echo " 3. Then re-run this installation script" + exit 1 +} + +# Check disk space +check_disk_space() { + CURRENT_STEP="Disk space check" + if ! command -v df >/dev/null 2>&1; then + print_warning "df command not available, skipping disk space check" + return 0 + fi + + # Check available space in MB + AVAILABLE_SPACE=$(df -m / | awk 'NR==2{print $4}' || echo "0") + + if [ "$AVAILABLE_SPACE" -lt 500 ]; then + print_error "Insufficient disk space: ${AVAILABLE_SPACE}MB available (need at least 500MB)" + echo "" + echo "Please free up disk space before continuing:" + echo " - Remove unnecessary packages: sudo apt autoremove" + echo " - Clean APT cache: sudo apt clean" + echo " - Check large files: sudo du -sh /* | sort -h" + exit 1 + elif [ "$AVAILABLE_SPACE" -lt 1024 ]; then + print_warning "Limited disk space: ${AVAILABLE_SPACE}MB available (recommend at least 1GB)" + else + print_success "Disk space sufficient: ${AVAILABLE_SPACE}MB available" + fi +} + +# Check for curl or wget, install if missing +ensure_download_tool() { + CURRENT_STEP="Download tool check" + if command -v curl >/dev/null 2>&1; then + print_success "curl is available" + return 0 + fi + + if command -v wget >/dev/null 2>&1; then + print_success "wget is available" + return 0 + fi + + print_warning "Neither curl nor wget found, installing curl..." + + # Try to install curl (may fail if not sudo, but we'll check sudo next) + if command -v apt-get >/dev/null 2>&1; then + print_step "Installing curl..." + if [ "$EUID" -eq 0 ]; then + retry apt-get update + retry apt-get install -y curl + print_success "curl installed successfully" + else + print_error "Need sudo to install curl. Please run: sudo apt-get update && sudo apt-get install -y curl" + echo "Then re-run this installation script." + exit 1 + fi + else + print_error "Cannot install curl: apt-get not available" + exit 1 + fi +} + +# Check and elevate to sudo if needed +check_sudo() { + CURRENT_STEP="Privilege check" + if [ "$EUID" -eq 0 ]; then + print_success "Running with root privileges" + return 0 + fi + + print_warning "Script needs administrator privileges" + + # Check if sudo is available + if ! command -v sudo >/dev/null 2>&1; then + print_error "sudo is not available and script is not running as root" + echo "" + echo "Please either:" + echo " 1. Run as root: sudo bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh)\"" + echo " 2. Or install sudo first" + exit 1 + fi + + # Test sudo access + if ! sudo -n true 2>/dev/null; then + print_warning "Need sudo password - you may be prompted" + if ! sudo -v; then + print_error "Failed to obtain sudo privileges" + exit 1 + fi + fi + + print_success "Sudo access confirmed" +} + +# Check if running on Raspberry Pi (warning only, don't fail) +check_raspberry_pi() { + CURRENT_STEP="Hardware check" + if [ -r /proc/device-tree/model ]; then + DEVICE_MODEL=$(tr -d '\0' /dev/null 2>&1 && git status >/dev/null 2>&1; then + # Check for local modifications + if [ -z "$(git status --porcelain)" ]; then + print_success "Pulling latest changes..." + retry git pull || print_warning "Could not pull latest changes (continuing with existing code)" + else + print_warning "Repository has local modifications, skipping pull" + print_warning "Using existing repository state" + fi + else + print_warning "Git repository appears corrupted or has issues" + print_warning "Attempting to re-clone..." + cd "$HOME" + rm -rf "$REPO_DIR" + print_success "Cloning fresh repository..." + retry git clone "$REPO_URL" "$REPO_DIR" + fi + else + print_warning "Directory exists but is not a git repository" + print_warning "Removing and cloning fresh..." + cd "$HOME" + rm -rf "$REPO_DIR" + print_success "Cloning repository..." + retry git clone "$REPO_URL" "$REPO_DIR" + fi + else + print_success "Cloning repository to $REPO_DIR..." + retry git clone "$REPO_URL" "$REPO_DIR" + fi + + # Verify repository is accessible + if [ ! -d "$REPO_DIR" ] || [ ! -f "$REPO_DIR/first_time_install.sh" ]; then + print_error "Repository setup failed: $REPO_DIR/first_time_install.sh not found" + exit 1 + fi + + print_success "Repository ready at $REPO_DIR" + + # Execute main installation script + CURRENT_STEP="Main installation" + print_step "Running main installation script..." + + cd "$REPO_DIR" + + # Make sure the script is executable + chmod +x first_time_install.sh + + # Check if script exists + if [ ! -f "first_time_install.sh" ]; then + print_error "first_time_install.sh not found in $REPO_DIR" + exit 1 + fi + + print_success "Starting main installation (this may take 10-30 minutes)..." + echo "" + + # Execute with proper error handling + # Use sudo if we're not root, otherwise run directly + if [ "$EUID" -eq 0 ]; then + bash ./first_time_install.sh + else + sudo bash ./first_time_install.sh + fi + + INSTALL_EXIT_CODE=$? + + if [ $INSTALL_EXIT_CODE -eq 0 ]; then + echo "" + print_step "Installation Complete!" + print_success "LED Matrix has been successfully installed!" + echo "" + echo "Next steps:" + echo " 1. Configure your settings: sudo nano $REPO_DIR/config/config.json" + echo " 2. Or use the web interface: http://$(hostname -I | awk '{print $1}'):5000" + echo " 3. Start the service: sudo systemctl start ledmatrix.service" + echo "" + else + print_error "Main installation script exited with code $INSTALL_EXIT_CODE" + echo "" + echo "The installation may have partially completed." + echo "You can:" + echo " 1. Re-run this script to continue (it's safe to run multiple times)" + echo " 2. Check logs in $REPO_DIR/logs/" + echo " 3. Review the error messages above" + exit $INSTALL_EXIT_CODE + fi +} + +# Run main function +main "$@" From 38ffaf01881d9aaf5c2a6315cf7be3a27813cc2b Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 12:17:26 -0500 Subject: [PATCH 32/74] fix: Remove accidental plugins/7-segment-clock submodule entry Remove uninitialized submodule 'plugins/7-segment-clock' that was accidentally included. This submodule is not related to the one-shot installer feature and should not be part of this PR. - Remove submodule entry from .gitmodules - Remove submodule from git index - Clean up submodule configuration --- .gitmodules | 3 --- plugins/7-segment-clock | 1 - 2 files changed, 4 deletions(-) delete mode 160000 plugins/7-segment-clock diff --git a/.gitmodules b/.gitmodules index 389ed7930..80cf455e3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -61,9 +61,6 @@ [submodule "plugins/ledmatrix-of-the-day"] path = plugins/ledmatrix-of-the-day url = https://github.com/ChuckBuilds/ledmatrix-of-the-day.git -[submodule "plugins/7-segment-clock"] - path = plugins/7-segment-clock - url = https://github.com/ChuckBuilds/ledmatrix-7-segment-clock [submodule "plugins/youtube-stats"] path = plugins/youtube-stats url = https://github.com/ChuckBuilds/ledmatrix-youtube-stats.git diff --git a/plugins/7-segment-clock b/plugins/7-segment-clock deleted file mode 160000 index cf58d50b9..000000000 --- a/plugins/7-segment-clock +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cf58d50b9083d61ef30b279f90270f11b4e3df40 From b88842e672766394efc8a240e3ab99f2e79886f1 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 12:26:08 -0500 Subject: [PATCH 33/74] fix(array-objects): Fix schema lookup, reindexing, and disable file upload Address PR review feedback for array-of-objects helpers: 1. Schema resolution: Use getSchemaProperty() instead of manual traversal - Fixes nested array-of-objects schema lookup (e.g., news.custom_feeds) - Now properly descends through .properties for nested objects 2. Reindexing: Replace brittle regex with targeted patterns - Only replace index in bracket notation [0], [1], etc. for names - Only replace _item_ pattern for IDs (not arbitrary digits) - Use specific function parameter patterns for onclick handlers - Prevents corruption of fieldId, pluginId, or other numeric values 3. File upload: Disable widget until properly implemented - Hide/disable upload button with clear message - Show existing logos if present but disable upload functionality - Prevents silent failures when users attempt to upload files - Added TODO comments for future implementation Also fixes exit code handling in one-shot-install.sh to properly capture first_time_install.sh exit status before error trap fires. --- scripts/install/one-shot-install.sh | 4 +- web_interface/static/v3/plugins_manager.js | 119 +++++++++++++-------- 2 files changed, 78 insertions(+), 45 deletions(-) diff --git a/scripts/install/one-shot-install.sh b/scripts/install/one-shot-install.sh index 8478d04fe..c477d4bb8 100755 --- a/scripts/install/one-shot-install.sh +++ b/scripts/install/one-shot-install.sh @@ -343,14 +343,16 @@ main() { echo "" # Execute with proper error handling + # Temporarily disable errexit to capture exit code instead of exiting immediately + set +e # Use sudo if we're not root, otherwise run directly if [ "$EUID" -eq 0 ]; then bash ./first_time_install.sh else sudo bash ./first_time_install.sh fi - INSTALL_EXIT_CODE=$? + set -e # Re-enable errexit if [ $INSTALL_EXIT_CODE -eq 0 ]; then echo "" diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 0e6f5f0ee..ce4330e5e 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -2485,6 +2485,10 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde html += `
`; // Handle file-upload widget (for logo field) + // NOTE: File upload for array-of-objects items is not yet implemented. + // The widget is disabled to prevent silent failures when users try to upload files. + // TODO: Implement handleArrayObjectFileUpload and removeArrayObjectFile with proper + // endpoint support and [data-file-data] attribute updates before enabling this widget. if (propSchema['x-widget'] === 'file-upload') { html += ``; if (propDescription) { @@ -2494,29 +2498,26 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || 'ledmatrix-news'; const logoValue = propValue || {}; - html += ` -
- - - `; - + // Display existing logo if present, but disable upload functionality if (logoValue.path) { html += ` -
- Logo +
+
+ Logo + File upload not yet available for array items +
+
+ `; + } else { + html += ` +
+

File upload functionality for array items is coming soon

`; } @@ -6433,19 +6434,13 @@ if (typeof window !== 'undefined') { const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema); if (!schema) return; - // Navigate to the items schema - const keys = fullKey.split('.'); - let itemsSchema = schema.properties; - for (const key of keys) { - if (itemsSchema && itemsSchema[key]) { - itemsSchema = itemsSchema[key]; - if (itemsSchema.type === 'array' && itemsSchema.items) { - itemsSchema = itemsSchema.items; - break; - } - } + // Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds) + const arraySchema = getSchemaProperty(schema, fullKey); + if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) { + return; } + const itemsSchema = arraySchema.items; if (!itemsSchema || !itemsSchema.properties) return; const newIndex = currentItems.length; @@ -6489,24 +6484,55 @@ if (typeof window !== 'undefined') { if (item) { item.remove(); // Re-index remaining items + // Use data-index for index storage - no need to encode index in onclick strings or IDs const remainingItems = itemsContainer.querySelectorAll('.array-object-item'); remainingItems.forEach((itemEl, newIndex) => { itemEl.setAttribute('data-index', newIndex); - // Update all inputs within this item - need to update name/id attributes + // Update all inputs within this item - only update index in array bracket notation itemEl.querySelectorAll('input, select, textarea').forEach(input => { - const name = input.getAttribute('name') || input.id; + const name = input.getAttribute('name'); + const id = input.id; if (name) { - // Update name/id attribute with new index - const newName = name.replace(/\[\d+\]/, `[${newIndex}]`); - if (input.getAttribute('name')) input.setAttribute('name', newName); - if (input.id) input.id = input.id.replace(/\d+/, newIndex); + // Only replace index in bracket notation like [0], [1], etc. + // Match pattern: field_name[index] but not field_name123 + const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`); + input.setAttribute('name', newName); + } + if (id) { + // Only update index in specific patterns like _item_0, _item_1 + // Match pattern: _item_ but be careful not to break other numeric IDs + const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`); + input.id = newId; } }); - // Update button onclick attributes + // Update button onclick attributes - only update the index parameter + // Since we use data-index for tracking, we can compute index from closest('.array-object-item') + // For now, update onclick strings but be more careful with the regex itemEl.querySelectorAll('button[onclick]').forEach(button => { const onclick = button.getAttribute('onclick'); if (onclick) { - button.setAttribute('onclick', onclick.replace(/\d+/, newIndex)); + // Match patterns like: + // removeArrayObjectItem('fieldId', 0) + // handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId') + // removeArrayObjectFile('fieldId', 0, 'propKey') + // Only replace the numeric index parameter (second or third argument depending on function) + let newOnclick = onclick; + // For removeArrayObjectItem('fieldId', index) - second param + newOnclick = newOnclick.replace( + /removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g, + `removeArrayObjectItem('$1', ${newIndex})` + ); + // For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param + newOnclick = newOnclick.replace( + /handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g, + `handleArrayObjectFileUpload(event, '$1', ${newIndex},` + ); + // For removeArrayObjectFile('fieldId', index, ...) - second param + newOnclick = newOnclick.replace( + /removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g, + `removeArrayObjectFile('$1', ${newIndex},` + ); + button.setAttribute('onclick', newOnclick); } }); }); @@ -6514,12 +6540,17 @@ if (typeof window !== 'undefined') { // Update add button state const addButton = itemsContainer.nextElementSibling; - if (addButton) { - const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]); - if (remainingItems.length < maxItems) { - addButton.disabled = false; - addButton.style.opacity = '1'; - addButton.style.cursor = 'pointer'; + if (addButton && addButton.getAttribute('onclick')) { + // Extract maxItems from onclick attribute more safely + // Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems) + const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/); + if (onclickMatch && onclickMatch[1]) { + const maxItems = parseInt(onclickMatch[1]); + if (remainingItems.length < maxItems) { + addButton.disabled = false; + addButton.style.opacity = '1'; + addButton.style.cursor = 'pointer'; + } } } } From c80c23cd08334a33e1534a1e32b13a490969c873 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 12:27:41 -0500 Subject: [PATCH 34/74] fix(security): Fix XSS vulnerability in handleCustomFeedLogoUpload Replace innerHTML usage with safe DOM manipulation using createElement and setAttribute to prevent XSS when injecting uploadedFile.path and uploadedFile.id values. - Clear logoCell using textContent instead of innerHTML - Create all DOM elements using document.createElement - Set uploadedFile.path and uploadedFile.id via setAttribute (automatically escaped) - Properly structure DOM tree by appending elements in order - Prevents malicious HTML/script injection through file path or ID values --- web_interface/templates/v3/base.html | 70 +++++++++++++++++++++------- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 72a02d5e5..4263a091a 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4949,23 +4949,59 @@

const pathName = existingPathInput ? existingPathInput.name : `${fullKey}.${index}.logo.path`; const idName = existingIdInput ? existingIdInput.name : `${fullKey}.${index}.logo.id`; - logoCell.innerHTML = ` -
- - - Logo - - -
- `; + // Clear logoCell and build DOM safely to prevent XSS + logoCell.textContent = ''; // Clear existing content + + // Create container div + const container = document.createElement('div'); + container.className = 'flex items-center space-x-2'; + + // Create file input + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.id = `${fieldId}_logo_${index}`; + fileInput.accept = 'image/png,image/jpeg,image/bmp'; + fileInput.style.display = 'none'; + fileInput.setAttribute('onchange', `handleCustomFeedLogoUpload(event, '${fieldId}', ${index}, '${pluginId}', '${fullKey}')`); + + // Create upload button + const uploadButton = document.createElement('button'); + uploadButton.type = 'button'; + uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded'; + uploadButton.setAttribute('onclick', `document.getElementById('${fieldId}_logo_${index}').click()`); + const uploadIcon = document.createElement('i'); + uploadIcon.className = 'fas fa-upload mr-1'; + uploadButton.appendChild(uploadIcon); + uploadButton.appendChild(document.createTextNode(' Upload')); + + // Create img element - set src via setAttribute to prevent XSS + const img = document.createElement('img'); + img.setAttribute('src', `/${uploadedFile.path}`); + img.setAttribute('alt', 'Logo'); + img.className = 'w-8 h-8 object-cover rounded border'; + img.id = `${fieldId}_logo_preview_${index}`; + + // Create hidden input for path - set value via setAttribute to prevent XSS + const pathInput = document.createElement('input'); + pathInput.type = 'hidden'; + pathInput.setAttribute('name', pathName); + pathInput.setAttribute('value', uploadedFile.path); + + // Create hidden input for id - set value via setAttribute to prevent XSS + const idInput = document.createElement('input'); + idInput.type = 'hidden'; + idInput.setAttribute('name', idName); + idInput.setAttribute('value', String(uploadedFile.id)); // Ensure it's a string + + // Append all elements to container + container.appendChild(fileInput); + container.appendChild(uploadButton); + container.appendChild(img); + container.appendChild(pathInput); + container.appendChild(idInput); + + // Append container to logoCell + logoCell.appendChild(container); } } else { alert('Upload failed: ' + (data.message || 'Unknown error')); From 0eb457fbc3868352966b185ce49c59733c512721 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 12:31:26 -0500 Subject: [PATCH 35/74] fix: Update upload button onclick when reindexing custom feed rows Fix removeCustomFeedRow to update button onclick handlers that reference file input IDs with _logo_ when rows are reindexed after deletion. Previously, after deleting a row, the upload button's onclick still referenced the old file input ID, causing the upload functionality to fail. Now properly updates: - getElementById('..._logo_') patterns in onclick handlers - Other _logo_ patterns in button onclick strings - Function parameter indices in onclick handlers This ensures upload buttons continue to work correctly after row deletion. --- web_interface/templates/v3/base.html | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 4263a091a..3b4a026c3 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4920,6 +4920,29 @@

input.setAttribute('onchange', onchange.replace(/,\s*\d+\s*,/, `, ${index},`)); } } + // Update button onclick handlers that reference file input IDs with _logo_ + // Check for buttons (not just inputs) and update onclick if it contains _logo_ references + if (input.tagName === 'BUTTON') { + const onclick = input.getAttribute('onclick'); + if (onclick) { + let updatedOnclick = onclick; + // Replace getElementById('..._logo_') with getElementById('..._logo_') + updatedOnclick = updatedOnclick.replace( + /getElementById\(['"]([^'"]*_logo_)\d+['"]\)/g, + `getElementById('$1${index}')` + ); + // Also handle patterns like _logo_ in other contexts + updatedOnclick = updatedOnclick.replace( + /(['"])([^'"]*_logo_)\d+(['"])/g, + `$1$2${index}$3` + ); + // Update function call parameters (handleCustomFeedLogoUpload, removeCustomFeedRow, etc.) + updatedOnclick = updatedOnclick.replace(/,\s*\d+\s*,/g, `, ${index},`); + if (updatedOnclick !== onclick) { + input.setAttribute('onclick', updatedOnclick); + } + } + } }); }); } From 89f07b8b790efc790561dfcc333356b944aadbfa Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 12:43:04 -0500 Subject: [PATCH 36/74] fix: Make custom feeds table widget-specific instead of generic fallback Replace generic array-of-objects check with widget-specific check for 'custom-feeds' widget to prevent hardcoded schema from breaking other plugins with different array-of-objects structures. Changes: - Check for x-widget == 'custom-feeds' before rendering custom feeds table - Add schema validation to ensure required fields (name, url) exist - Show warning message if schema doesn't match expected structure - Fall back to generic array input for other array-of-objects schemas - Add comments for future generic array-of-objects support This ensures the hardcoded custom feeds table (name, url, logo, enabled) only renders when explicitly requested via widget type, preventing breakage for other plugins with different array-of-objects schemas. --- .../templates/v3/partials/plugin_config.html | 178 +++++++++--------- 1 file changed, 94 insertions(+), 84 deletions(-) diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 937d1fd72..ed59da874 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -177,95 +177,105 @@ {% endfor %}

{% else %} - {# Check if it's an array of objects (like custom_feeds) - use simple table interface #} + {# Check for custom-feeds widget first #} {% set items_schema = prop.get('items') or {} %} - {% set is_array_of_objects = items_schema.get('type') == 'object' and items_schema.get('properties') %} - {% if is_array_of_objects %} - {# Simple table-based interface for custom feeds #} + {% if x_widget == 'custom-feeds' %} + {# Custom feeds table interface - widget-specific implementation #} + {# Validate that required fields exist in schema #} {% set item_properties = items_schema.get('properties', {}) %} - {% set max_items = prop.get('maxItems', 50) %} - {% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %} - -
- - - - - - - - - - - - {% for item in array_value %} - {% set item_index = loop.index0 %} - - - - + + {% endfor %} + +
NameURLLogoEnabledActions
- - - - - {% set logo_value = item.get('logo') or {} %} - {% set logo_path = logo_value.get('path', '') %} -
- + {% if not (item_properties.get('name') and item_properties.get('url')) %} + {# Fallback to generic if schema doesn't match expected structure #} +

+ + Custom feeds widget requires 'name' and 'url' properties in items schema. +

+ {% else %} + {% set max_items = prop.get('maxItems', 50) %} + {% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %} + +
+ + + + + + + + + + + + {% for item in array_value %} + {% set item_index = loop.index0 %} + + + + + + - - - - {% endfor %} - -
NameURLLogoEnabledActions
+ + + + + {% set logo_value = item.get('logo') or {} %} + {% set logo_path = logo_value.get('path', '') %} +
+ + + {% if logo_path %} + Logo + + + {% else %} + No logo + {% endif %} +
+
+ + - {% if logo_path %} - Logo - - - {% else %} - No logo - {% endif %} - - - - - -
- -
+
+ +
+ {% endif %} {% else %} + {# Generic array-of-objects would go here if needed in the future #} + {# For now, fall back to regular array input (comma-separated) #} {# Regular array input (comma-separated) #} {% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %} Date: Thu, 8 Jan 2026 13:24:48 -0500 Subject: [PATCH 37/74] fix: Add image/gif to custom feed logo upload accept attribute Update file input accept attributes for custom feed logo uploads to include image/gif, making it consistent with the file-upload widget which also allows GIF images. Updated in three places: - Template file input (plugin_config.html) - JavaScript addCustomFeedRow function (base.html) - Dynamic file input creation in handleCustomFeedLogoUpload (base.html) All custom feed logo upload inputs now accept: image/png, image/jpeg, image/bmp, image/gif --- web_interface/templates/v3/base.html | 4 ++-- web_interface/templates/v3/partials/plugin_config.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 3b4a026c3..510de6ee3 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4857,7 +4857,7 @@

+ + Date: Thu, 8 Jan 2026 13:31:34 -0500 Subject: [PATCH 39/74] fix: Expose renderArrayObjectItem to window for addArrayObjectItem Fix scope issue where renderArrayObjectItem is defined inside IIFE but window.addArrayObjectItem is defined outside, causing the function check to always fail and fallback to degraded HTML rendering. Problem: - renderArrayObjectItem (line 2469) is inside IIFE (lines 796-6417) - window.addArrayObjectItem (line 6422) is outside IIFE - Check 'typeof renderArrayObjectItem === function' at line 6454 always fails - Fallback code lacks file upload widgets, URL input types, descriptions, styling Solution: - Expose renderArrayObjectItem to window object before IIFE closes - Function maintains closure access to escapeHtml and other IIFE-scoped functions - Newly added items now have full functionality matching initially rendered items --- web_interface/static/v3/plugins_manager.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index ce4330e5e..5360a8c03 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -6414,6 +6414,9 @@ window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) { window.updateImageList(fieldId, currentImages); } +// Expose renderArrayObjectItem to window for use by window.addArrayObjectItem +window.renderArrayObjectItem = renderArrayObjectItem; + })(); // End IIFE // Functions to handle array-of-objects @@ -6446,8 +6449,8 @@ if (typeof window !== 'undefined') { const newIndex = currentItems.length; // Use renderArrayObjectItem if available, otherwise create basic HTML let itemHtml = ''; - if (typeof renderArrayObjectItem === 'function') { - itemHtml = renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema); + if (typeof window.renderArrayObjectItem === 'function') { + itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema); } else { // Fallback: create basic HTML structure itemHtml = `
`; From 77c68ce96a5db9d702c744699e55b6c50c672585 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 13:35:39 -0500 Subject: [PATCH 40/74] fix: Reorder array type checks to match template order Fix inconsistent rendering where JavaScript and Jinja template had opposite ordering for array type checks, causing schemas with both x-widget: file-upload AND items.type: object (like static-image) to render differently. Problem: - Template checks file-upload FIRST (to avoid breaking static-image plugin) - JavaScript checked array-of-objects FIRST - Server-rendered forms showed file-upload widget correctly - JS-rendered forms incorrectly displayed array-of-objects table widget Solution: - Reorder JavaScript checks to match template order: 1. Check file-upload widget FIRST 2. Check checkbox-group widget 3. Check custom-feeds widget 4. Check array-of-objects as fallback 5. Regular array input (comma-separated) This ensures consistent rendering between server-rendered and JS-rendered forms for schemas that have both x-widget: file-upload AND items.type: object. --- web_interface/static/v3/plugins_manager.js | 157 ++++++++++++++------- 1 file changed, 105 insertions(+), 52 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 5360a8c03..0bd239cc0 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -2874,57 +2874,26 @@ function generateFieldHtml(key, prop, value, prefix = '') { `; } 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 { - // Check if this is a file upload widget - try multiple ways to access x-widget - 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 - }); + // Array - check for file upload widget first (to avoid breaking static-image plugin), + // then checkbox-group, then custom-feeds, then array of objects + const hasXWidget = prop.hasOwnProperty('x-widget'); + const xWidgetValue = prop['x-widget']; + const xWidgetValue2 = prop['x-widget'] || prop['x_widget'] || prop.xWidget; - // Check for file-upload widget - be more defensive + 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'] || {}; @@ -3050,9 +3019,93 @@ function generateFieldHtml(key, prop, value, prefix = '') { `; }); 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 if (prop.items && prop.items.type === 'object' && prop.items.properties) { + // Array of objects widget (generic fallback - 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) => { + if (typeof window.renderArrayObjectItem === 'function') { + html += window.renderArrayObjectItem(fieldId, fullKey, itemProperties, item, index, itemsSchema); + } else { + // Fallback: create basic HTML structure + html += `
`; + Object.keys(itemProperties || {}).forEach(propKey => { + const propSchema = itemProperties[propKey]; + const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + html += `
`; + if (propSchema.type === 'boolean') { + html += ``; + } else { + html += ``; + } + html += `
`; + }); + html += `
`; + } + }); + + html += ` +
+ + +
+ `; } else { - // Regular array input - console.log(`[DEBUG] ❌ NOT a file upload widget for ${fullKey}, using regular array input`); + // 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) { From fc33bcf056a9db4d7a2e8de447d3402d6f97ba69 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 13:40:12 -0500 Subject: [PATCH 41/74] fix: Handle None value for feeds config to prevent TypeError Fix crash when plugin_config['feeds'] exists but is None, causing TypeError when checking 'custom_feeds' in feeds_config. Problem: - When plugin_config['feeds'] exists but is None, dict.get('feeds', {}) returns None (not the default {}) because dict.get() only uses default when key doesn't exist, not when value is None - Line 3642's 'custom_feeds' in feeds_config raises TypeError because None is not iterable - This can crash the API endpoint if a plugin config has feeds: null Solution: - Change plugin_config.get('feeds', {}) to plugin_config.get('feeds') or {} to ensure feeds_config is always a dict (never None) - Add feeds_config check before 'in' operator for extra safety This ensures the code gracefully handles feeds: null in plugin configuration. --- web_interface/blueprints/api_v3.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index bad7e5b1c..de16d59e0 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -3638,8 +3638,8 @@ def ensure_array_defaults(config_dict, schema_props, prefix=''): # Force fix for feeds.custom_feeds if it's still a dict (fallback) if 'feeds' in plugin_config: - feeds_config = plugin_config.get('feeds', {}) - if 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict): + feeds_config = plugin_config.get('feeds') or {} + if feeds_config and 'custom_feeds' in feeds_config and isinstance(feeds_config['custom_feeds'], dict): custom_feeds_dict = feeds_config['custom_feeds'] # Check if all keys are numeric keys = list(custom_feeds_dict.keys()) From 57aa7d101b024b4c08790f6cf287cd292826052e Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 13:41:30 -0500 Subject: [PATCH 42/74] fix: Add default value for AVAILABLE_SPACE to prevent TypeError Fix crash when df produces unexpected output that results in empty AVAILABLE_SPACE variable, causing 'integer expression expected' error. Problem: - df may produce unexpected output format (different locale, unusual filesystem name spanning lines, or non-standard df implementation) - While '|| echo "0"' handles pipeline failures, it doesn't trigger if awk succeeds but produces no output (empty string) - When AVAILABLE_SPACE is empty, comparison [ "$AVAILABLE_SPACE" -lt 500 ] fails with 'integer expression expected' error - With set -e, this causes script to exit unexpectedly Solution: - Add AVAILABLE_SPACE=${AVAILABLE_SPACE:-0} before comparison to ensure variable always has a numeric value (defaults to 0 if empty) - This gracefully handles edge cases where df/awk produces unexpected output --- scripts/install/one-shot-install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/install/one-shot-install.sh b/scripts/install/one-shot-install.sh index c477d4bb8..abe5456c1 100755 --- a/scripts/install/one-shot-install.sh +++ b/scripts/install/one-shot-install.sh @@ -122,6 +122,8 @@ check_disk_space() { # Check available space in MB AVAILABLE_SPACE=$(df -m / | awk 'NR==2{print $4}' || echo "0") + # Ensure AVAILABLE_SPACE has a default value if empty (handles unexpected df output) + AVAILABLE_SPACE=${AVAILABLE_SPACE:-0} if [ "$AVAILABLE_SPACE" -lt 500 ]; then print_error "Insufficient disk space: ${AVAILABLE_SPACE}MB available (need at least 500MB)" From 8c2e3500bf8dc1bf4d25c3d289752b1623345aab Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 13:43:10 -0500 Subject: [PATCH 43/74] fix: Wrap debug console.log in debug flag check Fix unconditional debug logging that outputs internal implementation details to browser console for all users. Problem: - console.log('[ARRAY-OBJECTS] Functions defined on window:', ...) executes unconditionally when page loads - Outputs debug information about function availability to all users - Appears to be development/debugging code inadvertently included - Noisy console output in production Solution: - Wrap console.log statement in _PLUGIN_DEBUG_EARLY check to only output when pluginDebug localStorage flag is enabled - Matches pattern used elsewhere in the file for debug logging - Debug info now only visible when explicitly enabled via localStorage.setItem('pluginDebug', 'true') --- web_interface/static/v3/plugins_manager.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 0bd239cc0..74b3e6a00 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -6671,13 +6671,16 @@ if (typeof window !== 'undefined') { window.updateArrayObjectData(fieldId); }; - console.log('[ARRAY-OBJECTS] Functions defined on window:', { - addArrayObjectItem: typeof window.addArrayObjectItem, - removeArrayObjectItem: typeof window.removeArrayObjectItem, - updateArrayObjectData: typeof window.updateArrayObjectData, - handleArrayObjectFileUpload: typeof window.handleArrayObjectFileUpload, - removeArrayObjectFile: typeof window.removeArrayObjectFile - }); + // Debug logging (only if pluginDebug is enabled) + if (_PLUGIN_DEBUG_EARLY) { + console.log('[ARRAY-OBJECTS] Functions defined on window:', { + addArrayObjectItem: typeof window.addArrayObjectItem, + removeArrayObjectItem: typeof window.removeArrayObjectItem, + updateArrayObjectData: typeof window.updateArrayObjectData, + handleArrayObjectFileUpload: typeof window.handleArrayObjectFileUpload, + removeArrayObjectFile: typeof window.removeArrayObjectFile + }); + } } // Make currentPluginConfig globally accessible (outside IIFE) From deef9a1e1e40bf306fffd35fb9b1c747d373716b Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 13:45:23 -0500 Subject: [PATCH 44/74] fix: Expose getSchemaProperty, disable upload widget, handle bracket notation arrays Multiple fixes for array-of-objects and form processing: 1. Expose getSchemaProperty to window (plugins_manager.js): - getSchemaProperty was defined inside IIFE but needed by global functions - Added window.getSchemaProperty = getSchemaProperty before IIFE closes - Updated window.addArrayObjectItem to use window.getSchemaProperty - Fixes ReferenceError when dynamically adding array items 2. Disable upload widget for custom feeds (plugin_config.html): - File input and Upload button were still active but should be disabled - Removed onchange/onclick handlers, added disabled and aria-disabled - Added visible disabled styling and tooltip - Existing logos continue to display but uploads are prevented - Matches PR objectives to disable upload until fully implemented 3. Handle bracket notation array fields (api_v3.py): - checkbox-group uses name="field_name[]" which sends multiple values - request.form.to_dict() collapses duplicate keys (only keeps last value) - Added handling to detect fields ending with "[]" before to_dict() - Use request.form.getlist() to get all values, combine as comma-separated - Processed before existing array index field handling - Fixes checkbox-group losing all but last selected value --- web_interface/blueprints/api_v3.py | 28 ++++++++++++++++++- web_interface/static/v3/plugins_manager.js | 5 ++-- .../templates/v3/partials/plugin_config.html | 9 ++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index de16d59e0..25e9a7ce2 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -3335,7 +3335,33 @@ def save_plugin_config(): # Form fields can use dot notation for nested values (e.g., "transition.type") form_data = request.form.to_dict() - # First pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array) + # First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group) + # These fields use getlist() to preserve all values, then replace in form_data + bracket_array_fields = {} # Maps base field path to list of values + for key in request.form.keys(): + # Check if key ends with "[]" (bracket notation for array fields) + if key.endswith('[]'): + base_path = key[:-2] # Remove "[]" suffix + values = request.form.getlist(key) + if values: + bracket_array_fields[base_path] = values + # Remove the bracket notation key from form_data if present + if key in form_data: + del form_data[key] + + # Process bracket notation fields and add to form_data as comma-separated strings + for base_path, values in bracket_array_fields.items(): + # Get schema property to verify it's an array + base_prop = _get_schema_property(schema, base_path) + if base_prop and base_prop.get('type') == 'array': + # Combine values into comma-separated string for consistent parsing + combined_value = ', '.join(str(v) for v in values if v) + form_data[base_path] = combined_value + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Processed bracket notation array field {base_path}: {values} -> {combined_value}") + + # Second pass: detect and combine array index fields (e.g., "text_color.0", "text_color.1" -> "text_color" as array) # This handles cases where forms send array fields as indexed inputs array_fields = {} # Maps base field path to list of (index, value) tuples processed_keys = set() diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 74b3e6a00..7cf6337ed 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -6467,8 +6467,9 @@ window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) { window.updateImageList(fieldId, currentImages); } -// Expose renderArrayObjectItem to window for use by window.addArrayObjectItem +// Expose renderArrayObjectItem and getSchemaProperty to window for use by global functions window.renderArrayObjectItem = renderArrayObjectItem; +window.getSchemaProperty = getSchemaProperty; })(); // End IIFE @@ -6491,7 +6492,7 @@ if (typeof window !== 'undefined') { if (!schema) return; // Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds) - const arraySchema = getSchemaProperty(schema, fullKey); + const arraySchema = window.getSchemaProperty(schema, fullKey); if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) { return; } diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 687ac55dd..516089f27 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -232,10 +232,13 @@ id="{{ field_id }}_logo_{{ item_index }}" accept="image/png,image/jpeg,image/bmp,image/gif" style="display: none;" - onchange="handleCustomFeedLogoUpload(event, '{{ field_id }}', {{ item_index }}, '{{ plugin_id }}', '{{ full_key }}')"> + disabled + aria-disabled="true"> {% if logo_path %} From 97c684aad26e1616021be433b9f599b042153ba1 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 13:47:05 -0500 Subject: [PATCH 45/74] fix: Remove duplicate submit handler to prevent double POSTs Remove document-level submit listener that conflicts with handlePluginConfigSubmit, causing duplicate form submissions with divergent payloads. Problem: - handlePluginConfigSubmit correctly parses JSON from _data fields and maps to flatConfig[baseKey] for patternProperties and array-of-objects - Document-level listener (line 5368) builds its own config without understanding _data convention and posts independently via savePluginConfiguration - Every submit now sends two POSTs with divergent payloads: - First POST: Correct structure with parsed _data fields - Second POST: Incorrect structure with raw _data fields, missing structure - Arrays-of-objects and patternProperties saved incorrectly in second request Solution: - Remove document-level submit listener for #plugin-config-form - Rely solely on handlePluginConfigSubmit which is already attached to the form - handlePluginConfigSubmit properly handles all form-to-config conversion including: - _data field parsing (JSON from hidden fields) - Type-aware conversion using schema - Dot notation to nested object conversion - PatternProperties and array-of-objects support Note: savePluginConfiguration function remains for use by JSON editor saves --- web_interface/static/v3/plugins_manager.js | 42 ++-------------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 7cf6337ed..631a9c367 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -5364,45 +5364,9 @@ function showError(message) { `; } -// Plugin configuration form submission -document.addEventListener('submit', function(e) { - if (e.target.id === 'plugin-config-form') { - e.preventDefault(); - - const formData = new FormData(e.target); - const config = {}; - const schema = currentPluginConfig?.schema; - - // Convert form data to config object - // Note: 'enabled' is managed separately via the header toggle, not through this form - for (let [key, value] of formData.entries()) { - // Skip enabled - it's managed separately via the header toggle - if (key === 'enabled') continue; - - // Check if this field is an array type in the schema - if (schema?.properties?.[key]?.type === 'array') { - // Convert comma-separated string to array - const arrayValue = value.split(',').map(item => item.trim()).filter(item => item.length > 0); - config[key] = arrayValue; - console.log(`Array field ${key}: "${value}" -> `, arrayValue); - } else if (key === 'display_duration' || schema?.properties?.[key]?.type === 'integer') { - config[key] = parseInt(value); - } else if (schema?.properties?.[key]?.type === 'number') { - config[key] = parseFloat(value); - } else if (schema?.properties?.[key]?.type === 'boolean') { - config[key] = value === 'true' || value === true; - } else { - config[key] = value; - } - } - - console.log('Final config to save:', config); - console.log('Schema loaded:', schema ? 'Yes' : 'No'); - - // Save the configuration - savePluginConfiguration(currentPluginConfig.pluginId, config); - } -}); +// 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 From f17214f76c402a23b5fba8f773375a56ebd625d8 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 8 Jan 2026 13:51:21 -0500 Subject: [PATCH 46/74] fix: Use indexed names for checkbox-group to work with existing parser Change checkbox-group widget to use indexed field names instead of bracket notation, so the existing indexed field parser correctly handles multiple selected values. Problem: - checkbox-group uses name="{{ full_key }}[]" which requires bracket notation handling in backend - While bracket notation handler exists, using indexed names is more robust and leverages existing well-tested indexed field parser - Indexed field parser already handles fields like "field_name.0", "field_name.1" correctly Solution: - Template: Change name="{{ full_key }}[]" to name="{{ full_key }}.{{ loop.index0 }}" - JavaScript: Update checkbox-group rendering to use name="." - Backend indexed field parser (lines 3364-3388) already handles this pattern: - Detects fields ending with numeric indices (e.g., ".0", ".1") - Groups them by base_path and sorts by index - Combines into array correctly This ensures checkbox-group values are properly preserved when multiple options are selected, working with the existing schema-based parsing system. --- web_interface/static/v3/plugins_manager.js | 4 ++-- web_interface/templates/v3/partials/plugin_config.html | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 631a9c367..f6a430574 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -3002,7 +3002,7 @@ function generateFieldHtml(key, prop, value, prefix = '') { const labels = xOptions.labels || {}; html += `
`; - enumItems.forEach(option => { + enumItems.forEach((option, index) => { const isChecked = arrayValue.includes(option); const label = labels[option] || option.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); const checkboxId = `${fullKey.replace(/\./g, '_')}_${option}`; @@ -3010,7 +3010,7 @@ function generateFieldHtml(key, prop, value, prefix = '') {