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

@@ -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