Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation

This commit is contained in:
Chuck
2026-09-24 17:44:33 -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.
- 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
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
-2
View File
@@ -31,8 +31,6 @@ if args.emulator:
print("Using pygame/RGBMatrixEmulator for display")
print("Press ESC to exit\n")
# Project directory already added above
# Debug output (only in debug mode or emulator mode)
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
if debug_mode:
+13 -36
View File
@@ -238,20 +238,9 @@ class ScrollHelper:
self.cached_image = full_image
# Convert to numpy array for fast operations
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
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.total_distance_scrolled = 0.0
self.scroll_complete = False
@@ -339,10 +328,8 @@ class ScrollHelper:
# gained. This is what the one visibly smooth scroller on the
# hardware (the stock ticker) was already doing by virtue of never
# 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
else:
pixels_per_second = self.scroll_speed * 100.0
pixels_to_move = pixels_per_second * delta_time
self.last_step_time = current_time
else:
@@ -353,11 +340,11 @@ class ScrollHelper:
self.scroll_position += pixels_to_move
self.total_distance_scrolled += pixels_to_move
# Calculate required total distance: total_scroll_width only.
# The image already includes display_width pixels of blank padding at the start
# (added by create_scrolling_image), so once scroll_position reaches
# total_scroll_width the last card has fully scrolled off the left edge.
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains.
# One pass is total_scroll_width. With the default lead_gap the strip
# starts with display_width of blank, so by then the last item has
# fully left the panel; a caller passing a smaller lead_gap (Vegas)
# decides for itself where its cycle ends. Adding display_width here
# caused 1-2 extra wrap-arounds on wide chains.
required_total_distance = self.total_scroll_width
# 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
):
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
# Progress telemetry, emitted every few seconds for the whole of
# 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``.
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
installed on the target devices, and the old scipy-based sub-pixel path
was dead code -- get_visible_portion never consulted the flag. The scipy
import was removed with it; installing scipy has no effect.
Implemented with numpy rather than scipy.ndimage.shift, which is not
installed on the target devices.
Args:
start_x: Left column of the earlier of the two frames
@@ -571,22 +555,16 @@ class ScrollHelper:
return self.min_duration
try:
# Calculate total scroll distance needed
# The image already includes display_width padding at the start, so we need
# to scroll total_scroll_width pixels to show all content, plus display_width
# more pixels to ensure the last content scrolls completely off the screen
# The strip's width plus one more screen, so the duration covers
# the last item leaving the panel even when the strip has less
# than display_width of lead-in blank (lead_gap).
total_scroll_distance = self.total_scroll_width + self.display_width
# Calculate effective pixels per second based on scrolling mode
if self.frame_based_scrolling:
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame
# Effective pixels per second = pixels per frame / seconds per frame
if self.scroll_delay > 0:
# Frame-based mode: scroll_speed is pixels per scroll_delay
# seconds, and set_scroll_delay keeps scroll_delay >= 0.001.
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"
else:
# Time-based mode: scroll_speed is already pixels per second
@@ -1072,7 +1050,6 @@ class ScrollHelper:
Returns:
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
return {
'scroll_position': self.scroll_position,
+299 -370
View File
File diff suppressed because it is too large Load Diff
+31 -71
View File
@@ -39,8 +39,7 @@ from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src import scan_order
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
resolve_double_sided,
compose_pixel_mapper_config, physical_size, resolve_double_sided,
)
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
from src.pi5_matrix_support import is_raspberry_pi_5
@@ -48,7 +47,6 @@ import threading
import time
from collections import OrderedDict, deque
from typing import Dict, Any, List, Optional, Tuple
import logging
import math
import zlib
import freetype
@@ -56,6 +54,7 @@ import freetype
from src.common import snapshot_policy
from src.common.frame_timing import FrameTimingRecorder
from src.deprecation import deprecated
from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_directory_permissions,
ensure_file_permissions,
@@ -63,9 +62,7 @@ from src.common.permission_utils import (
get_assets_file_mode,
)
# Get logger without configuring
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
logger = get_logger(__name__)
#: 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 --
@@ -136,11 +133,6 @@ class _LogicalMatrix:
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:
"""
@@ -162,7 +154,6 @@ class DisplayManager:
"""
_instance = None
_initialized = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
@@ -257,19 +248,15 @@ class DisplayManager:
self._load_fonts()
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
# Initialize managers
# Calendar manager is now initialized by DisplayController
def _new_canvas(self, width: int, height: int) -> None:
"""Replace ``image``/``draw`` with a black canvas of the given size.
# 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:
"""Compose pixel_mapper_config with the orientation setting.
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
web preview shares so it sizes the canvas the same way.
Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
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
def _fallback_advice(cause: str, error: Exception) -> str:
@@ -338,7 +325,7 @@ class DisplayManager:
# logical (per-screen) size, and keep a full-chain buffer to tile
# the rendered screen into once per frame.
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
if ds is not None:
self._physical_image = Image.new(
@@ -347,9 +334,7 @@ class DisplayManager:
self.matrix, ds['logical_width'], ds['logical_height'])
# Create image with the (logical) display dimensions
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.
self._new_canvas(self.matrix.width, self.matrix.height)
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P
@@ -380,7 +365,7 @@ class DisplayManager:
fallback_width, fallback_height = physical_size(self.config)
# 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 = _resolve_double_sided(fallback_width, fallback_height, ds_config)
ds = resolve_double_sided(fallback_width, fallback_height, ds_config)
self._double_sided = ds
if ds is not None:
fallback_width = ds['logical_width']
@@ -388,9 +373,7 @@ class DisplayManager:
except Exception:
fallback_width, fallback_height = 128, 32
self.image = Image.new('RGB', (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.
self._new_canvas(fallback_width, fallback_height)
# Simple fallback visualization so web UI shows a realistic canvas
try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
@@ -619,18 +602,14 @@ class DisplayManager:
line, font=font, fill=(0, 0, 255))
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:
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
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
@@ -645,7 +624,7 @@ class DisplayManager:
# Update the display once after everything is drawn
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:
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@@ -720,9 +699,7 @@ class DisplayManager:
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
# With no hardware, the width/height properties fall through to
# self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (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.
self._new_canvas(target_w, target_h)
yield
finally:
self.matrix = real_matrix
@@ -890,28 +867,14 @@ class DisplayManager:
try:
if self.matrix is None:
# Fallback mode - just clear the image
# Explicitly clear old image reference to help garbage collection
old_image = getattr(self, 'image', None)
width = old_image.width if old_image else 64
height = old_image.height if old_image else 64
if old_image is not None:
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.
self._new_canvas(width, height)
logger.debug("Cleared display in fallback mode")
return
# Explicitly clear old image reference to help garbage collection
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.
self._new_canvas(self.matrix.width, self.matrix.height)
if not self._capture_mode_active:
# Clear both canvases and the underlying matrix to ensure no artifacts.
@@ -1371,14 +1334,11 @@ class DisplayManager:
# Ensure image/draw are reset to a blank state
if hasattr(self, 'image') and hasattr(self, 'draw'):
try:
self.image = Image.new('RGB', (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.
self._new_canvas(self.width, self.height)
except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True)
# Reset the singleton state when cleaning up
DisplayManager._instance = None
DisplayManager._initialized = False
def format_date_with_ordinal(self, dt):
"""Formats a datetime object into 'Mon Aug 30th' style."""
@@ -1416,9 +1376,9 @@ class DisplayManager:
options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
# _build_pixel_mapper_config reads only class attributes, so the class
# stands in for an instance here.
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config)
# Orientation becomes a "Rotate:<deg>" pixel mapper; the web preview
# composes it the same way so it sizes the canvas identically.
options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '')
@@ -1532,13 +1492,16 @@ class DisplayManager:
that does not care gets a new frame every refresh.
"""
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
if is_scrolling:
self._scrolling_state['last_scroll_activity'] = current_time
self.set_frame_hold(frame_hold)
else:
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:
"""Check if the display is currently in a scrolling state."""
@@ -1607,9 +1570,6 @@ class DisplayManager:
if not self._scrolling_state['deferred_updates']:
return
if not self._scrolling_state['deferred_updates']:
return
# Process only a limited number of updates per call to avoid blocking
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
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:
- VegasModeCoordinator: Main orchestrator for Vegas mode
- StreamManager: Manages plugin content streaming with 1-2 ahead buffering
- RenderPipeline: Handles 125 FPS rendering with double-buffering
- StreamManager: Plugin rotation, content fetching and pending-update tracking
- RenderPipeline: Strip composition and per-frame rendering
- PluginAdapter: Converts plugin content to scrollable images
- VegasModeConfig: Configuration management
"""
+38 -48
View File
@@ -167,7 +167,7 @@ class VegasModeConfig:
# Dynamic duration
dynamic_duration_enabled: bool = True
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
def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig':
@@ -181,44 +181,50 @@ class VegasModeConfig:
VegasModeConfig instance
"""
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(
enabled=vegas_config.get('enabled', False),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
separator_width=int(vegas_config.get('separator_width', 32)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
enabled=get('enabled', d.enabled),
scroll_speed=float(get('scroll_speed', d.scroll_speed)),
separator_width=int(get('separator_width', d.separator_width)),
intra_plugin_gap=int(get('intra_plugin_gap', d.intra_plugin_gap)),
render_width_pct=int(get('render_width_pct', d.render_width_pct)),
min_content_separation=int(
vegas_config.get('min_content_separation', 24)),
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
smooth_scroll=vegas_config.get('smooth_scroll', True),
continuous_scroll=vegas_config.get('continuous_scroll', True),
get('min_content_separation', d.min_content_separation)),
min_cut_gap=int(get('min_cut_gap', d.min_cut_gap)),
smooth_scroll=get('smooth_scroll', d.smooth_scroll),
continuous_scroll=get('continuous_scroll', d.continuous_scroll),
extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)),
auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)),
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
get('extend_threshold_screens', d.extend_threshold_screens)),
auto_trim=get('auto_trim', d.auto_trim),
trim_threshold=int(get('trim_threshold', d.trim_threshold)),
content_padding=int(get('content_padding', d.content_padding)),
min_plugin_width=int(get('min_plugin_width', d.min_plugin_width)),
lead_in_width=int(get('lead_in_width', d.lead_in_width)),
plugins_per_cycle=int(get('plugins_per_cycle', d.plugins_per_cycle)),
max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 0.0)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
live_in_ticker=bool(vegas_config.get('live_in_ticker', False)),
get('max_plugin_width_ratio', d.max_plugin_width_ratio)),
overflow_mode=str(get('overflow_mode', d.overflow_mode)),
plugin_order=list(get('plugin_order', d.plugin_order)),
excluded_plugins=set(get('excluded_plugins', d.excluded_plugins)),
live_in_ticker=bool(get('live_in_ticker', d.live_in_ticker)),
# Clamped: a weight below 1 would drop the plugin from the rotation
# entirely, and a very large one starves everything else.
live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))),
favorite_live_weight=max(
1, min(10, int(vegas_config.get('favorite_live_weight', 5)))),
target_fps=int(vegas_config.get('target_fps', 125)),
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
scroll_delay=float(vegas_config.get('scroll_delay', 0.02)),
dynamic_duration_enabled=vegas_config.get('dynamic_duration_enabled', True),
min_cycle_duration=int(vegas_config.get('min_cycle_duration', 60)),
max_cycle_duration=int(vegas_config.get('max_cycle_duration', 600)),
live_weight=max(1, min(10, int(get('live_weight', d.live_weight)))),
favorite_live_weight=max(1, min(10, int(
get('favorite_live_weight', d.favorite_live_weight)))),
target_fps=int(get('target_fps', d.target_fps)),
buffer_ahead=int(get('buffer_ahead', d.buffer_ahead)),
frame_based_scrolling=get(
'frame_based_scrolling', d.frame_based_scrolling),
scroll_delay=float(get('scroll_delay', d.scroll_delay)),
dynamic_duration_enabled=get(
'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]:
@@ -260,22 +266,6 @@ class VegasModeConfig:
"""Get the frame interval in seconds for 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]:
"""
Get plugins in configured order, filtering excluded ones.
+5 -45
View File
@@ -145,13 +145,8 @@ class VegasModeCoordinator:
# Static pause handling
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
# Track which plugins should use STATIC mode (pause scroll)
self._static_mode_plugins: set = set()
# Statistics
self.stats = {
'total_runtime_seconds': 0.0,
@@ -235,7 +230,9 @@ class VegasModeCoordinator:
returns immediately, collapsing the inter-iteration gap to <1 ms.
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
@@ -417,9 +414,6 @@ class VegasModeCoordinator:
if not self.start():
return False
# Update static mode plugin list on iteration start
self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval()
if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says
@@ -733,13 +727,6 @@ class VegasModeCoordinator:
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)
# -------------------------------------------------------------------------
@@ -795,8 +782,6 @@ class VegasModeCoordinator:
# Save current scroll position for smooth resume
self._saved_scroll_position = self.render_pipeline.get_scroll_position()
self._static_pause_active = True
self._static_pause_plugin = plugin
self._static_pause_start = time.time()
self.stats['static_pauses'] += 1
logger.info("Static pause started for plugin: %s", plugin_id)
@@ -823,9 +808,9 @@ class VegasModeCoordinator:
logger.info("Static pause interrupted by live priority")
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():
logger.info("Static pause interrupted by sync follower mode")
logger.info("Static pause interrupted by the display controller")
return False
# Sleep in small increments to remain responsive
@@ -860,8 +845,6 @@ class VegasModeCoordinator:
# Clear pause state
self._static_pause_active = False
self._static_pause_plugin = None
self._static_pause_start = None
# Restore scroll position if we're resuming
if should_resume_scrolling and self._saved_scroll_position is not None:
@@ -875,29 +858,6 @@ class VegasModeCoordinator:
else:
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:
"""Clean up all resources."""
self.stop()
-48
View File
@@ -230,54 +230,6 @@ def blank_runs(
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):
"""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
config = VegasModeConfig()
self.config = config
# Handle both property and method access patterns
self.display_width = (
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
)
self.display_width = display_manager.width
self.display_height = display_manager.height
# Cache for recently fetched content (prevents redundant fetch)
self._content_cache: dict = {}
@@ -185,13 +178,14 @@ class PluginAdapter:
Trim dead space off a segment, then cache it.
Every content path funnels through here so trimming is applied
uniformly. Previously only the scroll_helper path had its margins
stripped, which left plugins that render onto a full-display canvas
contributing their entire blank canvas to the ticker.
uniformly; a plugin that renders onto a full-display canvas would
otherwise contribute its whole blank canvas to the ticker.
Each image is trimmed independently because compose_scroll_content()
treats every image as its own item and inserts separator_width between
them — so a per-image trim is what makes that separator the real gap.
Each image is trimmed independently. The render pipeline joins one
plugin's images with a gap measured from their ink
(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:
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
through it across cycles.
The cut is snapped to the nearest blank column so it does not slice
through a glyph or logo and leave half a character at the panel edge.
Cuts land only at item boundaries: the middle of a blank run at least
``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
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
Handles high-FPS (125 FPS) rendering with double-buffering for smooth scrolling.
Uses the existing ScrollHelper for numpy-optimized scroll operations.
Composes plugin content into one wide strip and renders the visible window of
it each frame, using ScrollHelper for the numpy-backed scroll.
"""
import logging
@@ -10,7 +10,7 @@ import os
import time
import threading
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 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.stream_manager import StreamManager
if TYPE_CHECKING:
pass
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:
"""
@@ -31,8 +33,9 @@ class RenderPipeline:
Key responsibilities:
- Compose content segments into scrollable image
- Manage scroll position and velocity
- Handle 125 FPS rendering loop
- Double-buffer for hot-swap during updates
- Render one frame per call at the target FPS
- Extend the strip (continuous mode) or recompose and hot-swap it (swap
mode) as content changes
- Track scroll cycle completion
"""
@@ -59,18 +62,10 @@ class RenderPipeline:
self.stream_manager = stream_manager
self.sync_manager = None # Optional DisplaySyncManager — set by coordinator
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
# Display dimensions (handle both property and method access patterns)
self.display_width = (
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
)
self.display_width = display_manager.width
self.display_height = display_manager.height
# ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper(
@@ -82,11 +77,6 @@ class RenderPipeline:
# 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.
self._prepared_group = None
# Plugins that need the shared canvas, appended one at a time.
@@ -96,13 +86,9 @@ class RenderPipeline:
self._prefetch_lock = threading.Lock()
# Render state
self._is_rendering = False
self._cycle_complete = False
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
# Statistics
@@ -192,10 +178,6 @@ class RenderPipeline:
logger.error("ScrollHelper failed to create cached image")
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)
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
@@ -335,8 +317,6 @@ class RenderPipeline:
element_gap=0,
)
if appended:
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
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.
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.stats['composition_count'] += 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.
if self.sync_manager:
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.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
@@ -603,7 +580,6 @@ class RenderPipeline:
Returns True when:
- Cycle is complete and we should start fresh
- Staging buffer has new content
- A plugin currently visible in the scroll has pending updated data
(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
# 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
# as a speed-up. Deferring to cycle boundaries keeps transitions clean.
# Staging buffer content is still pre-loaded; it just applies at cycle end.
# as a speed-up. Deferring to cycle boundaries keeps transitions clean;
# the pending updates are still applied, by the recompose at cycle end.
if self.sync_manager is not None:
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
# live score/status changes reach the display within a few seconds
# instead of waiting for the next full cycle.
@@ -652,10 +623,12 @@ class RenderPipeline:
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.
Swaps atomically to prevent visual glitches.
Swap mode only (continuous mode uses :meth:`refresh_updated_plugins`).
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:
True if swap occurred
@@ -668,9 +641,7 @@ class RenderPipeline:
old_width = self.scroll_helper.total_scroll_width
old_pos = self.scroll_helper.scroll_position
# Process any pending updates
self.stream_manager.process_updates()
self.stream_manager.swap_buffers()
# Recompose with updated content
if self.compose_scroll_content():
@@ -729,21 +700,17 @@ class RenderPipeline:
result = self.compose_scroll_content()
if result and self.sync_manager:
# When sync is active, start the leader past the lead-in gap so it
# immediately shows content, leaving the follower on the blank gap
# for a clean transition rather than near-end content wrapping
# around. This tracks lead_in_width rather than assuming a full
# display width of gap, which is no longer the default.
# Start the leader past the lead-in gap so it immediately shows
# content, leaving the follower on the blank gap for a clean
# transition rather than near-end content wrapping around.
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)
self.sync_manager.send_new_cycle()
# 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).
if self.scroll_helper.cached_image is not None:
import threading as _t
_t.Thread(
threading.Thread(
target=self.sync_manager.send_scroll_image,
args=(self.scroll_helper.cached_image,),
daemon=True, name="sync-image-push"
@@ -792,7 +759,6 @@ class RenderPipeline:
"""
old_fps = self.config.target_fps
self.config = new_config
self._frame_interval = new_config.get_frame_interval()
# Reconfigure scroll helper
self._configure_scroll_helper()
@@ -805,10 +771,6 @@ class RenderPipeline:
self.scroll_helper.reset_scroll()
self.scroll_helper.clear_cache()
with self._buffer_lock:
self._active_scroll_image = None
self._staging_scroll_image = None
self._cycle_complete = False
self._segments_in_scroll = []
self._frame_times = deque(maxlen=100)
+53 -130
View File
@@ -31,22 +31,14 @@ logger = logging.getLogger(__name__)
@dataclass
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
images: List[Image.Image]
total_width: int
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:
@@ -54,10 +46,12 @@ class StreamManager:
Manages streaming of plugin content for Vegas scroll mode.
Key responsibilities:
- Maintain ordered list of plugins to stream
- Prefetch content 1-2 plugins ahead of current position
- Handle plugin data updates via double-buffer swap
- Manage content lifecycle and staleness
- Maintain the ordered (and priority-weighted) rotation of plugins
- Fill the active buffer with a cycle's worth of segments (swap mode), or
hand out the next group of plugins directly (continuous mode)
- Track plugins whose data changed in ``_pending_updates``, which
:meth:`process_updates` (swap mode) or
:meth:`invalidate_pending_updates` (continuous mode) consumes
"""
def __init__(
@@ -78,14 +72,14 @@ class StreamManager:
self.plugin_manager = plugin_manager
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._staging_buffer: Deque[ContentSegment] = deque()
self._buffer_lock = threading.RLock() # RLock for reentrant access
# Reentrant: _prefetch_content releases and re-acquires it around the
# 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._current_index: int = 0
self._prefetch_index: int = 0
# Update tracking
@@ -97,7 +91,6 @@ class StreamManager:
self.stats = {
'segments_fetched': 0,
'segments_served': 0,
'buffer_swaps': 0,
'fetch_errors': 0,
}
@@ -167,9 +160,7 @@ class StreamManager:
with self._buffer_lock:
return {
'active_count': len(self._active_buffer),
'staging_count': len(self._staging_buffer),
'total_plugins': len(self._ordered_plugins),
'current_index': self._current_index,
'prefetch_index': self._prefetch_index,
'stats': self.stats.copy(),
}
@@ -190,8 +181,9 @@ class StreamManager:
"""
Mark a plugin as having updated data.
Called when a plugin's data changes. Triggers content refresh
for that plugin in the staging buffer.
Only records it in ``_pending_updates``. The refetch happens later,
in :meth:`process_updates` (swap mode) or by dropping the plugin's
caches in :meth:`invalidate_pending_updates` (continuous mode).
Args:
plugin_id: Plugin that was updated
@@ -242,11 +234,6 @@ class StreamManager:
)
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:
"""Check if pending updates affect plugins currently in the active buffer."""
with self._buffer_lock:
@@ -309,19 +296,6 @@ class StreamManager:
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:
"""
Refresh the plugin list and content.
@@ -337,38 +311,30 @@ class StreamManager:
self._refresh_plugin_list()
if len(self._ordered_plugins) != old_count:
logger.info(
logger.debug(
"Plugin list refreshed: %d -> %d plugins",
old_count, len(self._ordered_plugins)
)
def _refresh_plugin_list(self) -> None:
"""Refresh the ordered list of plugins from plugin manager."""
logger.info("=" * 60)
logger.info("REFRESHING PLUGIN LIST FOR VEGAS SCROLL")
logger.info("=" * 60)
"""Refresh the ordered list of plugins from plugin manager.
# 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 = []
loaded = 0
if hasattr(self.plugin_manager, 'plugins'):
logger.info(
"Checking %d loaded plugins for Vegas scroll",
len(self.plugin_manager.plugins)
)
loaded = len(self.plugin_manager.plugins)
for plugin_id, plugin in self.plugin_manager.plugins.items():
has_enabled = hasattr(plugin, 'enabled')
is_enabled = getattr(plugin, 'enabled', False)
logger.info(
"[%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)
if not getattr(plugin, 'enabled', False):
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
continue
# Also check display mode - STATIC plugins should be included
# even if their content_type is 'none'
# Content type 'none' is left out, except for STATIC plugins,
# which pause the scroll rather than contributing to it.
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
display_mode = VegasDisplayMode.FIXED_SEGMENT
try:
display_mode = plugin.get_vegas_display_mode()
@@ -379,19 +345,15 @@ class StreamManager:
plugin_id, plugin.__class__.__name__
)
logger.info(
"[%s] content_type=%s, display_mode=%s",
plugin_id, content_type, display_mode.value
included = (content_type != 'none'
or display_mode == VegasDisplayMode.STATIC)
logger.debug(
"[%s] Vegas: %s (content_type=%s, display_mode=%s)",
plugin_id, "included" if included else "excluded",
content_type, display_mode.value
)
if content_type != 'none' or display_mode == VegasDisplayMode.STATIC:
if included:
available_plugins.append(plugin_id)
logger.info("[%s] --> INCLUDED in Vegas scroll", plugin_id)
else:
logger.info("[%s] --> EXCLUDED from Vegas scroll", plugin_id)
else:
logger.info("[%s] --> SKIPPED (not enabled)", plugin_id)
else:
logger.warning(
"plugin_manager does not have plugins attribute: %s",
@@ -400,24 +362,20 @@ class StreamManager:
# Apply ordering from config (outside lock for potentially slow operation)
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)
# Atomically update shared state under lock to avoid races with prefetchers
with self._buffer_lock:
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):
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:
"""Slots per cycle for one plugin.
@@ -490,7 +448,7 @@ class StreamManager:
schedule = self._unclump_seam(schedule)
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)",
len(schedule), len(ordered), boosted)
return schedule
@@ -607,11 +565,6 @@ class StreamManager:
ContentSegment or None if fetch failed
"""
try:
logger.info("=" * 60)
logger.info("[%s] FETCHING CONTENT", plugin_id)
logger.info("=" * 60)
# Get plugin instance
if not hasattr(self.plugin_manager, 'plugins'):
logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id)
return None
@@ -621,18 +574,11 @@ class StreamManager:
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
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
try:
display_mode = plugin.get_vegas_display_mode()
logger.info("[%s] Display mode: %s", plugin_id, display_mode.value)
except (AttributeError, TypeError) as e:
logger.info(
logger.debug(
"[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)",
plugin_id, e
)
@@ -644,21 +590,21 @@ class StreamManager:
segment = ContentSegment(
plugin_id=plugin_id,
images=[], # No images needed for static pause
total_width=0,
display_mode=display_mode
)
self.stats['segments_fetched'] += 1
logger.info(
logger.debug(
"[%s] Created STATIC placeholder (pause trigger)",
plugin_id
)
return segment
# 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)
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
# Calculate total width
@@ -667,17 +613,14 @@ class StreamManager:
segment = ContentSegment(
plugin_id=plugin_id,
images=images,
total_width=total_width,
display_mode=display_mode
)
self.stats['segments_fetched'] += 1
logger.info(
"[%s] SEGMENT CREATED: %d images, %dpx total, mode=%s",
logger.debug(
"[%s] Segment: %d image(s), %dpx, mode=%s",
plugin_id, len(images), total_width, display_mode.value
)
logger.info("=" * 60)
return segment
except Exception:
@@ -696,24 +639,6 @@ class StreamManager:
if len(self._active_buffer) < low_water:
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]]]:
"""
Get buffered content grouped by the plugin that produced it.
@@ -816,8 +741,6 @@ class StreamManager:
"""Reset the stream manager state."""
with self._buffer_lock:
self._active_buffer.clear()
self._staging_buffer.clear()
self._current_index = 0
self._prefetch_index = 0
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
# gets back -- potentially one built against a MagicMock matrix.
dm_mod.DisplayManager._instance = None
dm_mod.DisplayManager._initialized = False
# Put a binding back if a patch outlived the module that started it.
# 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.PLUGIN_DEBUG = false;
window.installedPlugins = installed;
window.escapeHtml = function (text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
require('../led_escape').install(window);
// Load the helper as a real <script>.
const s = document.createElement('script');
@@ -69,7 +64,8 @@ function get(path) {
const s2 = document.createElement('script');
s2.textContent = `
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;
${src.slice(a, b)}
${src.slice(b, c)}
+1
View File
@@ -50,6 +50,7 @@ const get = p => new Promise((res, rej) =>
const s0 = window.document.createElement('script');
s0.textContent = `
window.debugLog = function () {};
${require('../led_escape').source}
window.showNotification = function () {};
window.showError = 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_collision.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',
'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)
//
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
// exactly this. This suite pins the fix at the source: it reads each real
// implementation out of the shipped file and runs it, so an escaper that loses
// its quote handling again fails here rather than in a scanner run weeks later.
// exactly this. The web UI now has one implementation, window.LEDEscape in
// app-early.js, and the old per-file escapers are one-line names for it. This
// 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 path = require('path');
@@ -41,6 +42,7 @@ class FakeEl {
}
global.document = { createElement: () => new FakeEl() };
global.window = global;
const LEDEscape = require('../led_escape').install(window);
// ── source extraction ──────────────────────────────────────────────────────
// 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.
function loadFn(file, opener, name, { method = false } = {}) {
if (file === null) return LEDEscape[name];
const body = extract(file, opener);
// Class/object methods (`escapeHtml(text) {...}`) are not valid statements on
// 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 ───────────────────────────────────────────────
const ESCAPERS = [
['app-early.js (LEDEscape.html)', null, null, 'html'],
['app-early.js (LEDEscape.attr)', null, null, 'attr'],
['base-widget.js (BaseWidget.escapeHtml)',
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
['plugins_manager.js (top-level escapeHtml)',
@@ -90,8 +95,26 @@ const ESCAPERS = [
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
['plugin-file-manager.js (escHtml)',
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
['app-shell.js (escapeHtml)',
'static/v3/js/app-shell.js', 'escapeHtml(text) {', 'escapeHtml', true],
['plugins_manager.js (escapeAttribute)',
'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)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
['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);
}
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) ─────────────────────────
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
// 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
// last, so its copy beats the file-upload widget's) put the uploaded file's
// original name into the markup unescaped.
// handler of its own. (The uploaded-image list is no longer built here: the
// file-upload widget owns it, and test_file_upload_widget.js covers it.)
//
// 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
@@ -54,6 +53,7 @@ global.document = {
createElement: () => new TextEl(),
};
global.window = global;
require('../led_escape').install(window);
global.pluginLog = () => {};
global.isStorePluginInstalled = () => false;
global.isNewPlugin = () => false;
@@ -68,8 +68,6 @@ eval([
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
// eslint-disable-next-line no-eval
eval(extract('window.updateImageList = function(fieldId, images) {'));
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
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);
}
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`);
process.exit(fail ? 1 : 0);
@@ -24,7 +24,7 @@ function slice(startMarker, endMarker) {
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)');
let pass = 0, fail = 0;
+2 -1
View File
@@ -30,12 +30,13 @@ global.document = {
createElement: () => new FakeEl(),
};
global.window = global;
require('../led_escape').install(window);
global.pluginLog = () => {};
global.PLUGIN_DEBUG = false;
global.debugLog = () => {};
function setupInstalledEventDelegation() {} // stubbed; tested separately
eval(slice('function escapeHtml(text)', '\nfunction ', ));
eval(slice('function escapeHtml(text)', '\nfunction isNewPlugin'));
eval(slice('function renderInstalledCards(plugins, total)',
'// 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()
import src.display_controller as dc_module
original_imported = dc_module._vegas_mode_imported
original_class = dc_module.VegasModeCoordinator
try:
dc_module._vegas_mode_imported = True
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
from unittest.mock import patch
with patch('src.vegas_mode.VegasModeCoordinator',
MagicMock(return_value=fake_coordinator)):
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)
-4
View File
@@ -28,7 +28,6 @@ def dm(tmp_path_factory):
"""One real DisplayManager on the emulator (it's a process singleton)."""
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
manager = DisplayManager({
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
@@ -60,7 +59,6 @@ def dm(tmp_path_factory):
"test module.")
yield manager
DisplayManager._instance = None
DisplayManager._initialized = False
class _SwapSpy:
@@ -230,7 +228,6 @@ class TestKillSwitch:
def test_config_flag_wires_through(self):
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
try:
manager = DisplayManager({
"display": {
@@ -243,7 +240,6 @@ class TestKillSwitch:
assert manager._dirty_tracking_enabled is False
finally:
DisplayManager._instance = None
DisplayManager._initialized = False
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_tick_running = False
coord.sync_manager = None
coord._update_static_mode_plugins = lambda: None
coord._check_static_plugin_trigger = lambda: None
coord.frames = 0
+24 -19
View File
@@ -2,7 +2,7 @@
Tests for src/vegas_mode/config.py
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
is_plugin_included, get_ordered_plugins, validate.
get_ordered_plugins, validate.
"""
import pytest
@@ -38,6 +38,29 @@ class TestVegasModeConfigDefaults:
cfg = VegasModeConfig()
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
@@ -147,24 +170,6 @@ class TestGetFrameInterval:
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
# ---------------------------------------------------------------------------
+2 -4
View File
@@ -108,10 +108,10 @@ class TestInvalidatePendingUpdates:
# Left unconsumed they accumulate forever and nothing ever refreshes.
stream = self._manager({'baseball': SimpleNamespace()})
stream.mark_plugin_updated('baseball')
assert stream.has_pending_updates() is True
assert stream._pending_updates
stream.invalidate_pending_updates()
assert stream.has_pending_updates() is False
assert not stream._pending_updates
assert stream.invalidate_pending_updates() == []
def test_no_pending_updates_does_no_work(self):
@@ -133,7 +133,6 @@ class TestInvalidatePendingUpdates:
class TestContinuousModeReachesTheRefresh:
def _pipeline(self):
stream = MagicMock()
stream.get_buffer_status.return_value = {'staging_count': 0}
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
def test_refresh_delegates_to_the_stream_manager(self):
@@ -161,7 +160,6 @@ class TestContinuousModeReachesTheRefresh:
pipeline.refresh_updated_plugins()
assert pipeline.scroll_helper.scroll_position == 1234
stream.swap_buffers.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
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
return ContentSegment(
plugin_id=plugin_id, images=imgs, total_width=10 * count,
plugin_id=plugin_id, images=imgs,
display_mode=mode or VegasDisplayMode.SCROLL)
def test_grouping_preserves_plugin_boundaries(self):
@@ -437,10 +437,6 @@ class TestStreamGrouping:
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
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:
"""
@@ -980,8 +976,8 @@ class TestRotationAcrossMultipleCycles:
"""
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
crashed find_blank_cut in the field and lost that plugin's content for the
cycle. First-pass-only tests never reach it.
once crashed the cut and lost that plugin's content for the cycle.
First-pass-only tests never reach it.
"""
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,
dead_window_stats,
edge_blank,
find_blank_cut,
separation_gap,
trim_to_content,
window_coverage_stats,
@@ -320,51 +319,3 @@ class TestSeparationGap:
# flat gap; measured separation lifts them to the 24px target.
card = paint(make_img(150), 0, 150)
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):
stream_manager = MagicMock()
stream_manager.get_buffer_status.return_value = {'staging_count': 0}
pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager)
pipeline.sync_manager = sync_manager
return pipeline, stream_manager
@@ -37,7 +36,7 @@ class TestShouldRecompose:
stream_manager.has_pending_updates_for_visible_segments.return_value = False
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()
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is False
@@ -49,12 +48,6 @@ class TestShouldRecompose:
stream_manager.has_pending_updates_for_visible_segments.return_value = 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):
"""Sync-mode deferral (PR #330's actual intent) must still hold:
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 */
// 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
document.body.addEventListener('htmx:beforeRequest', function(event) {
@@ -22,9 +53,15 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
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;
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 {
const data = JSON.parse(response.responseText);
if (data.message) {
@@ -48,6 +85,30 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
} 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 =====
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS
// the panel and revisiting re-fetches it, silently discarding any edits.
@@ -242,9 +303,8 @@ window.updateFloatingPreviewVisibility = function(tab) {
if (!panel || !toggle) return;
let active = tab;
if (!active) {
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
const data = el && el._x_dataStack && el._x_dataStack[0];
active = data && data.activeTab;
const app = window.getApp();
active = app && app.activeTab;
}
const onOverview = active === 'overview';
let open = false;
@@ -308,13 +368,9 @@ window.updateNavAriaCurrent = function(tab) {
// Escape closes the mobile nav drawer and returns focus to the hamburger;
// opening the drawer moves focus to its first tab.
(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) {
if (e.key !== 'Escape') return;
const data = appData();
const data = window.getApp();
if (data && data.mobileNavOpen) {
data.mobileNavOpen = false;
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 drawer has slid in (matches the CSS transition timing).
setTimeout(function() {
const data = appData();
const data = window.getApp();
if (data && data.mobileNavOpen) {
const first = document.querySelector('#site-nav .nav-tab');
if (first) first.focus();
+105 -37
View File
@@ -1,8 +1,89 @@
/* 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.
// Helper function to get installed plugins with fallback
// Must be defined before app() function that uses it
/*
* app-early.js -- helpers every other script relies on, and the app() stub.
*
* 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() {
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
try {
@@ -20,32 +101,19 @@
return await response.json();
}
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
// This ensures tabs update when plugins_manager.js loads plugins
// Builds the plugin tab row from a pluginsUpdated event while the app is
// 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) {
const appComponent = window.getApp();
if (appComponent && typeof appComponent._doUpdatePluginTabs === 'function') return;
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
const plugins = event.detail?.plugins || [];
// Update window.installedPlugins
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];
if (appComponent) {
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 pluginTabsNav = pluginTabsRow?.querySelector('nav');
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
@@ -60,15 +128,11 @@
tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab`;
tabButton.onclick = function() {
// Try to set activeTab via Alpine if available
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
appElement._x_dataStack[0].activeTab = plugin.id;
// Only call updatePluginTabStates if it exists
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
appElement._x_dataStack[0].updatePluginTabStates();
}
const app = window.getApp();
if (app) {
app.activeTab = plugin.id;
if (typeof app.updatePluginTabStates === 'function') {
app.updatePluginTabStates();
}
}
};
@@ -89,8 +153,13 @@
// Guard flag to prevent duplicate stub-to-full enhancement
window._appEnhanced = false;
// Define app() function early so Alpine can find it when it initializes
// This is a complete implementation that will work immediately
// The stub app(). If Alpine initialises before app-shell.js has run,
// 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() {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
@@ -349,8 +418,7 @@
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
}, 100);
},
showNotification: function(message, type) {},
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
showNotification: function(message, type) {}
};
};
})();
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
// when Alpine evaluates x-data, permanently failing with
// "wifiSetup is not defined" (Alpine does not retry).
// Disable htmx's own native script re-execution so the
// same script doesn't also run a second time via settle.
if (typeof htmx !== 'undefined' && htmx.config) {
htmx.config.allowScriptTags = false;
}
// htmx's own script handling is switched off here, in the
// handler, rather than once at setup: base.html injects
// htmx with a dynamic <script>, so at setup htmx is usually
// 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) {
htmx.config.allowScriptTags = false;
const target = event.detail && event.detail.target;
if (!target || !(target instanceof Element)) return;
target.querySelectorAll('script').forEach(function(oldScript) {
@@ -1,3 +1,4 @@
/* global debugLog */
/**
* API client for plugin operations.
*
@@ -10,7 +11,6 @@ const RequestThrottler = {
pending: new Map(),
cache: new Map(),
cacheTTL: 5000, // 5 seconds cache for GET requests
debug: false, // Set to true to enable logging
/**
* Throttle a request to prevent rapid-fire calls
@@ -19,23 +19,17 @@ const RequestThrottler = {
// Check cache first
const cached = this.cache.get(key);
if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) {
if (this.debug) {
console.log('[RequestThrottler] Cache hit for:', key);
}
debugLog('[RequestThrottler] Cache hit for:', key);
return cached.data;
}
// Check if request is already pending
if (this.pending.has(key)) {
if (this.debug) {
console.log('[RequestThrottler] Reusing pending request for:', key);
}
debugLog('[RequestThrottler] Reusing pending request for:', key);
return this.pending.get(key);
}
if (this.debug) {
console.log('[RequestThrottler] Creating new request for:', key);
}
debugLog('[RequestThrottler] Creating new request for:', key);
// Create throttled request with abort support
let abortController = null;
@@ -49,16 +43,12 @@ const RequestThrottler = {
data: result,
timestamp: Date.now()
});
if (this.debug) {
console.log('[RequestThrottler] Cached response for:', key);
}
debugLog('[RequestThrottler] Cached response for:', key);
}
resolve(result);
} catch (error) {
// Don't cache errors
if (this.debug) {
console.error('[RequestThrottler] Request failed for:', key, error);
}
debugLog('[RequestThrottler] Request failed for:', key, error);
reject(error);
} finally {
this.pending.delete(key);
@@ -74,9 +64,7 @@ const RequestThrottler = {
// Add abort method if available
if (abortController) {
promise.abort = () => {
if (this.debug) {
console.log('[RequestThrottler] Aborting request for:', key);
}
debugLog('[RequestThrottler] Aborting request for:', key);
abortController.abort();
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
*/
@@ -1,3 +1,4 @@
/* global debugLog */
/*
* settings-search.js — global settings search + per-tab filter for the v3 UI.
*
@@ -162,11 +163,7 @@
// --- Navigation to a setting ---------------------------------------------
function getAppData() {
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
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;
return window.getApp();
}
function setActiveTab(tab) {
@@ -478,5 +475,5 @@
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.
*
@@ -157,5 +158,5 @@
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);
} else {
// Simple notification
if (typeof showNotification === 'function') {
showNotification(fullMessage, 'error');
} else {
console.error('Error:', fullMessage);
alert(fullMessage);
}
}
}
@@ -289,22 +284,7 @@ function closeErrorModal() {
}
}
/**
* 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;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
/**
* Copy error details to clipboard.
@@ -315,16 +295,12 @@ function copyErrorDetails(error) {
const errorText = JSON.stringify(error, null, 2);
function copyFailed(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');
}
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(errorText).then(() => {
if (typeof showNotification === 'function') {
showNotification('Error details copied to clipboard', 'success');
}
}).catch(copyFailed);
} else {
// Fallback for older browsers
@@ -340,9 +316,7 @@ function copyErrorDetails(error) {
textArea.select();
try {
document.execCommand('copy');
if (typeof showNotification === 'function') {
showNotification('Error details copied to clipboard', 'success');
}
} catch (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 escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// The page's shared escaper covers HTML content and quoted attribute
// values. A textContent/innerHTML round trip leaves quotes alone, so it
// is not safe inside value="...".
const escapeHtml = (text) => window.LEDEscape.html(text);
window.LEDMatrixWidgets.register('my-custom-widget', {
name: 'My Custom Widget',
@@ -806,14 +806,13 @@
const file = event.target.files && event.target.files[0];
if (!file) return;
const notifyFn = window.showNotification || console.log;
const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
if (!allowed.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error');
window.showNotification(`File type "${file.type}" not allowed`, 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
notifyFn('File exceeds 5MB limit', 'error');
window.showNotification('File exceeds 5MB limit', 'error');
return;
}
@@ -829,12 +828,12 @@
const path = data.uploaded_files[0].path;
pathInput.value = path;
if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; }
notifyFn('Image uploaded', 'success');
window.showNotification('Image uploaded', 'success');
} else {
throw new Error(data.message || 'Upload failed');
}
} catch (err) {
notifyFn('Upload error: ' + err.message, 'error');
window.showNotification('Upload error: ' + err.message, 'error');
} finally {
event.target.value = '';
}
@@ -853,7 +852,6 @@
addButton.style.opacity = isAtMax ? '0.5' : '';
}
window.updateArrayTableAddButtonState = updateAddButtonState;
window.addArrayTableRow = function(button) {
const fieldId = button.getAttribute('data-field-id');
@@ -874,7 +872,7 @@
const currentRows = tbody.querySelectorAll('.array-table-row').length;
if (currentRows >= maxItems) {
(window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error');
window.showNotification(`Maximum ${maxItems} items allowed`, 'error');
return;
}
@@ -116,29 +116,14 @@
}
/**
* Escape HTML to prevent XSS
* Always escapes the input, even for non-strings, by coercing to string first
*
* The result is safe in text content AND inside quoted attribute values.
* 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)
* Escape text for HTML content or a quoted attribute value.
* Kept for widgets that call it as a method; it is window.LEDEscape.html
* (app-early.js).
* @param {*} text - Text to escape (null/undefined become '')
* @returns {string} Escaped text, safe for text and attribute contexts
*/
escapeHtml(text) {
// Always coerce to string first, then escape
const textStr = String(text);
const div = document.createElement('div');
div.textContent = textStr;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return window.LEDEscape.html(text);
}
/**
@@ -173,9 +158,7 @@
* @returns {Function|null} Notification function or null
*/
getNotificationFunction() {
if (typeof window.showNotification === 'function') {
return window.showNotification;
}
return null;
}
@@ -24,15 +24,10 @@
version: '1.0.0',
/**
* Render the checkbox group widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
* No-op: plugin_config.html renders this widget server-side. The
* registration exists for getValue/setValue and updateCheckboxGroupData.
*/
render: function(container, config, value, options) {
// 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)');
},
render: function() {},
/**
* Get current value from widget
@@ -25,12 +25,7 @@
const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -24,15 +24,10 @@
version: '1.0.0',
/**
* Render the custom feeds widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
* No-op: plugin_config.html renders this widget server-side. The
* registration exists for getValue/setValue and the window.* feed-table handlers.
*/
render: function(container, config, value, options) {
// 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)');
},
render: function() {},
/**
* Get current value from widget
@@ -246,8 +241,7 @@
const currentRows = tbody.querySelectorAll('.custom-feed-row');
if (currentRows.length >= maxItems) {
const notifyFn = window.showNotification || alert;
notifyFn(`Maximum ${maxItems} feeds allowed`, 'error');
window.showNotification(`Maximum ${maxItems} feeds allowed`, 'error');
return;
}
@@ -507,14 +501,12 @@
logoCell.appendChild(container);
}
} else {
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
window.showNotification('Upload failed: ' + (data.message || 'Unknown error'), 'error');
}
})
.catch(error => {
console.error('Upload error:', error);
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + error.message, 'error');
window.showNotification('Upload failed: ' + error.message, 'error');
})
.finally(() => {
// 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;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -50,12 +50,7 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('DaySelector', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -24,12 +24,7 @@
const base = window.BaseWidget ? new window.BaseWidget('EmailInput', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -33,12 +33,7 @@
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -199,7 +194,6 @@
const safeId = sanitizeId(fieldId);
const fileInput = document.getElementById(`${safeId}_file_input`);
const statusDiv = document.getElementById(`${safeId}_status`);
const notifyFn = window.showNotification || console.log;
// Read config from the file input data attributes
const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
@@ -208,19 +202,19 @@
.split(',').map(t => t.trim());
if (!pluginId) {
notifyFn('Plugin ID not set — cannot upload', 'error');
window.showNotification('Plugin ID not set — cannot upload', 'error');
return;
}
// Validate type
if (!allowedTypes.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error');
window.showNotification(`File type "${file.type}" not allowed`, 'error');
return;
}
// Validate size
if (file.size > maxSizeMb * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error');
window.showNotification(`File exceeds ${maxSizeMb}MB limit`, 'error');
return;
}
@@ -266,7 +260,7 @@
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
}
notifyFn('Image uploaded successfully', 'success');
window.showNotification('Image uploaded successfully', 'success');
} else {
throw new Error(data.message || 'Upload failed');
}
@@ -279,7 +273,7 @@
statusDiv.appendChild(errIcon);
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
}
notifyFn(`Upload error: ${error.message}`, 'error');
window.showNotification(`Upload error: ${error.message}`, 'error');
} finally {
if (fileInput) fileInput.value = '';
}
+224 -360
View File
@@ -24,16 +24,11 @@
version: '1.0.0',
/**
* Render the file upload widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
* Future enhancement: Full client-side rendering support.
* No-op: plugin_config.html renders this widget server-side. The
* registration exists for getValue/setValue and the window.* handlers
* below.
*/
render: function(container, config, value, options) {
// 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)');
},
render: function() {},
/**
* Get current value from widget
@@ -142,24 +137,23 @@
.split(',').map(e => e.trim().toLowerCase());
const statusDiv = document.getElementById(`${fieldId}_upload_status`);
const notifyFn = window.showNotification || console.log;
// Guard: endpoint must be configured
if (!uploadEndpoint) {
notifyFn('No upload endpoint configured for this field', 'error');
window.showNotification('No upload endpoint configured for this field', 'error');
return;
}
// Validate extension
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(fileExt)) {
notifyFn(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
window.showNotification(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
return;
}
// Validate size
if (file.size > maxSizeMB * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMB}MB limit`, 'error');
window.showNotification(`File exceeds ${maxSizeMB}MB limit`, 'error');
return;
}
@@ -198,7 +192,7 @@
// Update hidden input with the target filename
const hiddenInput = document.getElementById(fieldId);
if (hiddenInput) hiddenInput.value = targetFilename;
notifyFn(`${targetFilename} uploaded successfully`, 'success');
window.showNotification(`${targetFilename} uploaded successfully`, 'success');
} else {
if (statusDiv) {
statusDiv.className = 'mt-2 text-xs text-red-600';
@@ -208,7 +202,7 @@
statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`));
}
notifyFn(`Upload failed: ${data.message}`, 'error');
window.showNotification(`Upload failed: ${data.message}`, 'error');
}
} catch (error) {
if (statusDiv) {
@@ -219,7 +213,7 @@
statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`));
}
notifyFn(`Upload error: ${error.message}`, 'error');
window.showNotification(`Upload error: ${error.message}`, 'error');
} finally {
const fileInput = document.getElementById(`${fieldId}_file_input`);
if (fileInput) fileInput.value = '';
@@ -249,23 +243,20 @@
const validFiles = [];
for (const file of files) {
if (file.size > maxSizeMB * 1024 * 1024) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
window.showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
continue;
}
if (fileType === 'json') {
// Validate JSON files
if (!file.name.toLowerCase().endsWith('.json')) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} must be a JSON file (.json)`, 'error');
window.showNotification(`File ${file.name} must be a JSON file (.json)`, 'error');
continue;
}
} else {
// Validate image files using allowedTypes from config
if (!allowedTypes.includes(file.type)) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} is not a valid image type`, 'error');
window.showNotification(`File ${file.name} is not a valid image type`, 'error');
continue;
}
}
@@ -275,8 +266,7 @@
// Check max files AFTER building validFiles
if (currentFiles.length + validFiles.length > maxFiles) {
const notifyFn = window.showNotification || console.error;
notifyFn(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
window.showNotification(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
return;
}
@@ -317,16 +307,13 @@
window.updateImageList(fieldId, newFiles);
}
const notifyFn = window.showNotification || console.log;
notifyFn(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
window.showNotification(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
} else {
const notifyFn = window.showNotification || console.error;
notifyFn(`Upload failed: ${data.message}`, 'error');
window.showNotification(`Upload failed: ${data.message}`, 'error');
}
} catch (error) {
console.error('Upload error:', error);
const notifyFn = window.showNotification || console.error;
notifyFn(`Upload error: ${error.message}`, 'error');
window.showNotification(`Upload error: ${error.message}`, 'error');
} finally {
if (window.hideUploadProgress) {
window.hideUploadProgress(fieldId);
@@ -394,16 +381,13 @@
window.updateImageList(fieldId, newFiles);
}
const notifyFn = window.showNotification || console.log;
notifyFn(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
window.showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
} else {
const notifyFn = window.showNotification || console.error;
notifyFn(`Delete failed: ${data.message}`, 'error');
window.showNotification(`Delete failed: ${data.message}`, 'error');
}
} catch (error) {
console.error('Delete error:', error);
const notifyFn = window.showNotification || console.error;
notifyFn(`Delete error: ${error.message}`, 'error');
window.showNotification(`Delete error: ${error.message}`, 'error');
}
};
@@ -492,9 +476,18 @@
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
* Uses DOM creation to prevent XSS and preserves open schedule editors
* Replace the image list: writes the hidden input the form saves and
* re-renders the cards. A schedule editor that was open stays open,
* rebuilt from the new data.
* @param {string} fieldId - Field ID
* @param {Array} images - Array of image objects
*/
@@ -504,63 +497,40 @@
hiddenInput.value = JSON.stringify(images);
}
// Update the display
const imageList = document.getElementById(`${fieldId}_image_list`);
if (!imageList) return;
const uploadConfig = window.getUploadConfig(fieldId);
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
// Detect which schedule is currently open (if any)
const openScheduleId = (() => {
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;
})();
const openEditor = imageList.querySelector('[id^="schedule_"]:not(.hidden)');
const openScheduleId = openEditor ? openEditor.id.slice('schedule_'.length) : 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 = '';
images.forEach((img, idx) => {
const imgId = img.id || idx;
const sanitizedId = String(imgId).replace(/[^a-zA-Z0-9_-]/g, '_');
const imgSchedule = img.schedule || {};
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
const domId = imageDomId(imgId);
const label = img.original_filename || img.filename || '';
// Create container 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';
// Create main content div
const mainDiv = document.createElement('div');
mainDiv.className = 'flex items-center justify-between mb-2';
// Create left section with image and info
const leftSection = document.createElement('div');
leftSection.className = 'flex items-center space-x-3 flex-1';
// Create image element
const imgEl = document.createElement('img');
const imgPath = String(img.path || '').replace(/^\/+/, '');
imgEl.src = '/' + imgPath;
// A stored path names a file under the project root. Encoding each
// 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.loading = 'lazy';
imgEl.decoding = 'async';
imgEl.className = 'w-16 h-16 object-cover rounded';
imgEl.addEventListener('error', function() {
this.style.display = 'none';
@@ -569,7 +539,7 @@
}
});
// Create placeholder div for broken images
// Shown in place of a thumbnail that fails to load
const placeholderDiv = document.createElement('div');
placeholderDiv.style.display = 'none';
placeholderDiv.className = 'w-16 h-16 bg-gray-200 rounded flex items-center justify-center';
@@ -577,29 +547,20 @@
placeholderIcon.className = 'fas fa-image text-gray-400';
placeholderDiv.appendChild(placeholderIcon);
// Create info div
const infoDiv = document.createElement('div');
infoDiv.className = 'flex-1 min-w-0';
// Filename
const filenameP = document.createElement('p');
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');
sizeDateP.className = 'text-xs text-gray-500';
const fileSize = window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB');
const uploadedDate = window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '');
sizeDateP.textContent = `${fileSize} • ${uploadedDate}`;
sizeDateP.textContent = `${window.formatFileSize(img.size || 0)} • ${window.formatDate(img.uploaded_at)}`;
// Schedule summary
const scheduleP = document.createElement('p');
scheduleP.className = 'text-xs text-blue-600 mt-1';
const clockIcon = document.createElement('i');
clockIcon.className = 'fas fa-clock mr-1';
scheduleP.appendChild(clockIcon);
scheduleP.appendChild(document.createTextNode(scheduleSummary));
scheduleP.className = 'text-xs text-blue-600 mt-1 image-schedule-summary';
renderScheduleSummary(scheduleP, img.schedule);
infoDiv.appendChild(filenameP);
infoDiv.appendChild(sizeDateP);
@@ -609,39 +570,34 @@
leftSection.appendChild(placeholderDiv);
leftSection.appendChild(infoDiv);
// Create right section with buttons
const rightSection = document.createElement('div');
rightSection.className = 'flex items-center space-x-2 ml-4';
// Schedule button
const scheduleBtn = document.createElement('button');
scheduleBtn.type = 'button';
scheduleBtn.className = 'text-blue-600 hover:text-blue-800 p-2';
scheduleBtn.title = 'Schedule this image';
scheduleBtn.setAttribute('aria-label', `Schedule image ${label}`);
scheduleBtn.dataset.fieldId = fieldId;
scheduleBtn.dataset.imageId = String(imgId);
scheduleBtn.dataset.imageIdx = String(idx);
scheduleBtn.addEventListener('click', function() {
window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId, parseInt(this.dataset.imageIdx, 10));
});
const scheduleIcon = document.createElement('i');
scheduleIcon.className = 'fas fa-calendar-alt';
scheduleBtn.appendChild(scheduleIcon);
scheduleBtn.appendChild(iconEl('fas fa-calendar-alt'));
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'text-red-600 hover:text-red-800 p-2';
deleteBtn.title = 'Delete image';
deleteBtn.setAttribute('aria-label', `Delete image ${label}`);
deleteBtn.dataset.fieldId = fieldId;
deleteBtn.dataset.imageId = String(imgId);
deleteBtn.dataset.pluginId = pluginId;
deleteBtn.addEventListener('click', function() {
window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId);
});
const deleteIcon = document.createElement('i');
deleteIcon.className = 'fas fa-trash';
deleteBtn.appendChild(deleteIcon);
deleteBtn.appendChild(iconEl('fas fa-trash'));
rightSection.appendChild(scheduleBtn);
rightSection.appendChild(deleteBtn);
@@ -649,23 +605,53 @@
mainDiv.appendChild(leftSection);
mainDiv.appendChild(rightSection);
// Create schedule container
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';
// 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(scheduleContainer);
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
* @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|number} imageId - Image ID
* @param {string|number} imageId - Image ID (the index when the image has none)
* @param {number} imageIdx - Image index
*/
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
const idx = Number(imageIdx);
const image = Number.isInteger(idx) && idx >= 0 ? window.getCurrentImages(fieldId).at(idx) : undefined;
if (!image) return;
// Sanitize imageId to match updateImageList's sanitization
const sanitizedId = (imageId || imageIdx).toString().replace(/[^a-zA-Z0-9_-]/g, '_');
const scheduleContainer = document.getElementById(`schedule_${sanitizedId}`);
const scheduleContainer = document.getElementById(`schedule_${imageDomId(imageId || imageIdx)}`);
if (!scheduleContainer) return;
// Toggle visibility
const isVisible = !scheduleContainer.classList.contains('hidden');
if (isVisible) {
if (!scheduleContainer.classList.contains('hidden')) {
scheduleContainer.classList.add('hidden');
return;
}
renderScheduleEditor(scheduleContainer, fieldId, imageId || imageIdx, imageIdx, image.schedule);
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>
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
<!-- 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>
// 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;
}
<!-- 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>
function displayStyle(visible, shown) {
return `display: ${visible ? shown : 'none'};`;
}
<!-- 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>
function scheduleModeOption(schedule, value, label) {
return h('option', { value: value, selected: schedule.mode === value }, [label]);
}
<!-- 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 => {
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' };
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 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),
]),
]);
}
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);
});
// 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),
]),
]),
]));
}
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);
});
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;
}
});
};
/**
* Toggle image schedule enabled state
*/
window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const checkbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
const domId = imageDomId(imageId);
const checkbox = document.getElementById(`schedule_enabled_${domId}`);
const enabled = checkbox ? checkbox.checked : false;
if (!image.schedule) {
image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
}
image.schedule.enabled = enabled;
const optionsDiv = document.getElementById(`schedule_options_${sanitizedId}`);
const optionsDiv = document.getElementById(`schedule_options_${domId}`);
if (optionsDiv) {
optionsDiv.style.display = enabled ? 'block' : 'none';
}
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
commitScheduleEdit(fieldId, currentImages, imageId);
};
/**
* Update image schedule mode
*/
window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const domId = imageDomId(imageId);
if (!image.schedule) {
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';
image.schedule.mode = mode;
const timeRangeDiv = document.getElementById(`time_range_${sanitizedId}`);
const perDayDiv = document.getElementById(`per_day_${sanitizedId}`);
const timeRangeDiv = document.getElementById(`time_range_${domId}`);
const perDayDiv = document.getElementById(`per_day_${domId}`);
if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none';
if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none';
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
commitScheduleEdit(fieldId, currentImages, imageId);
};
/**
* Update image schedule time
*/
window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const domId = imageDomId(imageId);
if (!image.schedule) {
image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' };
}
const startInput = document.getElementById(`schedule_start_${sanitizedId}`);
const endInput = document.getElementById(`schedule_end_${sanitizedId}`);
const startInput = document.getElementById(`schedule_start_${domId}`);
const endInput = document.getElementById(`schedule_end_${domId}`);
if (startInput) image.schedule.start_time = startInput.value || '08:00';
if (endInput) image.schedule.end_time = endInput.value || '18:00';
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
commitScheduleEdit(fieldId, currentImages, imageId);
};
/**
* Update image schedule day
*/
window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const domId = imageDomId(imageId);
if (!image.schedule) {
image.schedule = { enabled: true, mode: 'per_day', days: {} };
}
if (!image.schedule.days) {
image.schedule.days = {};
}
const checkbox = document.getElementById(`day_${day}_${sanitizedId}`);
const startInput = document.getElementById(`day_${day}_start_${sanitizedId}`);
const endInput = document.getElementById(`day_${day}_end_${sanitizedId}`);
const checkbox = document.getElementById(`day_${day}_${domId}`);
const startInput = document.getElementById(`day_${day}_start_${domId}`);
const endInput = document.getElementById(`day_${day}_end_${domId}`);
const enabled = checkbox ? checkbox.checked : true;
if (!image.schedule.days[day]) {
image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' };
}
image.schedule.days[day].enabled = enabled;
if (startInput) image.schedule.days[day].start_time = startInput.value || '08: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) {
dayTimesDiv.style.display = enabled ? 'grid' : 'none';
}
if (startInput) startInput.disabled = !enabled;
if (endInput) endInput.disabled = !enabled;
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
commitScheduleEdit(fieldId, currentImages, imageId);
};
})();
@@ -31,12 +31,7 @@
let fontCatalogCache = null;
let fontCatalogPromise = null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -152,10 +152,8 @@
if (selectedIds.length === 0) {
checkbox.checked = true;
selectedIds.push(cal.id);
if (window.showNotification) {
window.showNotification('At least one calendar must be selected.', 'warning');
}
}
syncHiddenAndSummary();
});
@@ -185,11 +183,5 @@
container.appendChild(p);
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeHtml(str) { return window.LEDEscape.html(str); }
})();
@@ -760,11 +760,7 @@
}
_notify(msg, type) {
if (typeof window.showNotification === 'function') {
window.showNotification(msg, type || 'info');
} else {
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
}
}
_busy(btn, label) {
@@ -785,15 +781,8 @@
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) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return window.LEDEscape.html(str);
}
_fmtSize(bytes) {
@@ -1,10 +1,13 @@
/* global debugLog */
/**
* LEDMatrix Notification Widget
*
* Global notification/toast system for displaying messages to users.
* This is the single implementation: the early fallbacks in app-shell.js,
* app.js and partials/fonts.html only queue messages until this widget has
* loaded (see window.__pendingNotifications) and then delegate to it.
* This is the single implementation. app-shell.js defines a stand-in first
* (it runs before every other script that notifies), which queues messages
* 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:
* window.showNotification('Message here', 'success');
@@ -158,16 +161,7 @@
setTimeout(() => { region.textContent = text; }, 50);
}
/**
* 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 escapeHtml(text) { return window.LEDEscape.html(text); }
function clearTimer(notificationId) {
const t = timers.get(notificationId);
@@ -360,8 +354,7 @@
});
}
// Log for debugging
console.log(`[${type.toUpperCase()}]`, message);
debugLog(`[${type.toUpperCase()}]`, message);
return notificationId;
}
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('NumberInput', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -235,20 +235,10 @@
}
function notify(msg, type) {
if (window.showNotification) window.showNotification(msg, type);
else console.log(`[PFM][${type}] ${msg}`);
window.showNotification(msg, type);
}
// Quotes too: the result lands in quoted attribute values (id=, value=,
// 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 escHtml(s) { return window.LEDEscape.html(s); }
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
@@ -745,13 +735,12 @@
window._pfmUpload = async function (fieldId, file) {
const st = getState(fieldId);
const notifyFn = window.showNotification || console.log;
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;
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, {
filename: file.name, content
@@ -33,12 +33,7 @@
const base = window.BaseWidget ? new window.BaseWidget('RadioGroup', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -1,3 +1,4 @@
/* global debugLog */
/**
* LEDMatrix Widget Registry
*
@@ -183,7 +184,7 @@
const removed = this._widgets.delete(widgetName);
this._handlers.delete(widgetName);
if (removed) {
console.log(`[WidgetRegistry] Unregistered widget: ${widgetName}`);
debugLog(`[WidgetRegistry] Unregistered widget: ${widgetName}`);
}
return removed;
},
@@ -194,7 +195,7 @@
clear: function() {
this._widgets.clear();
this._handlers.clear();
console.log('[WidgetRegistry] Cleared all widgets');
debugLog('[WidgetRegistry] Cleared all widgets');
}
};
@@ -54,12 +54,7 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('SelectDropdown', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function 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;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
// Escape for use in HTML attributes (also escapes quotes)
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(text) { return window.LEDEscape.attr(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -29,12 +29,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TextInput', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('Textarea', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -25,12 +25,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -32,12 +32,7 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('TimeRange', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -23,12 +23,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TimezoneSelector', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('ToggleSwitch', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('UrlInput', '1.0.0') : null;
function escapeHtml(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 escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
File diff suppressed because it is too large Load Diff
+88 -172
View File
@@ -168,54 +168,109 @@
</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>
function loadPluginsDirect() {
const content = document.getElementById('plugins-content');
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
debugLog('Loading plugins directly via fetch (HTMX fallback)...');
(function() {
const PANEL_LABELS = {
'overview-content': 'the Overview',
'wifi-content': 'WiFi setup',
'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 timeout = setTimeout(() => controller.abort(), 10000);
fetch('/v3/partials/plugins', { signal: controller.signal })
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text();
})
.then(html => {
clearTimeout(timeout);
content.innerHTML = html;
if (typeof htmx !== 'undefined') {
htmx.process(content);
}
// Trigger full initialization chain
el.innerHTML = html;
el.querySelectorAll('script').forEach(oldScript => {
const script = document.createElement('script');
for (const attr of oldScript.attributes) script.setAttribute(attr.name, attr.value);
script.textContent = oldScript.textContent;
oldScript.replaceWith(script);
});
el.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') htmx.process(el);
if (window.Alpine) window.Alpine.initTree(el);
if (id === 'plugins-content' && window.initPluginsPage) {
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
if (window.initPluginsPage) {
window.initPluginsPage();
}
})
.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);
console.error('Failed to load plugins:', err);
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>';
el.removeAttribute('data-direct-loading');
});
};
function activeTab() {
const appEl = document.querySelector('[x-data="app()"]');
try {
return (window.Alpine && appEl) ? window.Alpine.$data(appEl).activeTab : null;
} catch (e) {
return null; // Alpine has not initialised the app yet
}
}
// Fallback if HTMX doesn't load within 5 seconds
var _pluginsFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab,
// so it's ready when the user navigates to it
loadPluginsDirect();
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);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
// 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>
<!-- 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>
@@ -246,45 +301,6 @@
});
</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
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
<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) -->
<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">
<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="nav-tab">
<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">
<!-- Overview tab -->
<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="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -602,65 +618,6 @@
</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 -->
<div x-show="activeTab === 'general'" x-transition>
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="loadtab" hx-swap="innerHTML">
@@ -683,7 +640,7 @@
hx-get="/v3/partials/wifi"
hx-trigger="loadtab"
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="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -697,42 +654,6 @@
</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 -->
<div x-show="activeTab === 'schedule'" x-transition>
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="loadtab" hx-swap="innerHTML">
@@ -806,7 +727,7 @@
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'" x-transition>
<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="bg-white rounded-lg shadow p-6">
<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. -->
<script src="{{ widgets_bundle_url() }}" defer></script>
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
window.* implementations for several plugin actions (installPlugin,
uninstallPlugin, executePluginAction, ...) and intentionally wins
over same-named definitions in js/app.js and js/app-shell.js. Do not
remove or reorder without resolving that overlap first. -->
<!-- plugins_manager.js: loaded last. It uses PluginAPI, ListFilter,
LEDDialog, the widgets and app-shell.js's updatePlugin at load time
or when a card is clicked, so it must follow all of them. No other
script defines the same globals. -->
<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
@@ -1147,16 +1067,12 @@
'services to run the new code');
}
}
if (typeof showNotification === 'function') {
showNotification(data.message || 'Update complete', data.status || 'success');
}
})
.catch(function() {
btn.innerHTML = originalHTML;
btn.disabled = false;
if (typeof showNotification === 'function') {
showNotification('Update failed — check your connection', 'error');
}
});
};
@@ -121,11 +121,7 @@
let inspectedFile = null;
function notify(message, kind) {
if (typeof showNotification === 'function') {
showNotification(message, kind || 'info');
} else {
console.log('[backup]', kind || 'info', message);
}
}
function formatSize(bytes) {
@@ -136,11 +132,7 @@
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function escapeHtml(value) { return window.LEDEscape.html(value); }
async function loadPreview() {
const el = document.getElementById('export-preview');
+7 -23
View File
@@ -55,13 +55,12 @@
</div>
<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() {
loadCacheFiles();
// Event listeners
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
})();
loadCacheFiles();
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
function loadCacheFiles() {
const tbody = document.getElementById('cache-files-tbody');
@@ -190,21 +189,15 @@ function deleteCacheFile(key) {
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
if (typeof showNotification !== 'undefined') {
showNotification(data.message || 'Cache file deleted successfully', 'success');
}
// Reload cache files list
loadCacheFiles();
} else {
if (typeof showNotification !== 'undefined') {
showNotification(data.message || 'Failed to delete cache file', 'error');
}
}
})
.catch(error => {
if (typeof showNotification !== 'undefined') {
showNotification('Error deleting cache file: ' + error.message, 'error');
}
});
}
@@ -220,17 +213,8 @@ function showError(message) {
errorMessage.textContent = message;
}
// Utility function to escape HTML
// 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;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
// Make deleteCacheFile available globally for onclick handlers
window.deleteCacheFile = deleteCacheFile;
})();
</script>
@@ -35,7 +35,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
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"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
@@ -863,62 +863,12 @@ document.getElementById('brightness').addEventListener('input', function() {
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
(function() {
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
// Escape for use in HTML attributes
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(text) { return window.LEDEscape.attr(text); }
// Toggle settings visibility
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
@@ -11,7 +11,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
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"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
@@ -164,21 +164,6 @@ function initializeFontsTab() {
// Reset retry counter on successful init
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...');
initializeFontManagement();
@@ -349,14 +334,7 @@ async function loadFontData() {
// Show error states
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
// Only show notification if showNotification is available
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-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="
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);
"
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Settings saved', 'Failed to save settings')"
class="space-y-6">
{# Marks this post as the whole form, so an unchecked box saves as
false. Without it the save endpoint treats absent keys as unchanged
+7 -23
View File
@@ -126,8 +126,10 @@
</div>
<script>
// Global variables - use window properties to avoid redeclaration errors with HTMX reloads
// Initialize only if not already defined
// Scoped: this script runs at global scope after every HTMX swap, and the
// 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') {
window._logsEventSource = null;
}
@@ -624,9 +626,7 @@ function toggleRealtime() {
function refreshLogs() {
loadLogs();
if (typeof showNotification !== 'undefined') {
showNotification('Logs refreshed', 'success');
}
}
function clearLogs() {
@@ -637,16 +637,12 @@ function clearLogs() {
}
showEmptyState();
updateLogStats();
if (typeof showNotification !== 'undefined') {
showNotification('Logs cleared', 'info');
}
}
function downloadLogs() {
if (window._filteredLogs.length === 0) {
if (typeof showNotification !== 'undefined') {
showNotification('No logs to download', 'warning');
}
return;
}
@@ -662,9 +658,7 @@ function downloadLogs() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (typeof showNotification !== 'undefined') {
showNotification('Logs downloaded', 'success');
}
}
// 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
// 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 escapeHtml(text) { return window.LEDEscape.html(text); }
function refreshCurrentPluginStatus() {
fetch('/api/v3/display/current-status')
@@ -920,15 +905,13 @@ function clearPluginErrors() {
})
.then(response => response.json())
.then(data => {
if (typeof showNotification !== 'undefined') {
showNotification(data.status === 'success' ? 'Plugin errors cleared'
: (data.message || 'Could not clear plugin errors'),
data.status === 'success' ? 'success' : 'error');
}
refreshPluginErrors();
})
.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;
}
});
})();
</script>
@@ -260,18 +260,10 @@
return 'bg-gray-100 text-gray-800';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function showError(message) {
if (typeof showNotification === 'function') {
showNotification(message, 'error');
} else {
alert(message);
}
}
// Populate plugin filter
@@ -216,11 +216,7 @@
if (nowDone) maybeAutoHide();
return;
}
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
// 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));
var data = window.getApp();
if (data) {
data.activeTab = btn.dataset.tab;
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
@@ -443,7 +439,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}'
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">
<i class="fas fa-play mr-2"></i>
Start Display
@@ -452,7 +448,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
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">
<i class="fas fa-stop mr-2"></i>
Stop Display
@@ -462,7 +458,7 @@
hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?"
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">
<i class="fas fa-download mr-2"></i>
Update Code
@@ -472,7 +468,7 @@
hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?"
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">
<i class="fas fa-power-off mr-2"></i>
Reboot System
@@ -482,7 +478,7 @@
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-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">
<i class="fas fa-power-off mr-2"></i>
Shutdown System
@@ -491,7 +487,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}'
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">
<i class="fas fa-redo mr-2"></i>
Restart Display Service
@@ -500,7 +496,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}'
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">
<i class="fas fa-redo mr-2"></i>
Restart Web Service
@@ -423,11 +423,10 @@
{% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %}
{% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %}
</p>
{% if has_schedule %}
<p class="text-xs text-blue-600 mt-1">
<i class="fas fa-clock mr-1"></i>Scheduled
{# file-upload.js rewrites .image-schedule-summary in place when the schedule is edited #}
<p class="text-xs text-blue-600 mt-1 image-schedule-summary">
<i class="fas fa-clock mr-1"></i>{{ 'Scheduled' if has_schedule else 'Always shown' }}
</p>
{% endif %}
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
@@ -540,18 +540,6 @@
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 {
@@ -148,7 +148,7 @@
fieldId: 'schedule'
});
console.log('[Schedule] Schedule picker widget initialized');
debugLog('[Schedule] Schedule picker widget initialized');
}
// Handle form submission response
@@ -165,28 +165,7 @@
const type = response.status || 'info';
// Use global notification function if available
if (typeof window.showNotification === 'function') {
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
@@ -265,7 +244,7 @@
fieldId: 'dim_schedule'
});
console.log('[DimSchedule] Dim schedule picker widget initialized');
debugLog('[DimSchedule] Dim schedule picker widget initialized');
}
// Handle dim schedule form submission response
@@ -282,28 +261,7 @@
const type = response.status || 'info';
// Use global notification function if available
if (typeof window.showNotification === 'function') {
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
@@ -344,21 +344,15 @@ function forceRenderStarlarkApp(appId) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof showNotification === 'function') {
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
} else {
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
}
} else {
var msg = 'Render failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Render failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -371,22 +365,20 @@ function toggleStarlarkApp(appId, enabled) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
// A failed reload has already been reported by loadInstalledPlugins.
window.pluginManager.loadInstalledPlugins(true).catch(function() {});
var container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) {
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
}
} else {
var msg = 'Toggle failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Toggle failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -436,8 +428,7 @@ function saveStarlarkConfig(appId) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof showNotification === 'function') showNotification('Configuration saved!', 'success');
else alert('Configuration saved!');
showNotification('Configuration saved!', 'success');
// Reload partial to reflect updated status
var container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) {
@@ -445,14 +436,12 @@ function saveStarlarkConfig(appId) {
}
} else {
var msg = 'Save failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Save failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -435,9 +435,7 @@
el.innerHTML = html;
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escHtml(s) { return window.LEDEscape.html(s); }
// ── main action dispatcher ────────────────────────────────────────────────
@@ -910,11 +908,7 @@
};
// ── plugin health panel ──────────────────────────────────────────────────
function phEscape(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function phEscape(s) { return window.LEDEscape.html(s); }
function phFmtSecs(v) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(3) + 's';