Merge commit 'b9416ef8' into claude/frame-timing-harness

This commit is contained in:
Chuck
2026-09-24 17:42:43 -04:00
89 changed files with 2880 additions and 5492 deletions
+18
View File
@@ -64,6 +64,24 @@ accepts both, but the store flags the old spelling as deprecated
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do. - `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`. - The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
- Web UI:
- Cache tab errors no longer show up in the Logs tab.
- A tab that fails to load shows "Try again" instead of a skeleton that never goes away.
- Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen.
- The image schedule button works on uploaded images, and the editor stays open while you edit.
- A failed plugin toggle moves the switch back.
- Each save shows one notification; a failed Durations save says it failed.
- Stats the server can't read show `--`.
- New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
- Display and Vegas:
- Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once.
- The display controller stops Vegas mode on shutdown.
- Startup validation warnings are logged once, not twice.
- Vegas logs one INFO line per plugin-list refresh.
- `run.py -d` shows `display_manager` debug output.
- Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`.
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the - The web service (`ledmatrix-web`) logs through `src.logging_config` like the
display service, so `journalctl -p err -u ledmatrix-web` works. Successful display service, so `journalctl -p err -u ledmatrix-web` works. Successful
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
-2
View File
@@ -31,8 +31,6 @@ if args.emulator:
print("Using pygame/RGBMatrixEmulator for display") print("Using pygame/RGBMatrixEmulator for display")
print("Press ESC to exit\n") print("Press ESC to exit\n")
# Project directory already added above
# Debug output (only in debug mode or emulator mode) # Debug output (only in debug mode or emulator mode)
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true' debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
if debug_mode: if debug_mode:
+15 -38
View File
@@ -238,20 +238,9 @@ class ScrollHelper:
self.cached_image = full_image self.cached_image = full_image
# Convert to numpy array for fast operations # Convert to numpy array for fast operations
self.cached_array = np.array(full_image) self.cached_array = np.array(full_image)
# Use actual image width instead of calculated width to ensure accuracy
# This fixes cases where width calculation doesn't match actual positioning
actual_image_width = full_image.width actual_image_width = full_image.width
self.total_scroll_width = actual_image_width self.total_scroll_width = actual_image_width
# Log if there's a mismatch (indicating a bug in width calculation)
if actual_image_width != total_width:
self.logger.warning(
"Width calculation mismatch: calculated=%dpx, actual=%dpx (diff=%dpx). "
"Using actual width for scroll calculations.",
total_width, actual_image_width, abs(actual_image_width - total_width)
)
self.scroll_position = 0.0 self.scroll_position = 0.0
self.total_distance_scrolled = 0.0 self.total_distance_scrolled = 0.0
self.scroll_complete = False self.scroll_complete = False
@@ -339,10 +328,8 @@ class ScrollHelper:
# gained. This is what the one visibly smooth scroller on the # gained. This is what the one visibly smooth scroller on the
# hardware (the stock ticker) was already doing by virtue of never # hardware (the stock ticker) was already doing by virtue of never
# enabling frame-based mode. # enabling frame-based mode.
if self.scroll_delay > 0: # set_scroll_delay clamps scroll_delay to at least 0.001.
pixels_per_second = self.scroll_speed / self.scroll_delay pixels_per_second = self.scroll_speed / self.scroll_delay
else:
pixels_per_second = self.scroll_speed * 100.0
pixels_to_move = pixels_per_second * delta_time pixels_to_move = pixels_per_second * delta_time
self.last_step_time = current_time self.last_step_time = current_time
else: else:
@@ -353,11 +340,11 @@ class ScrollHelper:
self.scroll_position += pixels_to_move self.scroll_position += pixels_to_move
self.total_distance_scrolled += pixels_to_move self.total_distance_scrolled += pixels_to_move
# Calculate required total distance: total_scroll_width only. # One pass is total_scroll_width. With the default lead_gap the strip
# The image already includes display_width pixels of blank padding at the start # starts with display_width of blank, so by then the last item has
# (added by create_scrolling_image), so once scroll_position reaches # fully left the panel; a caller passing a smaller lead_gap (Vegas)
# total_scroll_width the last card has fully scrolled off the left edge. # decides for itself where its cycle ends. Adding display_width here
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains. # caused 1-2 extra wrap-arounds on wide chains.
required_total_distance = self.total_scroll_width required_total_distance = self.total_scroll_width
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip # Guard: zero-width content has nothing to scroll — keep position at 0 and skip
@@ -414,7 +401,6 @@ class ScrollHelper:
and current_time - self.last_progress_log_time >= self.progress_log_interval and current_time - self.last_progress_log_time >= self.progress_log_interval
): ):
elapsed_time = current_time - (self.scroll_start_time or current_time) elapsed_time = current_time - (self.scroll_start_time or current_time)
# The image already includes display_width padding, so we only need total_scroll_width
required_total_distance = self.total_scroll_width required_total_distance = self.total_scroll_width
# Progress telemetry, emitted every few seconds for the whole of # Progress telemetry, emitted every few seconds for the whole of
# every scroll. It says how far along a marquee is, which is what # every scroll. It says how far along a marquee is, which is what
@@ -461,10 +447,8 @@ class ScrollHelper:
""" """
Linear blend between the frames at ``start_x`` and ``start_x + 1``. Linear blend between the frames at ``start_x`` and ``start_x + 1``.
Implemented with numpy rather than scipy.ndimage.shift: scipy is not Implemented with numpy rather than scipy.ndimage.shift, which is not
installed on the target devices, and the old scipy-based sub-pixel path installed on the target devices.
was dead code -- get_visible_portion never consulted the flag. The scipy
import was removed with it; installing scipy has no effect.
Args: Args:
start_x: Left column of the earlier of the two frames start_x: Left column of the earlier of the two frames
@@ -571,22 +555,16 @@ class ScrollHelper:
return self.min_duration return self.min_duration
try: try:
# Calculate total scroll distance needed # The strip's width plus one more screen, so the duration covers
# The image already includes display_width padding at the start, so we need # the last item leaving the panel even when the strip has less
# to scroll total_scroll_width pixels to show all content, plus display_width # than display_width of lead-in blank (lead_gap).
# more pixels to ensure the last content scrolls completely off the screen
total_scroll_distance = self.total_scroll_width + self.display_width total_scroll_distance = self.total_scroll_width + self.display_width
# Calculate effective pixels per second based on scrolling mode # Calculate effective pixels per second based on scrolling mode
if self.frame_based_scrolling: if self.frame_based_scrolling:
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame # Frame-based mode: scroll_speed is pixels per scroll_delay
# Effective pixels per second = pixels per frame / seconds per frame # seconds, and set_scroll_delay keeps scroll_delay >= 0.001.
if self.scroll_delay > 0: pixels_per_second = self.scroll_speed / self.scroll_delay
pixels_per_second = self.scroll_speed / self.scroll_delay
else:
# Fallback if scroll_delay is invalid
pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default
self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay)
scroll_mode_str = "frame-based" scroll_mode_str = "frame-based"
else: else:
# Time-based mode: scroll_speed is already pixels per second # Time-based mode: scroll_speed is already pixels per second
@@ -1072,7 +1050,6 @@ class ScrollHelper:
Returns: Returns:
Dictionary with scroll state information Dictionary with scroll state information
""" """
# The image already includes display_width padding, so we only need total_scroll_width
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0 required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
return { return {
'scroll_position': self.scroll_position, 'scroll_position': self.scroll_position,
+628 -699
View File
File diff suppressed because it is too large Load Diff
+33 -73
View File
@@ -38,8 +38,7 @@ from src.common.bdf_font import draw_bdf_text, load_bdf_face
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src.display_geometry import ( from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size, compose_pixel_mapper_config, physical_size, resolve_double_sided,
resolve_double_sided,
) )
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
from src.pi5_matrix_support import is_raspberry_pi_5 from src.pi5_matrix_support import is_raspberry_pi_5
@@ -47,7 +46,6 @@ import threading
import time import time
from collections import OrderedDict from collections import OrderedDict
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
import logging
import math import math
import zlib import zlib
import freetype import freetype
@@ -55,6 +53,7 @@ import freetype
from src.common import snapshot_policy from src.common import snapshot_policy
from src.common.frame_timing import FrameTimingRecorder from src.common.frame_timing import FrameTimingRecorder
from src.deprecation import deprecated from src.deprecation import deprecated
from src.logging_config import get_logger
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, ensure_directory_permissions,
ensure_file_permissions, ensure_file_permissions,
@@ -62,9 +61,7 @@ from src.common.permission_utils import (
get_assets_file_mode, get_assets_file_mode,
) )
# Get logger without configuring logger = get_logger(__name__)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed #: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
#: size regardless, but a Face needs an active size before its metrics -- #: size regardless, but a Face needs an active size before its metrics --
@@ -135,11 +132,6 @@ class _LogicalMatrix:
setattr(object.__getattribute__(self, "_matrix"), name, value) setattr(object.__getattribute__(self, "_matrix"), name, value)
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
# sync handshake compute the display size exactly as DisplayManager does
# without importing rgbmatrix. Aliased here for existing callers.
_resolve_double_sided = resolve_double_sided
class DisplayManager: class DisplayManager:
""" """
@@ -161,7 +153,6 @@ class DisplayManager:
""" """
_instance = None _instance = None
_initialized = False
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if cls._instance is None: if cls._instance is None:
@@ -254,20 +245,16 @@ class DisplayManager:
font_time = time.time() font_time = time.time()
self._load_fonts() self._load_fonts()
logger.info("Font loading completed in %.3f seconds", time.time() - font_time) logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
# Initialize managers
# Calendar manager is now initialized by DisplayController
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
def _build_pixel_mapper_config(self, hardware_config: dict) -> str: def _new_canvas(self, width: int, height: int) -> None:
"""Compose pixel_mapper_config with the orientation setting. """Replace ``image``/``draw`` with a black canvas of the given size.
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
web preview shares so it sizes the canvas the same way. brightness, so anti-aliasing only smears glyphs.
""" """
return compose_pixel_mapper_config(hardware_config) self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1"
@staticmethod @staticmethod
def _fallback_advice(cause: str, error: Exception) -> str: def _fallback_advice(cause: str, error: Exception) -> str:
@@ -336,7 +323,7 @@ class DisplayManager:
# logical (per-screen) size, and keep a full-chain buffer to tile # logical (per-screen) size, and keep a full-chain buffer to tile
# the rendered screen into once per frame. # the rendered screen into once per frame.
ds_config = self.config.get('display', {}).get('double_sided', {}) ds_config = self.config.get('display', {}).get('double_sided', {})
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config) ds = resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
self._double_sided = ds self._double_sided = ds
if ds is not None: if ds is not None:
self._physical_image = Image.new( self._physical_image = Image.new(
@@ -345,9 +332,7 @@ class DisplayManager:
self.matrix, ds['logical_width'], ds['logical_height']) self.matrix, ds['logical_width'], ds['logical_height'])
# Create image with the (logical) display dimensions # Create image with the (logical) display dimensions
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self._new_canvas(self.matrix.width, self.matrix.height)
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P # Initialize font with Press Start 2P
@@ -378,7 +363,7 @@ class DisplayManager:
fallback_width, fallback_height = physical_size(self.config) fallback_width, fallback_height = physical_size(self.config)
# Mirror double-sided in fallback so the preview shows one screen. # Mirror double-sided in fallback so the preview shows one screen.
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {} ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config) ds = resolve_double_sided(fallback_width, fallback_height, ds_config)
self._double_sided = ds self._double_sided = ds
if ds is not None: if ds is not None:
fallback_width = ds['logical_width'] fallback_width = ds['logical_width']
@@ -386,9 +371,7 @@ class DisplayManager:
except Exception: except Exception:
fallback_width, fallback_height = 128, 32 fallback_width, fallback_height = 128, 32
self.image = Image.new('RGB', (fallback_width, fallback_height)) self._new_canvas(fallback_width, fallback_height)
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
# Simple fallback visualization so web UI shows a realistic canvas # Simple fallback visualization so web UI shows a realistic canvas
try: try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0)) self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
@@ -617,18 +600,14 @@ class DisplayManager:
line, font=font, fill=(0, 0, 255)) line, font=font, fill=(0, 0, 255))
def _draw_test_pattern(self): def _draw_test_pattern(self):
"""Draw a test pattern to verify the display is working.""" """Draw a test pattern to verify the display is working.
Only called from _setup_matrix once the matrix exists; fallback mode
draws its own "Simulation" canvas there.
"""
try: try:
self.clear() self.clear()
if self.matrix is None:
# Fallback mode - just draw on the image
self.draw.rectangle([0, 0, self.image.width-1, self.image.height-1], outline=(255, 0, 0))
self.draw.line([0, 0, self.image.width-1, self.image.height-1], fill=(0, 255, 0))
self.draw.text((10, 10), "Simulation", font=self.font, fill=(0, 0, 255))
logger.info("Drew test pattern in fallback mode")
return
# Draw a red rectangle border # Draw a red rectangle border
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0)) self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
@@ -643,7 +622,7 @@ class DisplayManager:
# Update the display once after everything is drawn # Update the display once after everything is drawn
self.update_display() self.update_display()
time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation time.sleep(0.5)
except Exception as e: except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True) logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@@ -718,9 +697,7 @@ class DisplayManager:
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h) self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
# With no hardware, the width/height properties fall through to # With no hardware, the width/height properties fall through to
# self.image, so swapping the buffer below is enough on its own. # self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h)) self._new_canvas(target_w, target_h)
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
yield yield
finally: finally:
self.matrix = real_matrix self.matrix = real_matrix
@@ -846,28 +823,14 @@ class DisplayManager:
try: try:
if self.matrix is None: if self.matrix is None:
# Fallback mode - just clear the image # Fallback mode - just clear the image
# Explicitly clear old image reference to help garbage collection
old_image = getattr(self, 'image', None) old_image = getattr(self, 'image', None)
width = old_image.width if old_image else 64 width = old_image.width if old_image else 64
height = old_image.height if old_image else 64 height = old_image.height if old_image else 64
if old_image is not None: self._new_canvas(width, height)
del old_image
self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.debug("Cleared display in fallback mode") logger.debug("Cleared display in fallback mode")
return return
# Explicitly clear old image reference to help garbage collection self._new_canvas(self.matrix.width, self.matrix.height)
old_image = getattr(self, 'image', None)
if old_image is not None:
del old_image
# Create a new black image
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
if not self._capture_mode_active: if not self._capture_mode_active:
# Clear both canvases and the underlying matrix to ensure no artifacts. # Clear both canvases and the underlying matrix to ensure no artifacts.
@@ -1327,14 +1290,11 @@ class DisplayManager:
# Ensure image/draw are reset to a blank state # Ensure image/draw are reset to a blank state
if hasattr(self, 'image') and hasattr(self, 'draw'): if hasattr(self, 'image') and hasattr(self, 'draw'):
try: try:
self.image = Image.new('RGB', (self.width, self.height)) self._new_canvas(self.width, self.height)
self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
except (OSError, RuntimeError, ValueError, MemoryError): except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True) logger.debug("Canvas reset during cleanup failed", exc_info=True)
# Reset the singleton state when cleaning up # Reset the singleton state when cleaning up
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
def format_date_with_ordinal(self, dt): def format_date_with_ordinal(self, dt):
"""Formats a datetime object into 'Mon Aug 30th' style.""" """Formats a datetime object into 'Mon Aug 30th' style."""
@@ -1372,9 +1332,9 @@ class DisplayManager:
options.pwm_bits = hardware_config.get('pwm_bits', 10) options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150) options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB') options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
# _build_pixel_mapper_config reads only class attributes, so the class # Orientation becomes a "Rotate:<deg>" pixel mapper; the web preview
# stands in for an instance here. # composes it the same way so it sizes the canvas identically.
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config) options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0) options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0) options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '') options.panel_type = hardware_config.get('panel_type', '')
@@ -1488,13 +1448,16 @@ class DisplayManager:
that does not care gets a new frame every refresh. that does not care gets a new frame every refresh.
""" """
current_time = time.time() current_time = time.time()
# Scrolling callers set this every frame; log transitions only.
changed = self._scrolling_state['is_scrolling'] != is_scrolling
self._scrolling_state['is_scrolling'] = is_scrolling self._scrolling_state['is_scrolling'] = is_scrolling
if is_scrolling: if is_scrolling:
self._scrolling_state['last_scroll_activity'] = current_time self._scrolling_state['last_scroll_activity'] = current_time
self.set_frame_hold(frame_hold) self.set_frame_hold(frame_hold)
else: else:
self._frame_hold = 1 self._frame_hold = 1
logger.debug(f"Scrolling state set to: {is_scrolling}") if changed:
logger.debug("Scrolling state set to: %s", is_scrolling)
def is_currently_scrolling(self) -> bool: def is_currently_scrolling(self) -> bool:
"""Check if the display is currently in a scrolling state.""" """Check if the display is currently in a scrolling state."""
@@ -1563,9 +1526,6 @@ class DisplayManager:
if not self._scrolling_state['deferred_updates']: if not self._scrolling_state['deferred_updates']:
return return
if not self._scrolling_state['deferred_updates']:
return
# Process only a limited number of updates per call to avoid blocking # Process only a limited number of updates per call to avoid blocking
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates'])) max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call] updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
+2 -2
View File
@@ -6,8 +6,8 @@ plugins' content is composed into a single horizontally scrolling display.
Components: Components:
- VegasModeCoordinator: Main orchestrator for Vegas mode - VegasModeCoordinator: Main orchestrator for Vegas mode
- StreamManager: Manages plugin content streaming with 1-2 ahead buffering - StreamManager: Plugin rotation, content fetching and pending-update tracking
- RenderPipeline: Handles 125 FPS rendering with double-buffering - RenderPipeline: Strip composition and per-frame rendering
- PluginAdapter: Converts plugin content to scrollable images - PluginAdapter: Converts plugin content to scrollable images
- VegasModeConfig: Configuration management - VegasModeConfig: Configuration management
""" """
+38 -48
View File
@@ -167,7 +167,7 @@ class VegasModeConfig:
# Dynamic duration # Dynamic duration
dynamic_duration_enabled: bool = True dynamic_duration_enabled: bool = True
min_cycle_duration: int = 60 # Minimum seconds per full cycle min_cycle_duration: int = 60 # Minimum seconds per full cycle
max_cycle_duration: int = 600 # Maximum seconds per full cycle max_cycle_duration: int = 240 # Maximum seconds per full cycle
@classmethod @classmethod
def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig': def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig':
@@ -181,44 +181,50 @@ class VegasModeConfig:
VegasModeConfig instance VegasModeConfig instance
""" """
vegas_config = config.get('display', {}).get('vegas_scroll', {}) vegas_config = config.get('display', {}).get('vegas_scroll', {})
# Missing keys fall back to the field defaults above, so each default
# is written once and the two cannot drift apart.
d = cls()
get = vegas_config.get
return cls( return cls(
enabled=vegas_config.get('enabled', False), enabled=get('enabled', d.enabled),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), scroll_speed=float(get('scroll_speed', d.scroll_speed)),
separator_width=int(vegas_config.get('separator_width', 32)), separator_width=int(get('separator_width', d.separator_width)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)), intra_plugin_gap=int(get('intra_plugin_gap', d.intra_plugin_gap)),
render_width_pct=int(vegas_config.get('render_width_pct', 100)), render_width_pct=int(get('render_width_pct', d.render_width_pct)),
min_content_separation=int( min_content_separation=int(
vegas_config.get('min_content_separation', 24)), get('min_content_separation', d.min_content_separation)),
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)), min_cut_gap=int(get('min_cut_gap', d.min_cut_gap)),
smooth_scroll=vegas_config.get('smooth_scroll', True), smooth_scroll=get('smooth_scroll', d.smooth_scroll),
continuous_scroll=vegas_config.get('continuous_scroll', True), continuous_scroll=get('continuous_scroll', d.continuous_scroll),
extend_threshold_screens=float( extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)), get('extend_threshold_screens', d.extend_threshold_screens)),
auto_trim=vegas_config.get('auto_trim', True), auto_trim=get('auto_trim', d.auto_trim),
trim_threshold=int(vegas_config.get('trim_threshold', 10)), trim_threshold=int(get('trim_threshold', d.trim_threshold)),
content_padding=int(vegas_config.get('content_padding', 8)), content_padding=int(get('content_padding', d.content_padding)),
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)), min_plugin_width=int(get('min_plugin_width', d.min_plugin_width)),
lead_in_width=int(vegas_config.get('lead_in_width', 0)), lead_in_width=int(get('lead_in_width', d.lead_in_width)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)), plugins_per_cycle=int(get('plugins_per_cycle', d.plugins_per_cycle)),
max_plugin_width_ratio=float( max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 0.0)), get('max_plugin_width_ratio', d.max_plugin_width_ratio)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')), overflow_mode=str(get('overflow_mode', d.overflow_mode)),
plugin_order=list(vegas_config.get('plugin_order', [])), plugin_order=list(get('plugin_order', d.plugin_order)),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])), excluded_plugins=set(get('excluded_plugins', d.excluded_plugins)),
live_in_ticker=bool(vegas_config.get('live_in_ticker', False)), live_in_ticker=bool(get('live_in_ticker', d.live_in_ticker)),
# Clamped: a weight below 1 would drop the plugin from the rotation # Clamped: a weight below 1 would drop the plugin from the rotation
# entirely, and a very large one starves everything else. # entirely, and a very large one starves everything else.
live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))), live_weight=max(1, min(10, int(get('live_weight', d.live_weight)))),
favorite_live_weight=max( favorite_live_weight=max(1, min(10, int(
1, min(10, int(vegas_config.get('favorite_live_weight', 5)))), get('favorite_live_weight', d.favorite_live_weight)))),
target_fps=int(vegas_config.get('target_fps', 125)), target_fps=int(get('target_fps', d.target_fps)),
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)), buffer_ahead=int(get('buffer_ahead', d.buffer_ahead)),
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True), frame_based_scrolling=get(
scroll_delay=float(vegas_config.get('scroll_delay', 0.02)), 'frame_based_scrolling', d.frame_based_scrolling),
dynamic_duration_enabled=vegas_config.get('dynamic_duration_enabled', True), scroll_delay=float(get('scroll_delay', d.scroll_delay)),
min_cycle_duration=int(vegas_config.get('min_cycle_duration', 60)), dynamic_duration_enabled=get(
max_cycle_duration=int(vegas_config.get('max_cycle_duration', 600)), 'dynamic_duration_enabled', d.dynamic_duration_enabled),
min_cycle_duration=int(get('min_cycle_duration', d.min_cycle_duration)),
max_cycle_duration=int(get('max_cycle_duration', d.max_cycle_duration)),
) )
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@@ -260,22 +266,6 @@ class VegasModeConfig:
"""Get the frame interval in seconds for target FPS.""" """Get the frame interval in seconds for target FPS."""
return 1.0 / max(1, self.target_fps) return 1.0 / max(1, self.target_fps)
def is_plugin_included(self, plugin_id: str) -> bool:
"""
Check if a plugin should be included in Vegas scroll.
This is consistent with get_ordered_plugins - plugins not explicitly
in plugin_order are still included (appended at the end) unless excluded.
Args:
plugin_id: Plugin identifier to check
Returns:
True if plugin should be included
"""
# Plugins are included unless explicitly excluded
return plugin_id not in self.excluded_plugins
def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]: def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]:
""" """
Get plugins in configured order, filtering excluded ones. Get plugins in configured order, filtering excluded ones.
+5 -45
View File
@@ -145,13 +145,8 @@ class VegasModeCoordinator:
# Static pause handling # Static pause handling
self._static_pause_active = False self._static_pause_active = False
self._static_pause_plugin: Optional['BasePlugin'] = None
self._static_pause_start: Optional[float] = None
self._saved_scroll_position: Optional[int] = None self._saved_scroll_position: Optional[int] = None
# Track which plugins should use STATIC mode (pause scroll)
self._static_mode_plugins: set = set()
# Statistics # Statistics
self.stats = { self.stats = {
'total_runtime_seconds': 0.0, 'total_runtime_seconds': 0.0,
@@ -235,7 +230,9 @@ class VegasModeCoordinator:
returns immediately, collapsing the inter-iteration gap to <1 ms. returns immediately, collapsing the inter-iteration gap to <1 ms.
Args: Args:
callback: Callable with no arguments (typically _tick_plugin_updates) callback: Callable with no arguments. The display controller
passes _tick_plugin_updates_for_vegas, which also reports the
plugins that got fresh data through mark_plugin_updated().
""" """
self._update_callback = callback self._update_callback = callback
@@ -417,9 +414,6 @@ class VegasModeCoordinator:
if not self.start(): if not self.start():
return False return False
# Update static mode plugin list on iteration start
self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval() frame_interval = self.vegas_config.get_frame_interval()
if self.vegas_config.continuous_scroll: if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says # The strip is continuously extended and trimmed, so its width says
@@ -733,13 +727,6 @@ class VegasModeCoordinator:
return status return status
def get_ordered_plugins(self) -> List[str]:
"""Get the current ordered list of plugins in Vegas scroll."""
if hasattr(self.plugin_manager, 'plugins'):
available = list(self.plugin_manager.plugins.keys())
return self.vegas_config.get_ordered_plugins(available)
return []
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Static pause handling (for STATIC display mode) # Static pause handling (for STATIC display mode)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -795,8 +782,6 @@ class VegasModeCoordinator:
# Save current scroll position for smooth resume # Save current scroll position for smooth resume
self._saved_scroll_position = self.render_pipeline.get_scroll_position() self._saved_scroll_position = self.render_pipeline.get_scroll_position()
self._static_pause_active = True self._static_pause_active = True
self._static_pause_plugin = plugin
self._static_pause_start = time.time()
self.stats['static_pauses'] += 1 self.stats['static_pauses'] += 1
logger.info("Static pause started for plugin: %s", plugin_id) logger.info("Static pause started for plugin: %s", plugin_id)
@@ -823,9 +808,9 @@ class VegasModeCoordinator:
logger.info("Static pause interrupted by live priority") logger.info("Static pause interrupted by live priority")
return False return False
# Yield immediately if multi-display follower mode becomes active # On-demand, a WiFi message, the schedule, follower mode...
if self._interrupt_check and self._interrupt_check(): if self._interrupt_check and self._interrupt_check():
logger.info("Static pause interrupted by sync follower mode") logger.info("Static pause interrupted by the display controller")
return False return False
# Sleep in small increments to remain responsive # Sleep in small increments to remain responsive
@@ -860,8 +845,6 @@ class VegasModeCoordinator:
# Clear pause state # Clear pause state
self._static_pause_active = False self._static_pause_active = False
self._static_pause_plugin = None
self._static_pause_start = None
# Restore scroll position if we're resuming # Restore scroll position if we're resuming
if should_resume_scrolling and self._saved_scroll_position is not None: if should_resume_scrolling and self._saved_scroll_position is not None:
@@ -875,29 +858,6 @@ class VegasModeCoordinator:
else: else:
logger.debug("Static pause ended (interrupted, not resuming scroll)") logger.debug("Static pause ended (interrupted, not resuming scroll)")
def _update_static_mode_plugins(self) -> None:
"""Update the set of plugins using STATIC display mode."""
self._static_mode_plugins.clear()
for plugin_id in self.get_ordered_plugins():
plugin = self.plugin_manager.get_plugin(plugin_id)
if plugin:
try:
mode = plugin.get_vegas_display_mode()
if mode == VegasDisplayMode.STATIC:
self._static_mode_plugins.add(plugin_id)
except Exception:
logger.exception(
"Error getting vegas display mode for plugin %s",
plugin_id
)
if self._static_mode_plugins:
logger.info(
"Static mode plugins: %s",
', '.join(self._static_mode_plugins)
)
def cleanup(self) -> None: def cleanup(self) -> None:
"""Clean up all resources.""" """Clean up all resources."""
self.stop() self.stop()
-48
View File
@@ -230,54 +230,6 @@ def blank_runs(
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist())) return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
def find_blank_cut(
img: Image.Image,
target: int,
search_radius: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Find a column near ``target`` that carries no ink, so an image can be cut
there without slicing through a glyph or logo.
Used when a single oversized segment has to be narrowed to fit a width
budget. Cutting at an arbitrary column would leave half a character
hanging at the panel edge; snapping to the nearest gap hides the cut.
Args:
img: Image to cut
target: Preferred cut column
search_radius: How far either side of ``target`` to look
threshold: Ink threshold
Returns:
A blank column within the search window, or ``target`` clamped to the
image bounds when the window contains no blank column at all.
"""
width = img.width
target = max(0, min(target, width))
if search_radius <= 0 or width == 0:
return target
ink = column_has_ink(img, threshold)
# target may legitimately equal width (a cut after the last column), but
# there is no column to inspect there, so both bounds stop at width - 1.
lo = max(0, min(target - search_radius, width - 1))
hi = max(0, min(target + search_radius, width - 1))
# Walk outwards from target so the nearest gap wins.
for offset in range(0, search_radius + 1):
right = target + offset
if lo <= right <= hi and not ink[right]:
return right
left = target - offset
if lo <= left <= hi and not ink[left]:
return left
return target
class DeadWindowStats(NamedTuple): class DeadWindowStats(NamedTuple):
"""How much of a composed ticker reads as blank to a viewer.""" """How much of a composed ticker reads as blank to a viewer."""
+14 -17
View File
@@ -48,15 +48,8 @@ class PluginAdapter:
from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.config import VegasModeConfig
config = VegasModeConfig() config = VegasModeConfig()
self.config = config self.config = config
# Handle both property and method access patterns self.display_width = display_manager.width
self.display_width = ( self.display_height = display_manager.height
display_manager.width() if callable(display_manager.width)
else display_manager.width
)
self.display_height = (
display_manager.height() if callable(display_manager.height)
else display_manager.height
)
# Cache for recently fetched content (prevents redundant fetch) # Cache for recently fetched content (prevents redundant fetch)
self._content_cache: dict = {} self._content_cache: dict = {}
@@ -185,13 +178,14 @@ class PluginAdapter:
Trim dead space off a segment, then cache it. Trim dead space off a segment, then cache it.
Every content path funnels through here so trimming is applied Every content path funnels through here so trimming is applied
uniformly. Previously only the scroll_helper path had its margins uniformly; a plugin that renders onto a full-display canvas would
stripped, which left plugins that render onto a full-display canvas otherwise contribute its whole blank canvas to the ticker.
contributing their entire blank canvas to the ticker.
Each image is trimmed independently because compose_scroll_content() Each image is trimmed independently. The render pipeline joins one
treats every image as its own item and inserts separator_width between plugin's images with a gap measured from their ink
them — so a per-image trim is what makes that separator the real gap. (RenderPipeline._join_plugin_rows) and puts separator_width only
between plugins, so the margins a row keeps are content_padding, not
whatever blank canvas the plugin happened to draw it on.
Args: Args:
images: Raw content from one of the fetch paths images: Raw content from one of the fetch paths
@@ -602,8 +596,11 @@ class PluginAdapter:
Narrow a single oversized image to the budget, advancing a window Narrow a single oversized image to the budget, advancing a window
through it across cycles. through it across cycles.
The cut is snapped to the nearest blank column so it does not slice Cuts land only at item boundaries: the middle of a blank run at least
through a glyph or logo and leave half a character at the panel edge. ``min_cut_gap`` columns wide. The window ends at the last boundary
inside the budget, or overruns to the next one when there is none, so
an item is never sliced. An image with no such runs (a map, a chart)
is continuous content and is cropped to the budget exactly.
Rotation is tracked as an index into the strip's item boundaries rather Rotation is tracked as an index into the strip's item boundaries rather
than as a pixel column, because a ticker re-renders between fetches. A than as a pixel column, because a ticker re-renders between fetches. A
+25 -63
View File
@@ -1,8 +1,8 @@
""" """
Render Pipeline for Vegas Mode Render Pipeline for Vegas Mode
Handles high-FPS (125 FPS) rendering with double-buffering for smooth scrolling. Composes plugin content into one wide strip and renders the visible window of
Uses the existing ScrollHelper for numpy-optimized scroll operations. it each frame, using ScrollHelper for the numpy-backed scroll.
""" """
import logging import logging
@@ -10,7 +10,7 @@ import os
import time import time
import threading import threading
from collections import deque from collections import deque
from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING from typing import Optional, List, Any, Dict, Deque
from PIL import Image from PIL import Image
from src.common.scroll_helper import ScrollHelper from src.common.scroll_helper import ScrollHelper
@@ -18,11 +18,13 @@ from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager from src.vegas_mode.stream_manager import StreamManager
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: Shortest gap between multi-display sync sends from the leader, for both the
#: Vegas scroll position and the controller's per-frame follower images. The
#: payloads are raw and cheap, and 90/s is above the follower's render rate.
SYNC_SEND_INTERVAL = 1.0 / 90
class RenderPipeline: class RenderPipeline:
""" """
@@ -31,8 +33,9 @@ class RenderPipeline:
Key responsibilities: Key responsibilities:
- Compose content segments into scrollable image - Compose content segments into scrollable image
- Manage scroll position and velocity - Manage scroll position and velocity
- Handle 125 FPS rendering loop - Render one frame per call at the target FPS
- Double-buffer for hot-swap during updates - Extend the strip (continuous mode) or recompose and hot-swap it (swap
mode) as content changes
- Track scroll cycle completion - Track scroll cycle completion
""" """
@@ -59,18 +62,10 @@ class RenderPipeline:
self.stream_manager = stream_manager self.stream_manager = stream_manager
self.sync_manager = None # Optional DisplaySyncManager — set by coordinator self.sync_manager = None # Optional DisplaySyncManager — set by coordinator
self.sync_follower_left = True # True = follower is LEFT of leader (default) self.sync_follower_left = True # True = follower is LEFT of leader (default)
self._sync_send_interval = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
self._last_sync_send = 0.0 self._last_sync_send = 0.0
# Display dimensions (handle both property and method access patterns) self.display_width = display_manager.width
self.display_width = ( self.display_height = display_manager.height
display_manager.width() if callable(display_manager.width)
else display_manager.width
)
self.display_height = (
display_manager.height() if callable(display_manager.height)
else display_manager.height
)
# ScrollHelper for optimized scrolling # ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper( self.scroll_helper = ScrollHelper(
@@ -82,11 +77,6 @@ class RenderPipeline:
# Configure scroll helper # Configure scroll helper
self._configure_scroll_helper() self._configure_scroll_helper()
# Double-buffer for composed images
self._active_scroll_image: Optional[Image.Image] = None
self._staging_scroll_image: Optional[Image.Image] = None
self._buffer_lock = threading.Lock()
# Group prepared off the render thread, waiting to be appended. # Group prepared off the render thread, waiting to be appended.
self._prepared_group = None self._prepared_group = None
# Plugins that need the shared canvas, appended one at a time. # Plugins that need the shared canvas, appended one at a time.
@@ -96,13 +86,9 @@ class RenderPipeline:
self._prefetch_lock = threading.Lock() self._prefetch_lock = threading.Lock()
# Render state # Render state
self._is_rendering = False
self._cycle_complete = False self._cycle_complete = False
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
# Timing
self._last_frame_time = 0.0
self._frame_interval = config.get_frame_interval()
self._cycle_start_time = 0.0 self._cycle_start_time = 0.0
# Statistics # Statistics
@@ -192,10 +178,6 @@ class RenderPipeline:
logger.error("ScrollHelper failed to create cached image") logger.error("ScrollHelper failed to create cached image")
return False return False
# Store reference to composed image
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
# Track which plugins are in this scroll (get safely via buffer status) # Track which plugins are in this scroll (get safely via buffer status)
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids() self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
@@ -335,8 +317,6 @@ class RenderPipeline:
element_gap=0, element_gap=0,
) )
if appended: if appended:
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
logger.info( logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead", "[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width, plugin_id, self.scroll_helper.total_scroll_width,
@@ -420,9 +400,6 @@ class RenderPipeline:
# Keep a screen's worth behind the viewport as a safety margin. # Keep a screen's worth behind the viewport as a safety margin.
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width) self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
self._segments_in_scroll = [pid for pid, _ in grouped] self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1 self.stats['composition_count'] += 1
self.stats['extensions'] = self.stats.get('extensions', 0) + 1 self.stats['extensions'] = self.stats.get('extensions', 0) + 1
@@ -565,7 +542,7 @@ class RenderPipeline:
# leader's via TCP image transfer at each new_cycle) at scroll_x ± display_width. # leader's via TCP image transfer at each new_cycle) at scroll_x ± display_width.
if self.sync_manager: if self.sync_manager:
now = time.time() now = time.time()
if now - self._last_sync_send >= self._sync_send_interval: if now - self._last_sync_send >= SYNC_SEND_INTERVAL:
self._last_sync_send = now self._last_sync_send = now
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position) self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
@@ -603,7 +580,6 @@ class RenderPipeline:
Returns True when: Returns True when:
- Cycle is complete and we should start fresh - Cycle is complete and we should start fresh
- Staging buffer has new content
- A plugin currently visible in the scroll has pending updated data - A plugin currently visible in the scroll has pending updated data
(e.g. a live score changed) — standalone (non-sync) mode only (e.g. a live score changed) — standalone (non-sync) mode only
""" """
@@ -613,16 +589,11 @@ class RenderPipeline:
# When multi-display sync is active, defer mid-cycle hot swaps until the # When multi-display sync is active, defer mid-cycle hot swaps until the
# cycle ends naturally. Hot swaps block the render loop for 15-30ms while # cycle ends naturally. Hot swaps block the render loop for 15-30ms while
# the image is rebuilt, causing a freeze+jump that the follower perceives # the image is rebuilt, causing a freeze+jump that the follower perceives
# as a speed-up. Deferring to cycle boundaries keeps transitions clean. # as a speed-up. Deferring to cycle boundaries keeps transitions clean;
# Staging buffer content is still pre-loaded; it just applies at cycle end. # the pending updates are still applied, by the recompose at cycle end.
if self.sync_manager is not None: if self.sync_manager is not None:
return False return False
# Check if we need more content in the buffer
buffer_status = self.stream_manager.get_buffer_status()
if buffer_status['staging_count'] > 0:
return True
# Trigger recompose when pending updates affect visible segments, so # Trigger recompose when pending updates affect visible segments, so
# live score/status changes reach the display within a few seconds # live score/status changes reach the display within a few seconds
# instead of waiting for the next full cycle. # instead of waiting for the next full cycle.
@@ -652,10 +623,12 @@ class RenderPipeline:
def hot_swap_content(self) -> bool: def hot_swap_content(self) -> bool:
""" """
Hot-swap to new composed content. Refetch the plugins with pending updates and recompose the strip.
Called when staging buffer has updated content. Swap mode only (continuous mode uses :meth:`refresh_updated_plugins`).
Swaps atomically to prevent visual glitches. Called when :meth:`should_recompose` finds a visible plugin with
pending updates. The scroll resumes at the same relative position in
the rebuilt strip.
Returns: Returns:
True if swap occurred True if swap occurred
@@ -668,9 +641,7 @@ class RenderPipeline:
old_width = self.scroll_helper.total_scroll_width old_width = self.scroll_helper.total_scroll_width
old_pos = self.scroll_helper.scroll_position old_pos = self.scroll_helper.scroll_position
# Process any pending updates
self.stream_manager.process_updates() self.stream_manager.process_updates()
self.stream_manager.swap_buffers()
# Recompose with updated content # Recompose with updated content
if self.compose_scroll_content(): if self.compose_scroll_content():
@@ -729,21 +700,17 @@ class RenderPipeline:
result = self.compose_scroll_content() result = self.compose_scroll_content()
if result and self.sync_manager: if result and self.sync_manager:
# When sync is active, start the leader past the lead-in gap so it # Start the leader past the lead-in gap so it immediately shows
# immediately shows content, leaving the follower on the blank gap # content, leaving the follower on the blank gap for a clean
# for a clean transition rather than near-end content wrapping # transition rather than near-end content wrapping around.
# around. This tracks lead_in_width rather than assuming a full
# display width of gap, which is no longer the default.
self.scroll_helper.scroll_position = float(self.config.lead_in_width) self.scroll_helper.scroll_position = float(self.config.lead_in_width)
if result and self.sync_manager:
# Signal follower that a new cycle started (triggers its own rebuild) # Signal follower that a new cycle started (triggers its own rebuild)
self.sync_manager.send_new_cycle() self.sync_manager.send_new_cycle()
# Push the actual scroll image over TCP so follower has identical pixels. # Push the actual scroll image over TCP so follower has identical pixels.
# Done in a background thread to not block the render loop (~15ms transfer). # Done in a background thread to not block the render loop (~15ms transfer).
if self.scroll_helper.cached_image is not None: if self.scroll_helper.cached_image is not None:
import threading as _t threading.Thread(
_t.Thread(
target=self.sync_manager.send_scroll_image, target=self.sync_manager.send_scroll_image,
args=(self.scroll_helper.cached_image,), args=(self.scroll_helper.cached_image,),
daemon=True, name="sync-image-push" daemon=True, name="sync-image-push"
@@ -792,7 +759,6 @@ class RenderPipeline:
""" """
old_fps = self.config.target_fps old_fps = self.config.target_fps
self.config = new_config self.config = new_config
self._frame_interval = new_config.get_frame_interval()
# Reconfigure scroll helper # Reconfigure scroll helper
self._configure_scroll_helper() self._configure_scroll_helper()
@@ -805,10 +771,6 @@ class RenderPipeline:
self.scroll_helper.reset_scroll() self.scroll_helper.reset_scroll()
self.scroll_helper.clear_cache() self.scroll_helper.clear_cache()
with self._buffer_lock:
self._active_scroll_image = None
self._staging_scroll_image = None
self._cycle_complete = False self._cycle_complete = False
self._segments_in_scroll = [] self._segments_in_scroll = []
self._frame_times = deque(maxlen=100) self._frame_times = deque(maxlen=100)
+63 -140
View File
@@ -31,22 +31,14 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class ContentSegment: class ContentSegment:
"""Represents a segment of scrollable content from a plugin.""" """One plugin's content for a cycle.
A STATIC segment carries no images: it marks where the coordinator pauses
the scroll to show the plugin full-screen.
"""
plugin_id: str plugin_id: str
images: List[Image.Image] images: List[Image.Image]
total_width: int
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT) display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT)
fetched_at: float = field(default_factory=time.time)
is_stale: bool = False
@property
def image_count(self) -> int:
return len(self.images)
@property
def is_static(self) -> bool:
"""Check if this segment should trigger a static pause."""
return self.display_mode == VegasDisplayMode.STATIC
class StreamManager: class StreamManager:
@@ -54,10 +46,12 @@ class StreamManager:
Manages streaming of plugin content for Vegas scroll mode. Manages streaming of plugin content for Vegas scroll mode.
Key responsibilities: Key responsibilities:
- Maintain ordered list of plugins to stream - Maintain the ordered (and priority-weighted) rotation of plugins
- Prefetch content 1-2 plugins ahead of current position - Fill the active buffer with a cycle's worth of segments (swap mode), or
- Handle plugin data updates via double-buffer swap hand out the next group of plugins directly (continuous mode)
- Manage content lifecycle and staleness - Track plugins whose data changed in ``_pending_updates``, which
:meth:`process_updates` (swap mode) or
:meth:`invalidate_pending_updates` (continuous mode) consumes
""" """
def __init__( def __init__(
@@ -78,14 +72,14 @@ class StreamManager:
self.plugin_manager = plugin_manager self.plugin_manager = plugin_manager
self.plugin_adapter = plugin_adapter self.plugin_adapter = plugin_adapter
# Content queue (double-buffered) # Segments composed into the current cycle (swap mode only).
self._active_buffer: Deque[ContentSegment] = deque() self._active_buffer: Deque[ContentSegment] = deque()
self._staging_buffer: Deque[ContentSegment] = deque() # Reentrant: _prefetch_content releases and re-acquires it around the
self._buffer_lock = threading.RLock() # RLock for reentrant access # slow fetch while a caller may already hold it.
self._buffer_lock = threading.RLock()
# Plugin rotation state # Plugin rotation, and the position of the next plugin to fetch in it.
self._ordered_plugins: List[str] = [] self._ordered_plugins: List[str] = []
self._current_index: int = 0
self._prefetch_index: int = 0 self._prefetch_index: int = 0
# Update tracking # Update tracking
@@ -97,7 +91,6 @@ class StreamManager:
self.stats = { self.stats = {
'segments_fetched': 0, 'segments_fetched': 0,
'segments_served': 0, 'segments_served': 0,
'buffer_swaps': 0,
'fetch_errors': 0, 'fetch_errors': 0,
} }
@@ -167,9 +160,7 @@ class StreamManager:
with self._buffer_lock: with self._buffer_lock:
return { return {
'active_count': len(self._active_buffer), 'active_count': len(self._active_buffer),
'staging_count': len(self._staging_buffer),
'total_plugins': len(self._ordered_plugins), 'total_plugins': len(self._ordered_plugins),
'current_index': self._current_index,
'prefetch_index': self._prefetch_index, 'prefetch_index': self._prefetch_index,
'stats': self.stats.copy(), 'stats': self.stats.copy(),
} }
@@ -190,8 +181,9 @@ class StreamManager:
""" """
Mark a plugin as having updated data. Mark a plugin as having updated data.
Called when a plugin's data changes. Triggers content refresh Only records it in ``_pending_updates``. The refetch happens later,
for that plugin in the staging buffer. in :meth:`process_updates` (swap mode) or by dropping the plugin's
caches in :meth:`invalidate_pending_updates` (continuous mode).
Args: Args:
plugin_id: Plugin that was updated plugin_id: Plugin that was updated
@@ -242,11 +234,6 @@ class StreamManager:
) )
return updated return updated
def has_pending_updates(self) -> bool:
"""Check if any plugins have pending updates awaiting processing."""
with self._buffer_lock:
return len(self._pending_updates) > 0
def has_pending_updates_for_visible_segments(self) -> bool: def has_pending_updates_for_visible_segments(self) -> bool:
"""Check if pending updates affect plugins currently in the active buffer.""" """Check if pending updates affect plugins currently in the active buffer."""
with self._buffer_lock: with self._buffer_lock:
@@ -309,19 +296,6 @@ class StreamManager:
logger.debug("Processed in-place updates for %d plugins", len(updated_plugins)) logger.debug("Processed in-place updates for %d plugins", len(updated_plugins))
def swap_buffers(self) -> None:
"""
Swap active and staging buffers.
Called when staging buffer has updated content ready.
"""
with self._buffer_lock:
if self._staging_buffer:
# True swap: staging becomes active, old active is discarded
self._active_buffer, self._staging_buffer = self._staging_buffer, deque()
self.stats['buffer_swaps'] += 1
logger.debug("Swapped buffers, active now has %d segments", len(self._active_buffer))
def refresh(self) -> None: def refresh(self) -> None:
""" """
Refresh the plugin list and content. Refresh the plugin list and content.
@@ -337,61 +311,49 @@ class StreamManager:
self._refresh_plugin_list() self._refresh_plugin_list()
if len(self._ordered_plugins) != old_count: if len(self._ordered_plugins) != old_count:
logger.info( logger.debug(
"Plugin list refreshed: %d -> %d plugins", "Plugin list refreshed: %d -> %d plugins",
old_count, len(self._ordered_plugins) old_count, len(self._ordered_plugins)
) )
def _refresh_plugin_list(self) -> None: def _refresh_plugin_list(self) -> None:
"""Refresh the ordered list of plugins from plugin manager.""" """Refresh the ordered list of plugins from plugin manager.
logger.info("=" * 60)
logger.info("REFRESHING PLUGIN LIST FOR VEGAS SCROLL")
logger.info("=" * 60)
# Get all enabled plugins Runs at every cycle start and every ``_refresh_interval`` seconds, so
it logs one INFO summary; the per-plugin decisions are at DEBUG.
"""
available_plugins = [] available_plugins = []
loaded = 0
if hasattr(self.plugin_manager, 'plugins'): if hasattr(self.plugin_manager, 'plugins'):
logger.info( loaded = len(self.plugin_manager.plugins)
"Checking %d loaded plugins for Vegas scroll",
len(self.plugin_manager.plugins)
)
for plugin_id, plugin in self.plugin_manager.plugins.items(): for plugin_id, plugin in self.plugin_manager.plugins.items():
has_enabled = hasattr(plugin, 'enabled') if not getattr(plugin, 'enabled', False):
is_enabled = getattr(plugin, 'enabled', False) logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
logger.info( continue
"[%s] class=%s, has_enabled=%s, enabled=%s",
plugin_id, plugin.__class__.__name__, has_enabled, is_enabled
)
if has_enabled and is_enabled:
# Check vegas content type - skip 'none' unless in STATIC mode
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
# Also check display mode - STATIC plugins should be included # Content type 'none' is left out, except for STATIC plugins,
# even if their content_type is 'none' # which pause the scroll rather than contributing to it.
display_mode = VegasDisplayMode.FIXED_SEGMENT content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
try: display_mode = VegasDisplayMode.FIXED_SEGMENT
display_mode = plugin.get_vegas_display_mode() try:
except Exception: display_mode = plugin.get_vegas_display_mode()
# Plugin error should not abort refresh; use default mode except Exception:
logger.exception( # Plugin error should not abort refresh; use default mode
"[%s] (%s) get_vegas_display_mode() failed, using default", logger.exception(
plugin_id, plugin.__class__.__name__ "[%s] (%s) get_vegas_display_mode() failed, using default",
) plugin_id, plugin.__class__.__name__
logger.info(
"[%s] content_type=%s, display_mode=%s",
plugin_id, content_type, display_mode.value
) )
if content_type != 'none' or display_mode == VegasDisplayMode.STATIC: included = (content_type != 'none'
available_plugins.append(plugin_id) or display_mode == VegasDisplayMode.STATIC)
logger.info("[%s] --> INCLUDED in Vegas scroll", plugin_id) logger.debug(
else: "[%s] Vegas: %s (content_type=%s, display_mode=%s)",
logger.info("[%s] --> EXCLUDED from Vegas scroll", plugin_id) plugin_id, "included" if included else "excluded",
else: content_type, display_mode.value
logger.info("[%s] --> SKIPPED (not enabled)", plugin_id) )
if included:
available_plugins.append(plugin_id)
else: else:
logger.warning( logger.warning(
"plugin_manager does not have plugins attribute: %s", "plugin_manager does not have plugins attribute: %s",
@@ -400,24 +362,20 @@ class StreamManager:
# Apply ordering from config (outside lock for potentially slow operation) # Apply ordering from config (outside lock for potentially slow operation)
ordered_plugins = self.config.get_ordered_plugins(available_plugins) ordered_plugins = self.config.get_ordered_plugins(available_plugins)
logger.info(
"Vegas scroll plugin list: %d available -> %d ordered",
len(available_plugins), len(ordered_plugins)
)
logger.info("Ordered plugins: %s", ordered_plugins)
ordered_plugins = self._apply_priority_weights(ordered_plugins) ordered_plugins = self._apply_priority_weights(ordered_plugins)
# Atomically update shared state under lock to avoid races with prefetchers # Atomically update shared state under lock to avoid races with prefetchers
with self._buffer_lock: with self._buffer_lock:
self._ordered_plugins = ordered_plugins self._ordered_plugins = ordered_plugins
# Reset indices if needed
if self._current_index >= len(self._ordered_plugins):
self._current_index = 0
if self._prefetch_index >= len(self._ordered_plugins): if self._prefetch_index >= len(self._ordered_plugins):
self._prefetch_index = 0 self._prefetch_index = 0
logger.info("=" * 60) slots = (f", {len(ordered_plugins)} slots"
if len(ordered_plugins) != len(set(ordered_plugins)) else "")
logger.info(
"Vegas rotation: %d of %d loaded plugin(s)%s: %s",
len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins)
)
def _plugin_weight(self, plugin_id: str) -> int: def _plugin_weight(self, plugin_id: str) -> int:
"""Slots per cycle for one plugin. """Slots per cycle for one plugin.
@@ -490,7 +448,7 @@ class StreamManager:
schedule = self._unclump_seam(schedule) schedule = self._unclump_seam(schedule)
boosted = {p: w for p, w in weights.items() if w > 1} boosted = {p: w for p, w in weights.items() if w > 1}
logger.info( logger.debug(
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)", "Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
len(schedule), len(ordered), boosted) len(schedule), len(ordered), boosted)
return schedule return schedule
@@ -607,11 +565,6 @@ class StreamManager:
ContentSegment or None if fetch failed ContentSegment or None if fetch failed
""" """
try: try:
logger.info("=" * 60)
logger.info("[%s] FETCHING CONTENT", plugin_id)
logger.info("=" * 60)
# Get plugin instance
if not hasattr(self.plugin_manager, 'plugins'): if not hasattr(self.plugin_manager, 'plugins'):
logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id) logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id)
return None return None
@@ -621,18 +574,11 @@ class StreamManager:
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id) logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
return None return None
logger.info(
"[%s] Plugin found: class=%s, enabled=%s",
plugin_id, plugin.__class__.__name__, getattr(plugin, 'enabled', 'N/A')
)
# Get display mode from plugin
display_mode = VegasDisplayMode.FIXED_SEGMENT display_mode = VegasDisplayMode.FIXED_SEGMENT
try: try:
display_mode = plugin.get_vegas_display_mode() display_mode = plugin.get_vegas_display_mode()
logger.info("[%s] Display mode: %s", plugin_id, display_mode.value)
except (AttributeError, TypeError) as e: except (AttributeError, TypeError) as e:
logger.info( logger.debug(
"[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)", "[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)",
plugin_id, e plugin_id, e
) )
@@ -644,21 +590,21 @@ class StreamManager:
segment = ContentSegment( segment = ContentSegment(
plugin_id=plugin_id, plugin_id=plugin_id,
images=[], # No images needed for static pause images=[], # No images needed for static pause
total_width=0,
display_mode=display_mode display_mode=display_mode
) )
self.stats['segments_fetched'] += 1 self.stats['segments_fetched'] += 1
logger.info( logger.debug(
"[%s] Created STATIC placeholder (pause trigger)", "[%s] Created STATIC placeholder (pause trigger)",
plugin_id plugin_id
) )
return segment return segment
# Get content via adapter for SCROLL/FIXED_SEGMENT modes # Get content via adapter for SCROLL/FIXED_SEGMENT modes
logger.info("[%s] Calling plugin_adapter.get_content()...", plugin_id)
images = self.plugin_adapter.get_content(plugin, plugin_id) images = self.plugin_adapter.get_content(plugin, plugin_id)
if not images: if not images:
logger.warning("[%s] NO CONTENT RETURNED from plugin_adapter", plugin_id) # The adapter already warns when every content path failed;
# an empty result is otherwise routine (nothing scheduled).
logger.debug("[%s] No Vegas content this cycle", plugin_id)
return None return None
# Calculate total width # Calculate total width
@@ -667,17 +613,14 @@ class StreamManager:
segment = ContentSegment( segment = ContentSegment(
plugin_id=plugin_id, plugin_id=plugin_id,
images=images, images=images,
total_width=total_width,
display_mode=display_mode display_mode=display_mode
) )
self.stats['segments_fetched'] += 1 self.stats['segments_fetched'] += 1
logger.info( logger.debug(
"[%s] SEGMENT CREATED: %d images, %dpx total, mode=%s", "[%s] Segment: %d image(s), %dpx, mode=%s",
plugin_id, len(images), total_width, display_mode.value plugin_id, len(images), total_width, display_mode.value
) )
logger.info("=" * 60)
return segment return segment
except Exception: except Exception:
@@ -696,24 +639,6 @@ class StreamManager:
if len(self._active_buffer) < low_water: if len(self._active_buffer) < low_water:
self._prefetch_content(count=low_water - len(self._active_buffer)) self._prefetch_content(count=low_water - len(self._active_buffer))
def get_all_content_for_composition(self) -> List[Image.Image]:
"""
Get all buffered content as a flat list of images.
Skips STATIC segments as they don't have images to compose.
Prefer get_grouped_content_for_composition(): flattening loses the
plugin boundaries, which is what tells the compositor where a
separator belongs and where it does not.
Returns:
List of all images in buffer order
"""
all_images = []
for _plugin_id, images in self.get_grouped_content_for_composition():
all_images.extend(images)
return all_images
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]: def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
""" """
Get buffered content grouped by the plugin that produced it. Get buffered content grouped by the plugin that produced it.
@@ -816,8 +741,6 @@ class StreamManager:
"""Reset the stream manager state.""" """Reset the stream manager state."""
with self._buffer_lock: with self._buffer_lock:
self._active_buffer.clear() self._active_buffer.clear()
self._staging_buffer.clear()
self._current_index = 0
self._prefetch_index = 0 self._prefetch_index = 0
self._pending_updates.clear() self._pending_updates.clear()
-1
View File
@@ -105,7 +105,6 @@ def _reset_display_manager_globals():
# An instance left here is what the next module's DisplayManager() call # An instance left here is what the next module's DisplayManager() call
# gets back -- potentially one built against a MagicMock matrix. # gets back -- potentially one built against a MagicMock matrix.
dm_mod.DisplayManager._instance = None dm_mod.DisplayManager._instance = None
dm_mod.DisplayManager._initialized = False
# Put a binding back if a patch outlived the module that started it. # Put a binding back if a patch outlived the module that started it.
# Restoring rather than failing: a leak reported against an innocent module # Restoring rather than failing: a leak reported against an innocent module
+3 -7
View File
@@ -43,12 +43,7 @@ function get(path) {
window.debugLog = () => {}; window.debugLog = () => {};
window.PLUGIN_DEBUG = false; window.PLUGIN_DEBUG = false;
window.installedPlugins = installed; window.installedPlugins = installed;
window.escapeHtml = function (text) { require('../led_escape').install(window);
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// Load the helper as a real <script>. // Load the helper as a real <script>.
const s = document.createElement('script'); const s = document.createElement('script');
@@ -69,7 +64,8 @@ function get(path) {
const s2 = document.createElement('script'); const s2 = document.createElement('script');
s2.textContent = ` s2.textContent = `
var installedPlugins = window.installedPlugins; var installedPlugins = window.installedPlugins;
var escapeHtml = window.escapeHtml, pluginLog = window.pluginLog, debugLog = window.debugLog; var escapeHtml = window.LEDEscape.html, escapeAttribute = window.LEDEscape.attr;
var pluginLog = window.pluginLog, debugLog = window.debugLog;
var PLUGIN_DEBUG = false; var PLUGIN_DEBUG = false;
${src.slice(a, b)} ${src.slice(a, b)}
${src.slice(b, c)} ${src.slice(b, c)}
+1
View File
@@ -50,6 +50,7 @@ const get = p => new Promise((res, rej) =>
const s0 = window.document.createElement('script'); const s0 = window.document.createElement('script');
s0.textContent = ` s0.textContent = `
window.debugLog = function () {}; window.debugLog = function () {};
${require('../led_escape').source}
window.showNotification = function () {}; window.showNotification = function () {};
window.showError = function () {}; window.showError = function () {};
window.updateSystemStatus = function () {}; window.updateSystemStatus = function () {};
+27
View File
@@ -0,0 +1,27 @@
// window.LEDEscape, taken verbatim from web_interface/static/v3/js/app-early.js.
//
// Scripts that the suites evaluate on their own (a slice of plugins_manager.js,
// a widget file) call window.LEDEscape, which the page defines in app-early.js
// before anything else runs. `source` is that definition, for a jsdom <script>;
// install(win) evaluates it onto a plain object standing in for window.
const fs = require('fs');
const path = require('path');
const APP_EARLY = path.resolve(__dirname, '../../web_interface/static/v3/js/app-early.js');
const text = fs.readFileSync(APP_EARLY, 'utf8');
const start = text.indexOf('window.LEDEscape = (function() {');
const endMark = '})();';
const end = text.indexOf(endMark, start);
if (start < 0 || end < 0) {
console.error('FAIL: cannot find the window.LEDEscape definition in app-early.js');
process.exit(1);
}
const source = text.slice(start, end + endMark.length);
function install(win) {
// eslint-disable-next-line no-new-func
new Function('window', source)(win);
return win.LEDEscape;
}
module.exports = { source, install };
+1 -1
View File
@@ -19,7 +19,7 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_style_editor_layout_leaf_columns.js', 'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js', 'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js', 'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js']; 'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js', const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js']; 'dom/test_tools_sections.js'];
+212
View File
@@ -0,0 +1,212 @@
// The image list and schedule editor of the file-upload widget
// (web_interface/static/v3/js/widgets/file-upload.js), which owns them since
// plugins_manager.js stopped shipping its own copies.
//
// 1. Upload-supplied strings (file name, id) reach the page as text and data
// attributes only, and the delete button hands them back intact.
// 2. Card and editor ids use the same rule as plugin_config.html
// (img_id|replace('.', '_')|replace('-', '_')), so the schedule button of a
// server-rendered card with a UUID id finds its editor.
// 3. A schedule edit saves to the hidden input and updates the card's summary
// in place: the open editor is not rebuilt, so what the user just changed
// stays on screen and keeps focus.
// 4. Re-rendering the list (upload, delete) keeps an open editor open.
//
// Plain node with a minimal DOM shim, like the other unit suites.
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(
path.resolve(__dirname, '../../../web_interface/static/v3/js/widgets/file-upload.js'), 'utf8');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
// ── DOM shim ───────────────────────────────────────────────────────────────
class ClassList {
constructor(el) { this.el = el; }
get list() { return (this.el.className || '').split(/\s+/).filter(Boolean); }
contains(c) { return this.list.includes(c); }
add(...cs) { this.el.className = [...new Set([...this.list, ...cs])].join(' '); }
remove(...cs) { this.el.className = this.list.filter(c => !cs.includes(c)).join(' '); }
}
class El {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = []; this.parent = null; this.attrs = {}; this.dataset = {}; this.style = {};
this.className = ''; this.id = ''; this._text = ''; this._html = null; this.listeners = {};
this.classList = new ClassList(this);
}
appendChild(c) { c.parent = this; this.children.push(c); return c; }
append(...nodes) { nodes.forEach(n => this.appendChild(typeof n === 'string' ? new TextNode(n) : n)); }
replaceChildren(...nodes) { this.children = []; this._text = ''; this._html = null; this.append(...nodes); }
setAttribute(n, v) { this.attrs[n] = String(v); }
getAttribute(n) { return this.attrs[n]; }
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); }
click() { (this.listeners.click || []).forEach(f => f.call(this, { target: this })); }
set textContent(v) { this.children = []; this._html = null; this._text = String(v); }
get textContent() { return this._text + this.children.map(c => c.textContent).join(''); }
// Markup written with innerHTML is kept as a string; the shim never parses it.
set innerHTML(v) { this.children = []; this._text = ''; this._html = String(v); }
get innerHTML() {
if (this._html !== null) return this._html;
return this._text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
*walk() { for (const c of this.children) { yield c; yield* c.walk(); } }
querySelector(sel) {
const tests = {
'[id^="schedule_"]:not(.hidden)': e => e instanceof El && e.id.startsWith('schedule_') && !e.classList.contains('hidden'),
'.image-schedule-summary': e => e instanceof El && e.classList.contains('image-schedule-summary'),
};
if (!tests[sel]) throw new Error('shim: unsupported selector ' + sel);
for (const e of this.walk()) if (tests[sel](e)) return e;
return null;
}
}
class TextNode { constructor(t) { this.textContent = String(t); } *walk() {} }
const root = new El('body');
const docListeners = {};
global.document = {
createElement: t => new El(t),
createTextNode: t => new TextNode(t),
getElementById: id => {
if (root.id === id) return root;
for (const e of root.walk()) if (e.id === id) return e;
return null;
},
addEventListener: (t, f) => { (docListeners[t] ||= []).push(f); },
};
global.window = global;
require('../led_escape').install(window);
window.LEDMatrixWidgets = { register() {} };
window.currentPluginConfig = null;
window.getUploadConfig = () => ({ plugin_id: 'static-image' });
// eslint-disable-next-line no-eval
eval(SRC);
window.getUploadConfig = () => ({ plugin_id: 'static-image' });
function mount(fieldId, images) {
root.children = [];
const list = root.appendChild(new El('div')); list.id = `${fieldId}_image_list`;
const hidden = root.appendChild(new El('input')); hidden.id = `${fieldId}_images_data`;
hidden.value = JSON.stringify(images);
return { list, hidden };
}
const buttons = card => [...card.walk()].filter(e => e.tagName === 'BUTTON');
const SQ = "x' onmouseover='alert(1)";
const DQ = 'x" onmouseover="alert(1)';
const UUID = '1b2c3d4e-aaaa-bbbb-cccc-123456789abc';
console.log('\n1. upload-supplied strings stay text and data');
{
const name = '<img src=x onerror=alert(1)>' + DQ + '.png';
const { list } = mount('f', []);
window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]);
const card = list.children[0];
const all = [...card.walk()];
ok('no element carries markup written with innerHTML', all.every(e => !(e instanceof El) || e._html === null || e._html === ''),
all.filter(e => e instanceof El && e._html).map(e => e._html));
const p = all.find(e => e.tagName === 'P');
ok('file name is text', p.textContent === name, p.textContent);
const img = all.find(e => e.tagName === 'IMG');
ok('alt is the stored filename', img.alt === DQ, img.alt);
ok('thumbnail loads lazily', img.loading === 'lazy' && img.decoding === 'async', [img.loading, img.decoding]);
const [sched, del] = buttons(card);
ok('buttons are labelled for screen readers',
sched.attrs['aria-label'] === 'Schedule image ' + name && del.attrs['aria-label'] === 'Delete image ' + name,
[sched.attrs, del.attrs]);
const calls = [];
window.deleteUploadedImage = (...a) => calls.push(a);
del.click();
ok('delete gets field, image and plugin ids intact',
calls.length === 1 && calls[0][0] === 'f' && calls[0][1] === SQ && calls[0][2] === 'static-image', calls);
sched.click();
const editor = card.children[1];
ok('editor opens for a hostile id', !editor.classList.contains('hidden'), editor.className);
// The editor is built with DOM calls: the id must arrive as attribute data,
// and no element may carry markup written with innerHTML.
const editorEls = [...editor.walk()].filter(e => e instanceof El);
const withId = editorEls.filter(e => e.attrs['data-image-id'] !== undefined);
ok('editor stores the id as attribute data, not markup',
withId.length > 0 && withId.every(e => e.attrs['data-image-id'] === SQ) && editorEls.every(e => e._html === null),
withId.map(e => e.attrs['data-image-id']));
}
console.log('\n2. ids match the server-rendered template');
{
const { list } = mount('g', [{ id: UUID, path: 'a.png', filename: 'a.png' }]);
window.updateImageList('g', [{ id: UUID, path: 'a.png', filename: 'a.png' }]);
const want = UUID.replace(/[.-]/g, '_');
ok('card id replaces "-" like the template', list.children[0].id === 'img_' + want, list.children[0].id);
ok('editor id replaces "-" like the template', list.children[0].children[1].id === 'schedule_' + want,
list.children[0].children[1].id);
// A card as plugin_config.html renders it: its button passes the raw UUID.
root.children = [];
const hidden = root.appendChild(new El('input')); hidden.id = 'g_images_data';
hidden.value = JSON.stringify([{ id: UUID, path: 'a.png', filename: 'a.png' }]);
const editor = root.appendChild(new El('div'));
editor.id = 'schedule_' + want; editor.className = 'hidden mt-3';
window.openImageSchedule('g', UUID, 0);
ok('schedule button of a server-rendered card opens its editor',
!editor.classList.contains('hidden') && editor.textContent.includes('Schedule Settings'), editor.className);
}
console.log('\n3. a schedule edit saves without rebuilding the editor');
{
const images = [{ id: UUID, path: 'a.png', filename: 'a.png' }];
const { list, hidden } = mount('h', images);
window.updateImageList('h', images);
window.openImageSchedule('h', UUID, 0);
const editor = list.children[0].children[1];
const before = editor.innerHTML;
// The shim does not parse the editor markup, so stand in the controls the
// update functions read.
const want = UUID.replace(/[.-]/g, '_');
const check = root.appendChild(new El('input')); check.id = 'schedule_enabled_' + want; check.checked = true;
const mode = root.appendChild(new El('select')); mode.id = 'schedule_mode_' + want; mode.value = 'time_range';
window.toggleImageScheduleEnabled('h', UUID, 0);
window.updateImageScheduleMode('h', UUID, 0);
const saved = JSON.parse(hidden.value)[0].schedule;
ok('hidden input holds the new schedule', saved.enabled === true && saved.mode === 'time_range', saved);
ok('editor was not rebuilt', editor.innerHTML === before && !editor.classList.contains('hidden'));
const summary = list.children[0].querySelector('.image-schedule-summary');
ok('card summary updated in place', summary.textContent === '08:00 - 18:00 (daily)', summary.textContent);
}
console.log('\n4. re-rendering the list keeps an open editor open');
{
const images = [{ id: UUID, path: 'a.png', filename: 'a.png', schedule: { enabled: true, mode: 'per_day', days: {} } },
{ id: 'other', path: 'b.png', filename: 'b.png' }];
const { list } = mount('k', images);
window.updateImageList('k', images);
window.openImageSchedule('k', UUID, 0);
window.updateImageList('k', images.slice(0, 1));
const editor = list.children[0].children[1];
ok('editor still open after re-render', !editor.classList.contains('hidden'), editor.className);
const perDay = [...editor.walk()].find(e => e instanceof El && e.tagName === 'OPTION' && e.attrs.value === 'per_day');
ok('editor rebuilt from the saved schedule', !!perDay && 'selected' in perDay.attrs);
}
console.log('\n5. the delegated change listener routes editor controls');
{
const calls = [];
const saved = window.updateImageScheduleDay;
window.updateImageScheduleDay = (...a) => calls.push(a);
const control = new El('input');
control.dataset = { scheduleControl: 'day', fieldId: SQ, imageId: DQ, imageIdx: '2', day: 'monday' };
control.closest = () => control;
(docListeners.change || []).forEach(f => f({ target: control }));
window.updateImageScheduleDay = saved;
ok('day control reaches updateImageScheduleDay with its data intact',
calls.length === 1 && calls[0][0] === SQ && calls[0][1] === DQ && calls[0][2] === 2 && calls[0][3] === 'monday', calls);
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
+73 -5
View File
@@ -11,9 +11,10 @@
// name" onfocus="alert(1) // name" onfocus="alert(1)
// //
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for // CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
// exactly this. This suite pins the fix at the source: it reads each real // exactly this. The web UI now has one implementation, window.LEDEscape in
// implementation out of the shipped file and runs it, so an escaper that loses // app-early.js, and the old per-file escapers are one-line names for it. This
// its quote handling again fails here rather than in a scanner run weeks later. // suite runs LEDEscape and every one of those names as shipped, and fails if a
// hand-rolled escaper appears anywhere else in web_interface/.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@@ -41,6 +42,7 @@ class FakeEl {
} }
global.document = { createElement: () => new FakeEl() }; global.document = { createElement: () => new FakeEl() };
global.window = global; global.window = global;
const LEDEscape = require('../led_escape').install(window);
// ── source extraction ────────────────────────────────────────────────────── // ── source extraction ──────────────────────────────────────────────────────
// Pull a function out of a real source file by its opening line and balanced // Pull a function out of a real source file by its opening line and balanced
@@ -66,6 +68,7 @@ function extract(file, opener) {
// Evaluate an extracted escaper and return it as a callable. // Evaluate an extracted escaper and return it as a callable.
function loadFn(file, opener, name, { method = false } = {}) { function loadFn(file, opener, name, { method = false } = {}) {
if (file === null) return LEDEscape[name];
const body = extract(file, opener); const body = extract(file, opener);
// Class/object methods (`escapeHtml(text) {...}`) are not valid statements on // Class/object methods (`escapeHtml(text) {...}`) are not valid statements on
// their own -- wrap them in an object literal so they can be evaluated. // their own -- wrap them in an object literal so they can be evaluated.
@@ -78,6 +81,8 @@ function loadFn(file, opener, name, { method = false } = {}) {
// ── the escapers, as shipped ─────────────────────────────────────────────── // ── the escapers, as shipped ───────────────────────────────────────────────
const ESCAPERS = [ const ESCAPERS = [
['app-early.js (LEDEscape.html)', null, null, 'html'],
['app-early.js (LEDEscape.attr)', null, null, 'attr'],
['base-widget.js (BaseWidget.escapeHtml)', ['base-widget.js (BaseWidget.escapeHtml)',
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true], 'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
['plugins_manager.js (top-level escapeHtml)', ['plugins_manager.js (top-level escapeHtml)',
@@ -90,8 +95,26 @@ const ESCAPERS = [
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true], 'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
['plugin-file-manager.js (escHtml)', ['plugin-file-manager.js (escHtml)',
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false], 'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
['app-shell.js (escapeHtml)', ['plugins_manager.js (escapeAttribute)',
'static/v3/js/app-shell.js', 'escapeHtml(text) {', 'escapeHtml', true], 'static/v3/plugins_manager.js', 'function escapeAttribute(text) {', 'escapeAttribute', false],
['notification.js (escapeHtml)',
'static/v3/js/widgets/notification.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['google-calendar-picker.js (escapeHtml)',
'static/v3/js/widgets/google-calendar-picker.js', 'function escapeHtml(str) {', 'escapeHtml', false],
['text-input.js (escapeHtml)',
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['slider.js (escapeAttr)',
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
['display.html (escapeAttr)',
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
['backup_restore.html (escapeHtml)',
'templates/v3/partials/backup_restore.html', 'function escapeHtml(value) {', 'escapeHtml', false],
['operation_history.html (escapeHtml)',
'templates/v3/partials/operation_history.html', 'function escapeHtml(text) {', 'escapeHtml', false],
['tools.html (escHtml)',
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
['tools.html (phEscape)',
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
['logs.html (escapeHtml)', ['logs.html (escapeHtml)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false], 'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
['cache.html (escapeHtml)', ['cache.html (escapeHtml)',
@@ -135,6 +158,51 @@ for (const [label, file, opener, name, method] of ESCAPERS) {
ok(`${label}: &quot; input stays inert`, out === '&amp;quot;', out); ok(`${label}: &quot; input stays inert`, out === '&amp;quot;', out);
} }
console.log('\n4b. LEDEscape.jsStringAttr: a JS string literal that survives an attribute');
{
const decode = s => s.replace(/&(quot|#39|lt|gt|amp);/g, (m, e) =>
({ quot: '"', '#39': "'", lt: '<', gt: '>', amp: '&' })[e]);
for (const v of ["x' onmouseover='alert(1)", 'x" onmouseover="alert(1)', '</script><b>', 'a&#39;b']) {
const out = LEDEscape.jsStringAttr(v);
ok(`${JSON.stringify(v)}: no raw quote or bracket`, !/["'<>]/.test(out), out);
// eslint-disable-next-line no-eval
ok(`${JSON.stringify(v)}: decodes back to the same string`, eval(decode(out)) === v, out);
}
ok('null and undefined become the empty string',
LEDEscape.html(null) === '' && LEDEscape.html(undefined) === '' && LEDEscape.jsStringAttr(null) === '&quot;&quot;');
ok('numbers are kept', LEDEscape.html(0) === '0', LEDEscape.html(0));
}
console.log('\n4c. no hand-rolled escaper outside app-early.js');
{
const skip = new Set(['static/v3/js/app-early.js',
// documentation example, kept self-contained on purpose
'static/v3/js/widgets/example-color-picker.js']);
const found = [];
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => {
const p = path.join(dir, e.name);
const rel = path.relative(ROOT, p).split(path.sep).join('/');
if (e.isDirectory()) { if (e.name !== 'vendor') walk(p); return; }
if (!/\.(js|html)$/.test(e.name) || /\.min\.js$/.test(e.name) || skip.has(rel)) return;
// Writing the entity for a quote is what an escaper does; nothing else in
// the UI needs to.
let text = fs.readFileSync(p, 'utf8');
// In templates only the inline scripts count; Jinja's own |replace("'", "&#39;")
// escaping of server-rendered values is not a JS escaper.
if (e.name.endsWith('.html')) text = (text.match(/<script[^>]*>[\s\S]*?<\/script\s*>/gi) || []).join('\n');
if (/['"`]&quot;['"`]|['"`]&#39;['"`]/.test(text)) found.push(rel);
});
walk(path.join(ROOT, 'static'));
walk(path.join(ROOT, 'templates'));
ok('every escaper is window.LEDEscape', found.length === 0, found);
// Plugin pages may call these globals; they stay, as aliases.
const early = fs.readFileSync(path.join(ROOT, 'static/v3/js/app-early.js'), 'utf8');
ok('window.escapeHtml is kept as an alias of LEDEscape.html',
early.includes('window.escapeHtml = window.LEDEscape.html;'));
ok('window.escapeAttribute is kept as an alias of LEDEscape.attr',
early.includes('window.escapeAttribute = window.LEDEscape.attr;'));
}
// ── url-input scheme handling (js/xss-through-dom) ───────────────────────── // ── url-input scheme handling (js/xss-through-dom) ─────────────────────────
console.log('\n5. url-input never treats a scriptable scheme as a valid URL'); console.log('\n5. url-input never treats a scriptable scheme as a valid URL');
{ {
+3 -26
View File
@@ -7,9 +7,8 @@
// //
// JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id // JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id
// of x' onmouseover='alert(1) closed the single-quoted attribute and added a // of x' onmouseover='alert(1) closed the single-quoted attribute and added a
// handler of its own. The live window.updateImageList (plugins_manager.js loads // handler of its own. (The uploaded-image list is no longer built here: the
// last, so its copy beats the file-upload widget's) put the uploaded file's // file-upload widget owns it, and test_file_upload_widget.js covers it.)
// original name into the markup unescaped.
// //
// Each case renders with the shipped function, parses the tag the way a browser // Each case renders with the shipped function, parses the tag the way a browser
// does (quoted attribute values, entities decoded), and checks two things: no // does (quoted attribute values, entities decoded), and checks two things: no
@@ -54,6 +53,7 @@ global.document = {
createElement: () => new TextEl(), createElement: () => new TextEl(),
}; };
global.window = global; global.window = global;
require('../led_escape').install(window);
global.pluginLog = () => {}; global.pluginLog = () => {};
global.isStorePluginInstalled = () => false; global.isStorePluginInstalled = () => false;
global.isNewPlugin = () => false; global.isNewPlugin = () => false;
@@ -68,8 +68,6 @@ eval([
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;' ].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;' + '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;'); + '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
// eslint-disable-next-line no-eval
eval(extract('window.updateImageList = function(fieldId, images) {'));
// ── minimal HTML start-tag tokenizer ─────────────────────────────────────── // ── minimal HTML start-tag tokenizer ───────────────────────────────────────
function decodeEntities(s) { function decodeEntities(s) {
@@ -190,26 +188,5 @@ for (const hostile of [SQ, DQ, AMP]) {
calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls); calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls);
} }
console.log('\n5. live window.updateImageList escapes the uploaded file name');
window.getUploadConfig = () => ({ plugin_id: SQ });
window.currentPluginConfig = null;
{
const name = '<img src=x onerror=alert(1)>' + DQ + '.png';
window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]);
const html = els['f_image_list'].innerHTML;
ok('no raw markup from original_filename', !html.includes('<img src=x onerror'), html);
const imgs = tags(html, 'img');
ok('one <img>, only the template attributes',
imgs.length === 1 && names(imgs[0]).join(',') === 'src,alt,loading,decoding,class,onerror', imgs.map(names));
ok('alt carries the stored filename as text', attr(imgs[0], 'alt') === DQ, imgs[0]);
const buttons = tags(html, 'button');
ok('two buttons, no extra attributes',
buttons.length === 2 && buttons.every(b => names(b).join(',') === 'type,onclick,class,title,aria-label'),
buttons.map(names));
const del = runHandler(attr(buttons[1], 'onclick'));
ok('delete handler gets field, image and plugin ids intact',
del.length === 1 && del[0][0] === 'deleteUploadedImage' && del[0][1] === 'f' && del[0][2] === SQ && del[0][3] === SQ, del);
}
console.log(`\n${pass} passed, ${fail} failed\n`); console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);
@@ -24,7 +24,7 @@ function slice(startMarker, endMarker) {
return SRC.slice(a, b); return SRC.slice(a, b);
} }
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// Note: configurePlugin'); const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// GitHub Token Collapse Handler');
const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)'); const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)');
let pass = 0, fail = 0; let pass = 0, fail = 0;
+2 -1
View File
@@ -30,12 +30,13 @@ global.document = {
createElement: () => new FakeEl(), createElement: () => new FakeEl(),
}; };
global.window = global; global.window = global;
require('../led_escape').install(window);
global.pluginLog = () => {}; global.pluginLog = () => {};
global.PLUGIN_DEBUG = false; global.PLUGIN_DEBUG = false;
global.debugLog = () => {}; global.debugLog = () => {};
function setupInstalledEventDelegation() {} // stubbed; tested separately function setupInstalledEventDelegation() {} // stubbed; tested separately
eval(slice('function escapeHtml(text)', '\nfunction ', )); eval(slice('function escapeHtml(text)', '\nfunction isNewPlugin'));
eval(slice('function renderInstalledCards(plugins, total)', eval(slice('function renderInstalledCards(plugins, total)',
'// Set up event delegation for plugin action buttons')); '// Set up event delegation for plugin action buttons'));
+59
View File
@@ -0,0 +1,59 @@
"""DisplayController.cleanup() must tear down Vegas mode.
VegasModeCoordinator.cleanup() stops the scroll, resets the render pipeline and
stream manager and drops the adapter's content cache, but the controller never
called it, so none of that teardown ran at shutdown.
"""
import os
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController
def _controller(vegas_coordinator):
dc = object.__new__(DisplayController)
dc.plugin_manager = MagicMock()
dc.config_service = MagicMock()
dc._font_usage_publisher = None
dc.display_manager = MagicMock()
dc.vegas_coordinator = vegas_coordinator
return dc
def test_cleanup_tears_down_vegas():
vegas = MagicMock()
_controller(vegas).cleanup()
vegas.cleanup.assert_called_once_with()
def test_vegas_is_torn_down_before_the_display_manager():
# Stopping Vegas resets the display's scrolling state, so the display
# manager has to still be there when it runs.
order = []
vegas = MagicMock()
vegas.cleanup.side_effect = lambda: order.append('vegas')
dc = _controller(vegas)
dc.display_manager.cleanup.side_effect = lambda: order.append('display')
dc.cleanup()
assert order == ['vegas', 'display']
def test_cleanup_without_vegas_does_not_raise():
dc = _controller(None)
dc.cleanup()
dc.display_manager.cleanup.assert_called_once_with()
def test_a_failing_vegas_teardown_does_not_stop_the_rest():
vegas = MagicMock()
vegas.cleanup.side_effect = RuntimeError('boom')
dc = _controller(vegas)
dc.cleanup()
dc.display_manager.cleanup.assert_called_once_with()
@@ -0,0 +1,77 @@
"""Startup validation runs each check once.
DisplayController ran StartupValidator.validate_all() twice -- once before the
plugin manager existed and again after -- so every config, cache, display and
systemd-unit warning was logged twice at every boot. The second pass only
needs the plugin checks.
"""
import logging
from unittest.mock import MagicMock, patch
class RecordingValidator:
"""Stands in for StartupValidator and records which checks ran."""
calls = []
def __init__(self, config_manager, plugin_manager=None, cache_manager=None):
self.plugin_manager = plugin_manager
self.errors = []
self.warnings = []
def validate_all(self):
RecordingValidator.calls.append(('validate_all', self.plugin_manager is not None))
self.warnings = ['config warning']
return True, [], list(self.warnings)
def _validate_plugins(self):
RecordingValidator.calls.append(('plugins',))
self.warnings = ['plugin warning']
def _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, config):
from src.display_controller import DisplayController
mock_config_manager.get_config.return_value = config
mock_config_manager.load_config.return_value = config
mock_pm = MagicMock()
mock_pm.discover_plugins.return_value = []
mock_pm.plugins = {}
mock_pm.plugin_manifests = {}
mock_pm.plugin_last_update = {}
mock_pm.health_tracker = None
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
patch('src.display_controller.FontManager'), \
patch('src.plugin_system.PluginManager', return_value=mock_pm), \
patch('src.startup_validator.StartupValidator', RecordingValidator):
controller = DisplayController()
return controller
def test_each_check_runs_once(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins,
emulator_mode, caplog):
RecordingValidator.calls = []
with caplog.at_level(logging.WARNING, logger='src.display_controller'):
controller = _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins)
try:
full_passes = [c for c in RecordingValidator.calls if c[0] == 'validate_all']
assert len(full_passes) == 1, RecordingValidator.calls
plugin_checks = (
sum(1 for c in RecordingValidator.calls if c[0] == 'plugins')
+ sum(1 for c in full_passes if c[1]))
assert plugin_checks == 1, RecordingValidator.calls
config_warnings = [r for r in caplog.records
if 'config warning' in r.getMessage()]
assert len(config_warnings) == 1
assert any('plugin warning' in r.getMessage() for r in caplog.records)
finally:
controller.cleanup()
+3 -9
View File
@@ -83,15 +83,9 @@ class TestVegasCoordinatorCallbackWiring:
fake_coordinator = MagicMock() fake_coordinator = MagicMock()
import src.display_controller as dc_module from unittest.mock import patch
original_imported = dc_module._vegas_mode_imported with patch('src.vegas_mode.VegasModeCoordinator',
original_class = dc_module.VegasModeCoordinator MagicMock(return_value=fake_coordinator)):
try:
dc_module._vegas_mode_imported = True
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
dc._initialize_vegas_mode() dc._initialize_vegas_mode()
finally:
dc_module._vegas_mode_imported = original_imported
dc_module.VegasModeCoordinator = original_class
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas) fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
-4
View File
@@ -28,7 +28,6 @@ def dm(tmp_path_factory):
"""One real DisplayManager on the emulator (it's a process singleton).""" """One real DisplayManager on the emulator (it's a process singleton)."""
from src.display_manager import DisplayManager from src.display_manager import DisplayManager
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
manager = DisplayManager({ manager = DisplayManager({
"display": { "display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2, "hardware": {"rows": 32, "cols": 64, "chain_length": 2,
@@ -60,7 +59,6 @@ def dm(tmp_path_factory):
"test module.") "test module.")
yield manager yield manager
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
class _SwapSpy: class _SwapSpy:
@@ -230,7 +228,6 @@ class TestKillSwitch:
def test_config_flag_wires_through(self): def test_config_flag_wires_through(self):
from src.display_manager import DisplayManager from src.display_manager import DisplayManager
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
try: try:
manager = DisplayManager({ manager = DisplayManager({
"display": { "display": {
@@ -243,7 +240,6 @@ class TestKillSwitch:
assert manager._dirty_tracking_enabled is False assert manager._dirty_tracking_enabled is False
finally: finally:
DisplayManager._instance = None DisplayManager._instance = None
DisplayManager._initialized = False
if __name__ == "__main__": if __name__ == "__main__":
+49
View File
@@ -0,0 +1,49 @@
"""display_manager's logger must follow the level run.py configures.
The module pinned its own logger to INFO at import, which overrides the root
level, so ``run.py -d`` never showed a single DEBUG line from the display
manager -- the dirty-tracking, fallback-mode and scrolling-state traces were
unreachable without editing the source.
"""
import logging
import os
os.environ.setdefault("EMULATOR", "true")
import src.display_manager as dm
def test_the_module_sets_no_level_of_its_own():
assert dm.logger.level == logging.NOTSET
def test_scrolling_state_logs_only_when_it_changes(caplog):
# Vegas and every scrolling plugin set the state on each frame, so a line
# per call was ~120 DEBUG lines a second once debug output was visible.
dm_obj = object.__new__(dm.DisplayManager)
dm_obj._frame_hold = 1
dm_obj._scrolling_state = {'is_scrolling': False, 'last_scroll_activity': 0}
with caplog.at_level(logging.DEBUG, logger='src.display_manager'):
for _ in range(5):
dm_obj.set_scrolling_state(True, frame_hold=2)
dm_obj.set_scrolling_state(False)
dm_obj.set_scrolling_state(False)
lines = [r.getMessage() for r in caplog.records
if r.getMessage().startswith('Scrolling state set to')]
assert lines == ['Scrolling state set to: True', 'Scrolling state set to: False']
# The state itself still updates on every call.
assert dm_obj._scrolling_state['is_scrolling'] is False
assert dm_obj._frame_hold == 1
def test_debug_output_appears_when_the_root_is_at_debug():
root = logging.getLogger()
previous = root.level
root.setLevel(logging.DEBUG)
try:
assert dm.logger.isEnabledFor(logging.DEBUG)
finally:
root.setLevel(previous)
-1
View File
@@ -154,7 +154,6 @@ def vegas_coordinator(controller):
coord._update_callback = None coord._update_callback = None
coord._update_tick_running = False coord._update_tick_running = False
coord.sync_manager = None coord.sync_manager = None
coord._update_static_mode_plugins = lambda: None
coord._check_static_plugin_trigger = lambda: None coord._check_static_plugin_trigger = lambda: None
coord.frames = 0 coord.frames = 0
+24 -19
View File
@@ -2,7 +2,7 @@
Tests for src/vegas_mode/config.py Tests for src/vegas_mode/config.py
Covers VegasModeConfig: from_config, to_dict, get_frame_interval, Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
is_plugin_included, get_ordered_plugins, validate. get_ordered_plugins, validate.
""" """
import pytest import pytest
@@ -38,6 +38,29 @@ class TestVegasModeConfigDefaults:
cfg = VegasModeConfig() cfg = VegasModeConfig()
assert len(cfg.excluded_plugins) == 0 assert len(cfg.excluded_plugins) == 0
def test_defaults_match_the_shipped_template(self):
# The template, the web UI help and CONFIG_REFERENCE all document
# these values. max_cycle_duration defaulted to 600 in code while all
# three said 240, so an install without the key ran iterations 2.5x
# longer than documented.
import json
from pathlib import Path
template = json.loads(
(Path(__file__).resolve().parent.parent / "config"
/ "config.template.json").read_text(encoding="utf-8"))
shipped = template["display"]["vegas_scroll"]
defaults = VegasModeConfig().to_dict()
mismatched = {k: (v, defaults[k]) for k, v in shipped.items()
if k in defaults and defaults[k] != v}
assert not mismatched, f"template vs code default: {mismatched}"
def test_missing_keys_read_the_field_defaults(self):
# from_config used to repeat every default; with no keys set it must
# produce exactly the dataclass defaults.
assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict()
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict()
== VegasModeConfig().to_dict())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# from_config # from_config
@@ -147,24 +170,6 @@ class TestGetFrameInterval:
assert result == 1.0 assert result == 1.0
# ---------------------------------------------------------------------------
# is_plugin_included
# ---------------------------------------------------------------------------
class TestIsPluginIncluded:
def test_not_excluded_is_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("good_plugin") is True
def test_excluded_plugin_not_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("bad_plugin") is False
def test_empty_exclusions_all_included(self):
cfg = VegasModeConfig()
assert cfg.is_plugin_included("anything") is True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_ordered_plugins # get_ordered_plugins
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -4
View File
@@ -108,10 +108,10 @@ class TestInvalidatePendingUpdates:
# Left unconsumed they accumulate forever and nothing ever refreshes. # Left unconsumed they accumulate forever and nothing ever refreshes.
stream = self._manager({'baseball': SimpleNamespace()}) stream = self._manager({'baseball': SimpleNamespace()})
stream.mark_plugin_updated('baseball') stream.mark_plugin_updated('baseball')
assert stream.has_pending_updates() is True assert stream._pending_updates
stream.invalidate_pending_updates() stream.invalidate_pending_updates()
assert stream.has_pending_updates() is False assert not stream._pending_updates
assert stream.invalidate_pending_updates() == [] assert stream.invalidate_pending_updates() == []
def test_no_pending_updates_does_no_work(self): def test_no_pending_updates_does_no_work(self):
@@ -133,7 +133,6 @@ class TestInvalidatePendingUpdates:
class TestContinuousModeReachesTheRefresh: class TestContinuousModeReachesTheRefresh:
def _pipeline(self): def _pipeline(self):
stream = MagicMock() stream = MagicMock()
stream.get_buffer_status.return_value = {'staging_count': 0}
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
def test_refresh_delegates_to_the_stream_manager(self): def test_refresh_delegates_to_the_stream_manager(self):
@@ -161,7 +160,6 @@ class TestContinuousModeReachesTheRefresh:
pipeline.refresh_updated_plugins() pipeline.refresh_updated_plugins()
assert pipeline.scroll_helper.scroll_position == 1234 assert pipeline.scroll_helper.scroll_position == 1234
stream.swap_buffers.assert_not_called()
stream.process_updates.assert_not_called() stream.process_updates.assert_not_called()
+54
View File
@@ -0,0 +1,54 @@
"""run_iteration() does no per-iteration static-mode bookkeeping.
Every iteration used to rebuild a set of STATIC-mode plugins -- asking each
plugin for its display mode and logging the result at INFO -- that nothing
ever read. The static pause is driven by _check_static_plugin_trigger(), which
looks at the next segment, not at that set.
"""
import logging
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
def _coordinator(plugins):
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'enabled': True, 'max_cycle_duration': 0}}})
coord.render_pipeline = MagicMock()
coord.stream_manager = MagicMock()
coord.display_manager = MagicMock()
coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get)
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
coord._state_lock = threading.Lock()
coord._is_active = True
coord._is_paused = False
coord._should_stop = False
coord._fps_last_health_log = 0.0
coord._fps_was_degraded = False
coord._live_priority_check = None
coord._live_priority_active = False
coord._interrupt_check = None
coord._interrupt_check_interval = 10
coord._update_callback = None
coord._update_tick_running = False
coord._check_static_plugin_trigger = lambda: None
coord.run_frame = lambda: True
return coord
def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
static = MagicMock()
static.get_vegas_display_mode.return_value = VegasDisplayMode.STATIC
coord = _coordinator({'static-plugin': static})
with caplog.at_level(logging.INFO, logger='src.vegas_mode.coordinator'):
assert coord.run_iteration() is True
static.get_vegas_display_mode.assert_not_called()
assert not any('Static mode plugins' in r.getMessage() for r in caplog.records)
+3 -7
View File
@@ -416,7 +416,7 @@ class TestStreamGrouping:
from src.plugin_system.base_plugin import VegasDisplayMode from src.plugin_system.base_plugin import VegasDisplayMode
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)] imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
return ContentSegment( return ContentSegment(
plugin_id=plugin_id, images=imgs, total_width=10 * count, plugin_id=plugin_id, images=imgs,
display_mode=mode or VegasDisplayMode.SCROLL) display_mode=mode or VegasDisplayMode.SCROLL)
def test_grouping_preserves_plugin_boundaries(self): def test_grouping_preserves_plugin_boundaries(self):
@@ -437,10 +437,6 @@ class TestStreamGrouping:
sm = self._stream([self._seg('a', 0), self._seg('b', 2)]) sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b'] assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
def test_flat_accessor_still_matches_grouped_total(self):
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
assert len(sm.get_all_content_for_composition()) == 5
class TestApiBoundsMatchValidate: class TestApiBoundsMatchValidate:
""" """
@@ -980,8 +976,8 @@ class TestRotationAcrossMultipleCycles:
""" """
The single-image crop advances a window across cycles. The second and later The single-image crop advances a window across cycles. The second and later
passes are where start + budget can land exactly on the image width, which passes are where start + budget can land exactly on the image width, which
crashed find_blank_cut in the field and lost that plugin's content for the once crashed the cut and lost that plugin's content for the cycle.
cycle. First-pass-only tests never reach it. First-pass-only tests never reach it.
""" """
def test_window_advances_over_many_cycles_without_error(self): def test_window_advances_over_many_cycles_without_error(self):
-49
View File
@@ -10,7 +10,6 @@ from src.vegas_mode.geometry import (
content_bounds, content_bounds,
dead_window_stats, dead_window_stats,
edge_blank, edge_blank,
find_blank_cut,
separation_gap, separation_gap,
trim_to_content, trim_to_content,
window_coverage_stats, window_coverage_stats,
@@ -320,51 +319,3 @@ class TestSeparationGap:
# flat gap; measured separation lifts them to the 24px target. # flat gap; measured separation lifts them to the 24px target.
card = paint(make_img(150), 0, 150) card = paint(make_img(150), 0, 150)
assert separation_gap(card, card, target=24, minimum=8) == 24 assert separation_gap(card, card, target=24, minimum=8) == 24
class TestFindBlankCut:
def test_snaps_to_the_nearest_gap(self):
img = paint(make_img(200), 0, 90)
paint(img, 110, 200)
# 100 is inside the 90..110 gap already.
assert find_blank_cut(img, 100, 20) == 100
def test_walks_outwards_to_find_a_gap(self):
img = paint(make_img(200), 0, 95)
paint(img, 105, 200)
cut = find_blank_cut(img, 90, 20)
assert 95 <= cut < 105
def test_solid_ink_returns_the_target(self):
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
def test_target_at_image_width_does_not_index_past_the_end(self):
# A cut after the last column is legal. Indexing ink[width] raised
# IndexError in the field, losing that plugin's content for the cycle.
# Reached once the rotation offset advances so start + budget lands
# exactly on the image width.
img = paint(make_img(1840), 0, 1840)
assert find_blank_cut(img, 1840, 32) == 1840
def test_target_past_image_width_is_clamped(self):
img = paint(make_img(100), 0, 100)
assert find_blank_cut(img, 500, 32) == 100
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
# Content 0..179, blank 180..199. The nearest blank column to 200 is
# 199, not the start of the gap — nearest is what keeps the cut as
# close as possible to the requested budget.
img = paint(make_img(200), 0, 180)
assert find_blank_cut(img, 200, 32) == 199
def test_zero_radius_returns_the_target(self):
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
def test_negative_target_is_clamped_to_zero(self):
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
def test_never_raises_across_the_range(self, target):
img = paint(make_img(100), 0, 100)
cut = find_blank_cut(img, target, 16)
assert 0 <= cut <= 100
+1 -8
View File
@@ -24,7 +24,6 @@ class FakeDisplayManager:
def _make_pipeline(sync_manager=None): def _make_pipeline(sync_manager=None):
stream_manager = MagicMock() stream_manager = MagicMock()
stream_manager.get_buffer_status.return_value = {'staging_count': 0}
pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager) pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager)
pipeline.sync_manager = sync_manager pipeline.sync_manager = sync_manager
return pipeline, stream_manager return pipeline, stream_manager
@@ -37,7 +36,7 @@ class TestShouldRecompose:
stream_manager.has_pending_updates_for_visible_segments.return_value = False stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True assert pipeline.should_recompose() is True
def test_no_pending_updates_no_staging_does_not_recompose(self): def test_no_pending_updates_does_not_recompose(self):
pipeline, stream_manager = _make_pipeline() pipeline, stream_manager = _make_pipeline()
stream_manager.has_pending_updates_for_visible_segments.return_value = False stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is False assert pipeline.should_recompose() is False
@@ -49,12 +48,6 @@ class TestShouldRecompose:
stream_manager.has_pending_updates_for_visible_segments.return_value = True stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is True assert pipeline.should_recompose() is True
def test_staging_buffer_content_triggers_recompose(self):
pipeline, stream_manager = _make_pipeline()
stream_manager.get_buffer_status.return_value = {'staging_count': 1}
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True
def test_sync_active_defers_pending_updates_to_cycle_boundary(self): def test_sync_active_defers_pending_updates_to_cycle_boundary(self):
"""Sync-mode deferral (PR #330's actual intent) must still hold: """Sync-mode deferral (PR #330's actual intent) must still hold:
pending updates alone must NOT trigger a mid-cycle hot-swap when a pending updates alone must NOT trigger a mid-cycle hot-swap when a
+79
View File
@@ -0,0 +1,79 @@
"""StreamManager logs one INFO line per plugin-list refresh.
The refresh runs at each cycle start and every 30 seconds. It used to write
decorative "=" * 60 banners plus a line per plugin ("INCLUDED", "SKIPPED"),
and each fetch added its own banners and "FETCHING CONTENT" / "SEGMENT
CREATED" lines, all at INFO: dozens of journal lines a minute on a Pi, burying
anything that mattered. Per-plugin detail belongs at DEBUG.
"""
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock
from PIL import Image
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.stream_manager import StreamManager
LOGGER = "src.vegas_mode.stream_manager"
class _Plugin:
def __init__(self, enabled=True, mode=VegasDisplayMode.SCROLL):
self.enabled = enabled
self._mode = mode
def get_vegas_display_mode(self):
return self._mode
def _stream(plugins):
adapter = MagicMock()
adapter.get_content_type.return_value = 'multi'
adapter.get_content.return_value = [Image.new('RGB', (20, 8))]
return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter)
def _info(caplog):
return [r for r in caplog.records
if r.name == LOGGER and r.levelno == logging.INFO]
def test_a_refresh_logs_a_single_info_summary(caplog):
stream = _stream({
'clock': _Plugin(),
'weather': _Plugin(),
'off': _Plugin(enabled=False),
})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
stream._refresh_plugin_list()
info = _info(caplog)
assert len(info) == 1, [r.getMessage() for r in info]
summary = info[0].getMessage()
assert 'clock' in summary and 'weather' in summary
assert 'off' not in summary.split(':', 1)[1]
# The per-plugin decisions are still there for anyone at DEBUG.
debug = [r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG]
assert any('off' in m and 'not enabled' in m for m in debug)
def test_fetching_content_logs_nothing_at_info(caplog):
stream = _stream({'clock': _Plugin()})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
segment = stream._fetch_plugin_content('clock')
assert segment is not None
assert _info(caplog) == []
def test_no_decorative_banners(caplog):
stream = _stream({'clock': _Plugin(), 'static': _Plugin(mode=VegasDisplayMode.STATIC)})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
stream._refresh_plugin_list()
stream._fetch_plugin_content('clock')
stream._fetch_plugin_content('static')
assert not any(set(r.getMessage()) == {'='} for r in caplog.records)
+69 -13
View File
@@ -1,6 +1,37 @@
/* global showNotification */ /* global showNotification */
// LED Matrix v3 JavaScript /*
// Additional helpers for HTMX and Alpine.js integration * app.js -- page-wide behaviour that is not the Alpine app itself.
*
* Deferred, first of the scripts at the end of <body>; after app-shell.js
* and Alpine.
*
* Load order (templates/v3/base.html):
* <head>, blocking: debugLog and theme inline scripts; the htmx loader
* (injects htmx.min.js with a dynamic <script>);
* js/htmx-config.js; the loadPartialDirect fallback;
* js/app-early.js
* <head>, defer: js/app-shell.js, then js/alpinejs.min.js (Alpine
* starts as soon as it runs, so app-shell.js's app()
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
* Tab partials arrive later through htmx; their inline scripts run on
* htmx:afterSwap (js/htmx-config.js).
*
* Owns: button loading states and the fallback result toast for htmx
* requests; the unsaved-changes guard for plugin config forms; the "restart
* the display" banner; the floating live preview; aria-current on the nav;
* the mobile nav drawer's keyboard handling; header widget placement.
*
* Globals: showSaveResult, showRestartPending, dismissRestartPending,
* restartPendingNow, toggleFloatingPreview, applyFloatingPreviewSize,
* cycleFloatingPreviewSize, updateFloatingPreviewVisibility,
* previewPluginNow, updateNavAriaCurrent, placeHeaderWidgets.
*/
// HTMX response handlers // HTMX response handlers
document.body.addEventListener('htmx:beforeRequest', function(event) { document.body.addEventListener('htmx:beforeRequest', function(event) {
@@ -22,9 +53,15 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
if (textEl) textEl.style.opacity = '1'; if (textEl) textEl.style.opacity = '1';
} }
// Handle response notifications // Show the server's message, unless the element that made the request
// (or its form) has its own after-request handler: every such handler in
// the templates reports the result itself, and this used to repeat it,
// so each save showed two toasts.
const response = event.detail.xhr; const response = event.detail.xhr;
if (response && response.responseText) { const elt = event.detail.elt;
const reportsItself = elt && elt.closest &&
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request]');
if (!reportsItself && response && response.responseText) {
try { try {
const data = JSON.parse(response.responseText); const data = JSON.parse(response.responseText);
if (data.message) { if (data.message) {
@@ -48,6 +85,30 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
} catch { /* banner is best-effort */ } } catch { /* banner is best-effort */ }
}); });
/**
* Shows the outcome of a settings form save as one notification. Used by the
* hx-on:htmx:after-request of the Display, Rotation & Durations and General
* forms. Only a 2xx response counts as saved (a network failure is status 0);
* the server's message is shown when there is one, and its status can refine
* a success but never overturn a failure.
* @param {XMLHttpRequest} xhr - event.detail.xhr
* @param {string} savedText - message for a success without one
* @param {string} failedText - message for a failure without one
*/
window.showSaveResult = function(xhr, savedText, failedText) {
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
let message = httpSuccess ? savedText : failedText;
let status = httpSuccess ? 'success' : 'error';
try {
const data = JSON.parse(xhr.responseText);
if (data.message) message = data.message;
if (httpSuccess && data.status) status = data.status;
} catch {
// Non-JSON body: keep the status-code verdict.
}
showNotification(message, status);
};
// ===== Unsaved-changes guard ===== // ===== Unsaved-changes guard =====
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS // Plugin config panels are Alpine x-if templates: navigating away DESTROYS
// the panel and revisiting re-fetches it, silently discarding any edits. // the panel and revisiting re-fetches it, silently discarding any edits.
@@ -242,9 +303,8 @@ window.updateFloatingPreviewVisibility = function(tab) {
if (!panel || !toggle) return; if (!panel || !toggle) return;
let active = tab; let active = tab;
if (!active) { if (!active) {
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]'); const app = window.getApp();
const data = el && el._x_dataStack && el._x_dataStack[0]; active = app && app.activeTab;
active = data && data.activeTab;
} }
const onOverview = active === 'overview'; const onOverview = active === 'overview';
let open = false; let open = false;
@@ -308,13 +368,9 @@ window.updateNavAriaCurrent = function(tab) {
// Escape closes the mobile nav drawer and returns focus to the hamburger; // Escape closes the mobile nav drawer and returns focus to the hamburger;
// opening the drawer moves focus to its first tab. // opening the drawer moves focus to its first tab.
(function() { (function() {
function appData() {
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
return el && el._x_dataStack && el._x_dataStack[0];
}
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
const data = appData(); const data = window.getApp();
if (data && data.mobileNavOpen) { if (data && data.mobileNavOpen) {
data.mobileNavOpen = false; data.mobileNavOpen = false;
const burger = document.querySelector('[aria-controls="site-nav"]'); const burger = document.querySelector('[aria-controls="site-nav"]');
@@ -328,7 +384,7 @@ window.updateNavAriaCurrent = function(tab) {
// The click handler toggles mobileNavOpen; focus the first tab once // The click handler toggles mobileNavOpen; focus the first tab once
// the drawer has slid in (matches the CSS transition timing). // the drawer has slid in (matches the CSS transition timing).
setTimeout(function() { setTimeout(function() {
const data = appData(); const data = window.getApp();
if (data && data.mobileNavOpen) { if (data && data.mobileNavOpen) {
const first = document.querySelector('#site-nav .nav-tab'); const first = document.querySelector('#site-nav .nav-tab');
if (first) first.focus(); if (first) first.focus();
+107 -39
View File
@@ -1,8 +1,89 @@
/* global debugLog */ /* global debugLog */
// Early helpers and the app() stub (must run before Alpine init) /*
// Extracted from templates/v3/base.html so browsers cache it as a static asset. * app-early.js -- helpers every other script relies on, and the app() stub.
// Helper function to get installed plugins with fallback *
// Must be defined before app() function that uses it * A blocking <script> in <head>, so everything here exists before any other
* script, widget or partial runs.
*
* Load order (templates/v3/base.html):
* <head>, blocking: debugLog and theme inline scripts; the htmx loader
* (injects htmx.min.js with a dynamic <script>);
* js/htmx-config.js; the loadPartialDirect fallback;
* js/app-early.js
* <head>, defer: js/app-shell.js, then js/alpinejs.min.js (Alpine
* starts as soon as it runs, so app-shell.js's app()
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
* Tab partials arrive later through htmx; their inline scripts run on
* htmx:afterSwap (js/htmx-config.js).
*
* Globals:
* window.LEDEscape html / attr / jsStringAttr, the only HTML escaper
* window.escapeHtml / window.escapeAttribute aliases of LEDEscape.html /
* .attr, kept for plugin pages
* window.getApp() the root Alpine component (<body x-data="app()">)
* window.app a stub app() so Alpine can start before app-shell.js
* has run; app-shell.js replaces it with the full one
* (in the normal load order Alpine only ever sees the
* full one, see the note on the stub below)
* getInstalledPluginsSafe() installed list via PluginAPI or fetch
* a pluginsUpdated listener that draws the plugin tab row while the app is
* not the full implementation yet
*/
// ===== window.LEDEscape: the web UI's HTML escaping =====
// This file is a blocking <script> in <head>, so every later script,
// widget and partial can call these directly.
// html(v) text for element content or a quoted attribute
// value: & < > " ' become entities, null and
// undefined become ''. Escaping quotes is what makes
// it attribute-safe; a textContent/innerHTML round
// trip only escapes & < and >.
// attr(v) the same function, for call sites that want the
// attribute context to read explicitly.
// jsStringAttr(v) a quoted JS string literal for an inline handler
// attribute: onclick='f(${jsStringAttr(id)})'.
// JSON.stringify alone leaves ' and & untouched, so a
// value containing ' could close a single-quoted
// attribute. The browser decodes the entities before
// it parses the handler, so the JS sees the literal.
window.LEDEscape = (function() {
const ENTITIES = new Map([['&', '&amp;'], ['<', '&lt;'], ['>', '&gt;'], ['"', '&quot;'], ["'", '&#39;']]);
function html(value) {
return value == null ? '' : String(value).replace(/[&<>"']/g, c => ENTITIES.get(c));
}
function jsStringAttr(value) {
return html(JSON.stringify(value == null ? '' : String(value)));
}
// An attribute value needs no more than html(): it escapes both quote characters.
function attr(value) {
return html(value);
}
return Object.freeze({ html: html, attr: attr, jsStringAttr: jsStringAttr });
})();
// Kept for plugin web UIs and third-party plugin pages that call them.
window.escapeHtml = window.LEDEscape.html;
window.escapeAttribute = window.LEDEscape.attr;
// ===== window.getApp(): the root Alpine component =====
// The data of <body x-data="app()"> (activeTab, installedPlugins, ...),
// read through Alpine's public Alpine.$data rather than the private
// el._x_dataStack. Null until Alpine has initialised the app.
window.getApp = function() {
const el = document.querySelector('[x-data="app()"]');
if (!el || !window.Alpine) return null;
const data = window.Alpine.$data(el);
return data && 'activeTab' in data ? data : null;
};
// The installed-plugin list through PluginAPI, or a plain fetch if
// PluginAPI (loaded later, deferred) is not there yet.
async function getInstalledPluginsSafe() { async function getInstalledPluginsSafe() {
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) { if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
try { try {
@@ -20,32 +101,19 @@
return await response.json(); return await response.json();
} }
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet // Builds the plugin tab row from a pluginsUpdated event while the app is
// This ensures tabs update when plugins_manager.js loads plugins // not app-shell.js's full implementation (Alpine not started yet, or
// still on the stub below). The full app() has its own pluginsUpdated
// listener, registered in its init().
document.addEventListener('pluginsUpdated', function(event) { document.addEventListener('pluginsUpdated', function(event) {
const appComponent = window.getApp();
if (appComponent && typeof appComponent._doUpdatePluginTabs === 'function') return;
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins'); debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
const plugins = event.detail?.plugins || []; const plugins = event.detail?.plugins || [];
if (appComponent) {
// Update window.installedPlugins appComponent.installedPlugins = plugins;
window.installedPlugins = plugins;
// Try to update Alpine component if it exists (only if using full implementation)
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
const appComponent = appElement._x_dataStack[0];
appComponent.installedPlugins = plugins;
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
if (typeof appComponent.updatePluginTabs === 'function' &&
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
debugLog('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
appComponent.updatePluginTabs();
return; // Full implementation handles it, don't do direct update
}
}
} }
// Only do direct DOM update if full implementation isn't available yet
const pluginTabsRow = document.getElementById('plugin-tabs-row'); const pluginTabsRow = document.getElementById('plugin-tabs-row');
const pluginTabsNav = pluginTabsRow?.querySelector('nav'); const pluginTabsNav = pluginTabsRow?.querySelector('nav');
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) { if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
@@ -60,15 +128,11 @@
tabButton.setAttribute('data-plugin-id', plugin.id); tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab`; tabButton.className = `plugin-tab nav-tab`;
tabButton.onclick = function() { tabButton.onclick = function() {
// Try to set activeTab via Alpine if available const app = window.getApp();
if (window.Alpine) { if (app) {
const appElement = document.querySelector('[x-data="app()"]'); app.activeTab = plugin.id;
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) { if (typeof app.updatePluginTabStates === 'function') {
appElement._x_dataStack[0].activeTab = plugin.id; app.updatePluginTabStates();
// Only call updatePluginTabStates if it exists
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
appElement._x_dataStack[0].updatePluginTabStates();
}
} }
} }
}; };
@@ -89,8 +153,13 @@
// Guard flag to prevent duplicate stub-to-full enhancement // Guard flag to prevent duplicate stub-to-full enhancement
window._appEnhanced = false; window._appEnhanced = false;
// Define app() function early so Alpine can find it when it initializes // The stub app(). If Alpine initialises before app-shell.js has run,
// This is a complete implementation that will work immediately // this object is what it gets; its init() copies the full
// implementation in as soon as app-shell.js has replaced window.app
// (the "enhancement", guarded by window._appEnhanced), and draws the
// plugin tabs itself until then. base.html now loads app-shell.js
// before Alpine, so in practice Alpine calls the full app() directly
// and this stub never runs.
(function() { (function() {
const isAPMode = window.location.hostname === '192.168.4.1' || const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.'); window.location.hostname.startsWith('192.168.4.');
@@ -349,8 +418,7 @@
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs'); debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
}, 100); }, 100);
}, },
showNotification: function(message, type) {}, showNotification: function(message, type) {}
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
}; };
}; };
})(); })();
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -113,12 +113,15 @@
// that same partial would have that script still un-run // that same partial would have that script still un-run
// when Alpine evaluates x-data, permanently failing with // when Alpine evaluates x-data, permanently failing with
// "wifiSetup is not defined" (Alpine does not retry). // "wifiSetup is not defined" (Alpine does not retry).
// Disable htmx's own native script re-execution so the // htmx's own script handling is switched off here, in the
// same script doesn't also run a second time via settle. // handler, rather than once at setup: base.html injects
if (typeof htmx !== 'undefined' && htmx.config) { // htmx with a dynamic <script>, so at setup htmx is usually
htmx.config.allowScriptTags = false; // not defined yet and the switch never took effect. htmx
} // then tried to run each script again in its settle phase,
// found it already replaced (no parent) and threw, which
// also skipped the rest of that swap's settle tasks.
document.body.addEventListener('htmx:afterSwap', function(event) { document.body.addEventListener('htmx:afterSwap', function(event) {
htmx.config.allowScriptTags = false;
const target = event.detail && event.detail.target; const target = event.detail && event.detail.target;
if (!target || !(target instanceof Element)) return; if (!target || !(target instanceof Element)) return;
target.querySelectorAll('script').forEach(function(oldScript) { target.querySelectorAll('script').forEach(function(oldScript) {
@@ -1,3 +1,4 @@
/* global debugLog */
/** /**
* API client for plugin operations. * API client for plugin operations.
* *
@@ -10,7 +11,6 @@ const RequestThrottler = {
pending: new Map(), pending: new Map(),
cache: new Map(), cache: new Map(),
cacheTTL: 5000, // 5 seconds cache for GET requests cacheTTL: 5000, // 5 seconds cache for GET requests
debug: false, // Set to true to enable logging
/** /**
* Throttle a request to prevent rapid-fire calls * Throttle a request to prevent rapid-fire calls
@@ -19,23 +19,17 @@ const RequestThrottler = {
// Check cache first // Check cache first
const cached = this.cache.get(key); const cached = this.cache.get(key);
if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) { if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) {
if (this.debug) { debugLog('[RequestThrottler] Cache hit for:', key);
console.log('[RequestThrottler] Cache hit for:', key);
}
return cached.data; return cached.data;
} }
// Check if request is already pending // Check if request is already pending
if (this.pending.has(key)) { if (this.pending.has(key)) {
if (this.debug) { debugLog('[RequestThrottler] Reusing pending request for:', key);
console.log('[RequestThrottler] Reusing pending request for:', key);
}
return this.pending.get(key); return this.pending.get(key);
} }
if (this.debug) { debugLog('[RequestThrottler] Creating new request for:', key);
console.log('[RequestThrottler] Creating new request for:', key);
}
// Create throttled request with abort support // Create throttled request with abort support
let abortController = null; let abortController = null;
@@ -49,16 +43,12 @@ const RequestThrottler = {
data: result, data: result,
timestamp: Date.now() timestamp: Date.now()
}); });
if (this.debug) { debugLog('[RequestThrottler] Cached response for:', key);
console.log('[RequestThrottler] Cached response for:', key);
}
} }
resolve(result); resolve(result);
} catch (error) { } catch (error) {
// Don't cache errors // Don't cache errors
if (this.debug) { debugLog('[RequestThrottler] Request failed for:', key, error);
console.error('[RequestThrottler] Request failed for:', key, error);
}
reject(error); reject(error);
} finally { } finally {
this.pending.delete(key); this.pending.delete(key);
@@ -74,9 +64,7 @@ const RequestThrottler = {
// Add abort method if available // Add abort method if available
if (abortController) { if (abortController) {
promise.abort = () => { promise.abort = () => {
if (this.debug) { debugLog('[RequestThrottler] Aborting request for:', key);
console.log('[RequestThrottler] Aborting request for:', key);
}
abortController.abort(); abortController.abort();
this.pending.delete(key); this.pending.delete(key);
}; };
@@ -97,13 +85,6 @@ const RequestThrottler = {
} }
}, },
/**
* Enable or disable debug logging
*/
setDebug(enabled) {
this.debug = enabled;
},
/** /**
* Get statistics about pending requests and cache * Get statistics about pending requests and cache
*/ */
@@ -1,3 +1,4 @@
/* global debugLog */
/* /*
* settings-search.js — global settings search + per-tab filter for the v3 UI. * settings-search.js — global settings search + per-tab filter for the v3 UI.
* *
@@ -162,11 +163,7 @@
// --- Navigation to a setting --------------------------------------------- // --- Navigation to a setting ---------------------------------------------
function getAppData() { function getAppData() {
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]'); return window.getApp();
if (!appEl) return null;
if (appEl._x_dataStack && appEl._x_dataStack[0]) return appEl._x_dataStack[0];
if (appEl.__x && appEl.__x.$data) return appEl.__x.$data;
return null;
} }
function setActiveTab(tab) { function setActiveTab(tab) {
@@ -478,5 +475,5 @@
navigateToSetting: navigateToSetting navigateToSetting: navigateToSetting
}; };
console.log('[SettingsSearch] registered'); debugLog('[SettingsSearch] registered');
})(); })();
+2 -1
View File
@@ -1,3 +1,4 @@
/* global debugLog */
/* /*
* tooltips.js — accessible, delegated tooltip controller for the v3 web UI. * tooltips.js — accessible, delegated tooltip controller for the v3 web UI.
* *
@@ -157,5 +158,5 @@
if (currentTrigger && !document.body.contains(currentTrigger)) hide(); if (currentTrigger && !document.body.contains(currentTrigger)) hide();
}); });
console.log('[Tooltips] controller registered'); debugLog('[Tooltips] controller registered');
})(); })();
@@ -139,12 +139,7 @@ function displayError(error, context = null, options = {}) {
showErrorModal(error, context, message, suggestions, docLink); showErrorModal(error, context, message, suggestions, docLink);
} else { } else {
// Simple notification // Simple notification
if (typeof showNotification === 'function') { showNotification(fullMessage, 'error');
showNotification(fullMessage, 'error');
} else {
console.error('Error:', fullMessage);
alert(fullMessage);
}
} }
} }
@@ -289,22 +284,7 @@ function closeErrorModal() {
} }
} }
/** function escapeHtml(text) { return window.LEDEscape.html(text); }
* Escape HTML to prevent XSS.
*
* Quotes are escaped as well so the result is safe inside a quoted attribute
* value -- the textContent/innerHTML round-trip alone only escapes &, < and >.
*/
function escapeHtml(text) {
if (typeof text !== 'string') {
text = String(text);
}
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** /**
* Copy error details to clipboard. * Copy error details to clipboard.
@@ -315,16 +295,12 @@ function copyErrorDetails(error) {
const errorText = JSON.stringify(error, null, 2); const errorText = JSON.stringify(error, null, 2);
function copyFailed(err) { function copyFailed(err) {
console.error('Failed to copy error details:', err); console.error('Failed to copy error details:', err);
if (typeof showNotification === 'function') { showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
}
} }
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(errorText).then(() => { navigator.clipboard.writeText(errorText).then(() => {
if (typeof showNotification === 'function') { showNotification('Error details copied to clipboard', 'success');
showNotification('Error details copied to clipboard', 'success');
}
}).catch(copyFailed); }).catch(copyFailed);
} else { } else {
// Fallback for older browsers // Fallback for older browsers
@@ -340,9 +316,7 @@ function copyErrorDetails(error) {
textArea.select(); textArea.select();
try { try {
document.execCommand('copy'); document.execCommand('copy');
if (typeof showNotification === 'function') { showNotification('Error details copied to clipboard', 'success');
showNotification('Error details copied to clipboard', 'success');
}
} catch (err) { } catch (err) {
copyFailed(err); copyFailed(err);
} }
+4 -5
View File
@@ -227,11 +227,10 @@ directory is the only place the core serves plugin widgets from.
} }
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const escapeHtml = (text) => { // The page's shared escaper covers HTML content and quoted attribute
const div = document.createElement('div'); // values. A textContent/innerHTML round trip leaves quotes alone, so it
div.textContent = text; // is not safe inside value="...".
return div.innerHTML; const escapeHtml = (text) => window.LEDEscape.html(text);
};
window.LEDMatrixWidgets.register('my-custom-widget', { window.LEDMatrixWidgets.register('my-custom-widget', {
name: 'My Custom Widget', name: 'My Custom Widget',
@@ -806,14 +806,13 @@
const file = event.target.files && event.target.files[0]; const file = event.target.files && event.target.files[0];
if (!file) return; if (!file) return;
const notifyFn = window.showNotification || console.log;
const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
if (!allowed.includes(file.type)) { if (!allowed.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error'); window.showNotification(`File type "${file.type}" not allowed`, 'error');
return; return;
} }
if (file.size > 5 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
notifyFn('File exceeds 5MB limit', 'error'); window.showNotification('File exceeds 5MB limit', 'error');
return; return;
} }
@@ -829,12 +828,12 @@
const path = data.uploaded_files[0].path; const path = data.uploaded_files[0].path;
pathInput.value = path; pathInput.value = path;
if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; } if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; }
notifyFn('Image uploaded', 'success'); window.showNotification('Image uploaded', 'success');
} else { } else {
throw new Error(data.message || 'Upload failed'); throw new Error(data.message || 'Upload failed');
} }
} catch (err) { } catch (err) {
notifyFn('Upload error: ' + err.message, 'error'); window.showNotification('Upload error: ' + err.message, 'error');
} finally { } finally {
event.target.value = ''; event.target.value = '';
} }
@@ -853,7 +852,6 @@
addButton.style.opacity = isAtMax ? '0.5' : ''; addButton.style.opacity = isAtMax ? '0.5' : '';
} }
window.updateArrayTableAddButtonState = updateAddButtonState;
window.addArrayTableRow = function(button) { window.addArrayTableRow = function(button) {
const fieldId = button.getAttribute('data-field-id'); const fieldId = button.getAttribute('data-field-id');
@@ -874,7 +872,7 @@
const currentRows = tbody.querySelectorAll('.array-table-row').length; const currentRows = tbody.querySelectorAll('.array-table-row').length;
if (currentRows >= maxItems) { if (currentRows >= maxItems) {
(window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error'); window.showNotification(`Maximum ${maxItems} items allowed`, 'error');
return; return;
} }
@@ -116,29 +116,14 @@
} }
/** /**
* Escape HTML to prevent XSS * Escape text for HTML content or a quoted attribute value.
* Always escapes the input, even for non-strings, by coercing to string first * Kept for widgets that call it as a method; it is window.LEDEscape.html
* * (app-early.js).
* The result is safe in text content AND inside quoted attribute values. * @param {*} text - Text to escape (null/undefined become '')
* The textContent/innerHTML round-trip only escapes `&`, `<` and `>` --
* the HTML serializer leaves quotes alone because they are harmless in a
* text node. Every widget here interpolates the result into attributes
* (`value="${escapeHtml(v)}"`), where an unescaped `"` closes the
* attribute and lets the value inject its own, so the quotes have to go
* too. Each widget's standalone fallback already did this; the shared
* implementation they all prefer did not.
*
* @param {*} text - Text to escape (will be coerced to string)
* @returns {string} Escaped text, safe for text and attribute contexts * @returns {string} Escaped text, safe for text and attribute contexts
*/ */
escapeHtml(text) { escapeHtml(text) {
// Always coerce to string first, then escape return window.LEDEscape.html(text);
const textStr = String(text);
const div = document.createElement('div');
div.textContent = textStr;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
/** /**
@@ -173,9 +158,7 @@
* @returns {Function|null} Notification function or null * @returns {Function|null} Notification function or null
*/ */
getNotificationFunction() { getNotificationFunction() {
if (typeof window.showNotification === 'function') { return window.showNotification;
return window.showNotification;
}
return null; return null;
} }
@@ -24,15 +24,10 @@
version: '1.0.0', version: '1.0.0',
/** /**
* Render the checkbox group widget * No-op: plugin_config.html renders this widget server-side. The
* Note: This widget is currently server-side rendered via Jinja2 template. * registration exists for getValue/setValue and updateCheckboxGroupData.
* This registration ensures the handlers are available globally.
*/ */
render: function(container, config, value, options) { render: function() {},
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
if (window.debugLog) window.debugLog('[CheckboxGroupWidget] Render called (server-side rendered)');
},
/** /**
* Get current value from widget * Get current value from widget
@@ -25,12 +25,7 @@
const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -24,15 +24,10 @@
version: '1.0.0', version: '1.0.0',
/** /**
* Render the custom feeds widget * No-op: plugin_config.html renders this widget server-side. The
* Note: This widget is currently server-side rendered via Jinja2 template. * registration exists for getValue/setValue and the window.* feed-table handlers.
* This registration ensures the handlers are available globally.
*/ */
render: function(container, config, value, options) { render: function() {},
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
if (window.debugLog) window.debugLog('[CustomFeedsWidget] Render called (server-side rendered)');
},
/** /**
* Get current value from widget * Get current value from widget
@@ -246,8 +241,7 @@
const currentRows = tbody.querySelectorAll('.custom-feed-row'); const currentRows = tbody.querySelectorAll('.custom-feed-row');
if (currentRows.length >= maxItems) { if (currentRows.length >= maxItems) {
const notifyFn = window.showNotification || alert; window.showNotification(`Maximum ${maxItems} feeds allowed`, 'error');
notifyFn(`Maximum ${maxItems} feeds allowed`, 'error');
return; return;
} }
@@ -507,14 +501,12 @@
logoCell.appendChild(container); logoCell.appendChild(container);
} }
} else { } else {
const notifyFn = window.showNotification || alert; window.showNotification('Upload failed: ' + (data.message || 'Unknown error'), 'error');
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
} }
}) })
.catch(error => { .catch(error => {
console.error('Upload error:', error); console.error('Upload error:', error);
const notifyFn = window.showNotification || alert; window.showNotification('Upload failed: ' + error.message, 'error');
notifyFn('Upload failed: ' + error.message, 'error');
}) })
.finally(() => { .finally(() => {
// Reset regardless of outcome, so the same file can be re-selected // Reset regardless of outcome, so the same file can be re-selected
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -50,12 +50,7 @@
// Use BaseWidget utilities if available // Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('DaySelector', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('DaySelector', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -24,12 +24,7 @@
const base = window.BaseWidget ? new window.BaseWidget('EmailInput', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('EmailInput', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -33,12 +33,7 @@
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -199,7 +194,6 @@
const safeId = sanitizeId(fieldId); const safeId = sanitizeId(fieldId);
const fileInput = document.getElementById(`${safeId}_file_input`); const fileInput = document.getElementById(`${safeId}_file_input`);
const statusDiv = document.getElementById(`${safeId}_status`); const statusDiv = document.getElementById(`${safeId}_status`);
const notifyFn = window.showNotification || console.log;
// Read config from the file input data attributes // Read config from the file input data attributes
const pluginId = (fileInput && fileInput.dataset.pluginId) || ''; const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
@@ -208,19 +202,19 @@
.split(',').map(t => t.trim()); .split(',').map(t => t.trim());
if (!pluginId) { if (!pluginId) {
notifyFn('Plugin ID not set — cannot upload', 'error'); window.showNotification('Plugin ID not set — cannot upload', 'error');
return; return;
} }
// Validate type // Validate type
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error'); window.showNotification(`File type "${file.type}" not allowed`, 'error');
return; return;
} }
// Validate size // Validate size
if (file.size > maxSizeMb * 1024 * 1024) { if (file.size > maxSizeMb * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error'); window.showNotification(`File exceeds ${maxSizeMb}MB limit`, 'error');
return; return;
} }
@@ -266,7 +260,7 @@
statusDiv.appendChild(document.createTextNode('Uploaded successfully')); statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000); setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
} }
notifyFn('Image uploaded successfully', 'success'); window.showNotification('Image uploaded successfully', 'success');
} else { } else {
throw new Error(data.message || 'Upload failed'); throw new Error(data.message || 'Upload failed');
} }
@@ -279,7 +273,7 @@
statusDiv.appendChild(errIcon); statusDiv.appendChild(errIcon);
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed')); statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
} }
notifyFn(`Upload error: ${error.message}`, 'error'); window.showNotification(`Upload error: ${error.message}`, 'error');
} finally { } finally {
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
} }
+277 -413
View File
@@ -24,16 +24,11 @@
version: '1.0.0', version: '1.0.0',
/** /**
* Render the file upload widget * No-op: plugin_config.html renders this widget server-side. The
* Note: This widget is currently server-side rendered via Jinja2 template. * registration exists for getValue/setValue and the window.* handlers
* This registration ensures the handlers are available globally. * below.
* Future enhancement: Full client-side rendering support.
*/ */
render: function(container, config, value, options) { render: function() {},
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
if (window.debugLog) window.debugLog('[FileUploadWidget] Render called (server-side rendered)');
},
/** /**
* Get current value from widget * Get current value from widget
@@ -142,24 +137,23 @@
.split(',').map(e => e.trim().toLowerCase()); .split(',').map(e => e.trim().toLowerCase());
const statusDiv = document.getElementById(`${fieldId}_upload_status`); const statusDiv = document.getElementById(`${fieldId}_upload_status`);
const notifyFn = window.showNotification || console.log;
// Guard: endpoint must be configured // Guard: endpoint must be configured
if (!uploadEndpoint) { if (!uploadEndpoint) {
notifyFn('No upload endpoint configured for this field', 'error'); window.showNotification('No upload endpoint configured for this field', 'error');
return; return;
} }
// Validate extension // Validate extension
const fileExt = '.' + file.name.split('.').pop().toLowerCase(); const fileExt = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(fileExt)) { if (!allowedExtensions.includes(fileExt)) {
notifyFn(`File must be one of: ${allowedExtensions.join(', ')}`, 'error'); window.showNotification(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
return; return;
} }
// Validate size // Validate size
if (file.size > maxSizeMB * 1024 * 1024) { if (file.size > maxSizeMB * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMB}MB limit`, 'error'); window.showNotification(`File exceeds ${maxSizeMB}MB limit`, 'error');
return; return;
} }
@@ -198,7 +192,7 @@
// Update hidden input with the target filename // Update hidden input with the target filename
const hiddenInput = document.getElementById(fieldId); const hiddenInput = document.getElementById(fieldId);
if (hiddenInput) hiddenInput.value = targetFilename; if (hiddenInput) hiddenInput.value = targetFilename;
notifyFn(`${targetFilename} uploaded successfully`, 'success'); window.showNotification(`${targetFilename} uploaded successfully`, 'success');
} else { } else {
if (statusDiv) { if (statusDiv) {
statusDiv.className = 'mt-2 text-xs text-red-600'; statusDiv.className = 'mt-2 text-xs text-red-600';
@@ -208,7 +202,7 @@
statusDiv.appendChild(icon); statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`)); statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`));
} }
notifyFn(`Upload failed: ${data.message}`, 'error'); window.showNotification(`Upload failed: ${data.message}`, 'error');
} }
} catch (error) { } catch (error) {
if (statusDiv) { if (statusDiv) {
@@ -219,7 +213,7 @@
statusDiv.appendChild(icon); statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`)); statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`));
} }
notifyFn(`Upload error: ${error.message}`, 'error'); window.showNotification(`Upload error: ${error.message}`, 'error');
} finally { } finally {
const fileInput = document.getElementById(`${fieldId}_file_input`); const fileInput = document.getElementById(`${fieldId}_file_input`);
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
@@ -249,23 +243,20 @@
const validFiles = []; const validFiles = [];
for (const file of files) { for (const file of files) {
if (file.size > maxSizeMB * 1024 * 1024) { if (file.size > maxSizeMB * 1024 * 1024) {
const notifyFn = window.showNotification || console.error; window.showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
notifyFn(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
continue; continue;
} }
if (fileType === 'json') { if (fileType === 'json') {
// Validate JSON files // Validate JSON files
if (!file.name.toLowerCase().endsWith('.json')) { if (!file.name.toLowerCase().endsWith('.json')) {
const notifyFn = window.showNotification || console.error; window.showNotification(`File ${file.name} must be a JSON file (.json)`, 'error');
notifyFn(`File ${file.name} must be a JSON file (.json)`, 'error');
continue; continue;
} }
} else { } else {
// Validate image files using allowedTypes from config // Validate image files using allowedTypes from config
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
const notifyFn = window.showNotification || console.error; window.showNotification(`File ${file.name} is not a valid image type`, 'error');
notifyFn(`File ${file.name} is not a valid image type`, 'error');
continue; continue;
} }
} }
@@ -275,8 +266,7 @@
// Check max files AFTER building validFiles // Check max files AFTER building validFiles
if (currentFiles.length + validFiles.length > maxFiles) { if (currentFiles.length + validFiles.length > maxFiles) {
const notifyFn = window.showNotification || console.error; window.showNotification(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
notifyFn(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
return; return;
} }
@@ -317,16 +307,13 @@
window.updateImageList(fieldId, newFiles); window.updateImageList(fieldId, newFiles);
} }
const notifyFn = window.showNotification || console.log; window.showNotification(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
notifyFn(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
} else { } else {
const notifyFn = window.showNotification || console.error; window.showNotification(`Upload failed: ${data.message}`, 'error');
notifyFn(`Upload failed: ${data.message}`, 'error');
} }
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
const notifyFn = window.showNotification || console.error; window.showNotification(`Upload error: ${error.message}`, 'error');
notifyFn(`Upload error: ${error.message}`, 'error');
} finally { } finally {
if (window.hideUploadProgress) { if (window.hideUploadProgress) {
window.hideUploadProgress(fieldId); window.hideUploadProgress(fieldId);
@@ -394,16 +381,13 @@
window.updateImageList(fieldId, newFiles); window.updateImageList(fieldId, newFiles);
} }
const notifyFn = window.showNotification || console.log; window.showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
notifyFn(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
} else { } else {
const notifyFn = window.showNotification || console.error; window.showNotification(`Delete failed: ${data.message}`, 'error');
notifyFn(`Delete failed: ${data.message}`, 'error');
} }
} catch (error) { } catch (error) {
console.error('Delete error:', error); console.error('Delete error:', error);
const notifyFn = window.showNotification || console.error; window.showNotification(`Delete error: ${error.message}`, 'error');
notifyFn(`Delete error: ${error.message}`, 'error');
} }
}; };
@@ -492,9 +476,18 @@
return []; return [];
}; };
// DOM id suffix for an image's card and schedule editor. Matches the
// template's img_id|replace('.', '_')|replace('-', '_') in
// plugin_config.html, so server-rendered cards (UUID ids contain '-') and
// cards rendered here are found by the same lookup.
function imageDomId(imageId) {
return String(imageId).replace(/[^a-zA-Z0-9_]/g, '_');
}
/** /**
* Update image list display and hidden input * Replace the image list: writes the hidden input the form saves and
* Uses DOM creation to prevent XSS and preserves open schedule editors * re-renders the cards. A schedule editor that was open stays open,
* rebuilt from the new data.
* @param {string} fieldId - Field ID * @param {string} fieldId - Field ID
* @param {Array} images - Array of image objects * @param {Array} images - Array of image objects
*/ */
@@ -503,64 +496,41 @@
if (hiddenInput) { if (hiddenInput) {
hiddenInput.value = JSON.stringify(images); hiddenInput.value = JSON.stringify(images);
} }
// Update the display
const imageList = document.getElementById(`${fieldId}_image_list`); const imageList = document.getElementById(`${fieldId}_image_list`);
if (!imageList) return; if (!imageList) return;
const uploadConfig = window.getUploadConfig(fieldId); const uploadConfig = window.getUploadConfig(fieldId);
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image'; const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
// Detect which schedule is currently open (if any) const openEditor = imageList.querySelector('[id^="schedule_"]:not(.hidden)');
const openScheduleId = (() => { const openScheduleId = openEditor ? openEditor.id.slice('schedule_'.length) : null;
const existingItems = imageList.querySelectorAll('[id^="img_"]');
for (const item of existingItems) {
const scheduleDiv = item.querySelector('[id^="schedule_"]');
if (scheduleDiv && !scheduleDiv.classList.contains('hidden')) {
// Extract the ID from schedule_<id>
const match = scheduleDiv.id.match(/^schedule_(.+)$/);
if (match) {
return match[1];
}
}
}
return null;
})();
// Preserve open schedule content if it exists
const preservedScheduleContent = openScheduleId ? (() => {
const scheduleDiv = document.getElementById(`schedule_${openScheduleId}`);
return scheduleDiv ? scheduleDiv.innerHTML : null;
})() : null;
// Clear and rebuild using DOM creation
imageList.innerHTML = ''; imageList.innerHTML = '';
images.forEach((img, idx) => { images.forEach((img, idx) => {
const imgId = img.id || idx; const imgId = img.id || idx;
const sanitizedId = String(imgId).replace(/[^a-zA-Z0-9_-]/g, '_'); const domId = imageDomId(imgId);
const imgSchedule = img.schedule || {}; const label = img.original_filename || img.filename || '';
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
// Create container div
const container = document.createElement('div'); const container = document.createElement('div');
container.id = `img_${sanitizedId}`; container.id = `img_${domId}`;
container.className = 'bg-gray-50 p-3 rounded-lg border border-gray-200'; container.className = 'bg-gray-50 p-3 rounded-lg border border-gray-200';
// Create main content div
const mainDiv = document.createElement('div'); const mainDiv = document.createElement('div');
mainDiv.className = 'flex items-center justify-between mb-2'; mainDiv.className = 'flex items-center justify-between mb-2';
// Create left section with image and info
const leftSection = document.createElement('div'); const leftSection = document.createElement('div');
leftSection.className = 'flex items-center space-x-3 flex-1'; leftSection.className = 'flex items-center space-x-3 flex-1';
// Create image element
const imgEl = document.createElement('img'); const imgEl = document.createElement('img');
const imgPath = String(img.path || '').replace(/^\/+/, ''); // A stored path names a file under the project root. Encoding each
imgEl.src = '/' + imgPath; // segment keeps it a same-origin path whatever characters it holds.
imgEl.src = '/' + String(img.path || '').replace(/^\/+/, '')
.split('/').map(encodeURIComponent).join('/');
imgEl.alt = String(img.filename || ''); imgEl.alt = String(img.filename || '');
imgEl.loading = 'lazy';
imgEl.decoding = 'async';
imgEl.className = 'w-16 h-16 object-cover rounded'; imgEl.className = 'w-16 h-16 object-cover rounded';
imgEl.addEventListener('error', function() { imgEl.addEventListener('error', function() {
this.style.display = 'none'; this.style.display = 'none';
@@ -568,104 +538,120 @@
this.nextElementSibling.style.display = 'block'; this.nextElementSibling.style.display = 'block';
} }
}); });
// Create placeholder div for broken images // Shown in place of a thumbnail that fails to load
const placeholderDiv = document.createElement('div'); const placeholderDiv = document.createElement('div');
placeholderDiv.style.display = 'none'; placeholderDiv.style.display = 'none';
placeholderDiv.className = 'w-16 h-16 bg-gray-200 rounded flex items-center justify-center'; placeholderDiv.className = 'w-16 h-16 bg-gray-200 rounded flex items-center justify-center';
const placeholderIcon = document.createElement('i'); const placeholderIcon = document.createElement('i');
placeholderIcon.className = 'fas fa-image text-gray-400'; placeholderIcon.className = 'fas fa-image text-gray-400';
placeholderDiv.appendChild(placeholderIcon); placeholderDiv.appendChild(placeholderIcon);
// Create info div
const infoDiv = document.createElement('div'); const infoDiv = document.createElement('div');
infoDiv.className = 'flex-1 min-w-0'; infoDiv.className = 'flex-1 min-w-0';
// Filename
const filenameP = document.createElement('p'); const filenameP = document.createElement('p');
filenameP.className = 'text-sm font-medium text-gray-900 truncate'; filenameP.className = 'text-sm font-medium text-gray-900 truncate';
filenameP.textContent = img.original_filename || img.filename || 'Image'; filenameP.textContent = label || 'Image';
// Size and date
const sizeDateP = document.createElement('p'); const sizeDateP = document.createElement('p');
sizeDateP.className = 'text-xs text-gray-500'; sizeDateP.className = 'text-xs text-gray-500';
const fileSize = window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB'); sizeDateP.textContent = `${window.formatFileSize(img.size || 0)} • ${window.formatDate(img.uploaded_at)}`;
const uploadedDate = window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '');
sizeDateP.textContent = `${fileSize} • ${uploadedDate}`;
// Schedule summary
const scheduleP = document.createElement('p'); const scheduleP = document.createElement('p');
scheduleP.className = 'text-xs text-blue-600 mt-1'; scheduleP.className = 'text-xs text-blue-600 mt-1 image-schedule-summary';
const clockIcon = document.createElement('i'); renderScheduleSummary(scheduleP, img.schedule);
clockIcon.className = 'fas fa-clock mr-1';
scheduleP.appendChild(clockIcon);
scheduleP.appendChild(document.createTextNode(scheduleSummary));
infoDiv.appendChild(filenameP); infoDiv.appendChild(filenameP);
infoDiv.appendChild(sizeDateP); infoDiv.appendChild(sizeDateP);
infoDiv.appendChild(scheduleP); infoDiv.appendChild(scheduleP);
leftSection.appendChild(imgEl); leftSection.appendChild(imgEl);
leftSection.appendChild(placeholderDiv); leftSection.appendChild(placeholderDiv);
leftSection.appendChild(infoDiv); leftSection.appendChild(infoDiv);
// Create right section with buttons
const rightSection = document.createElement('div'); const rightSection = document.createElement('div');
rightSection.className = 'flex items-center space-x-2 ml-4'; rightSection.className = 'flex items-center space-x-2 ml-4';
// Schedule button
const scheduleBtn = document.createElement('button'); const scheduleBtn = document.createElement('button');
scheduleBtn.type = 'button'; scheduleBtn.type = 'button';
scheduleBtn.className = 'text-blue-600 hover:text-blue-800 p-2'; scheduleBtn.className = 'text-blue-600 hover:text-blue-800 p-2';
scheduleBtn.title = 'Schedule this image'; scheduleBtn.title = 'Schedule this image';
scheduleBtn.setAttribute('aria-label', `Schedule image ${label}`);
scheduleBtn.dataset.fieldId = fieldId; scheduleBtn.dataset.fieldId = fieldId;
scheduleBtn.dataset.imageId = String(imgId); scheduleBtn.dataset.imageId = String(imgId);
scheduleBtn.dataset.imageIdx = String(idx); scheduleBtn.dataset.imageIdx = String(idx);
scheduleBtn.addEventListener('click', function() { scheduleBtn.addEventListener('click', function() {
window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId, parseInt(this.dataset.imageIdx, 10)); window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId, parseInt(this.dataset.imageIdx, 10));
}); });
const scheduleIcon = document.createElement('i'); scheduleBtn.appendChild(iconEl('fas fa-calendar-alt'));
scheduleIcon.className = 'fas fa-calendar-alt';
scheduleBtn.appendChild(scheduleIcon);
// Delete button
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.type = 'button'; deleteBtn.type = 'button';
deleteBtn.className = 'text-red-600 hover:text-red-800 p-2'; deleteBtn.className = 'text-red-600 hover:text-red-800 p-2';
deleteBtn.title = 'Delete image'; deleteBtn.title = 'Delete image';
deleteBtn.setAttribute('aria-label', `Delete image ${label}`);
deleteBtn.dataset.fieldId = fieldId; deleteBtn.dataset.fieldId = fieldId;
deleteBtn.dataset.imageId = String(imgId); deleteBtn.dataset.imageId = String(imgId);
deleteBtn.dataset.pluginId = pluginId; deleteBtn.dataset.pluginId = pluginId;
deleteBtn.addEventListener('click', function() { deleteBtn.addEventListener('click', function() {
window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId); window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId);
}); });
const deleteIcon = document.createElement('i'); deleteBtn.appendChild(iconEl('fas fa-trash'));
deleteIcon.className = 'fas fa-trash';
deleteBtn.appendChild(deleteIcon);
rightSection.appendChild(scheduleBtn); rightSection.appendChild(scheduleBtn);
rightSection.appendChild(deleteBtn); rightSection.appendChild(deleteBtn);
mainDiv.appendChild(leftSection); mainDiv.appendChild(leftSection);
mainDiv.appendChild(rightSection); mainDiv.appendChild(rightSection);
// Create schedule container
const scheduleContainer = document.createElement('div'); const scheduleContainer = document.createElement('div');
scheduleContainer.id = `schedule_${sanitizedId}`; scheduleContainer.id = `schedule_${domId}`;
scheduleContainer.className = 'hidden mt-3 pt-3 border-t border-gray-300'; scheduleContainer.className = 'hidden mt-3 pt-3 border-t border-gray-300';
// Restore preserved schedule content if this is the open one
if (openScheduleId === sanitizedId && preservedScheduleContent) {
scheduleContainer.innerHTML = preservedScheduleContent;
scheduleContainer.classList.remove('hidden');
}
container.appendChild(mainDiv); container.appendChild(mainDiv);
container.appendChild(scheduleContainer); container.appendChild(scheduleContainer);
imageList.appendChild(container); imageList.appendChild(container);
if (openScheduleId === domId) {
renderScheduleEditor(scheduleContainer, fieldId, imgId, idx, img.schedule);
scheduleContainer.classList.remove('hidden');
}
}); });
}; };
// Decorative icon, hidden from screen readers (the button has a label)
function iconEl(className) {
const i = document.createElement('i');
i.className = className;
i.setAttribute('aria-hidden', 'true');
return i;
}
function renderScheduleSummary(el, schedule) {
el.textContent = '';
const clock = document.createElement('i');
clock.className = 'fas fa-clock mr-1';
el.appendChild(clock);
el.appendChild(document.createTextNode(window.getScheduleSummary(schedule || {})));
}
// Saves a schedule edit: writes the hidden input and refreshes that
// card's summary line in place. The list is not re-rendered, so the open
// editor keeps its state and the control being edited keeps focus.
function commitScheduleEdit(fieldId, images, imageId) {
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
if (hiddenInput) {
hiddenInput.value = JSON.stringify(images);
}
const card = document.getElementById(`img_${imageDomId(imageId)}`);
const image = images.find((img, idx) => String(img.id || idx) === String(imageId));
const summary = card && card.querySelector('.image-schedule-summary');
if (summary && image) {
renderScheduleSummary(summary, image.schedule);
}
}
/** /**
* Show upload progress * Show upload progress
* @param {string} fieldId - Field ID * @param {string} fieldId - Field ID
@@ -776,364 +762,242 @@
}; };
/** /**
* Open image schedule editor * Show or hide an image's schedule editor.
* @param {string} fieldId - Field ID * @param {string} fieldId - Field ID
* @param {string|number} imageId - Image ID * @param {string|number} imageId - Image ID (the index when the image has none)
* @param {number} imageIdx - Image index * @param {number} imageIdx - Image index
*/ */
window.openImageSchedule = function(fieldId, imageId, imageIdx) { window.openImageSchedule = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId); const idx = Number(imageIdx);
const image = currentImages[imageIdx]; const image = Number.isInteger(idx) && idx >= 0 ? window.getCurrentImages(fieldId).at(idx) : undefined;
if (!image) return; if (!image) return;
// Sanitize imageId to match updateImageList's sanitization const scheduleContainer = document.getElementById(`schedule_${imageDomId(imageId || imageIdx)}`);
const sanitizedId = (imageId || imageIdx).toString().replace(/[^a-zA-Z0-9_-]/g, '_');
const scheduleContainer = document.getElementById(`schedule_${sanitizedId}`);
if (!scheduleContainer) return; if (!scheduleContainer) return;
// Toggle visibility if (!scheduleContainer.classList.contains('hidden')) {
const isVisible = !scheduleContainer.classList.contains('hidden');
if (isVisible) {
scheduleContainer.classList.add('hidden'); scheduleContainer.classList.add('hidden');
return; return;
} }
renderScheduleEditor(scheduleContainer, fieldId, imageId || imageIdx, imageIdx, image.schedule);
scheduleContainer.classList.remove('hidden'); scheduleContainer.classList.remove('hidden');
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
// Escape HTML helper
// Quotes too: the result lands in quoted attribute values below, and the
// textContent/innerHTML round-trip only escapes &, < and >.
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = String(text ?? '');
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
// Use sanitizedId for all ID references in the schedule HTML
// Use data attributes instead of inline handlers to prevent JS injection
scheduleContainer.innerHTML = `
<div class="bg-white rounded-lg border border-blue-200 p-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">
<i class="fas fa-clock mr-2"></i>Schedule Settings
</h4>
<!-- Enable Schedule -->
<div class="mb-4">
<label class="flex items-center">
<input type="checkbox"
id="schedule_enabled_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
${schedule.enabled ? 'checked' : ''}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-700">Enable schedule for this image</span>
</label>
<p class="ml-6 text-xs text-gray-500 mt-1">When enabled, this image will only display during scheduled times</p>
</div>
<!-- Schedule Mode -->
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
<div>
<label for="schedule_mode_${sanitizedId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<select id="schedule_mode_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
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">
<option value="always" ${schedule.mode === 'always' ? 'selected' : ''}>Always Show (No Schedule)</option>
<option value="time_range" ${schedule.mode === 'time_range' ? 'selected' : ''}>Same Time Every Day</option>
<option value="per_day" ${schedule.mode === 'per_day' ? 'selected' : ''}>Different Times Per Day</option>
</select>
</div>
<!-- Time Range Mode -->
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
<div>
<label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time"
id="schedule_start_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
value="${escapeHtml(schedule.start_time || '08:00')}"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
<div>
<label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time"
id="schedule_end_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
value="${escapeHtml(schedule.end_time || '18:00')}"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
</div>
<!-- Per-Day Mode -->
<div id="per_day_${sanitizedId}" style="display: ${schedule.mode === 'per_day' ? 'block' : 'none'};">
<label class="block text-xs font-medium text-gray-700 mb-2">Day-Specific Times</label>
<div class="bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto">
${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => {
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
return `
<div class="bg-white rounded p-2 border border-gray-200">
<div class="flex items-center justify-between mb-2">
<label class="flex items-center">
<input type="checkbox"
id="day_${day}_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
${dayConfig.enabled ? 'checked' : ''}
class="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-xs font-medium text-gray-700 capitalize">${day}</span>
</label>
</div>
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
<input type="time"
id="day_${day}_start_${sanitizedId}"
aria-label="${day} start time"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
value="${escapeHtml(dayConfig.start_time || '08:00')}"
class="text-xs px-2 py-1 border border-gray-300 rounded"
${!dayConfig.enabled ? 'disabled' : ''}>
<input type="time"
id="day_${day}_end_${sanitizedId}"
aria-label="${day} end time"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
value="${escapeHtml(dayConfig.end_time || '18:00')}"
class="text-xs px-2 py-1 border border-gray-300 rounded"
${!dayConfig.enabled ? 'disabled' : ''}>
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
</div>
`;
// Attach event listeners using data attributes (prevents JS injection)
const enabledCheckbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
if (enabledCheckbox) {
enabledCheckbox.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.toggleImageScheduleEnabled(fieldId, imageId, imageIdx);
});
}
const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`);
if (modeSelect) {
modeSelect.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleMode(fieldId, imageId, imageIdx);
});
}
const startInput = document.getElementById(`schedule_start_${sanitizedId}`);
if (startInput) {
startInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleTime(fieldId, imageId, imageIdx);
});
}
const endInput = document.getElementById(`schedule_end_${sanitizedId}`);
if (endInput) {
endInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleTime(fieldId, imageId, imageIdx);
});
}
// Attach listeners for per-day inputs
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].forEach(day => {
const dayCheckbox = document.getElementById(`day_${day}_${sanitizedId}`);
if (dayCheckbox) {
dayCheckbox.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
const dayStartInput = document.getElementById(`day_${day}_start_${sanitizedId}`);
if (dayStartInput) {
dayStartInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
const dayEndInput = document.getElementById(`day_${day}_end_${sanitizedId}`);
if (dayEndInput) {
dayEndInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
});
}; };
/** const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
* Toggle image schedule enabled state
*/ // Builds a DOM element. Attribute values and text children go through the
// DOM APIs, so nothing passed in is ever parsed as HTML. `true` sets an
// empty (boolean) attribute; `false`, null and undefined leave it off.
function h(tag, attrs, children) {
const node = document.createElement(tag);
for (const [name, value] of Object.entries(attrs || {})) {
if (value === false || value == null) continue;
node.setAttribute(name, value === true ? '' : String(value));
}
for (const child of children || []) node.append(child);
return node;
}
function displayStyle(visible, shown) {
return `display: ${visible ? shown : 'none'};`;
}
function scheduleModeOption(schedule, value, label) {
return h('option', { value: value, selected: schedule.mode === value }, [label]);
}
function scheduleRangeTime(which, label, value, domId, ids) {
return h('div', {}, [
h('label', { for: `schedule_${which}_${domId}`, class: 'block text-xs font-medium text-gray-700 mb-1' }, [label]),
h('input', {
type: 'time', id: `schedule_${which}_${domId}`, 'data-schedule-control': 'time', ...ids,
value: value, class: 'block w-full px-2 py-1 text-sm border border-gray-300 rounded-md',
}),
]);
}
function scheduleDayTime(day, which, value, enabled, domId, dayIds) {
return h('input', {
type: 'time', id: `day_${day}_${which}_${domId}`, 'aria-label': `${day} ${which} time`, ...dayIds,
value: value, class: 'text-xs px-2 py-1 border border-gray-300 rounded', disabled: !enabled,
});
}
function scheduleDayRow(schedule, day, domId, ids) {
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
const dayIds = { 'data-schedule-control': 'day', 'data-day': day, ...ids };
return h('div', { class: 'bg-white rounded p-2 border border-gray-200' }, [
h('div', { class: 'flex items-center justify-between mb-2' }, [
h('label', { class: 'flex items-center' }, [
h('input', {
type: 'checkbox', id: `day_${day}_${domId}`, ...dayIds, checked: !!dayConfig.enabled,
class: 'h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded',
}),
h('span', { class: 'ml-2 text-xs font-medium text-gray-700 capitalize' }, [day]),
]),
]),
h('div', { class: 'grid grid-cols-2 gap-2 ml-5', id: `day_times_${day}_${domId}`, style: displayStyle(dayConfig.enabled, 'grid') }, [
scheduleDayTime(day, 'start', dayConfig.start_time || '08:00', dayConfig.enabled, domId, dayIds),
scheduleDayTime(day, 'end', dayConfig.end_time || '18:00', dayConfig.enabled, domId, dayIds),
]),
]);
}
// Builds the schedule editor. Controls carry data-schedule-control and
// their ids as data attributes; one delegated change listener (below)
// routes them, so the editor needs no per-element listeners.
function renderScheduleEditor(container, fieldId, imageId, imageIdx, savedSchedule) {
const schedule = savedSchedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
const domId = imageDomId(imageId);
const ids = { 'data-field-id': fieldId, 'data-image-id': imageId, 'data-image-idx': Number(imageIdx) };
const dayRows = [];
for (const day of DAYS) dayRows.push(scheduleDayRow(schedule, day, domId, ids));
container.replaceChildren(h('div', { class: 'bg-white rounded-lg border border-blue-200 p-4' }, [
h('h4', { class: 'text-sm font-semibold text-gray-900 mb-3' }, [
h('i', { class: 'fas fa-clock mr-2' }), 'Schedule Settings',
]),
h('div', { class: 'mb-4' }, [
h('label', { class: 'flex items-center' }, [
h('input', {
type: 'checkbox', id: `schedule_enabled_${domId}`, 'data-schedule-control': 'enabled', ...ids,
checked: !!schedule.enabled, class: 'h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded',
}),
h('span', { class: 'ml-2 text-sm font-medium text-gray-700' }, ['Enable schedule for this image']),
]),
h('p', { class: 'ml-6 text-xs text-gray-500 mt-1' }, ['When enabled, this image will only display during scheduled times']),
]),
h('div', { id: `schedule_options_${domId}`, class: 'space-y-4', style: displayStyle(schedule.enabled, 'block') }, [
h('div', {}, [
h('label', { for: `schedule_mode_${domId}`, class: 'block text-sm font-medium text-gray-700 mb-2' }, ['Schedule Type']),
h('select', {
id: `schedule_mode_${domId}`, 'data-schedule-control': 'mode', ...ids,
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',
}, [
scheduleModeOption(schedule, 'always', 'Always Show (No Schedule)'),
scheduleModeOption(schedule, 'time_range', 'Same Time Every Day'),
scheduleModeOption(schedule, 'per_day', 'Different Times Per Day'),
]),
]),
h('div', { id: `time_range_${domId}`, class: 'grid grid-cols-2 gap-4', style: displayStyle(schedule.mode === 'time_range', 'grid') }, [
scheduleRangeTime('start', 'Start Time', schedule.start_time || '08:00', domId, ids),
scheduleRangeTime('end', 'End Time', schedule.end_time || '18:00', domId, ids),
]),
h('div', { id: `per_day_${domId}`, style: displayStyle(schedule.mode === 'per_day', 'block') }, [
h('label', { class: 'block text-xs font-medium text-gray-700 mb-2' }, ['Day-Specific Times']),
h('div', { class: 'bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto' }, dayRows),
]),
]),
]));
}
document.addEventListener('change', function(event) {
const el = event.target && event.target.closest && event.target.closest('[data-schedule-control]');
if (!el) return;
const { fieldId, imageId, day } = el.dataset;
const imageIdx = parseInt(el.dataset.imageIdx, 10);
switch (el.dataset.scheduleControl) {
case 'enabled': window.toggleImageScheduleEnabled(fieldId, imageId, imageIdx); break;
case 'mode': window.updateImageScheduleMode(fieldId, imageId, imageIdx); break;
case 'time': window.updateImageScheduleTime(fieldId, imageId, imageIdx); break;
case 'day': window.updateImageScheduleDay(fieldId, imageId, imageIdx, day); break;
}
});
window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) { window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId); const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx]; const image = currentImages[imageIdx];
if (!image) return; if (!image) return;
// Sanitize imageId for DOM lookup const domId = imageDomId(imageId);
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_'); const checkbox = document.getElementById(`schedule_enabled_${domId}`);
const checkbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
const enabled = checkbox ? checkbox.checked : false; const enabled = checkbox ? checkbox.checked : false;
if (!image.schedule) { if (!image.schedule) {
image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
} }
image.schedule.enabled = enabled; image.schedule.enabled = enabled;
const optionsDiv = document.getElementById(`schedule_options_${sanitizedId}`); const optionsDiv = document.getElementById(`schedule_options_${domId}`);
if (optionsDiv) { if (optionsDiv) {
optionsDiv.style.display = enabled ? 'block' : 'none'; optionsDiv.style.display = enabled ? 'block' : 'none';
} }
if (window.updateImageList) { commitScheduleEdit(fieldId, currentImages, imageId);
window.updateImageList(fieldId, currentImages);
}
}; };
/**
* Update image schedule mode
*/
window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) { window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId); const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx]; const image = currentImages[imageIdx];
if (!image) return; if (!image) return;
// Sanitize imageId for DOM lookup const domId = imageDomId(imageId);
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
if (!image.schedule) { if (!image.schedule) {
image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
} }
const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`); const modeSelect = document.getElementById(`schedule_mode_${domId}`);
const mode = modeSelect ? modeSelect.value : 'always'; const mode = modeSelect ? modeSelect.value : 'always';
image.schedule.mode = mode; image.schedule.mode = mode;
const timeRangeDiv = document.getElementById(`time_range_${sanitizedId}`); const timeRangeDiv = document.getElementById(`time_range_${domId}`);
const perDayDiv = document.getElementById(`per_day_${sanitizedId}`); const perDayDiv = document.getElementById(`per_day_${domId}`);
if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none'; if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none';
if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none'; if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none';
if (window.updateImageList) { commitScheduleEdit(fieldId, currentImages, imageId);
window.updateImageList(fieldId, currentImages);
}
}; };
/**
* Update image schedule time
*/
window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) { window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId); const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx]; const image = currentImages[imageIdx];
if (!image) return; if (!image) return;
// Sanitize imageId for DOM lookup const domId = imageDomId(imageId);
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
if (!image.schedule) { if (!image.schedule) {
image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' }; image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' };
} }
const startInput = document.getElementById(`schedule_start_${sanitizedId}`); const startInput = document.getElementById(`schedule_start_${domId}`);
const endInput = document.getElementById(`schedule_end_${sanitizedId}`); const endInput = document.getElementById(`schedule_end_${domId}`);
if (startInput) image.schedule.start_time = startInput.value || '08:00'; if (startInput) image.schedule.start_time = startInput.value || '08:00';
if (endInput) image.schedule.end_time = endInput.value || '18:00'; if (endInput) image.schedule.end_time = endInput.value || '18:00';
if (window.updateImageList) { commitScheduleEdit(fieldId, currentImages, imageId);
window.updateImageList(fieldId, currentImages);
}
}; };
/**
* Update image schedule day
*/
window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) { window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
const currentImages = window.getCurrentImages(fieldId); const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx]; const image = currentImages[imageIdx];
if (!image) return; if (!image) return;
// Sanitize imageId for DOM lookup const domId = imageDomId(imageId);
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
if (!image.schedule) { if (!image.schedule) {
image.schedule = { enabled: true, mode: 'per_day', days: {} }; image.schedule = { enabled: true, mode: 'per_day', days: {} };
} }
if (!image.schedule.days) { if (!image.schedule.days) {
image.schedule.days = {}; image.schedule.days = {};
} }
const checkbox = document.getElementById(`day_${day}_${sanitizedId}`); const checkbox = document.getElementById(`day_${day}_${domId}`);
const startInput = document.getElementById(`day_${day}_start_${sanitizedId}`); const startInput = document.getElementById(`day_${day}_start_${domId}`);
const endInput = document.getElementById(`day_${day}_end_${sanitizedId}`); const endInput = document.getElementById(`day_${day}_end_${domId}`);
const enabled = checkbox ? checkbox.checked : true; const enabled = checkbox ? checkbox.checked : true;
if (!image.schedule.days[day]) { if (!image.schedule.days[day]) {
image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' }; image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' };
} }
image.schedule.days[day].enabled = enabled; image.schedule.days[day].enabled = enabled;
if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00'; if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00';
if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00'; if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00';
const dayTimesDiv = document.getElementById(`day_times_${day}_${sanitizedId}`); const dayTimesDiv = document.getElementById(`day_times_${day}_${domId}`);
if (dayTimesDiv) { if (dayTimesDiv) {
dayTimesDiv.style.display = enabled ? 'grid' : 'none'; dayTimesDiv.style.display = enabled ? 'grid' : 'none';
} }
if (startInput) startInput.disabled = !enabled; if (startInput) startInput.disabled = !enabled;
if (endInput) endInput.disabled = !enabled; if (endInput) endInput.disabled = !enabled;
if (window.updateImageList) { commitScheduleEdit(fieldId, currentImages, imageId);
window.updateImageList(fieldId, currentImages);
}
}; };
})(); })();
@@ -31,12 +31,7 @@
let fontCatalogCache = null; let fontCatalogCache = null;
let fontCatalogPromise = null; let fontCatalogPromise = null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -152,9 +152,7 @@
if (selectedIds.length === 0) { if (selectedIds.length === 0) {
checkbox.checked = true; checkbox.checked = true;
selectedIds.push(cal.id); selectedIds.push(cal.id);
if (window.showNotification) { window.showNotification('At least one calendar must be selected.', 'warning');
window.showNotification('At least one calendar must be selected.', 'warning');
}
} }
syncHiddenAndSummary(); syncHiddenAndSummary();
}); });
@@ -185,11 +183,5 @@
container.appendChild(p); container.appendChild(p);
} }
function escapeHtml(str) { function escapeHtml(str) { return window.LEDEscape.html(str); }
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
})(); })();
@@ -760,11 +760,7 @@
} }
_notify(msg, type) { _notify(msg, type) {
if (typeof window.showNotification === 'function') { window.showNotification(msg, type || 'info');
window.showNotification(msg, type || 'info');
} else {
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
}
} }
_busy(btn, label) { _busy(btn, label) {
@@ -785,15 +781,8 @@
delete btn._jfmOrigText; delete btn._jfmOrigText;
} }
// Quotes too: the result lands in quoted attribute values (title=,
// data-cat=, pattern=, ...), and the textContent/innerHTML round-trip
// only escapes &, < and >.
_esc(str) { _esc(str) {
const d = document.createElement('div'); return window.LEDEscape.html(str);
d.textContent = String(str ?? '');
return d.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
} }
_fmtSize(bytes) { _fmtSize(bytes) {
@@ -1,10 +1,13 @@
/* global debugLog */
/** /**
* LEDMatrix Notification Widget * LEDMatrix Notification Widget
* *
* Global notification/toast system for displaying messages to users. * Global notification/toast system for displaying messages to users.
* This is the single implementation: the early fallbacks in app-shell.js, * This is the single implementation. app-shell.js defines a stand-in first
* app.js and partials/fonts.html only queue messages until this widget has * (it runs before every other script that notifies), which queues messages
* loaded (see window.__pendingNotifications) and then delegate to it. * in window.__pendingNotifications until this widget loads, shows the queue
* and replaces it. So window.showNotification always exists: call it
* directly, without a typeof check or a fallback.
* *
* Usage: * Usage:
* window.showNotification('Message here', 'success'); * window.showNotification('Message here', 'success');
@@ -158,16 +161,7 @@
setTimeout(() => { region.textContent = text; }, 50); setTimeout(() => { region.textContent = text; }, 50);
} }
/** function escapeHtml(text) { return window.LEDEscape.html(text); }
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
function clearTimer(notificationId) { function clearTimer(notificationId) {
const t = timers.get(notificationId); const t = timers.get(notificationId);
@@ -360,8 +354,7 @@
}); });
} }
// Log for debugging debugLog(`[${type.toUpperCase()}]`, message);
console.log(`[${type.toUpperCase()}]`, message);
return notificationId; return notificationId;
} }
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('NumberInput', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('NumberInput', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -235,20 +235,10 @@
} }
function notify(msg, type) { function notify(msg, type) {
if (window.showNotification) window.showNotification(msg, type); window.showNotification(msg, type);
else console.log(`[PFM][${type}] ${msg}`);
} }
// Quotes too: the result lands in quoted attribute values (id=, value=, function escHtml(s) { return window.LEDEscape.html(s); }
// data-col=), and the textContent/innerHTML round-trip only escapes
// &, < and >.
function escHtml(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatSize(bytes) { function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'; if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
@@ -745,13 +735,12 @@
window._pfmUpload = async function (fieldId, file) { window._pfmUpload = async function (fieldId, file) {
const st = getState(fieldId); const st = getState(fieldId);
const notifyFn = window.showNotification || console.log;
if (!file.name.toLowerCase().endsWith('.json')) { if (!file.name.toLowerCase().endsWith('.json')) {
notifyFn('Only .json files can be uploaded', 'error'); return; window.showNotification('Only .json files can be uploaded', 'error'); return;
} }
let content; let content;
try { content = await file.text(); JSON.parse(content); } try { content = await file.text(); JSON.parse(content); }
catch { notifyFn('File contains invalid JSON', 'error'); return; } catch { window.showNotification('File contains invalid JSON', 'error'); return; }
const result = await callAction(st.pluginId, st.actions.upload, { const result = await callAction(st.pluginId, st.actions.upload, {
filename: file.name, content filename: file.name, content
@@ -33,12 +33,7 @@
const base = window.BaseWidget ? new window.BaseWidget('RadioGroup', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('RadioGroup', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -1,3 +1,4 @@
/* global debugLog */
/** /**
* LEDMatrix Widget Registry * LEDMatrix Widget Registry
* *
@@ -183,7 +184,7 @@
const removed = this._widgets.delete(widgetName); const removed = this._widgets.delete(widgetName);
this._handlers.delete(widgetName); this._handlers.delete(widgetName);
if (removed) { if (removed) {
console.log(`[WidgetRegistry] Unregistered widget: ${widgetName}`); debugLog(`[WidgetRegistry] Unregistered widget: ${widgetName}`);
} }
return removed; return removed;
}, },
@@ -194,7 +195,7 @@
clear: function() { clear: function() {
this._widgets.clear(); this._widgets.clear();
this._handlers.clear(); this._handlers.clear();
console.log('[WidgetRegistry] Cleared all widgets'); debugLog('[WidgetRegistry] Cleared all widgets');
} }
}; };
@@ -54,12 +54,7 @@
// Use BaseWidget utilities if available // Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('SelectDropdown', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('SelectDropdown', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
+2 -10
View File
@@ -28,17 +28,9 @@
const base = window.BaseWidget ? new window.BaseWidget('Slider', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('Slider', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Escape for use in HTML attributes (also escapes quotes) function escapeAttr(text) { return window.LEDEscape.attr(text); }
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -29,12 +29,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TextInput', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('TextInput', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('Textarea', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('Textarea', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -25,12 +25,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -32,12 +32,7 @@
// Use BaseWidget utilities if available // Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('TimeRange', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('TimeRange', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -23,12 +23,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TimezoneSelector', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('TimezoneSelector', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('ToggleSwitch', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('ToggleSwitch', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('UrlInput', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('UrlInput', '1.0.0') : null;
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) { function sanitizeId(id) {
if (base) return base.sanitizeId(id); if (base) return base.sanitizeId(id);
File diff suppressed because it is too large Load Diff
+97 -181
View File
@@ -168,54 +168,109 @@
</script> </script>
<script src="{{ url_for('static', filename='v3/js/htmx-config.js') }}"></script> <script src="{{ url_for('static', filename='v3/js/htmx-config.js') }}"></script>
<!-- Fallback for loading plugins if HTMX fails --> <!-- Direct-fetch fallback for tab panels. Used when HTMX never loads, and
by the hx-on::response-error handlers on the Overview, WiFi and
Plugin Manager panels when HTMX's own request fails. -->
<script> <script>
function loadPluginsDirect() { (function() {
const content = document.getElementById('plugins-content'); const PANEL_LABELS = {
if (content && !content.hasAttribute('data-loaded')) { 'overview-content': 'the Overview',
content.setAttribute('data-loaded', 'true'); 'wifi-content': 'WiFi setup',
debugLog('Loading plugins directly via fetch (HTMX fallback)...'); 'plugins-content': 'the Plugin Manager'
};
// Fetches `url` into the element `id`, runs the partial's inline
// scripts, and lets HTMX and Alpine process the new markup. The
// scripts run before Alpine sees the content for the same reason
// htmx-config.js runs them on htmx:afterSwap: partials define their
// x-data component functions in those scripts.
window.loadPartialDirect = function(id, url) {
const el = document.getElementById(id);
if (!el || !url || el.hasAttribute('data-loaded') || el.hasAttribute('data-direct-loading')) return;
el.setAttribute('data-direct-loading', 'true');
debugLog('Loading ' + url + ' directly via fetch (HTMX fallback)');
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); const timeout = setTimeout(() => controller.abort(), 10000);
fetch('/v3/partials/plugins', { signal: controller.signal }) fetch(url, { signal: controller.signal })
.then(r => { .then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText); if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text(); return r.text();
}) })
.then(html => { .then(html => {
clearTimeout(timeout); el.innerHTML = html;
content.innerHTML = html; el.querySelectorAll('script').forEach(oldScript => {
if (typeof htmx !== 'undefined') { const script = document.createElement('script');
htmx.process(content); for (const attr of oldScript.attributes) script.setAttribute(attr.name, attr.value);
} script.textContent = oldScript.textContent;
// Trigger full initialization chain oldScript.replaceWith(script);
if (window.pluginManager) { });
window.pluginManager.initialized = false; el.setAttribute('data-loaded', 'true');
window.pluginManager.initializing = false; if (typeof htmx !== 'undefined') htmx.process(el);
} if (window.Alpine) window.Alpine.initTree(el);
if (window.initPluginsPage) { if (id === 'plugins-content' && window.initPluginsPage) {
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
window.initPluginsPage(); window.initPluginsPage();
} }
}) })
.catch(err => { .catch(err => {
console.error('Failed to load ' + url + ':', err);
const label = PANEL_LABELS[id] || 'this page';
el.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load ' + label + '. Check that the device is still reachable, then try again.</p><button type="button" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
el.querySelector('button').addEventListener('click', () => window.loadPartialDirect(id, url));
})
.finally(() => {
clearTimeout(timeout); clearTimeout(timeout);
console.error('Failed to load plugins:', err); el.removeAttribute('data-direct-loading');
content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Plugin Manager. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadPluginsDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
}); });
} };
}
// Fallback if HTMX doesn't load within 5 seconds function activeTab() {
var _pluginsFallbackTimer = setTimeout(() => { const appEl = document.querySelector('[x-data="app()"]');
if (typeof htmx === 'undefined') { try {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins'); return (window.Alpine && appEl) ? window.Alpine.$data(appEl).activeTab : null;
// Load plugins tab content directly regardless of active tab, } catch (e) {
// so it's ready when the user navigates to it return null; // Alpine has not initialised the app yet
loadPluginsDirect(); }
} }
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true }); function loadActiveTabDirect() {
const el = document.getElementById((activeTab() || 'overview') + '-content');
if (el) window.loadPartialDirect(el.id, el.getAttribute('hx-get'));
}
// HTMX loaded but never initialised, even from the CDN rescue.
window.addEventListener('htmx-load-failed', function() {
console.warn('HTMX failed to load, loading the active tab directly');
setTimeout(loadActiveTabDirect, 2000);
});
// HTMX still missing after 5 seconds: load the active tab, and the
// Plugin Manager regardless of tab so the plugin tabs work.
const fallbackTimer = setTimeout(function() {
if (typeof htmx !== 'undefined') return;
console.warn('HTMX not loaded after 5 seconds, loading tabs directly');
window.loadPartialDirect('plugins-content', '/v3/partials/plugins');
loadActiveTabDirect();
}, 5000);
// AP mode (setting up WiFi) opens on the WiFi tab and can least
// afford to wait, so it gets an earlier check.
const apTimer = setTimeout(function() {
const host = window.location.hostname;
if (typeof htmx === 'undefined' && host.startsWith('192.168.4.')) {
console.warn('HTMX not loaded, loading WiFi setup directly');
window.loadPartialDirect('wifi-content', '/v3/partials/wifi');
}
}, 3000);
window.addEventListener('htmx:ready', function() {
clearTimeout(fallbackTimer);
clearTimeout(apTimer);
}, { once: true });
})();
</script> </script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes --> <!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script> <script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script>
@@ -246,45 +301,6 @@
}); });
</script> </script>
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}"></noscript>
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}"></noscript>
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
<script>
// Lazy load CodeMirror when needed
window.loadCodeMirror = function() {
if (window.CodeMirror) return Promise.resolve();
return new Promise((resolve, reject) => {
// Vendored CodeMirror 5.65.2 (note: the previously listed CDN
// mode/json/json.min.js never existed — it 404'd on every
// editor open; JSON highlighting comes from the javascript
// mode, so it's simply dropped here)
const scripts = [
'/static/v3/vendor/codemirror/codemirror.min.js',
'/static/v3/vendor/codemirror/mode/javascript/javascript.min.js',
'/static/v3/vendor/codemirror/addon/edit/closebrackets.min.js',
'/static/v3/vendor/codemirror/addon/edit/matchbrackets.min.js'
];
let loaded = 0;
scripts.forEach((src, index) => {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = () => {
loaded++;
if (loaded === scripts.length) resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
});
};
</script>
<!-- Font Awesome icons (vendored 6.0.0 — css/all.min.css references <!-- Font Awesome icons (vendored 6.0.0 — css/all.min.css references
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) --> ../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
@@ -548,7 +564,7 @@
<!-- Second row - Plugin tabs (populated dynamically) --> <!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200"> <div id="plugin-tabs-row" class="border-b border-gray-200">
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4"> <nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }" <button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPartialDirect('plugins-content', '/v3/partials/plugins')); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''" :class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager <i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
@@ -592,7 +608,7 @@
<div id="tab-content" class="space-y-6" tabindex="-1"> <div id="tab-content" class="space-y-6" tabindex="-1">
<!-- Overview tab --> <!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition> <div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()"> <div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse"> <div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div> <div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -601,65 +617,6 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Fallback: Load overview content directly if HTMX fails
function loadOverviewDirect() {
const overviewContent = document.getElementById('overview-content');
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/overview')
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') {
htmx.process(overviewContent);
}
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(overviewContent);
}
})
.catch(err => {
console.error('Failed to load overview content:', err);
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Overview. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadOverviewDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
// Listen for HTMX load failure
window.addEventListener('htmx-load-failed', function() {
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
// Try to load content directly after a delay
setTimeout(() => {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement.__x) {
const activeTab = appElement.__x.$data.activeTab || 'overview';
if (activeTab === 'overview') {
loadOverviewDirect();
}
}
}, 2000);
});
// Also try direct load if HTMX doesn't load within 5 seconds
var _overviewFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement.__x) {
const activeTab = appElement.__x.$data.activeTab || 'overview';
if (activeTab === 'overview') {
loadOverviewDirect();
}
}
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script>
<!-- General tab --> <!-- General tab -->
<div x-show="activeTab === 'general'" x-transition> <div x-show="activeTab === 'general'" x-transition>
@@ -683,7 +640,7 @@
hx-get="/v3/partials/wifi" hx-get="/v3/partials/wifi"
hx-trigger="loadtab" hx-trigger="loadtab"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-on::htmx:response-error="loadWifiDirect()"> hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse"> <div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div> <div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -696,42 +653,6 @@
</div> </div>
</div> </div>
</div> </div>
<script>
// Fallback: Load WiFi content directly if HTMX fails
function loadWifiDirect() {
const wifiContent = document.getElementById('wifi-content');
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/wifi')
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
wifiContent.innerHTML = html;
wifiContent.setAttribute('data-loaded', 'true');
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(wifiContent);
}
})
.catch(err => {
console.error('Failed to load WiFi content:', err);
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load WiFi setup. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadWifiDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
setTimeout(() => {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
if (isAPMode && typeof htmx === 'undefined') {
console.warn('HTMX not loaded, using direct fetch for WiFi content');
loadWifiDirect();
}
}, 3000);
</script>
<!-- Schedule tab --> <!-- Schedule tab -->
<div x-show="activeTab === 'schedule'" x-transition> <div x-show="activeTab === 'schedule'" x-transition>
@@ -806,7 +727,7 @@
<!-- Plugins tab --> <!-- Plugins tab -->
<div x-show="activeTab === 'plugins'" x-transition> <div x-show="activeTab === 'plugins'" x-transition>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML" <div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
hx-on::response-error="loadPluginsDirect()"> hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse"> <div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div> <div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -986,11 +907,10 @@
their own URLs for plugin-loader.js and debugging. --> their own URLs for plugin-loader.js and debugging. -->
<script src="{{ widgets_bundle_url() }}" defer></script> <script src="{{ widgets_bundle_url() }}" defer></script>
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live <!-- plugins_manager.js: loaded last. It uses PluginAPI, ListFilter,
window.* implementations for several plugin actions (installPlugin, LEDDialog, the widgets and app-shell.js's updatePlugin at load time
uninstallPlugin, executePluginAction, ...) and intentionally wins or when a card is clicked, so it must follow all of them. No other
over same-named definitions in js/app.js and js/app-shell.js. Do not script defines the same globals. -->
remove or reorder without resolving that overlap first. -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script> <script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the <!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
@@ -1147,16 +1067,12 @@
'services to run the new code'); 'services to run the new code');
} }
} }
if (typeof showNotification === 'function') { showNotification(data.message || 'Update complete', data.status || 'success');
showNotification(data.message || 'Update complete', data.status || 'success');
}
}) })
.catch(function() { .catch(function() {
btn.innerHTML = originalHTML; btn.innerHTML = originalHTML;
btn.disabled = false; btn.disabled = false;
if (typeof showNotification === 'function') { showNotification('Update failed — check your connection', 'error');
showNotification('Update failed — check your connection', 'error');
}
}); });
}; };
@@ -121,11 +121,7 @@
let inspectedFile = null; let inspectedFile = null;
function notify(message, kind) { function notify(message, kind) {
if (typeof showNotification === 'function') { showNotification(message, kind || 'info');
showNotification(message, kind || 'info');
} else {
console.log('[backup]', kind || 'info', message);
}
} }
function formatSize(bytes) { function formatSize(bytes) {
@@ -136,11 +132,7 @@
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
} }
function escapeHtml(value) { function escapeHtml(value) { return window.LEDEscape.html(value); }
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
async function loadPreview() { async function loadPreview() {
const el = document.getElementById('export-preview'); const el = document.getElementById('export-preview');
+10 -26
View File
@@ -55,13 +55,12 @@
</div> </div>
<script> <script>
// Initialize cache management when partial loads // Scoped: this script runs at global scope after every HTMX swap, and the Logs
// partial has helpers with the same names (showError, escapeHtml). Only
// deleteCacheFile, which the row buttons call from onclick, is exported.
(function() { (function() {
loadCacheFiles(); loadCacheFiles();
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
// Event listeners
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
})();
function loadCacheFiles() { function loadCacheFiles() {
const tbody = document.getElementById('cache-files-tbody'); const tbody = document.getElementById('cache-files-tbody');
@@ -190,21 +189,15 @@ function deleteCacheFile(key) {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.status === 'success') { if (data.status === 'success') {
if (typeof showNotification !== 'undefined') { showNotification(data.message || 'Cache file deleted successfully', 'success');
showNotification(data.message || 'Cache file deleted successfully', 'success');
}
// Reload cache files list // Reload cache files list
loadCacheFiles(); loadCacheFiles();
} else { } else {
if (typeof showNotification !== 'undefined') { showNotification(data.message || 'Failed to delete cache file', 'error');
showNotification(data.message || 'Failed to delete cache file', 'error');
}
} }
}) })
.catch(error => { .catch(error => {
if (typeof showNotification !== 'undefined') { showNotification('Error deleting cache file: ' + error.message, 'error');
showNotification('Error deleting cache file: ' + error.message, 'error');
}
}); });
} }
@@ -220,17 +213,8 @@ function showError(message) {
errorMessage.textContent = message; errorMessage.textContent = message;
} }
// Utility function to escape HTML function escapeHtml(text) { return window.LEDEscape.html(text); }
// Quotes too: the result is interpolated into quoted attribute values, and the
// textContent/innerHTML round-trip only escapes &, < and >.
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Make deleteCacheFile available globally for onclick handlers
window.deleteCacheFile = deleteCacheFile; window.deleteCacheFile = deleteCacheFile;
})();
</script> </script>
@@ -35,7 +35,7 @@
hx-ext="json-enc" hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}' hx-headers='{"Content-Type": "application/json"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)" hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Display settings saved', 'Display settings were not saved. Check your connection and try again.')"
class="space-y-6" class="space-y-6"
novalidate novalidate
onsubmit="fixInvalidNumberInputs(this); return true;"> onsubmit="fixInvalidNumberInputs(this); return true;">
@@ -863,62 +863,12 @@ document.getElementById('brightness').addEventListener('input', function() {
document.getElementById('brightness-display').textContent = this.value; document.getElementById('brightness-display').textContent = this.value;
}); });
// Fix invalid number inputs function (if not already defined globally)
if (typeof window.fixInvalidNumberInputs !== 'function') {
window.fixInvalidNumberInputs = function(form) {
if (!form) return;
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
};
}
// Report the outcome of a display-settings save. XMLHttpRequest has no
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
// status code, otherwise a failed save reports success.
window.showDisplaySaveResult = function(xhr) {
// Only 2xx counts as saved. A network failure reports status 0, which any
// `>= 400` test would wave through as success.
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
let message = httpSuccess
? 'Display settings saved'
: 'Display settings were not saved. Check your connection and try again.';
let status = httpSuccess ? 'success' : 'error';
try {
const data = JSON.parse(xhr.responseText);
if (data.message) message = data.message;
// A body can refine a successful verdict but never overturn a failed one.
if (httpSuccess && data.status) status = data.status;
} catch {
// Non-JSON body — fall back to the status-code verdict above.
}
showNotification(message, status);
};
// Vegas Scroll Mode Settings // Vegas Scroll Mode Settings
(function() { (function() {
// Escape HTML to prevent XSS function escapeHtml(text) { return window.LEDEscape.html(text); }
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
// Escape for use in HTML attributes function escapeAttr(text) { return window.LEDEscape.attr(text); }
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Toggle settings visibility // Toggle settings visibility
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled'); const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
@@ -11,7 +11,7 @@
hx-ext="json-enc" hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}' hx-headers='{"Content-Type": "application/json"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Durations saved', event.detail.xhr.responseJSON?.status || 'success')" hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Durations saved', 'Durations were not saved. Check your connection and try again.')"
class="space-y-6" class="space-y-6"
novalidate novalidate
onsubmit="fixInvalidNumberInputs(this); return true;"> onsubmit="fixInvalidNumberInputs(this); return true;">
+1 -23
View File
@@ -164,21 +164,6 @@ function initializeFontsTab() {
// Reset retry counter on successful init // Reset retry counter on successful init
initRetryCount = 0; initRetryCount = 0;
// showNotification is implemented by the notification widget
// (widgets/notification.js). If it hasn't loaded, queue the message; the
// widget shows queued messages as soon as it registers.
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
};
}
debugLog('Initializing font management...'); debugLog('Initializing font management...');
initializeFontManagement(); initializeFontManagement();
@@ -349,14 +334,7 @@ async function loadFontData() {
// Show error states // Show error states
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>'; availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
// Only show notification if showNotification is available showNotification('Error loading font configuration', 'error');
if (typeof showNotification === 'function') {
showNotification('Error loading font configuration', 'error');
} else if (typeof window.showNotification === 'function') {
window.showNotification('Error loading font configuration', 'error');
} else {
console.error('Error loading font configuration:', error.message);
}
} }
} }
@@ -11,24 +11,7 @@
hx-ext="json-enc" hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}' hx-headers='{"Content-Type": "application/json"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request=" hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Settings saved', 'Failed to save settings')"
var xhr = event.detail.xhr;
var isSuccess = xhr.status >= 200 && xhr.status < 300;
var message = '';
var status = 'success';
try {
var data = JSON.parse(xhr.responseText);
message = data.message || '';
status = data.status || status;
} catch (e) {}
if (isSuccess) {
message = message || 'Settings saved';
} else {
message = message || 'Failed to save settings';
status = 'error';
}
showNotification(message, status);
"
class="space-y-6"> class="space-y-6">
{# Marks this post as the whole form, so an unchecked box saves as {# Marks this post as the whole form, so an unchecked box saves as
false. Without it the save endpoint treats absent keys as unchanged false. Without it the save endpoint treats absent keys as unchanged
+14 -30
View File
@@ -126,8 +126,10 @@
</div> </div>
<script> <script>
// Global variables - use window properties to avoid redeclaration errors with HTMX reloads // Scoped: this script runs at global scope after every HTMX swap, and the
// Initialize only if not already defined // Cache partial has helpers with the same names (showError, escapeHtml). State
// that must survive a reload of this partial lives on window._logs* instead.
(function() {
if (typeof window._logsEventSource === 'undefined') { if (typeof window._logsEventSource === 'undefined') {
window._logsEventSource = null; window._logsEventSource = null;
} }
@@ -624,9 +626,7 @@ function toggleRealtime() {
function refreshLogs() { function refreshLogs() {
loadLogs(); loadLogs();
if (typeof showNotification !== 'undefined') { showNotification('Logs refreshed', 'success');
showNotification('Logs refreshed', 'success');
}
} }
function clearLogs() { function clearLogs() {
@@ -637,16 +637,12 @@ function clearLogs() {
} }
showEmptyState(); showEmptyState();
updateLogStats(); updateLogStats();
if (typeof showNotification !== 'undefined') { showNotification('Logs cleared', 'info');
showNotification('Logs cleared', 'info');
}
} }
function downloadLogs() { function downloadLogs() {
if (window._filteredLogs.length === 0) { if (window._filteredLogs.length === 0) {
if (typeof showNotification !== 'undefined') { showNotification('No logs to download', 'warning');
showNotification('No logs to download', 'warning');
}
return; return;
} }
@@ -662,9 +658,7 @@ function downloadLogs() {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
if (typeof showNotification !== 'undefined') { showNotification('Logs downloaded', 'success');
showNotification('Logs downloaded', 'success');
}
} }
// Track if user is near bottom for smart auto-scroll // Track if user is near bottom for smart auto-scroll
@@ -776,16 +770,7 @@ function showError(message) {
} }
} }
// Utility function to escape HTML. Quotes too: the result is interpolated into function escapeHtml(text) { return window.LEDEscape.html(text); }
// quoted attribute values (option value=, title=), and the
// textContent/innerHTML round-trip only escapes &, < and >.
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function refreshCurrentPluginStatus() { function refreshCurrentPluginStatus() {
fetch('/api/v3/display/current-status') fetch('/api/v3/display/current-status')
@@ -920,15 +905,13 @@ function clearPluginErrors() {
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (typeof showNotification !== 'undefined') { showNotification(data.status === 'success' ? 'Plugin errors cleared'
showNotification(data.status === 'success' ? 'Plugin errors cleared' : (data.message || 'Could not clear plugin errors'),
: (data.message || 'Could not clear plugin errors'), data.status === 'success' ? 'success' : 'error');
data.status === 'success' ? 'success' : 'error');
}
refreshPluginErrors(); refreshPluginErrors();
}) })
.catch(() => { .catch(() => {
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error'); showNotification('Could not clear plugin errors', 'error');
}); });
} }
@@ -946,4 +929,5 @@ window.addEventListener('beforeunload', function() {
window._pluginErrorsPollTimer = null; window._pluginErrorsPollTimer = null;
} }
}); });
})();
</script> </script>
@@ -260,18 +260,10 @@
return 'bg-gray-100 text-gray-800'; return 'bg-gray-100 text-gray-800';
} }
function escapeHtml(text) { function escapeHtml(text) { return window.LEDEscape.html(text); }
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showError(message) { function showError(message) {
if (typeof showNotification === 'function') { showNotification(message, 'error');
showNotification(message, 'error');
} else {
alert(message);
}
} }
// Populate plugin filter // Populate plugin filter
@@ -216,11 +216,7 @@
if (nowDone) maybeAutoHide(); if (nowDone) maybeAutoHide();
return; return;
} }
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]'); var data = window.getApp();
// Same two-tier resolution as settings-search.js's getAppData():
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
(appEl.__x && appEl.__x.$data));
if (data) { if (data) {
data.activeTab = btn.dataset.tab; data.activeTab = btn.dataset.tab;
if ('mobileNavOpen' in data) data.mobileNavOpen = false; if ('mobileNavOpen' in data) data.mobileNavOpen = false;
@@ -443,7 +439,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}' hx-vals='{"action": "start_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i> <i class="fas fa-play mr-2"></i>
Start Display Start Display
@@ -452,7 +448,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}' hx-vals='{"action": "stop_display"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i> <i class="fas fa-stop mr-2"></i>
Stop Display Stop Display
@@ -462,7 +458,7 @@
hx-vals='{"action": "git_pull"}' hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?" hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i> <i class="fas fa-download mr-2"></i>
Update Code Update Code
@@ -472,7 +468,7 @@
hx-vals='{"action": "reboot_system"}' hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?" hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Reboot System Reboot System
@@ -482,7 +478,7 @@
hx-vals='{"action": "shutdown_system"}' hx-vals='{"action": "shutdown_system"}'
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi." hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900"> class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
<i class="fas fa-power-off mr-2"></i> <i class="fas fa-power-off mr-2"></i>
Shutdown System Shutdown System
@@ -491,7 +487,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}' hx-vals='{"action": "restart_display_service"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i> <i class="fas fa-redo mr-2"></i>
Restart Display Service Restart Display Service
@@ -500,7 +496,7 @@
<button hx-post="/api/v3/system/action" <button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}' hx-vals='{"action": "restart_web_service"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }" hx-on:htmx:after-request="var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50"> class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i> <i class="fas fa-redo mr-2"></i>
Restart Web Service Restart Web Service
@@ -423,11 +423,10 @@
{% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %} {% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %}
{% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %} {% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %}
</p> </p>
{% if has_schedule %} {# file-upload.js rewrites .image-schedule-summary in place when the schedule is edited #}
<p class="text-xs text-blue-600 mt-1"> <p class="text-xs text-blue-600 mt-1 image-schedule-summary">
<i class="fas fa-clock mr-1"></i>Scheduled <i class="fas fa-clock mr-1"></i>{{ 'Scheduled' if has_schedule else 'Always shown' }}
</p> </p>
{% endif %}
</div> </div>
</div> </div>
<div class="flex items-center space-x-2 ml-4"> <div class="flex items-center space-x-2 ml-4">
@@ -540,18 +540,6 @@
background-color: #e5e7eb; background-color: #e5e7eb;
} }
/* CodeMirror editor styles */
.CodeMirror {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 14px;
height: auto;
min-height: 400px;
}
.CodeMirror.cm-error {
border-color: #ef4444;
}
/* Plugin config view styles */ /* Plugin config view styles */
.plugin-config-view { .plugin-config-view {
@@ -148,7 +148,7 @@
fieldId: 'schedule' fieldId: 'schedule'
}); });
console.log('[Schedule] Schedule picker widget initialized'); debugLog('[Schedule] Schedule picker widget initialized');
} }
// Handle form submission response // Handle form submission response
@@ -165,28 +165,7 @@
const type = response.status || 'info'; const type = response.status || 'info';
// Use global notification function if available // Use global notification function if available
if (typeof window.showNotification === 'function') { window.showNotification(message, type);
window.showNotification(message, type);
} else {
// Fallback notification
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(function() {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(function() { notification.remove(); }, 500);
}, 3000);
}
}; };
// Initialize when DOM is ready or if already loaded // Initialize when DOM is ready or if already loaded
@@ -265,7 +244,7 @@
fieldId: 'dim_schedule' fieldId: 'dim_schedule'
}); });
console.log('[DimSchedule] Dim schedule picker widget initialized'); debugLog('[DimSchedule] Dim schedule picker widget initialized');
} }
// Handle dim schedule form submission response // Handle dim schedule form submission response
@@ -282,28 +261,7 @@
const type = response.status || 'info'; const type = response.status || 'info';
// Use global notification function if available // Use global notification function if available
if (typeof window.showNotification === 'function') { window.showNotification(message, type);
window.showNotification(message, type);
} else {
// Fallback notification
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(function() {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(function() { notification.remove(); }, 500);
}, 3000);
}
}; };
// Initialize when DOM is ready // Initialize when DOM is ready
@@ -344,21 +344,15 @@ function forceRenderStarlarkApp(appId) {
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.status === 'success') { if (data.status === 'success') {
if (typeof showNotification === 'function') { showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
} else {
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
}
} else { } else {
var msg = 'Render failed: ' + (data.message || 'Unknown error'); var msg = 'Render failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error'); showNotification(msg, 'error');
else alert(msg);
} }
}) })
.catch(function(err) { .catch(function(err) {
var msg = 'Render failed: ' + err.message; var msg = 'Render failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error'); showNotification(msg, 'error');
else alert(msg);
}); });
} }
@@ -371,22 +365,20 @@ function toggleStarlarkApp(appId, enabled) {
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.status === 'success') { if (data.status === 'success') {
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); // A failed reload has already been reported by loadInstalledPlugins.
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins(); window.pluginManager.loadInstalledPlugins(true).catch(function() {});
var container = document.getElementById('plugin-config-starlark:' + appId); var container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) { if (container && window.htmx) {
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'}); htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
} }
} else { } else {
var msg = 'Toggle failed: ' + (data.message || 'Unknown error'); var msg = 'Toggle failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error'); showNotification(msg, 'error');
else alert(msg);
} }
}) })
.catch(function(err) { .catch(function(err) {
var msg = 'Toggle failed: ' + err.message; var msg = 'Toggle failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error'); showNotification(msg, 'error');
else alert(msg);
}); });
} }
@@ -436,8 +428,7 @@ function saveStarlarkConfig(appId) {
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
if (data.status === 'success') { if (data.status === 'success') {
if (typeof showNotification === 'function') showNotification('Configuration saved!', 'success'); showNotification('Configuration saved!', 'success');
else alert('Configuration saved!');
// Reload partial to reflect updated status // Reload partial to reflect updated status
var container = document.getElementById('plugin-config-starlark:' + appId); var container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) { if (container && window.htmx) {
@@ -445,14 +436,12 @@ function saveStarlarkConfig(appId) {
} }
} else { } else {
var msg = 'Save failed: ' + (data.message || 'Unknown error'); var msg = 'Save failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error'); showNotification(msg, 'error');
else alert(msg);
} }
}) })
.catch(function(err) { .catch(function(err) {
var msg = 'Save failed: ' + err.message; var msg = 'Save failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error'); showNotification(msg, 'error');
else alert(msg);
}); });
} }
@@ -435,9 +435,7 @@
el.innerHTML = html; el.innerHTML = html;
} }
function escHtml(s) { function escHtml(s) { return window.LEDEscape.html(s); }
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── main action dispatcher ──────────────────────────────────────────────── // ── main action dispatcher ────────────────────────────────────────────────
@@ -910,11 +908,7 @@
}; };
// ── plugin health panel ────────────────────────────────────────────────── // ── plugin health panel ──────────────────────────────────────────────────
function phEscape(s) { function phEscape(s) { return window.LEDEscape.html(s); }
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function phFmtSecs(v) { function phFmtSecs(v) {
if (typeof v !== 'number' || !isFinite(v)) return '—'; if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(3) + 's'; return v.toFixed(3) + 's';