fix(web): render file-upload drop zone for string-type config fields (#271)

* feat: add March Madness plugin and tournament round logos

New dedicated March Madness plugin with scrolling tournament ticker:
- Fetches NCAA tournament data from ESPN scoreboard API
- Shows seeded matchups with team logos, live scores, and round separators
- Highlights upsets (higher seed beating lower seed) in gold
- Auto-enables during tournament window (March 10 - April 10)
- Configurable for NCAAM and NCAAW tournaments
- Vegas mode support via get_vegas_content()

Tournament round logo assets:
- MARCH_MADNESS.png, ROUND_64.png, ROUND_32.png
- SWEET_16.png, ELITE_8.png, FINAL_4.png, CHAMPIONSHIP.png

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(store): prevent bulk-update from stalling on bundled/in-repo plugins

Three related bugs caused the bulk plugin update to stall at 3/19:

1. Bundled plugins (e.g. starlark-apps, shipped with LEDMatrix rather
   than the plugin registry) had no metadata file, so update_plugin()
   returned False → API returned 500 → frontend queue halted.
   Fix: check for .plugin_metadata.json with install_type=bundled and
   return True immediately (these plugins update with LEDMatrix itself).

2. git config --get remote.origin.url (without --local) walked up the
   directory tree and found the parent LEDMatrix repo's remote URL for
   plugins that live inside plugin-repos/. This caused the store manager
   to attempt a 60-second git clone of the wrong repo for every update.
   Fix: use --local to scope the lookup to the plugin directory only.

3. hello-world manifest.json had a trailing comma causing JSON parse
   errors on every plugin discovery cycle (fixed on devpi directly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address PR #263 code review findings

- Replace self.is_enabled with BasePlugin.self.enabled in update(),
  display(), and supports_dynamic_duration() so runtime toggles work
- Support quarter-based period labels for NCAAW (Q1..Q4 vs H1..H2),
  detected via league key or status_detail content
- Use live refresh interval (60s) for cache max_age during live games
  instead of hardcoded 300s
- Narrow broad except in _load_round_logos to (OSError, ValueError)
  with a fallback except Exception using logger.exception for traces
- Remove unused `situation` local variable from _parse_event()
- Add numpy>=1.24.0 to requirements.txt (imported but was missing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web): render file-upload drop zone for string-type config fields

String fields with x-widget: "file-upload" were falling through to a
plain text input because the template only handled the array case.
Adds a dedicated drop zone branch for string fields and corresponding
handleSingleFileSelect/handleSingleFileUpload JS handlers that POST to
the x-upload-config endpoint. Fixes credentials.json upload for the
calendar plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address PR #271 code review findings

Inline fixes:
- manager.py: swap min_duration/max_duration if misconfigured, log warning
- manager.py: call session.close() and null session in cleanup() to prevent
  socket leaks on constrained hardware
- manager.py: remove blocking network I/O from display(); update() is the
  sole fetch path (already uses 60s live-game interval)
- manager.py: guard scroll_helper None before create_scrolling_image() in
  _create_ticker_image() to prevent crash when ScrollHelper is unavailable
- store_manager.py: replace bare "except Exception: pass" with debug log
  including plugin_id and path when reading .plugin_metadata.json
- file-upload.js: add endpoint guard (error if uploadEndpoint is falsy),
  client-side extension validation from data-allowed-extensions, and
  response.ok check before response.json() in handleSingleFileUpload
- plugin_config.html: add data-allowed-extensions attribute to single-file
  input so JS handler can read the allowed extensions list

Nitpick fixes:
- manager.py: use logger.exception() (includes traceback) instead of
  logger.error() for league fetch errors
- manager.py: remove redundant "{e}" from logger.exception() calls for
  round logo and March Madness logo load errors

Not fixed (by design):
- manifest.json repo naming: monorepo pattern is correct per project docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(march-madness): address second round of PR #271 code review findings

Inline fixes:
- requirements.txt: bump Pillow to >=9.1.0 (required for Image.Resampling.LANCZOS)
- file-upload.js: replace all statusDiv.innerHTML assignments with safe DOM
  creation (textContent + createElement) to prevent XSS from untrusted strings
- plugin_config.html: add role="button", tabindex="0", aria-label, onkeydown
  (Enter/Space) to drop zone for keyboard accessibility; add aria-live="polite"
  to status div for screen-reader announcements
- file-upload.js: tighten handleFileDrop endpoint check to non-empty string
  (dataset.uploadEndpoint.trim() !== '') so an empty attribute falls back to
  the multi-file handler

Nitpick fixes:
- manager.py: remove redundant cached_image/cached_array reassignments after
  create_scrolling_image() which already sets them internally
- manager.py: narrow bare except in _get_team_logo to (FileNotFoundError,
  OSError, ValueError) for expected I/O errors; log unexpected exceptions
- store_manager.py: narrow except to (OSError, ValueError) when reading
  .plugin_metadata.json so unrelated exceptions propagate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-24 20:12:31 -05:00
committed by GitHub
parent 275fed402e
commit eb143c44fa
5 changed files with 182 additions and 33 deletions

View File

@@ -103,6 +103,11 @@ class MarchMadnessPlugin(BasePlugin):
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
self.min_duration: int = display_options.get("min_duration", 30)
self.max_duration: int = display_options.get("max_duration", 300)
if self.min_duration > self.max_duration:
self.logger.warning(
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
)
self.min_duration, self.max_duration = self.max_duration, self.min_duration
data_settings = config.get("data_settings", {})
self.update_interval: int = data_settings.get("update_interval", 300)
@@ -204,8 +209,8 @@ class MarchMadnessPlugin(BasePlugin):
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
except (OSError, ValueError) as e:
self.logger.warning(f"Could not load round logo {filename}: {e}")
except Exception as e:
self.logger.exception(f"Unexpected error loading round logo {filename}: {e}")
except Exception:
self.logger.exception(f"Unexpected error loading round logo {filename}")
# March Madness logo
mm_path = logo_dir / "MARCH_MADNESS.png"
@@ -217,8 +222,8 @@ class MarchMadnessPlugin(BasePlugin):
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
except (OSError, ValueError) as e:
self.logger.warning(f"Could not load March Madness logo: {e}")
except Exception as e:
self.logger.exception(f"Unexpected error loading March Madness logo: {e}")
except Exception:
self.logger.exception("Unexpected error loading March Madness logo")
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
if abbr in self._team_logo_cache:
@@ -233,7 +238,11 @@ class MarchMadnessPlugin(BasePlugin):
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
self._team_logo_cache[abbr] = img
return img
except (FileNotFoundError, OSError, ValueError):
self._team_logo_cache[abbr] = None
return None
except Exception:
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
self._team_logo_cache[abbr] = None
return None
@@ -285,8 +294,8 @@ class MarchMadnessPlugin(BasePlugin):
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
all_games.extend(league_games)
except Exception as e:
self.logger.error(f"Error fetching {league_key} tournament data: {e}")
except Exception:
self.logger.exception(f"Error fetching {league_key} tournament data")
return all_games
@@ -696,6 +705,10 @@ class MarchMadnessPlugin(BasePlugin):
self.scroll_helper.clear_cache()
return
if not self.scroll_helper:
self.ticker_image = None
return
gap_width = 16
# Use ScrollHelper to create the scrolling image
@@ -705,10 +718,6 @@ class MarchMadnessPlugin(BasePlugin):
element_gap=0,
)
# Update cached arrays
self.scroll_helper.cached_image = self.ticker_image
self.scroll_helper.cached_array = np.array(self.ticker_image)
self.total_scroll_width = self.scroll_helper.total_scroll_width
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
@@ -760,24 +769,6 @@ class MarchMadnessPlugin(BasePlugin):
if not self.enabled:
return
# Check for live update
current_time = time.time()
interval = 60 if self._has_live_games else self.update_interval
if current_time - self.last_update >= interval:
with self._update_lock:
self.last_update = current_time
try:
games = self._fetch_tournament_data()
self._has_live_games = any(g["is_live"] for g in games)
self.games_data = games
# Preserve scroll position during live updates
old_pos = self.scroll_helper.scroll_position if self.scroll_helper else 0
self._create_ticker_image()
if self.scroll_helper and self.ticker_image:
self.scroll_helper.scroll_position = min(old_pos, self.ticker_image.width - 1)
except Exception as e:
self.logger.error(f"Live update error: {e}", exc_info=True)
if force_clear or self._display_start_time is None:
self._display_start_time = time.time()
if self.scroll_helper:
@@ -913,4 +904,7 @@ class MarchMadnessPlugin(BasePlugin):
if self.scroll_helper:
self.scroll_helper.clear_cache()
self._team_logo_cache.clear()
if self.session:
self.session.close()
self.session = None
super().cleanup()

View File

@@ -1,4 +1,4 @@
requests>=2.28.0
Pillow>=9.0.0
Pillow>=9.1.0
pytz>=2022.1
numpy>=1.24.0

View File

@@ -1770,8 +1770,8 @@ class PluginStoreManager:
if metadata.get('install_type') == 'bundled':
self.logger.info(f"Plugin {plugin_id} is a bundled plugin; updates are delivered via LEDMatrix itself")
return True
except Exception:
pass
except (OSError, ValueError) as e:
self.logger.debug(f"[PluginStore] Could not read metadata for {plugin_id} at {metadata_path}: {e}")
# First check if it's a git repository - if so, we can update directly
git_info = self._get_local_git_info(plugin_path)

View File

@@ -71,7 +71,12 @@
window.handleFileDrop = function(event, fieldId) {
event.preventDefault();
const files = event.dataTransfer.files;
if (files.length > 0) {
if (files.length === 0) return;
// Route to single-file handler if this is a string file-upload widget
const fileInput = document.getElementById(`${fieldId}_file_input`);
if (fileInput && fileInput.dataset.uploadEndpoint && fileInput.dataset.uploadEndpoint.trim() !== '') {
window.handleSingleFileUpload(fieldId, files[0]);
} else {
window.handleFiles(fieldId, Array.from(files));
}
};
@@ -88,6 +93,118 @@
}
};
/**
* Handle single-file select for string file-upload widgets (e.g. credentials.json)
* @param {Event} event - Change event
* @param {string} fieldId - Field ID
*/
window.handleSingleFileSelect = function(event, fieldId) {
const files = event.target.files;
if (files.length > 0) {
window.handleSingleFileUpload(fieldId, files[0]);
}
};
/**
* Upload a single file for string file-upload widgets
* Reads upload config from data attributes on the file input element.
* @param {string} fieldId - Field ID
* @param {File} file - File to upload
*/
window.handleSingleFileUpload = async function(fieldId, file) {
const fileInput = document.getElementById(`${fieldId}_file_input`);
if (!fileInput) return;
const uploadEndpoint = fileInput.dataset.uploadEndpoint;
const targetFilename = fileInput.dataset.targetFilename || 'file.json';
const maxSizeMB = parseFloat(fileInput.dataset.maxSizeMb || '1');
const allowedExtensions = (fileInput.dataset.allowedExtensions || '.json')
.split(',').map(e => e.trim().toLowerCase());
const statusDiv = document.getElementById(`${fieldId}_upload_status`);
const notifyFn = window.showNotification || console.log;
// Guard: endpoint must be configured
if (!uploadEndpoint) {
notifyFn('No upload endpoint configured for this field', 'error');
return;
}
// Validate extension
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(fileExt)) {
notifyFn(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
return;
}
// Validate size
if (file.size > maxSizeMB * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMB}MB limit`, 'error');
return;
}
if (statusDiv) {
statusDiv.className = 'mt-2 text-xs text-gray-500';
statusDiv.textContent = '';
const spinner = document.createElement('i');
spinner.className = 'fas fa-spinner fa-spin mr-1';
statusDiv.appendChild(spinner);
statusDiv.appendChild(document.createTextNode('Uploading...'));
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(uploadEndpoint, {
method: 'POST',
body: formData
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Server error ${response.status}: ${body}`);
}
const data = await response.json();
if (data.status === 'success') {
if (statusDiv) {
statusDiv.className = 'mt-2 text-xs text-green-600';
statusDiv.textContent = '';
const icon = document.createElement('i');
icon.className = 'fas fa-check-circle mr-1';
statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Uploaded: ${targetFilename}`));
}
// Update hidden input with the target filename
const hiddenInput = document.getElementById(fieldId);
if (hiddenInput) hiddenInput.value = targetFilename;
notifyFn(`${targetFilename} uploaded successfully`, 'success');
} else {
if (statusDiv) {
statusDiv.className = 'mt-2 text-xs text-red-600';
statusDiv.textContent = '';
const icon = document.createElement('i');
icon.className = 'fas fa-exclamation-circle mr-1';
statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`));
}
notifyFn(`Upload failed: ${data.message}`, 'error');
}
} catch (error) {
if (statusDiv) {
statusDiv.className = 'mt-2 text-xs text-red-600';
statusDiv.textContent = '';
const icon = document.createElement('i');
icon.className = 'fas fa-exclamation-circle mr-1';
statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`));
}
notifyFn(`Upload error: ${error.message}`, 'error');
} finally {
if (fileInput) fileInput.value = '';
}
};
/**
* Handle multiple files upload
* @param {string} fieldId - Field ID

View File

@@ -537,7 +537,45 @@
{% else %}
{% set str_widget = prop.get('x-widget') or prop.get('x_widget') %}
{% set str_value = value if value is not none else (prop.default if prop.default is defined else '') %}
{% if str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{% if str_widget == 'file-upload' %}
{# Single-file upload widget for string fields (e.g., credentials.json) #}
{% set upload_config = prop.get('x-upload-config') or {} %}
{% set upload_endpoint = upload_config.get('upload_endpoint', '') %}
{% set target_filename = upload_config.get('target_filename', 'file.json') %}
{% set max_size_mb = upload_config.get('max_size_mb', 1) %}
{% set allowed_extensions = upload_config.get('allowed_extensions', ['.json']) %}
<div id="{{ field_id }}_upload_widget" class="mt-1">
<div id="{{ field_id }}_drop_zone"
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
role="button"
tabindex="0"
aria-label="Upload {{ target_filename }}"
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
ondragover="event.preventDefault()"
data-field-id="{{ field_id }}"
onclick="document.getElementById('{{ field_id }}_file_input').click()"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('{{ field_id }}_file_input').click();}">
<input type="file"
id="{{ field_id }}_file_input"
accept="{{ allowed_extensions|join(',') }}"
style="display: none;"
data-field-id="{{ field_id }}"
data-upload-endpoint="{{ upload_endpoint }}"
data-target-filename="{{ target_filename }}"
data-max-size-mb="{{ max_size_mb }}"
data-allowed-extensions="{{ allowed_extensions|join(',') }}"
onchange="window.handleSingleFileSelect(event, this.dataset.fieldId)">
<i class="fas fa-cloud-upload-alt text-2xl text-gray-400 mb-1"></i>
<p class="text-sm text-gray-600">Click to upload {{ target_filename }}</p>
<p class="text-xs text-gray-500 mt-1">Max {{ max_size_mb }}MB ({{ allowed_extensions|join(', ') }})</p>
</div>
<div id="{{ field_id }}_upload_status" class="mt-2 text-xs text-gray-500" aria-live="polite"></div>
<input type="hidden"
id="{{ field_id }}"
name="{{ full_key }}"
value="{{ str_value }}">
</div>
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{# Render widget container #}
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
<script>