mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
Merge branch 'claude/frame-timing-harness' into claude/scan-order-compensation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+31
-71
@@ -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]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 () {};
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
*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);
|
||||
@@ -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}: " input stays inert`, out === '&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'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) === '""');
|
||||
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("'", "'")
|
||||
// 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 (/['"`]"['"`]|['"`]'['"`]/.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');
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
|
||||
@@ -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([['&', '&'], ['<', '<'], ['>', '>'], ['"', '"'], ["'", ''']]);
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
showNotification: function(message, type) {}
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
+177
-1414
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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 = '';
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||
|
||||
function sanitizeId(id) {
|
||||
if (base) return base.sanitizeId(id);
|
||||
|
||||
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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
@@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
function escapeHtml(value) { return window.LEDEscape.html(value); }
|
||||
|
||||
async function loadPreview() {
|
||||
const el = document.getElementById('export-preview');
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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,10 +626,8 @@ function toggleRealtime() {
|
||||
|
||||
function refreshLogs() {
|
||||
loadLogs();
|
||||
if (typeof showNotification !== 'undefined') {
|
||||
showNotification('Logs refreshed', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
window._allLogs = [];
|
||||
@@ -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,10 +658,8 @@ 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
|
||||
if (typeof window._isUserNearBottom === 'undefined') {
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function phEscape(s) { return window.LEDEscape.html(s); }
|
||||
function phFmtSecs(v) {
|
||||
if (typeof v !== 'number' || !isFinite(v)) return '—';
|
||||
return v.toFixed(3) + 's';
|
||||
|
||||
Reference in New Issue
Block a user