Feature/one shot installer (#175)

* 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.

* fix(7-segment-clock): Update submodule with separator and spacing fixes

* 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.)

* Add array-of-objects widget support to web UI

- Add support for rendering arrays of objects in web UI (for custom_feeds)
- Implement add/remove/update functions for array-of-objects widgets
- Support file-upload widgets within array items
- Update form data handling to support array JSON data fields

* Update plugins_manager.js cache-busting version

Update version parameter to force browser to load new JavaScript with array-of-objects widget support.

* Fix: Move array-of-objects detection before file-upload/checkbox checks

Move the array-of-objects widget detection to the top of the array handler so it's checked before file-upload and checkbox-group widgets. This ensures custom_feeds is properly detected as an array of objects.

* Update cache-busting version for array-of-objects fix

* Remove duplicate array-of-objects check

* Update cache version again

* Add array-of-objects widget support to server-side template

Add detection and rendering for array-of-objects in the Jinja2 template (plugin_config.html).
This enables the custom_feeds widget to display properly with name, URL, enabled checkbox, and logo upload fields.

The widget is detected by checking if prop.items.type == 'object' && prop.items.properties,
and is rendered before the file-upload widget check.

* Use window. prefix for array-of-objects JavaScript functions

Explicitly use window.addArrayObjectItem, window.removeArrayObjectItem, etc.
in the template to ensure the functions are accessible from inline event handlers.
Also add safety checks to prevent errors if functions aren't loaded yet.

* Fix syntax error: Missing indentation for html += in array else block

The html += statement was outside the else block, causing a syntax error.
Fixed by properly indenting it inside the else block.

* Update cache version for syntax fix

* Add debug logging to diagnose addArrayObjectItem availability

* Fix: Wrap array-of-objects functions in window check and move outside IIFE

Ensure functions are available globally by wrapping them in a window check
and ensuring they're defined outside any IIFE scope. Also fix internal
function calls to use window.updateArrayObjectData for consistency.

* Update cache version for array-of-objects fix

* Move array-of-objects functions outside IIFE to make them globally available

The functions were inside the IIFE scope, making them inaccessible from
inline event handlers. Moving them outside the IIFE ensures they're
available on window when the script loads.

* Update cache version for IIFE fix

* Fix: Add array-of-objects functions after IIFE ends

The functions were removed from inside the IIFE but never added after it.
Also removed orphaned code that was causing syntax errors.

* Update cache version for array-of-objects fix

* Fix: Remove all orphaned code and properly add array-of-objects functions after IIFE

* Add array-of-objects functions after IIFE ends

These functions must be outside the IIFE to be accessible from inline
event handlers in the server-rendered template.

* Update cache version for syntax fix

* Fix syntax error: Add missing closing brace for else block

* Update cache version for syntax fix

* Replace complex array-of-objects widget with simple table interface

- Replace nested array-of-objects widget with clean table interface
- Table shows: Name, URL, Logo (with upload), Enabled checkbox, Delete button
- Fix file-upload widget detection order to prevent breaking static-image plugin
- Add simple JavaScript functions for add/remove rows and logo upload
- Much more intuitive and easier to use

* 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

* 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

* 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)

* 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)

* 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.

* 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

* 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_<digits> 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.

* 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

* fix: Update upload button onclick when reindexing custom feed rows

Fix removeCustomFeedRow to update button onclick handlers that reference
file input IDs with _logo_<index> 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_<num>') patterns in onclick handlers
- Other _logo_<num> patterns in button onclick strings
- Function parameter indices in onclick handlers

This ensures upload buttons continue to work correctly after row deletion.

* 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.

* 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

* fix: Add hidden input for enabled checkbox to ensure false is submitted

Add hidden input with value='false' before enabled checkbox in custom feeds
table to ensure an explicit false value is sent when checkbox is unchecked.

Pattern implemented:
- Hidden input: name='enabled', value='false' (always submitted)
- Checkbox: name='enabled', value='true' (only submitted when checked)
- When unchecked: only hidden input submits (false)
- When checked: both submit, checkbox value (true) overwrites hidden

Updated in two places:
- Template checkbox in plugin_config.html (existing rows)
- JavaScript addCustomFeedRow function in base.html (new rows)

Backend verification:
- Backend (api_v3.py) handles string boolean values and converts properly
- JavaScript form processing explicitly checks element.checked, independent of this pattern
- Standard form submission uses last value when multiple values share same name

* 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

* 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.

* 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.

* 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

* 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')

* 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

* 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

* 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.

* fix: Set values from item data in fallback array-of-objects rendering

Fix fallback code path for rendering array-of-objects items to properly
set input values from existing item data, matching behavior of proper
renderArrayObjectItem function.

Problem:
- Fallback code at lines 3078-3091 and 6471-6486 creates input elements
  without setting values from existing item data
- Text inputs have no value attribute set
- Checkboxes have no checked attribute computed from item properties
- Users would see empty form fields instead of existing configuration data
- Proper renderArrayObjectItem function correctly sets values (line 2556)

Solution:
- Extract propValue from item data: item[propKey] with schema default fallback
- For text inputs: Set value attribute with HTML-escaped propValue
- For checkboxes: Set checked attribute based on propValue truthiness
- Add inline HTML escaping for XSS prevention (since fallback code may
  run outside IIFE scope where escapeHtml function may not be available)

This ensures fallback rendering displays existing data correctly when
window.renderArrayObjectItem is not available.

* fix: Remove extra closing brace breaking if/else chain

Remove stray closing brace at line 3127 that was breaking the if/else chain
before the 'else if (prop.enum)' branch, causing 'Unexpected token else'
syntax error.

Problem:
- Extra '}' at line 3127 closed the prop.type === 'array' block prematurely
- This broke the if/else chain, causing syntax error when parser reached
  'else if (prop.enum)' at line 3128
- Structure was: } else if (array) { ... } } } else if (enum) - extra brace

Solution:
- Removed the extra closing brace at line 3127
- Structure now correctly: } else if (array) { ... } } else if (enum)
- Verified with Node.js syntax checker - no errors

* fix: Remove local logger assignments to prevent UnboundLocalError

Remove all local logger assignments inside save_plugin_config function that
were shadowing the module-level logger, causing UnboundLocalError when nested
helpers like normalize_config_values() or debug checks reference logger before
those assignments run.

Problem:
- Module-level logger exists at line 13: logger = logging.getLogger(__name__)
- Multiple local assignments inside save_plugin_config (lines 3361, 3401, 3421,
  3540, 3660, 3977, 4093, 4118) make logger a local variable for entire function
- Python treats logger as local for entire function scope when any assignment
  exists, causing UnboundLocalError if logger is used before assignments
- Nested helpers like normalize_config_values() or debug checks that reference
  logger before local assignments would fail

Solution:
- Removed all local logger = logging.getLogger(__name__) assignments in
  save_plugin_config function
- Use module-level logger directly throughout the function
- Removed redundant import logging statements that were only used for logger
- This ensures logger is always available and references the module-level logger

All logger references now use the module-level logger without shadowing.

* fix: Fix checkbox-group serialization and array-of-objects key leakage

Multiple fixes for array-of-objects and checkbox-group widgets:

1. Fix checkbox-group serialization (JS and template):
   - Changed from indexed names (categories.0, categories.1) to _data pattern
   - Added updateCheckboxGroupData() function to sync selected values
   - Hidden input stores JSON array of selected enum values
   - Checkboxes use data-checkbox-group and data-option-value attributes
   - Fixes issue where config.categories became {0: true, 1: true} instead of ['nfl', 'nba']
   - Now correctly serializes to array using existing _data handling logic

2. Prevent array-of-objects per-item key leakage:
   - Added skip pattern in handlePluginConfigSubmit for _item_<n>_ names
   - Removed name attributes from per-item inputs in renderArrayObjectItem
   - Per-item inputs now rely solely on hidden _data field
   - Prevents feeds_item_0_name from leaking into flatConfig

3. Add type coercion to updateArrayObjectData:
   - Consults itemsSchema.properties[propKey].type for coercion
   - Handles integer and number types correctly
   - Preserves string values as-is
   - Ensures numeric fields in array items are stored as numbers

4. Ensure currentPluginConfig is always available:
   - Updated addArrayObjectItem to check window.currentPluginConfig first
   - Added error logging if schema not available
   - Prevents ReferenceError when global helpers need schema

This ensures checkbox-group arrays serialize correctly and array-of-objects
per-item fields don't leak extra keys into the configuration.

* fix: Make _data field matching more specific to prevent false positives

Fix overly broad condition that matched any field containing '_data',
causing false positives and inconsistent key transformation.

Problem:
- Condition 'key.endsWith('_data') || key.includes('_data')' matches any
  field containing '_data' anywhere (e.g., 'meta_data_field', 'custom_data_config')
- key.replace(/_data$/, '') only removes '_data' from end, making logic inconsistent
- Fields with '_data' in middle get matched but key isn't transformed
- If their value happens to be valid JSON, it gets incorrectly parsed

Solution:
- Remove 'key.includes('_data')' clause
- Only check 'key.endsWith('_data')' to match actual _data suffix pattern
- Ensures consistent matching: only fields ending with '_data' are treated
  as JSON data fields, and only those get the suffix removed
- Prevents false positives on fields like 'meta_data_field' that happen to
  contain '_data' in their name

* fix: Add HTML escaping to prevent XSS in fallback code and checkbox-group

Add proper HTML escaping for schema-derived values to prevent XSS vulnerabilities
in fallback rendering code and checkbox-group widget.

Problem:
- Fallback code in generateFieldHtml (line 3094) doesn't escape propLabel
  when building HTML strings, while main renderArrayObjectItem uses escapeHtml()
- Checkbox-group widget (lines 3012-3025) doesn't escape option or label values
- While risk is limited (values come from plugin schemas), malicious plugin
  schemas or untrusted schema sources could inject XSS
- Inconsistent with main renderArrayObjectItem which properly escapes

Solution:
- Added escapeHtml() calls for propLabel in fallback array-of-objects rendering
  (both locations: generateFieldHtml and addArrayObjectItem fallback)
- Added escapeHtml() calls for option values in checkbox-group widget:
  - checkboxId (contains option)
  - data-option-value attribute
  - value attribute
  - label text in span
- Ensures consistent XSS protection across all rendering paths

This prevents potential XSS if plugin schemas contain malicious HTML/script
content in enum values or property titles.

---------

Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
Chuck
2026-01-08 15:38:08 -05:00
committed by GitHub
parent 3fa032f7f6
commit 7f230f625d
11 changed files with 1337 additions and 93 deletions

View File

@@ -3335,7 +3335,31 @@ 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
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()
@@ -3371,8 +3395,6 @@ def save_plugin_config():
# Parse as array using schema
parsed_value = _parse_form_value_with_schema(combined_value, base_path, schema)
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}")
# Only set if not skipped
if parsed_value is not _SKIP_FIELD:
@@ -3391,8 +3413,6 @@ def save_plugin_config():
if schema:
prop = _get_schema_property(schema, key)
if prop and prop.get('type') == 'array':
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}")
# Use helper to set nested values correctly (skips if _SKIP_FIELD)
if parsed_value is not _SKIP_FIELD:
@@ -3486,9 +3506,9 @@ def save_plugin_config():
# 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 +3529,8 @@ def save_plugin_config():
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:
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 +3642,28 @@ def save_plugin_config():
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
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') 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())
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
@@ -3923,8 +3963,6 @@ def save_plugin_config():
# Validate configuration against schema before saving
if schema:
# Log what we're validating for debugging
import logging
logger = logging.getLogger(__name__)
logger.info(f"Validating config for {plugin_id}")
logger.info(f"Config keys being validated: {list(plugin_config.keys())}")
logger.info(f"Full config: {plugin_config}")
@@ -4038,9 +4076,7 @@ def save_plugin_config():
api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
except PermissionError as e:
# Log the error with more details
import logging
import os
logger = logging.getLogger(__name__)
secrets_path = api_v3.config_manager.secrets_path
secrets_dir = os.path.dirname(secrets_path) if secrets_path else None
@@ -4063,9 +4099,7 @@ def save_plugin_config():
)
except Exception as e:
# Log the error but don't fail the entire config save
import logging
import os
logger = logging.getLogger(__name__)
secrets_path = api_v3.config_manager.secrets_path
logger.error(f"Error saving secrets config for {plugin_id}: {e}", exc_info=True)
# Return error response with more context

View File

@@ -2222,15 +2222,17 @@ function handlePluginConfigSubmit(e) {
// Process form data with type conversion (using dot notation for nested fields)
for (const [key, value] of formData.entries()) {
// Check if this is a patternProperties hidden input (contains JSON data)
if (key.endsWith('_data') || key.includes('_data')) {
// Check if this is a patternProperties or array-of-objects hidden input (contains JSON data)
// Only match keys ending with '_data' to avoid false positives like 'meta_data_field'
if (key.endsWith('_data')) {
try {
const baseKey = key.replace(/_data$/, '');
const jsonValue = JSON.parse(value);
if (typeof jsonValue === 'object' && !Array.isArray(jsonValue)) {
// Handle both objects (patternProperties) and arrays (array-of-objects)
if (typeof jsonValue === 'object') {
flatConfig[baseKey] = jsonValue;
console.log(`PatternProperties field ${baseKey}: parsed JSON object`, jsonValue);
continue; // Skip normal processing for patternProperties
console.log(`JSON data field ${baseKey}: parsed ${Array.isArray(jsonValue) ? 'array' : 'object'}`, jsonValue);
continue; // Skip normal processing for JSON data fields
}
} catch (e) {
// Not valid JSON, continue with normal processing
@@ -2242,6 +2244,12 @@ function handlePluginConfigSubmit(e) {
continue;
}
// Skip array-of-objects per-item inputs (they're handled by the hidden _data input)
// Pattern: feeds_item_0_name, feeds_item_1_url, etc.
if (key.includes('_item_') && /_item_\d+_/.test(key)) {
continue;
}
// Try to get schema property - handle both dot notation and underscore notation
let propSchema = getSchemaPropertyType(schema, key);
let actualKey = key;
@@ -2464,6 +2472,112 @@ function flattenConfig(obj, prefix = '') {
}
// Generate field HTML for a single property (used recursively)
// Helper function to render a single item in an array of objects
function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) {
const item = itemValue || {};
const itemId = `${fieldId}_item_${index}`;
let html = `<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${index}">`;
// Render each property of the object
const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties);
propertyOrder.forEach(propKey => {
if (!itemProperties[propKey]) return;
const propSchema = itemProperties[propKey];
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const propDescription = propSchema.description || '';
const propFullKey = `${fullKey}[${index}].${propKey}`;
html += `<div class="mb-3">`;
// 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 += `<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
if (propDescription) {
html += `<p class="text-xs text-gray-500 mb-2">${escapeHtml(propDescription)}</p>`;
}
const uploadConfig = propSchema['x-upload-config'] || {};
const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || 'ledmatrix-news';
const logoValue = propValue || {};
// Display existing logo if present, but disable upload functionality
if (logoValue.path) {
html += `
<div class="file-upload-widget-inline">
<div class="mt-2 flex items-center space-x-2">
<img src="/${logoValue.path}" alt="Logo" class="w-16 h-16 object-cover rounded border">
<span class="text-sm text-gray-500 italic">File upload not yet available for array items</span>
</div>
</div>
`;
} else {
html += `
<div class="file-upload-widget-inline">
<button type="button"
disabled
class="px-3 py-2 text-sm bg-gray-200 text-gray-400 rounded-md cursor-not-allowed opacity-50"
title="File upload for array items is not yet implemented">
<i class="fas fa-upload mr-1"></i> Upload Logo (Not Available)
</button>
<p class="text-xs text-gray-500 mt-1 italic">File upload functionality for array items is coming soon</p>
</div>
`;
}
html += `</div>`;
} else if (propSchema.type === 'boolean') {
// Boolean checkbox
html += `
<label class="flex items-center">
<input type="checkbox"
id="${itemId}_${propKey}"
data-prop-key="${propKey}"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
${propValue ? 'checked' : ''}
onchange="updateArrayObjectData('${fieldId}')">
<span class="ml-2 text-sm text-gray-700">${escapeHtml(propLabel)}</span>
</label>
`;
} else {
// Regular text/string input
html += `
<label for="${itemId}_${propKey}" class="block text-sm font-medium text-gray-700 mb-1">
${escapeHtml(propLabel)}
</label>
`;
if (propDescription) {
html += `<p class="text-xs text-gray-500 mb-1">${escapeHtml(propDescription)}</p>`;
}
html += `
<input type="${propSchema.format === 'uri' ? 'url' : 'text'}"
id="${itemId}_${propKey}"
data-prop-key="${propKey}"
class="block w-full 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 bg-white text-black"
value="${escapeHtml(propValue || '')}"
placeholder="${propSchema.format === 'uri' ? 'https://example.com/feed' : ''}"
onchange="updateArrayObjectData('${fieldId}')">
`;
}
html += `</div>`;
});
html += `
<button type="button"
onclick="removeArrayObjectItem('${fieldId}', ${index})"
class="mt-2 px-3 py-2 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
<i class="fas fa-trash mr-1"></i> Remove Feed
</button>
</div>`;
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());
@@ -2765,23 +2879,26 @@ function generateFieldHtml(key, prop, value, prefix = '') {
<input type="number" id="${fullKey}" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} class="mt-1 block w-full 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 bg-white text-black placeholder:text-gray-500">
`;
} else if (prop.type === 'array') {
// Check if this is a file upload widget - try multiple ways to access x-widget
// 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;
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),
propString: JSON.stringify(prop),
value: value
});
// Check for file-upload widget - be more defensive
// 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'] || {};
@@ -2883,33 +3000,127 @@ function generateFieldHtml(key, prop, value, prefix = '') {
`;
} else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') {
// Checkbox group widget for multi-select arrays with enum items
// Use _data hidden input pattern to serialize selected values correctly
console.log(`[DEBUG] ✅ Detected checkbox-group widget for ${fullKey} - rendering checkboxes`);
const arrayValue = Array.isArray(value) ? value : (prop.default || []);
const enumItems = prop.items && prop.items.enum ? prop.items.enum : [];
const xOptions = prop['x-options'] || {};
const labels = xOptions.labels || {};
const fieldId = fullKey.replace(/\./g, '_');
html += `<div class="mt-1 space-y-2">`;
enumItems.forEach(option => {
enumItems.forEach((option) => {
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}`;
const checkboxId = `${fieldId}_${escapeHtml(option)}`;
html += `
<label class="flex items-center">
<input type="checkbox"
id="${checkboxId}"
name="${fullKey}[]"
value="${option}"
data-checkbox-group="${fieldId}"
data-option-value="${escapeHtml(option)}"
value="${escapeHtml(option)}"
${isChecked ? 'checked' : ''}
onchange="updateCheckboxGroupData('${fieldId}')"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-700">${label}</span>
<span class="ml-2 text-sm text-gray-700">${escapeHtml(label)}</span>
</label>
`;
});
html += `</div>`;
// Hidden input to store selected values as JSON array (like array-of-objects pattern)
html += `<input type="hidden" id="${fieldId}_data" name="${fullKey}_data" value='${JSON.stringify(arrayValue).replace(/'/g, "&#39;")}'>`;
} 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 += `
<input type="text" id="${fullKey}" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full 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 bg-white text-black placeholder:text-gray-500">
<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>
`;
} 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 += `
<input type="text" id="${fullKey}" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full 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 bg-white text-black placeholder:text-gray-500">
<p class="text-sm text-gray-600 mt-1">Enter values separated by commas (custom feeds table rendered server-side)</p>
`;
}
} 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 += `
<div class="array-of-objects-container mt-1">
<div id="${fieldId}_items" class="space-y-4">
`;
// 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 += `<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${index}">`;
Object.keys(itemProperties || {}).forEach(propKey => {
const propSchema = itemProperties[propKey];
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
html += `<div class="mb-3"><label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
if (propSchema.type === 'boolean') {
const checked = propValue ? 'checked' : '';
html += `<input type="checkbox" data-prop-key="${propKey}" ${checked} class="h-4 w-4 text-blue-600" onchange="window.updateArrayObjectData('${fieldId}')">`;
} else {
// Escape HTML to prevent XSS
const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;') : (propValue || '');
html += `<input type="text" data-prop-key="${propKey}" value="${escapedValue}" class="block w-full px-3 py-2 border border-gray-300 rounded-md" onchange="window.updateArrayObjectData('${fieldId}')">`;
}
html += `</div>`;
});
html += `<button type="button" onclick="window.removeArrayObjectItem('${fieldId}', ${index})" class="mt-2 px-3 py-2 text-red-600 hover:text-red-800">Remove</button></div>`;
}
});
html += `
</div>
<button type="button"
onclick="window.addArrayObjectItem('${fieldId}', '${fullKey}', ${maxItems})"
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
${currentItems.length >= maxItems ? 'disabled style="opacity: 0.5; cursor: not-allowed;"' : ''}>
<i class="fas fa-plus mr-1"></i> Add Item
</button>
<input type="hidden" id="${fieldId}_data" name="${fullKey}_data" value='${JSON.stringify(currentItems).replace(/'/g, "&#39;")}'>
</div>
`;
} 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) {
@@ -2919,10 +3130,10 @@ function generateFieldHtml(key, prop, value, prefix = '') {
} else {
arrayValue = '';
}
html += `
<input type="text" id="${fullKey}" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full 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 bg-white text-black placeholder:text-gray-500">
<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>
`;
html += `
<input type="text" id="${fullKey}" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full 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 bg-white text-black placeholder:text-gray-500">
<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>
`;
}
} else if (prop.enum) {
html += `<select id="${fullKey}" name="${fullKey}" class="mt-1 block w-full 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 bg-white text-black">`;
@@ -5167,45 +5378,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
@@ -6270,8 +6445,285 @@ window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
window.updateImageList(fieldId, currentImages);
}
// Expose renderArrayObjectItem and getSchemaProperty to window for use by global functions
window.renderArrayObjectItem = renderArrayObjectItem;
window.getSchemaProperty = getSchemaProperty;
})(); // End IIFE
// Functions to handle array-of-objects
// Define these at the top level (outside any IIFE) to ensure they're always available
if (typeof window !== 'undefined') {
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!itemsContainer || !hiddenInput) return;
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
if (currentItems.length >= maxItems) {
alert(`Maximum ${maxItems} items allowed`);
return;
}
// Get schema for item properties - ensure currentPluginConfig is available
// Try window.currentPluginConfig first (most reliable), then currentPluginConfig
const schema = (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema) ||
(typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema);
if (!schema) {
console.error('addArrayObjectItem: Schema not available. currentPluginConfig may not be set.');
return;
}
// Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds)
const arraySchema = window.getSchemaProperty(schema, fullKey);
if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) {
return;
}
const itemsSchema = arraySchema.items;
if (!itemsSchema || !itemsSchema.properties) return;
const newIndex = currentItems.length;
// Use renderArrayObjectItem if available, otherwise create basic HTML
let itemHtml = '';
if (typeof window.renderArrayObjectItem === 'function') {
itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
} else {
// Fallback: create basic HTML structure
// Note: newItem is {} for newly added items, so this will use schema defaults
const newItem = {};
itemHtml = `<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${newIndex}">`;
Object.keys(itemsSchema.properties || {}).forEach(propKey => {
const propSchema = itemsSchema.properties[propKey];
const propValue = newItem[propKey] !== undefined ? newItem[propKey] : propSchema.default;
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
itemHtml += `<div class="mb-3"><label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
if (propSchema.type === 'boolean') {
const checked = propValue ? 'checked' : '';
// No name attribute - rely solely on _data field to prevent key leakage
itemHtml += `<input type="checkbox" data-prop-key="${propKey}" ${checked} class="h-4 w-4 text-blue-600" onchange="window.updateArrayObjectData('${fieldId}')">`;
} else {
// Escape HTML to prevent XSS
// No name attribute - rely solely on _data field to prevent key leakage
const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;') : (propValue || '');
itemHtml += `<input type="text" data-prop-key="${propKey}" value="${escapedValue}" class="block w-full px-3 py-2 border border-gray-300 rounded-md" onchange="window.updateArrayObjectData('${fieldId}')">`;
}
itemHtml += `</div>`;
});
itemHtml += `<button type="button" onclick="window.removeArrayObjectItem('${fieldId}', ${newIndex})" class="mt-2 px-3 py-2 text-red-600 hover:text-red-800">Remove</button></div>`;
}
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
window.updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && currentItems.length + 1 >= maxItems) {
addButton.disabled = true;
addButton.style.opacity = '0.5';
addButton.style.cursor = 'not-allowed';
}
};
window.removeArrayObjectItem = function(fieldId, index) {
const itemsContainer = document.getElementById(fieldId + '_items');
if (!itemsContainer) return;
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
if (item) {
item.remove();
// Re-index remaining items
// Use data-index for index storage - no need to encode index in onclick strings or IDs
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
remainingItems.forEach((itemEl, newIndex) => {
itemEl.setAttribute('data-index', newIndex);
// Update all inputs within this item - only update index in array bracket notation
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
const name = input.getAttribute('name');
const id = input.id;
if (name) {
// Only replace index in bracket notation like [0], [1], etc.
// Match pattern: field_name[index] but not field_name123
const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`);
input.setAttribute('name', newName);
}
if (id) {
// Only update index in specific patterns like _item_0, _item_1
// Match pattern: _item_<digits> but be careful not to break other numeric IDs
const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`);
input.id = newId;
}
});
// 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) {
// Match patterns like:
// removeArrayObjectItem('fieldId', 0)
// handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId')
// removeArrayObjectFile('fieldId', 0, 'propKey')
// Only replace the numeric index parameter (second or third argument depending on function)
let newOnclick = onclick;
// For removeArrayObjectItem('fieldId', index) - second param
newOnclick = newOnclick.replace(
/removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g,
`removeArrayObjectItem('$1', ${newIndex})`
);
// For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param
newOnclick = newOnclick.replace(
/handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
`handleArrayObjectFileUpload(event, '$1', ${newIndex},`
);
// For removeArrayObjectFile('fieldId', index, ...) - second param
newOnclick = newOnclick.replace(
/removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
`removeArrayObjectFile('$1', ${newIndex},`
);
button.setAttribute('onclick', newOnclick);
}
});
});
window.updateArrayObjectData(fieldId);
// Update add button state
const addButton = itemsContainer.nextElementSibling;
if (addButton && addButton.getAttribute('onclick')) {
// Extract maxItems from onclick attribute more safely
// Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems)
const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/);
if (onclickMatch && onclickMatch[1]) {
const maxItems = parseInt(onclickMatch[1]);
if (remainingItems.length < maxItems) {
addButton.disabled = false;
addButton.style.opacity = '1';
addButton.style.cursor = 'pointer';
}
}
}
}
};
window.updateArrayObjectData = function(fieldId) {
const itemsContainer = document.getElementById(fieldId + '_items');
const hiddenInput = document.getElementById(fieldId + '_data');
if (!itemsContainer || !hiddenInput) return;
// Get schema for type coercion
const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema);
// Extract fullKey from hidden input name (e.g., "feeds_data" -> "feeds")
const fullKey = hiddenInput.getAttribute('name').replace(/_data$/, '');
let itemsSchema = null;
if (schema && typeof window.getSchemaProperty === 'function') {
const arraySchema = window.getSchemaProperty(schema, fullKey);
if (arraySchema && arraySchema.type === 'array' && arraySchema.items && arraySchema.items.properties) {
itemsSchema = arraySchema.items;
}
}
const items = [];
const itemElements = itemsContainer.querySelectorAll('.array-object-item');
itemElements.forEach((itemEl, index) => {
const item = {};
const itemProperties = itemsSchema ? itemsSchema.properties : {};
// 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') {
let value = input.value.trim();
// Type coercion based on schema
if (itemsSchema && itemProperties[propKey]) {
const propSchema = itemProperties[propKey];
const propType = propSchema.type;
if (propType === 'integer') {
const numValue = parseInt(value, 10);
value = isNaN(numValue) ? value : numValue;
} else if (propType === 'number') {
const numValue = parseFloat(value);
value = isNaN(numValue) ? value : numValue;
}
// string and other types keep as-is
}
item[propKey] = value;
}
});
// Handle checkboxes
itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
const propKey = checkbox.getAttribute('data-prop-key');
if (propKey) {
item[propKey] = checkbox.checked;
}
});
// Handle file upload data (stored in data attributes)
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.updateCheckboxGroupData = function(fieldId) {
// Update hidden _data input with currently checked values
const hiddenInput = document.getElementById(fieldId + '_data');
if (!hiddenInput) return;
const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-checkbox-group="${fieldId}"]`);
const selectedValues = [];
checkboxes.forEach(checkbox => {
if (checkbox.checked) {
const optionValue = checkbox.getAttribute('data-option-value') || checkbox.value;
selectedValues.push(optionValue);
}
});
hiddenInput.value = JSON.stringify(selectedValues);
};
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);
};
// 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)
window.currentPluginConfig = null;

View File

@@ -4818,7 +4818,225 @@
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20241223j" defer></script>
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20250104i" defer></script>
<!-- Custom feeds table helper functions -->
<script>
function addCustomFeedRow(fieldId, fullKey, maxItems) {
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
const currentRows = tbody.querySelectorAll('.custom-feed-row');
if (currentRows.length >= maxItems) {
alert(`Maximum ${maxItems} feeds allowed`);
return;
}
const newIndex = currentRows.length;
const newRow = document.createElement('tr');
newRow.className = 'custom-feed-row';
newRow.setAttribute('data-index', newIndex);
newRow.innerHTML = `
<td class="px-4 py-3 whitespace-nowrap">
<input type="text"
name="${fullKey}.${newIndex}.name"
value=""
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
placeholder="Feed Name"
required>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<input type="url"
name="${fullKey}.${newIndex}.url"
value=""
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
placeholder="https://example.com/feed"
required>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center space-x-2">
<input type="file"
id="${fieldId}_logo_${newIndex}"
accept="image/png,image/jpeg,image/bmp,image/gif"
style="display: none;"
onchange="handleCustomFeedLogoUpload(event, '${fieldId}', ${newIndex}, 'ledmatrix-news', '${fullKey}')">
<button type="button"
onclick="document.getElementById('${fieldId}_logo_${newIndex}').click()"
class="px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded">
<i class="fas fa-upload mr-1"></i> Upload
</button>
<span class="text-xs text-gray-400">No logo</span>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<input type="hidden" name="${fullKey}.${newIndex}.enabled" value="false">
<input type="checkbox"
name="${fullKey}.${newIndex}.enabled"
checked
value="true"
class="h-4 w-4 text-blue-600">
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<button type="button"
onclick="removeCustomFeedRow(this)"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
</button>
</td>
`;
tbody.appendChild(newRow);
}
function removeCustomFeedRow(button) {
const row = button.closest('tr');
if (!row) return;
if (confirm('Remove this feed?')) {
const tbody = row.parentElement;
if (!tbody) return;
row.remove();
// Re-index remaining rows
const rows = tbody.querySelectorAll('.custom-feed-row');
rows.forEach((r, index) => {
r.setAttribute('data-index', index);
// Update all input names with new index
r.querySelectorAll('input, button').forEach(input => {
const name = input.getAttribute('name');
if (name) {
// Replace pattern like "feeds.custom_feeds.0.name" with "feeds.custom_feeds.1.name"
input.setAttribute('name', name.replace(/\.\d+\./, `.${index}.`));
}
const id = input.id;
if (id && id.includes('_logo_')) {
const newId = id.replace(/_logo_\d+/, `_logo_${index}`);
input.id = newId;
const onclick = input.getAttribute('onclick');
if (onclick) {
input.setAttribute('onclick', onclick.replace(/,\s*\d+\s*,/, `, ${index},`));
}
const onchange = input.getAttribute('onchange');
if (onchange) {
input.setAttribute('onchange', onchange.replace(/,\s*\d+\s*,/, `, ${index},`));
}
}
// Update button onclick handlers that reference file input IDs with _logo_<num>
// 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_<num>') with getElementById('..._logo_<newIndex>')
updatedOnclick = updatedOnclick.replace(
/getElementById\(['"]([^'"]*_logo_)\d+['"]\)/g,
`getElementById('$1${index}')`
);
// Also handle patterns like _logo_<num> 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);
}
}
}
});
});
}
}
function handleCustomFeedLogoUpload(event, fieldId, index, pluginId, fullKey) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.data.files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) {
const logoCell = row.querySelector('td:nth-child(3)');
const existingPathInput = logoCell.querySelector('input[name*=".logo.path"]');
const existingIdInput = logoCell.querySelector('input[name*=".logo.id"]');
const pathName = existingPathInput ? existingPathInput.name : `${fullKey}.${index}.logo.path`;
const idName = existingIdInput ? existingIdInput.name : `${fullKey}.${index}.logo.id`;
// 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,image/gif';
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'));
}
})
.catch(error => {
console.error('Upload error:', error);
alert('Upload failed: ' + error.message);
});
}
</script>
<!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">

View File

@@ -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 if it's a file upload widget #}
{# 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,16 +152,148 @@
<!-- Hidden input to store image data -->
<input type="hidden" id="{{ field_id }}_images_data" name="{{ full_key }}" value='{{ array_value|tojson|safe }}'>
</div>
{% 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 {} %}
<div class="mt-1 space-y-2">
{% 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(' ', '_') %}
<label class="flex items-center cursor-pointer">
<input type="checkbox"
id="{{ checkbox_id }}"
data-checkbox-group="{{ field_id }}"
data-option-value="{{ option }}"
value="{{ option }}"
{% if is_checked %}checked{% endif %}
onchange="updateCheckboxGroupData('{{ field_id }}')"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-700">{{ option_label }}</span>
</label>
{% endfor %}
</div>
{# Hidden input to store selected values as JSON array (like array-of-objects pattern) #}
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}_data" value='{{ array_value|tojson|safe }}'>
{% else %}
{# Regular array input (comma-separated) #}
{% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %}
<input type="text"
id="{{ field_id }}"
name="{{ full_key }}"
value="{{ array_value|join(', ') if array_value is iterable and array_value is not string else '' }}"
placeholder="Enter values separated by commas"
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">
<p class="text-xs text-gray-400 mt-1">Separate multiple values with commas</p>
{# Check for custom-feeds widget first #}
{% set items_schema = prop.get('items') or {} %}
{% 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', {}) %}
{% if not (item_properties.get('name') and item_properties.get('url')) %}
{# Fallback to generic if schema doesn't match expected structure #}
<p class="text-xs text-amber-600 mt-1">
<i class="fas fa-exclamation-triangle mr-1"></i>
Custom feeds widget requires 'name' and 'url' properties in items schema.
</p>
{% 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 []) %}
<div class="custom-feeds-table-container mt-1">
<table class="min-w-full divide-y divide-gray-200 border border-gray-300 rounded-lg">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Logo</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Enabled</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
{% for item in array_value %}
{% set item_index = loop.index0 %}
<tr class="custom-feed-row" data-index="{{ item_index }}">
<td class="px-4 py-3 whitespace-nowrap">
<input type="text"
name="{{ full_key }}.{{ item_index }}.name"
value="{{ item.get('name', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
placeholder="Feed Name"
required>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<input type="url"
name="{{ full_key }}.{{ item_index }}.url"
value="{{ item.get('url', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
placeholder="https://example.com/feed"
required>
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% set logo_value = item.get('logo') or {} %}
{% set logo_path = logo_value.get('path', '') %}
<div class="flex items-center space-x-2">
<input type="file"
id="{{ field_id }}_logo_{{ item_index }}"
accept="image/png,image/jpeg,image/bmp,image/gif"
style="display: none;"
disabled
aria-disabled="true">
<button type="button"
disabled
aria-disabled="true"
class="px-2 py-1 text-xs bg-gray-200 text-gray-400 rounded cursor-not-allowed opacity-50"
title="Logo upload for custom feeds is not yet implemented">
<i class="fas fa-upload mr-1"></i> Upload
</button>
{% if logo_path %}
<img src="/{{ logo_path }}" alt="Logo" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.path" value="{{ logo_path }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id', '') }}">
{% else %}
<span class="text-xs text-gray-400">No logo</span>
{% endif %}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.enabled" value="false">
<input type="checkbox"
name="{{ full_key }}.{{ item_index }}.enabled"
{% if item.get('enabled', true) %}checked{% endif %}
value="true"
class="h-4 w-4 text-blue-600">
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<button type="button"
onclick="removeCustomFeedRow(this)"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<button type="button"
onclick="addCustomFeedRow('{{ field_id }}', '{{ full_key }}', {{ max_items }})"
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md"
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
<i class="fas fa-plus mr-1"></i> Add Feed
</button>
</div>
{% 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 []) %}
<input type="text"
id="{{ field_id }}"
name="{{ full_key }}"
value="{{ array_value|join(', ') if array_value is iterable and array_value is not string else '' }}"
placeholder="Enter values separated by commas"
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">
<p class="text-xs text-gray-400 mt-1">Separate multiple values with commas</p>
{% endif %}
{% endif %}
{# Text input (default) #}