diff --git a/.gitignore b/.gitignore index 933f1e1f..420d421c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ plugins/* !plugins/soccer-scoreboard/ !plugins/calendar/ !plugins/mqtt-notifications/ +!plugins/youtube-stats/ !plugins/olympics-countdown/ !plugins/ledmatrix-stocks/ !plugins/ledmatrix-music/ diff --git a/.gitmodules b/.gitmodules index 24143855..80cf455e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -61,6 +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/README.md b/README.md index 871bb75e..4e784877 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/plugins/7-segment-clock b/plugins/7-segment-clock deleted file mode 160000 index cf58d50b..00000000 --- a/plugins/7-segment-clock +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cf58d50b9083d61ef30b279f90270f11b4e3df40 diff --git a/plugins/basketball-scoreboard b/plugins/basketball-scoreboard index f593b3bc..a250ebe8 160000 --- a/plugins/basketball-scoreboard +++ b/plugins/basketball-scoreboard @@ -1 +1 @@ -Subproject commit f593b3bc39a4754ae72a8716518c5bccbf89c931 +Subproject commit a250ebe8f1858b3350c13101b5d949838aa0e19a diff --git a/plugins/youtube-stats b/plugins/youtube-stats new file mode 160000 index 00000000..a305591b --- /dev/null +++ b/plugins/youtube-stats @@ -0,0 +1 @@ +Subproject commit a305591b069d9332a773b1e16c107d48e0c45566 diff --git a/scripts/install/one-shot-install.sh b/scripts/install/one-shot-install.sh new file mode 100755 index 00000000..abe5456c --- /dev/null +++ b/scripts/install/one-shot-install.sh @@ -0,0 +1,382 @@ +#!/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") + # 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)" + 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 + # 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 "" + 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 "$@" diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index bd0cbb28..ffec483c 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -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 diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 59ccb49f..2f13ac25 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -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 = `
`; + + // 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 += `
`; + + // 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) { + html += `

${escapeHtml(propDescription)}

`; + } + 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 += ` +
+
+ Logo + File upload not yet available for array items +
+
+ `; + } else { + html += ` +
+ +

File upload functionality for array items is coming soon

+
+ `; + } + + 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()); @@ -2765,23 +2879,26 @@ function generateFieldHtml(key, prop, value, prefix = '') { `; } 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 += `
`; - 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 += ` `; }); html += `
`; + // Hidden input to store selected values as JSON array (like array-of-objects pattern) + 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 propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default; + const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + html += `
`; + if (propSchema.type === 'boolean') { + const checked = propValue ? 'checked' : ''; + html += ``; + } else { + // Escape HTML to prevent XSS + const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') : (propValue || ''); + 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) { @@ -2919,10 +3130,10 @@ function generateFieldHtml(key, prop, value, prefix = '') { } else { arrayValue = ''; } - html += ` - -

Enter values separated by commas

- `; + html += ` + +

Enter values separated by commas

+ `; } } else if (prop.enum) { html += ``; + } 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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') : (propValue || ''); + itemHtml += ``; + } + itemHtml += ``; + }); + itemHtml += ``; + } + 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_ 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; diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index d36beca9..ce3fee56 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4818,7 +4818,225 @@ - + + + + + {% 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 %} +
+ {# Hidden input to store selected values as JSON array (like array-of-objects pattern) #} + {% 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 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 #} +

+ + 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 %} +
+
+ + + + +
+ +
+ {% 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 []) %} + +

Separate multiple values with commas

+ {% endif %} {% endif %} {# Text input (default) #}