From 079598b2846b8aadb1aa1a219a823a77b894b427 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 3 Jan 2026 10:47:21 -0500 Subject: [PATCH 01/16] 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 dc50cdc5..aa8c32cb 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/16] 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 61a9c71d..cf58d50b 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/16] 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 353714e8..59ccb49f 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/16] 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 d36beca9..8908aa2a 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4818,7 +4818,7 @@

- +