mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
Merge commit 'b9416ef8' into claude/frame-timing-harness
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.
|
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
|
||||||
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
|
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
|
||||||
|
|
||||||
|
- Web UI:
|
||||||
|
- Cache tab errors no longer show up in the Logs tab.
|
||||||
|
- A tab that fails to load shows "Try again" instead of a skeleton that never goes away.
|
||||||
|
- Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen.
|
||||||
|
- The image schedule button works on uploaded images, and the editor stays open while you edit.
|
||||||
|
- A failed plugin toggle moves the switch back.
|
||||||
|
- Each save shows one notification; a failed Durations save says it failed.
|
||||||
|
- Stats the server can't read show `--`.
|
||||||
|
- New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
|
||||||
|
|
||||||
|
- Display and Vegas:
|
||||||
|
- Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once.
|
||||||
|
- The display controller stops Vegas mode on shutdown.
|
||||||
|
- Startup validation warnings are logged once, not twice.
|
||||||
|
- Vegas logs one INFO line per plugin-list refresh.
|
||||||
|
- `run.py -d` shows `display_manager` debug output.
|
||||||
|
- Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`.
|
||||||
|
|
||||||
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
|
||||||
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
|
||||||
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
|
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ if args.emulator:
|
|||||||
print("Using pygame/RGBMatrixEmulator for display")
|
print("Using pygame/RGBMatrixEmulator for display")
|
||||||
print("Press ESC to exit\n")
|
print("Press ESC to exit\n")
|
||||||
|
|
||||||
# Project directory already added above
|
|
||||||
|
|
||||||
# Debug output (only in debug mode or emulator mode)
|
# Debug output (only in debug mode or emulator mode)
|
||||||
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
|
debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true'
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
|
|||||||
+15
-38
@@ -238,20 +238,9 @@ class ScrollHelper:
|
|||||||
self.cached_image = full_image
|
self.cached_image = full_image
|
||||||
# Convert to numpy array for fast operations
|
# Convert to numpy array for fast operations
|
||||||
self.cached_array = np.array(full_image)
|
self.cached_array = np.array(full_image)
|
||||||
|
|
||||||
# Use actual image width instead of calculated width to ensure accuracy
|
|
||||||
# This fixes cases where width calculation doesn't match actual positioning
|
|
||||||
actual_image_width = full_image.width
|
actual_image_width = full_image.width
|
||||||
self.total_scroll_width = actual_image_width
|
self.total_scroll_width = actual_image_width
|
||||||
|
|
||||||
# Log if there's a mismatch (indicating a bug in width calculation)
|
|
||||||
if actual_image_width != total_width:
|
|
||||||
self.logger.warning(
|
|
||||||
"Width calculation mismatch: calculated=%dpx, actual=%dpx (diff=%dpx). "
|
|
||||||
"Using actual width for scroll calculations.",
|
|
||||||
total_width, actual_image_width, abs(actual_image_width - total_width)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.scroll_position = 0.0
|
self.scroll_position = 0.0
|
||||||
self.total_distance_scrolled = 0.0
|
self.total_distance_scrolled = 0.0
|
||||||
self.scroll_complete = False
|
self.scroll_complete = False
|
||||||
@@ -339,10 +328,8 @@ class ScrollHelper:
|
|||||||
# gained. This is what the one visibly smooth scroller on the
|
# gained. This is what the one visibly smooth scroller on the
|
||||||
# hardware (the stock ticker) was already doing by virtue of never
|
# hardware (the stock ticker) was already doing by virtue of never
|
||||||
# enabling frame-based mode.
|
# enabling frame-based mode.
|
||||||
if self.scroll_delay > 0:
|
# set_scroll_delay clamps scroll_delay to at least 0.001.
|
||||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||||
else:
|
|
||||||
pixels_per_second = self.scroll_speed * 100.0
|
|
||||||
pixels_to_move = pixels_per_second * delta_time
|
pixels_to_move = pixels_per_second * delta_time
|
||||||
self.last_step_time = current_time
|
self.last_step_time = current_time
|
||||||
else:
|
else:
|
||||||
@@ -353,11 +340,11 @@ class ScrollHelper:
|
|||||||
self.scroll_position += pixels_to_move
|
self.scroll_position += pixels_to_move
|
||||||
self.total_distance_scrolled += pixels_to_move
|
self.total_distance_scrolled += pixels_to_move
|
||||||
|
|
||||||
# Calculate required total distance: total_scroll_width only.
|
# One pass is total_scroll_width. With the default lead_gap the strip
|
||||||
# The image already includes display_width pixels of blank padding at the start
|
# starts with display_width of blank, so by then the last item has
|
||||||
# (added by create_scrolling_image), so once scroll_position reaches
|
# fully left the panel; a caller passing a smaller lead_gap (Vegas)
|
||||||
# total_scroll_width the last card has fully scrolled off the left edge.
|
# decides for itself where its cycle ends. Adding display_width here
|
||||||
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains.
|
# caused 1-2 extra wrap-arounds on wide chains.
|
||||||
required_total_distance = self.total_scroll_width
|
required_total_distance = self.total_scroll_width
|
||||||
|
|
||||||
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip
|
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip
|
||||||
@@ -414,7 +401,6 @@ class ScrollHelper:
|
|||||||
and current_time - self.last_progress_log_time >= self.progress_log_interval
|
and current_time - self.last_progress_log_time >= self.progress_log_interval
|
||||||
):
|
):
|
||||||
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
||||||
# The image already includes display_width padding, so we only need total_scroll_width
|
|
||||||
required_total_distance = self.total_scroll_width
|
required_total_distance = self.total_scroll_width
|
||||||
# Progress telemetry, emitted every few seconds for the whole of
|
# Progress telemetry, emitted every few seconds for the whole of
|
||||||
# every scroll. It says how far along a marquee is, which is what
|
# every scroll. It says how far along a marquee is, which is what
|
||||||
@@ -461,10 +447,8 @@ class ScrollHelper:
|
|||||||
"""
|
"""
|
||||||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||||
|
|
||||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
Implemented with numpy rather than scipy.ndimage.shift, which is not
|
||||||
installed on the target devices, and the old scipy-based sub-pixel path
|
installed on the target devices.
|
||||||
was dead code -- get_visible_portion never consulted the flag. The scipy
|
|
||||||
import was removed with it; installing scipy has no effect.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
start_x: Left column of the earlier of the two frames
|
start_x: Left column of the earlier of the two frames
|
||||||
@@ -571,22 +555,16 @@ class ScrollHelper:
|
|||||||
return self.min_duration
|
return self.min_duration
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Calculate total scroll distance needed
|
# The strip's width plus one more screen, so the duration covers
|
||||||
# The image already includes display_width padding at the start, so we need
|
# the last item leaving the panel even when the strip has less
|
||||||
# to scroll total_scroll_width pixels to show all content, plus display_width
|
# than display_width of lead-in blank (lead_gap).
|
||||||
# more pixels to ensure the last content scrolls completely off the screen
|
|
||||||
total_scroll_distance = self.total_scroll_width + self.display_width
|
total_scroll_distance = self.total_scroll_width + self.display_width
|
||||||
|
|
||||||
# Calculate effective pixels per second based on scrolling mode
|
# Calculate effective pixels per second based on scrolling mode
|
||||||
if self.frame_based_scrolling:
|
if self.frame_based_scrolling:
|
||||||
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame
|
# Frame-based mode: scroll_speed is pixels per scroll_delay
|
||||||
# Effective pixels per second = pixels per frame / seconds per frame
|
# seconds, and set_scroll_delay keeps scroll_delay >= 0.001.
|
||||||
if self.scroll_delay > 0:
|
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||||||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
|
||||||
else:
|
|
||||||
# Fallback if scroll_delay is invalid
|
|
||||||
pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default
|
|
||||||
self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay)
|
|
||||||
scroll_mode_str = "frame-based"
|
scroll_mode_str = "frame-based"
|
||||||
else:
|
else:
|
||||||
# Time-based mode: scroll_speed is already pixels per second
|
# Time-based mode: scroll_speed is already pixels per second
|
||||||
@@ -1072,7 +1050,6 @@ class ScrollHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with scroll state information
|
Dictionary with scroll state information
|
||||||
"""
|
"""
|
||||||
# The image already includes display_width padding, so we only need total_scroll_width
|
|
||||||
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
|
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
|
||||||
return {
|
return {
|
||||||
'scroll_position': self.scroll_position,
|
'scroll_position': self.scroll_position,
|
||||||
|
|||||||
+628
-699
File diff suppressed because it is too large
Load Diff
+33
-73
@@ -38,8 +38,7 @@ from src.common.bdf_font import draw_bdf_text, load_bdf_face
|
|||||||
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
|
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
|
||||||
from src.display_geometry import (
|
from src.display_geometry import (
|
||||||
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
|
||||||
ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size,
|
compose_pixel_mapper_config, physical_size, resolve_double_sided,
|
||||||
resolve_double_sided,
|
|
||||||
)
|
)
|
||||||
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
|
from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message
|
||||||
from src.pi5_matrix_support import is_raspberry_pi_5
|
from src.pi5_matrix_support import is_raspberry_pi_5
|
||||||
@@ -47,7 +46,6 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
import logging
|
|
||||||
import math
|
import math
|
||||||
import zlib
|
import zlib
|
||||||
import freetype
|
import freetype
|
||||||
@@ -55,6 +53,7 @@ import freetype
|
|||||||
from src.common import snapshot_policy
|
from src.common import snapshot_policy
|
||||||
from src.common.frame_timing import FrameTimingRecorder
|
from src.common.frame_timing import FrameTimingRecorder
|
||||||
from src.deprecation import deprecated
|
from src.deprecation import deprecated
|
||||||
|
from src.logging_config import get_logger
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
ensure_file_permissions,
|
ensure_file_permissions,
|
||||||
@@ -62,9 +61,7 @@ from src.common.permission_utils import (
|
|||||||
get_assets_file_mode,
|
get_assets_file_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get logger without configuring
|
logger = get_logger(__name__)
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
|
||||||
|
|
||||||
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
|
#: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed
|
||||||
#: size regardless, but a Face needs an active size before its metrics --
|
#: size regardless, but a Face needs an active size before its metrics --
|
||||||
@@ -135,11 +132,6 @@ class _LogicalMatrix:
|
|||||||
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||||
|
|
||||||
|
|
||||||
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
|
|
||||||
# sync handshake compute the display size exactly as DisplayManager does
|
|
||||||
# without importing rgbmatrix. Aliased here for existing callers.
|
|
||||||
_resolve_double_sided = resolve_double_sided
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
"""
|
"""
|
||||||
@@ -161,7 +153,6 @@ class DisplayManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
def __new__(cls, *args, **kwargs):
|
def __new__(cls, *args, **kwargs):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
@@ -254,20 +245,16 @@ class DisplayManager:
|
|||||||
font_time = time.time()
|
font_time = time.time()
|
||||||
self._load_fonts()
|
self._load_fonts()
|
||||||
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
||||||
|
|
||||||
# Initialize managers
|
|
||||||
# Calendar manager is now initialized by DisplayController
|
|
||||||
|
|
||||||
# Orientation setting -> rpi-rgb-led-matrix "Rotate:<deg>" pixel-mapper suffix.
|
|
||||||
_ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES
|
|
||||||
|
|
||||||
def _build_pixel_mapper_config(self, hardware_config: dict) -> str:
|
def _new_canvas(self, width: int, height: int) -> None:
|
||||||
"""Compose pixel_mapper_config with the orientation setting.
|
"""Replace ``image``/``draw`` with a black canvas of the given size.
|
||||||
|
|
||||||
See :func:`src.display_geometry.compose_pixel_mapper_config`, which the
|
Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial
|
||||||
web preview shares so it sizes the canvas the same way.
|
brightness, so anti-aliasing only smears glyphs.
|
||||||
"""
|
"""
|
||||||
return compose_pixel_mapper_config(hardware_config)
|
self.image = Image.new('RGB', (width, height))
|
||||||
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
self.draw.fontmode = "1"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fallback_advice(cause: str, error: Exception) -> str:
|
def _fallback_advice(cause: str, error: Exception) -> str:
|
||||||
@@ -336,7 +323,7 @@ class DisplayManager:
|
|||||||
# logical (per-screen) size, and keep a full-chain buffer to tile
|
# logical (per-screen) size, and keep a full-chain buffer to tile
|
||||||
# the rendered screen into once per frame.
|
# the rendered screen into once per frame.
|
||||||
ds_config = self.config.get('display', {}).get('double_sided', {})
|
ds_config = self.config.get('display', {}).get('double_sided', {})
|
||||||
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
|
ds = resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
|
||||||
self._double_sided = ds
|
self._double_sided = ds
|
||||||
if ds is not None:
|
if ds is not None:
|
||||||
self._physical_image = Image.new(
|
self._physical_image = Image.new(
|
||||||
@@ -345,9 +332,7 @@ class DisplayManager:
|
|||||||
self.matrix, ds['logical_width'], ds['logical_height'])
|
self.matrix, ds['logical_width'], ds['logical_height'])
|
||||||
|
|
||||||
# Create image with the (logical) display dimensions
|
# Create image with the (logical) display dimensions
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
self._new_canvas(self.matrix.width, self.matrix.height)
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
|
||||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||||
|
|
||||||
# Initialize font with Press Start 2P
|
# Initialize font with Press Start 2P
|
||||||
@@ -378,7 +363,7 @@ class DisplayManager:
|
|||||||
fallback_width, fallback_height = physical_size(self.config)
|
fallback_width, fallback_height = physical_size(self.config)
|
||||||
# Mirror double-sided in fallback so the preview shows one screen.
|
# Mirror double-sided in fallback so the preview shows one screen.
|
||||||
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
||||||
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
ds = resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||||
self._double_sided = ds
|
self._double_sided = ds
|
||||||
if ds is not None:
|
if ds is not None:
|
||||||
fallback_width = ds['logical_width']
|
fallback_width = ds['logical_width']
|
||||||
@@ -386,9 +371,7 @@ class DisplayManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
fallback_width, fallback_height = 128, 32
|
fallback_width, fallback_height = 128, 32
|
||||||
|
|
||||||
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
self._new_canvas(fallback_width, fallback_height)
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
|
||||||
# Simple fallback visualization so web UI shows a realistic canvas
|
# Simple fallback visualization so web UI shows a realistic canvas
|
||||||
try:
|
try:
|
||||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||||
@@ -617,18 +600,14 @@ class DisplayManager:
|
|||||||
line, font=font, fill=(0, 0, 255))
|
line, font=font, fill=(0, 0, 255))
|
||||||
|
|
||||||
def _draw_test_pattern(self):
|
def _draw_test_pattern(self):
|
||||||
"""Draw a test pattern to verify the display is working."""
|
"""Draw a test pattern to verify the display is working.
|
||||||
|
|
||||||
|
Only called from _setup_matrix once the matrix exists; fallback mode
|
||||||
|
draws its own "Simulation" canvas there.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
if self.matrix is None:
|
|
||||||
# Fallback mode - just draw on the image
|
|
||||||
self.draw.rectangle([0, 0, self.image.width-1, self.image.height-1], outline=(255, 0, 0))
|
|
||||||
self.draw.line([0, 0, self.image.width-1, self.image.height-1], fill=(0, 255, 0))
|
|
||||||
self.draw.text((10, 10), "Simulation", font=self.font, fill=(0, 0, 255))
|
|
||||||
logger.info("Drew test pattern in fallback mode")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Draw a red rectangle border
|
# Draw a red rectangle border
|
||||||
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
|
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
|
||||||
|
|
||||||
@@ -643,7 +622,7 @@ class DisplayManager:
|
|||||||
|
|
||||||
# Update the display once after everything is drawn
|
# Update the display once after everything is drawn
|
||||||
self.update_display()
|
self.update_display()
|
||||||
time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation
|
time.sleep(0.5)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||||||
@@ -718,9 +697,7 @@ class DisplayManager:
|
|||||||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||||||
# With no hardware, the width/height properties fall through to
|
# With no hardware, the width/height properties fall through to
|
||||||
# self.image, so swapping the buffer below is enough on its own.
|
# self.image, so swapping the buffer below is enough on its own.
|
||||||
self.image = Image.new('RGB', (target_w, target_h))
|
self._new_canvas(target_w, target_h)
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
self.matrix = real_matrix
|
self.matrix = real_matrix
|
||||||
@@ -846,28 +823,14 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
if self.matrix is None:
|
if self.matrix is None:
|
||||||
# Fallback mode - just clear the image
|
# Fallback mode - just clear the image
|
||||||
# Explicitly clear old image reference to help garbage collection
|
|
||||||
old_image = getattr(self, 'image', None)
|
old_image = getattr(self, 'image', None)
|
||||||
width = old_image.width if old_image else 64
|
width = old_image.width if old_image else 64
|
||||||
height = old_image.height if old_image else 64
|
height = old_image.height if old_image else 64
|
||||||
if old_image is not None:
|
self._new_canvas(width, height)
|
||||||
del old_image
|
|
||||||
|
|
||||||
self.image = Image.new('RGB', (width, height))
|
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
|
||||||
logger.debug("Cleared display in fallback mode")
|
logger.debug("Cleared display in fallback mode")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Explicitly clear old image reference to help garbage collection
|
self._new_canvas(self.matrix.width, self.matrix.height)
|
||||||
old_image = getattr(self, 'image', None)
|
|
||||||
if old_image is not None:
|
|
||||||
del old_image
|
|
||||||
|
|
||||||
# Create a new black image
|
|
||||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
|
||||||
|
|
||||||
if not self._capture_mode_active:
|
if not self._capture_mode_active:
|
||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
@@ -1327,14 +1290,11 @@ class DisplayManager:
|
|||||||
# Ensure image/draw are reset to a blank state
|
# Ensure image/draw are reset to a blank state
|
||||||
if hasattr(self, 'image') and hasattr(self, 'draw'):
|
if hasattr(self, 'image') and hasattr(self, 'draw'):
|
||||||
try:
|
try:
|
||||||
self.image = Image.new('RGB', (self.width, self.height))
|
self._new_canvas(self.width, self.height)
|
||||||
self.draw = ImageDraw.Draw(self.image)
|
|
||||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
|
||||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||||
# Reset the singleton state when cleaning up
|
# Reset the singleton state when cleaning up
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
|
||||||
|
|
||||||
def format_date_with_ordinal(self, dt):
|
def format_date_with_ordinal(self, dt):
|
||||||
"""Formats a datetime object into 'Mon Aug 30th' style."""
|
"""Formats a datetime object into 'Mon Aug 30th' style."""
|
||||||
@@ -1372,9 +1332,9 @@ class DisplayManager:
|
|||||||
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
options.pwm_bits = hardware_config.get('pwm_bits', 10)
|
||||||
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
|
||||||
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
|
||||||
# _build_pixel_mapper_config reads only class attributes, so the class
|
# Orientation becomes a "Rotate:<deg>" pixel mapper; the web preview
|
||||||
# stands in for an instance here.
|
# composes it the same way so it sizes the canvas identically.
|
||||||
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config)
|
options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config)
|
||||||
options.row_address_type = hardware_config.get('row_address_type', 0)
|
options.row_address_type = hardware_config.get('row_address_type', 0)
|
||||||
options.multiplexing = hardware_config.get('multiplexing', 0)
|
options.multiplexing = hardware_config.get('multiplexing', 0)
|
||||||
options.panel_type = hardware_config.get('panel_type', '')
|
options.panel_type = hardware_config.get('panel_type', '')
|
||||||
@@ -1488,13 +1448,16 @@ class DisplayManager:
|
|||||||
that does not care gets a new frame every refresh.
|
that does not care gets a new frame every refresh.
|
||||||
"""
|
"""
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
# Scrolling callers set this every frame; log transitions only.
|
||||||
|
changed = self._scrolling_state['is_scrolling'] != is_scrolling
|
||||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||||
if is_scrolling:
|
if is_scrolling:
|
||||||
self._scrolling_state['last_scroll_activity'] = current_time
|
self._scrolling_state['last_scroll_activity'] = current_time
|
||||||
self.set_frame_hold(frame_hold)
|
self.set_frame_hold(frame_hold)
|
||||||
else:
|
else:
|
||||||
self._frame_hold = 1
|
self._frame_hold = 1
|
||||||
logger.debug(f"Scrolling state set to: {is_scrolling}")
|
if changed:
|
||||||
|
logger.debug("Scrolling state set to: %s", is_scrolling)
|
||||||
|
|
||||||
def is_currently_scrolling(self) -> bool:
|
def is_currently_scrolling(self) -> bool:
|
||||||
"""Check if the display is currently in a scrolling state."""
|
"""Check if the display is currently in a scrolling state."""
|
||||||
@@ -1563,9 +1526,6 @@ class DisplayManager:
|
|||||||
if not self._scrolling_state['deferred_updates']:
|
if not self._scrolling_state['deferred_updates']:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self._scrolling_state['deferred_updates']:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Process only a limited number of updates per call to avoid blocking
|
# Process only a limited number of updates per call to avoid blocking
|
||||||
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
|
max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates']))
|
||||||
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
|
updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ plugins' content is composed into a single horizontally scrolling display.
|
|||||||
|
|
||||||
Components:
|
Components:
|
||||||
- VegasModeCoordinator: Main orchestrator for Vegas mode
|
- VegasModeCoordinator: Main orchestrator for Vegas mode
|
||||||
- StreamManager: Manages plugin content streaming with 1-2 ahead buffering
|
- StreamManager: Plugin rotation, content fetching and pending-update tracking
|
||||||
- RenderPipeline: Handles 125 FPS rendering with double-buffering
|
- RenderPipeline: Strip composition and per-frame rendering
|
||||||
- PluginAdapter: Converts plugin content to scrollable images
|
- PluginAdapter: Converts plugin content to scrollable images
|
||||||
- VegasModeConfig: Configuration management
|
- VegasModeConfig: Configuration management
|
||||||
"""
|
"""
|
||||||
|
|||||||
+38
-48
@@ -167,7 +167,7 @@ class VegasModeConfig:
|
|||||||
# Dynamic duration
|
# Dynamic duration
|
||||||
dynamic_duration_enabled: bool = True
|
dynamic_duration_enabled: bool = True
|
||||||
min_cycle_duration: int = 60 # Minimum seconds per full cycle
|
min_cycle_duration: int = 60 # Minimum seconds per full cycle
|
||||||
max_cycle_duration: int = 600 # Maximum seconds per full cycle
|
max_cycle_duration: int = 240 # Maximum seconds per full cycle
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig':
|
def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig':
|
||||||
@@ -181,44 +181,50 @@ class VegasModeConfig:
|
|||||||
VegasModeConfig instance
|
VegasModeConfig instance
|
||||||
"""
|
"""
|
||||||
vegas_config = config.get('display', {}).get('vegas_scroll', {})
|
vegas_config = config.get('display', {}).get('vegas_scroll', {})
|
||||||
|
# Missing keys fall back to the field defaults above, so each default
|
||||||
|
# is written once and the two cannot drift apart.
|
||||||
|
d = cls()
|
||||||
|
get = vegas_config.get
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
enabled=vegas_config.get('enabled', False),
|
enabled=get('enabled', d.enabled),
|
||||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
scroll_speed=float(get('scroll_speed', d.scroll_speed)),
|
||||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
separator_width=int(get('separator_width', d.separator_width)),
|
||||||
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
|
intra_plugin_gap=int(get('intra_plugin_gap', d.intra_plugin_gap)),
|
||||||
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
|
render_width_pct=int(get('render_width_pct', d.render_width_pct)),
|
||||||
min_content_separation=int(
|
min_content_separation=int(
|
||||||
vegas_config.get('min_content_separation', 24)),
|
get('min_content_separation', d.min_content_separation)),
|
||||||
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
|
min_cut_gap=int(get('min_cut_gap', d.min_cut_gap)),
|
||||||
smooth_scroll=vegas_config.get('smooth_scroll', True),
|
smooth_scroll=get('smooth_scroll', d.smooth_scroll),
|
||||||
continuous_scroll=vegas_config.get('continuous_scroll', True),
|
continuous_scroll=get('continuous_scroll', d.continuous_scroll),
|
||||||
extend_threshold_screens=float(
|
extend_threshold_screens=float(
|
||||||
vegas_config.get('extend_threshold_screens', 2.0)),
|
get('extend_threshold_screens', d.extend_threshold_screens)),
|
||||||
auto_trim=vegas_config.get('auto_trim', True),
|
auto_trim=get('auto_trim', d.auto_trim),
|
||||||
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
|
trim_threshold=int(get('trim_threshold', d.trim_threshold)),
|
||||||
content_padding=int(vegas_config.get('content_padding', 8)),
|
content_padding=int(get('content_padding', d.content_padding)),
|
||||||
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
|
min_plugin_width=int(get('min_plugin_width', d.min_plugin_width)),
|
||||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
lead_in_width=int(get('lead_in_width', d.lead_in_width)),
|
||||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
plugins_per_cycle=int(get('plugins_per_cycle', d.plugins_per_cycle)),
|
||||||
max_plugin_width_ratio=float(
|
max_plugin_width_ratio=float(
|
||||||
vegas_config.get('max_plugin_width_ratio', 0.0)),
|
get('max_plugin_width_ratio', d.max_plugin_width_ratio)),
|
||||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
overflow_mode=str(get('overflow_mode', d.overflow_mode)),
|
||||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
plugin_order=list(get('plugin_order', d.plugin_order)),
|
||||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
excluded_plugins=set(get('excluded_plugins', d.excluded_plugins)),
|
||||||
live_in_ticker=bool(vegas_config.get('live_in_ticker', False)),
|
live_in_ticker=bool(get('live_in_ticker', d.live_in_ticker)),
|
||||||
# Clamped: a weight below 1 would drop the plugin from the rotation
|
# Clamped: a weight below 1 would drop the plugin from the rotation
|
||||||
# entirely, and a very large one starves everything else.
|
# entirely, and a very large one starves everything else.
|
||||||
live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))),
|
live_weight=max(1, min(10, int(get('live_weight', d.live_weight)))),
|
||||||
favorite_live_weight=max(
|
favorite_live_weight=max(1, min(10, int(
|
||||||
1, min(10, int(vegas_config.get('favorite_live_weight', 5)))),
|
get('favorite_live_weight', d.favorite_live_weight)))),
|
||||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
target_fps=int(get('target_fps', d.target_fps)),
|
||||||
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
buffer_ahead=int(get('buffer_ahead', d.buffer_ahead)),
|
||||||
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
frame_based_scrolling=get(
|
||||||
scroll_delay=float(vegas_config.get('scroll_delay', 0.02)),
|
'frame_based_scrolling', d.frame_based_scrolling),
|
||||||
dynamic_duration_enabled=vegas_config.get('dynamic_duration_enabled', True),
|
scroll_delay=float(get('scroll_delay', d.scroll_delay)),
|
||||||
min_cycle_duration=int(vegas_config.get('min_cycle_duration', 60)),
|
dynamic_duration_enabled=get(
|
||||||
max_cycle_duration=int(vegas_config.get('max_cycle_duration', 600)),
|
'dynamic_duration_enabled', d.dynamic_duration_enabled),
|
||||||
|
min_cycle_duration=int(get('min_cycle_duration', d.min_cycle_duration)),
|
||||||
|
max_cycle_duration=int(get('max_cycle_duration', d.max_cycle_duration)),
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
@@ -260,22 +266,6 @@ class VegasModeConfig:
|
|||||||
"""Get the frame interval in seconds for target FPS."""
|
"""Get the frame interval in seconds for target FPS."""
|
||||||
return 1.0 / max(1, self.target_fps)
|
return 1.0 / max(1, self.target_fps)
|
||||||
|
|
||||||
def is_plugin_included(self, plugin_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a plugin should be included in Vegas scroll.
|
|
||||||
|
|
||||||
This is consistent with get_ordered_plugins - plugins not explicitly
|
|
||||||
in plugin_order are still included (appended at the end) unless excluded.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_id: Plugin identifier to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if plugin should be included
|
|
||||||
"""
|
|
||||||
# Plugins are included unless explicitly excluded
|
|
||||||
return plugin_id not in self.excluded_plugins
|
|
||||||
|
|
||||||
def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]:
|
def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get plugins in configured order, filtering excluded ones.
|
Get plugins in configured order, filtering excluded ones.
|
||||||
|
|||||||
@@ -145,13 +145,8 @@ class VegasModeCoordinator:
|
|||||||
|
|
||||||
# Static pause handling
|
# Static pause handling
|
||||||
self._static_pause_active = False
|
self._static_pause_active = False
|
||||||
self._static_pause_plugin: Optional['BasePlugin'] = None
|
|
||||||
self._static_pause_start: Optional[float] = None
|
|
||||||
self._saved_scroll_position: Optional[int] = None
|
self._saved_scroll_position: Optional[int] = None
|
||||||
|
|
||||||
# Track which plugins should use STATIC mode (pause scroll)
|
|
||||||
self._static_mode_plugins: set = set()
|
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
self.stats = {
|
self.stats = {
|
||||||
'total_runtime_seconds': 0.0,
|
'total_runtime_seconds': 0.0,
|
||||||
@@ -235,7 +230,9 @@ class VegasModeCoordinator:
|
|||||||
returns immediately, collapsing the inter-iteration gap to <1 ms.
|
returns immediately, collapsing the inter-iteration gap to <1 ms.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
callback: Callable with no arguments (typically _tick_plugin_updates)
|
callback: Callable with no arguments. The display controller
|
||||||
|
passes _tick_plugin_updates_for_vegas, which also reports the
|
||||||
|
plugins that got fresh data through mark_plugin_updated().
|
||||||
"""
|
"""
|
||||||
self._update_callback = callback
|
self._update_callback = callback
|
||||||
|
|
||||||
@@ -417,9 +414,6 @@ class VegasModeCoordinator:
|
|||||||
if not self.start():
|
if not self.start():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Update static mode plugin list on iteration start
|
|
||||||
self._update_static_mode_plugins()
|
|
||||||
|
|
||||||
frame_interval = self.vegas_config.get_frame_interval()
|
frame_interval = self.vegas_config.get_frame_interval()
|
||||||
if self.vegas_config.continuous_scroll:
|
if self.vegas_config.continuous_scroll:
|
||||||
# The strip is continuously extended and trimmed, so its width says
|
# The strip is continuously extended and trimmed, so its width says
|
||||||
@@ -733,13 +727,6 @@ class VegasModeCoordinator:
|
|||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
def get_ordered_plugins(self) -> List[str]:
|
|
||||||
"""Get the current ordered list of plugins in Vegas scroll."""
|
|
||||||
if hasattr(self.plugin_manager, 'plugins'):
|
|
||||||
available = list(self.plugin_manager.plugins.keys())
|
|
||||||
return self.vegas_config.get_ordered_plugins(available)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Static pause handling (for STATIC display mode)
|
# Static pause handling (for STATIC display mode)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -795,8 +782,6 @@ class VegasModeCoordinator:
|
|||||||
# Save current scroll position for smooth resume
|
# Save current scroll position for smooth resume
|
||||||
self._saved_scroll_position = self.render_pipeline.get_scroll_position()
|
self._saved_scroll_position = self.render_pipeline.get_scroll_position()
|
||||||
self._static_pause_active = True
|
self._static_pause_active = True
|
||||||
self._static_pause_plugin = plugin
|
|
||||||
self._static_pause_start = time.time()
|
|
||||||
self.stats['static_pauses'] += 1
|
self.stats['static_pauses'] += 1
|
||||||
|
|
||||||
logger.info("Static pause started for plugin: %s", plugin_id)
|
logger.info("Static pause started for plugin: %s", plugin_id)
|
||||||
@@ -823,9 +808,9 @@ class VegasModeCoordinator:
|
|||||||
logger.info("Static pause interrupted by live priority")
|
logger.info("Static pause interrupted by live priority")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Yield immediately if multi-display follower mode becomes active
|
# On-demand, a WiFi message, the schedule, follower mode...
|
||||||
if self._interrupt_check and self._interrupt_check():
|
if self._interrupt_check and self._interrupt_check():
|
||||||
logger.info("Static pause interrupted by sync follower mode")
|
logger.info("Static pause interrupted by the display controller")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Sleep in small increments to remain responsive
|
# Sleep in small increments to remain responsive
|
||||||
@@ -860,8 +845,6 @@ class VegasModeCoordinator:
|
|||||||
|
|
||||||
# Clear pause state
|
# Clear pause state
|
||||||
self._static_pause_active = False
|
self._static_pause_active = False
|
||||||
self._static_pause_plugin = None
|
|
||||||
self._static_pause_start = None
|
|
||||||
|
|
||||||
# Restore scroll position if we're resuming
|
# Restore scroll position if we're resuming
|
||||||
if should_resume_scrolling and self._saved_scroll_position is not None:
|
if should_resume_scrolling and self._saved_scroll_position is not None:
|
||||||
@@ -875,29 +858,6 @@ class VegasModeCoordinator:
|
|||||||
else:
|
else:
|
||||||
logger.debug("Static pause ended (interrupted, not resuming scroll)")
|
logger.debug("Static pause ended (interrupted, not resuming scroll)")
|
||||||
|
|
||||||
def _update_static_mode_plugins(self) -> None:
|
|
||||||
"""Update the set of plugins using STATIC display mode."""
|
|
||||||
self._static_mode_plugins.clear()
|
|
||||||
|
|
||||||
for plugin_id in self.get_ordered_plugins():
|
|
||||||
plugin = self.plugin_manager.get_plugin(plugin_id)
|
|
||||||
if plugin:
|
|
||||||
try:
|
|
||||||
mode = plugin.get_vegas_display_mode()
|
|
||||||
if mode == VegasDisplayMode.STATIC:
|
|
||||||
self._static_mode_plugins.add(plugin_id)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Error getting vegas display mode for plugin %s",
|
|
||||||
plugin_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._static_mode_plugins:
|
|
||||||
logger.info(
|
|
||||||
"Static mode plugins: %s",
|
|
||||||
', '.join(self._static_mode_plugins)
|
|
||||||
)
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""Clean up all resources."""
|
"""Clean up all resources."""
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|||||||
@@ -230,54 +230,6 @@ def blank_runs(
|
|||||||
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
|
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
|
||||||
|
|
||||||
|
|
||||||
def find_blank_cut(
|
|
||||||
img: Image.Image,
|
|
||||||
target: int,
|
|
||||||
search_radius: int,
|
|
||||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Find a column near ``target`` that carries no ink, so an image can be cut
|
|
||||||
there without slicing through a glyph or logo.
|
|
||||||
|
|
||||||
Used when a single oversized segment has to be narrowed to fit a width
|
|
||||||
budget. Cutting at an arbitrary column would leave half a character
|
|
||||||
hanging at the panel edge; snapping to the nearest gap hides the cut.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Image to cut
|
|
||||||
target: Preferred cut column
|
|
||||||
search_radius: How far either side of ``target`` to look
|
|
||||||
threshold: Ink threshold
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A blank column within the search window, or ``target`` clamped to the
|
|
||||||
image bounds when the window contains no blank column at all.
|
|
||||||
"""
|
|
||||||
width = img.width
|
|
||||||
target = max(0, min(target, width))
|
|
||||||
if search_radius <= 0 or width == 0:
|
|
||||||
return target
|
|
||||||
|
|
||||||
ink = column_has_ink(img, threshold)
|
|
||||||
|
|
||||||
# target may legitimately equal width (a cut after the last column), but
|
|
||||||
# there is no column to inspect there, so both bounds stop at width - 1.
|
|
||||||
lo = max(0, min(target - search_radius, width - 1))
|
|
||||||
hi = max(0, min(target + search_radius, width - 1))
|
|
||||||
|
|
||||||
# Walk outwards from target so the nearest gap wins.
|
|
||||||
for offset in range(0, search_radius + 1):
|
|
||||||
right = target + offset
|
|
||||||
if lo <= right <= hi and not ink[right]:
|
|
||||||
return right
|
|
||||||
left = target - offset
|
|
||||||
if lo <= left <= hi and not ink[left]:
|
|
||||||
return left
|
|
||||||
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
class DeadWindowStats(NamedTuple):
|
class DeadWindowStats(NamedTuple):
|
||||||
"""How much of a composed ticker reads as blank to a viewer."""
|
"""How much of a composed ticker reads as blank to a viewer."""
|
||||||
|
|
||||||
|
|||||||
@@ -48,15 +48,8 @@ class PluginAdapter:
|
|||||||
from src.vegas_mode.config import VegasModeConfig
|
from src.vegas_mode.config import VegasModeConfig
|
||||||
config = VegasModeConfig()
|
config = VegasModeConfig()
|
||||||
self.config = config
|
self.config = config
|
||||||
# Handle both property and method access patterns
|
self.display_width = display_manager.width
|
||||||
self.display_width = (
|
self.display_height = display_manager.height
|
||||||
display_manager.width() if callable(display_manager.width)
|
|
||||||
else display_manager.width
|
|
||||||
)
|
|
||||||
self.display_height = (
|
|
||||||
display_manager.height() if callable(display_manager.height)
|
|
||||||
else display_manager.height
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cache for recently fetched content (prevents redundant fetch)
|
# Cache for recently fetched content (prevents redundant fetch)
|
||||||
self._content_cache: dict = {}
|
self._content_cache: dict = {}
|
||||||
@@ -185,13 +178,14 @@ class PluginAdapter:
|
|||||||
Trim dead space off a segment, then cache it.
|
Trim dead space off a segment, then cache it.
|
||||||
|
|
||||||
Every content path funnels through here so trimming is applied
|
Every content path funnels through here so trimming is applied
|
||||||
uniformly. Previously only the scroll_helper path had its margins
|
uniformly; a plugin that renders onto a full-display canvas would
|
||||||
stripped, which left plugins that render onto a full-display canvas
|
otherwise contribute its whole blank canvas to the ticker.
|
||||||
contributing their entire blank canvas to the ticker.
|
|
||||||
|
|
||||||
Each image is trimmed independently because compose_scroll_content()
|
Each image is trimmed independently. The render pipeline joins one
|
||||||
treats every image as its own item and inserts separator_width between
|
plugin's images with a gap measured from their ink
|
||||||
them — so a per-image trim is what makes that separator the real gap.
|
(RenderPipeline._join_plugin_rows) and puts separator_width only
|
||||||
|
between plugins, so the margins a row keeps are content_padding, not
|
||||||
|
whatever blank canvas the plugin happened to draw it on.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
images: Raw content from one of the fetch paths
|
images: Raw content from one of the fetch paths
|
||||||
@@ -602,8 +596,11 @@ class PluginAdapter:
|
|||||||
Narrow a single oversized image to the budget, advancing a window
|
Narrow a single oversized image to the budget, advancing a window
|
||||||
through it across cycles.
|
through it across cycles.
|
||||||
|
|
||||||
The cut is snapped to the nearest blank column so it does not slice
|
Cuts land only at item boundaries: the middle of a blank run at least
|
||||||
through a glyph or logo and leave half a character at the panel edge.
|
``min_cut_gap`` columns wide. The window ends at the last boundary
|
||||||
|
inside the budget, or overruns to the next one when there is none, so
|
||||||
|
an item is never sliced. An image with no such runs (a map, a chart)
|
||||||
|
is continuous content and is cropped to the budget exactly.
|
||||||
|
|
||||||
Rotation is tracked as an index into the strip's item boundaries rather
|
Rotation is tracked as an index into the strip's item boundaries rather
|
||||||
than as a pixel column, because a ticker re-renders between fetches. A
|
than as a pixel column, because a ticker re-renders between fetches. A
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Render Pipeline for Vegas Mode
|
Render Pipeline for Vegas Mode
|
||||||
|
|
||||||
Handles high-FPS (125 FPS) rendering with double-buffering for smooth scrolling.
|
Composes plugin content into one wide strip and renders the visible window of
|
||||||
Uses the existing ScrollHelper for numpy-optimized scroll operations.
|
it each frame, using ScrollHelper for the numpy-backed scroll.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -10,7 +10,7 @@ import os
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING
|
from typing import Optional, List, Any, Dict, Deque
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
from src.common.scroll_helper import ScrollHelper
|
||||||
@@ -18,11 +18,13 @@ from src.vegas_mode.config import VegasModeConfig
|
|||||||
from src.vegas_mode.geometry import separation_gap
|
from src.vegas_mode.geometry import separation_gap
|
||||||
from src.vegas_mode.stream_manager import StreamManager
|
from src.vegas_mode.stream_manager import StreamManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Shortest gap between multi-display sync sends from the leader, for both the
|
||||||
|
#: Vegas scroll position and the controller's per-frame follower images. The
|
||||||
|
#: payloads are raw and cheap, and 90/s is above the follower's render rate.
|
||||||
|
SYNC_SEND_INTERVAL = 1.0 / 90
|
||||||
|
|
||||||
|
|
||||||
class RenderPipeline:
|
class RenderPipeline:
|
||||||
"""
|
"""
|
||||||
@@ -31,8 +33,9 @@ class RenderPipeline:
|
|||||||
Key responsibilities:
|
Key responsibilities:
|
||||||
- Compose content segments into scrollable image
|
- Compose content segments into scrollable image
|
||||||
- Manage scroll position and velocity
|
- Manage scroll position and velocity
|
||||||
- Handle 125 FPS rendering loop
|
- Render one frame per call at the target FPS
|
||||||
- Double-buffer for hot-swap during updates
|
- Extend the strip (continuous mode) or recompose and hot-swap it (swap
|
||||||
|
mode) as content changes
|
||||||
- Track scroll cycle completion
|
- Track scroll cycle completion
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -59,18 +62,10 @@ class RenderPipeline:
|
|||||||
self.stream_manager = stream_manager
|
self.stream_manager = stream_manager
|
||||||
self.sync_manager = None # Optional DisplaySyncManager — set by coordinator
|
self.sync_manager = None # Optional DisplaySyncManager — set by coordinator
|
||||||
self.sync_follower_left = True # True = follower is LEFT of leader (default)
|
self.sync_follower_left = True # True = follower is LEFT of leader (default)
|
||||||
self._sync_send_interval = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
|
||||||
self._last_sync_send = 0.0
|
self._last_sync_send = 0.0
|
||||||
|
|
||||||
# Display dimensions (handle both property and method access patterns)
|
self.display_width = display_manager.width
|
||||||
self.display_width = (
|
self.display_height = display_manager.height
|
||||||
display_manager.width() if callable(display_manager.width)
|
|
||||||
else display_manager.width
|
|
||||||
)
|
|
||||||
self.display_height = (
|
|
||||||
display_manager.height() if callable(display_manager.height)
|
|
||||||
else display_manager.height
|
|
||||||
)
|
|
||||||
|
|
||||||
# ScrollHelper for optimized scrolling
|
# ScrollHelper for optimized scrolling
|
||||||
self.scroll_helper = ScrollHelper(
|
self.scroll_helper = ScrollHelper(
|
||||||
@@ -82,11 +77,6 @@ class RenderPipeline:
|
|||||||
# Configure scroll helper
|
# Configure scroll helper
|
||||||
self._configure_scroll_helper()
|
self._configure_scroll_helper()
|
||||||
|
|
||||||
# Double-buffer for composed images
|
|
||||||
self._active_scroll_image: Optional[Image.Image] = None
|
|
||||||
self._staging_scroll_image: Optional[Image.Image] = None
|
|
||||||
self._buffer_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Group prepared off the render thread, waiting to be appended.
|
# Group prepared off the render thread, waiting to be appended.
|
||||||
self._prepared_group = None
|
self._prepared_group = None
|
||||||
# Plugins that need the shared canvas, appended one at a time.
|
# Plugins that need the shared canvas, appended one at a time.
|
||||||
@@ -96,13 +86,9 @@ class RenderPipeline:
|
|||||||
self._prefetch_lock = threading.Lock()
|
self._prefetch_lock = threading.Lock()
|
||||||
|
|
||||||
# Render state
|
# Render state
|
||||||
self._is_rendering = False
|
|
||||||
self._cycle_complete = False
|
self._cycle_complete = False
|
||||||
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
||||||
|
|
||||||
# Timing
|
|
||||||
self._last_frame_time = 0.0
|
|
||||||
self._frame_interval = config.get_frame_interval()
|
|
||||||
self._cycle_start_time = 0.0
|
self._cycle_start_time = 0.0
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
@@ -192,10 +178,6 @@ class RenderPipeline:
|
|||||||
logger.error("ScrollHelper failed to create cached image")
|
logger.error("ScrollHelper failed to create cached image")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Store reference to composed image
|
|
||||||
with self._buffer_lock:
|
|
||||||
self._active_scroll_image = self.scroll_helper.cached_image
|
|
||||||
|
|
||||||
# Track which plugins are in this scroll (get safely via buffer status)
|
# Track which plugins are in this scroll (get safely via buffer status)
|
||||||
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
|
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
|
||||||
|
|
||||||
@@ -335,8 +317,6 @@ class RenderPipeline:
|
|||||||
element_gap=0,
|
element_gap=0,
|
||||||
)
|
)
|
||||||
if appended:
|
if appended:
|
||||||
with self._buffer_lock:
|
|
||||||
self._active_scroll_image = self.scroll_helper.cached_image
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
|
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
|
||||||
plugin_id, self.scroll_helper.total_scroll_width,
|
plugin_id, self.scroll_helper.total_scroll_width,
|
||||||
@@ -420,9 +400,6 @@ class RenderPipeline:
|
|||||||
# Keep a screen's worth behind the viewport as a safety margin.
|
# Keep a screen's worth behind the viewport as a safety margin.
|
||||||
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
||||||
|
|
||||||
with self._buffer_lock:
|
|
||||||
self._active_scroll_image = self.scroll_helper.cached_image
|
|
||||||
|
|
||||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||||
self.stats['composition_count'] += 1
|
self.stats['composition_count'] += 1
|
||||||
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
|
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
|
||||||
@@ -565,7 +542,7 @@ class RenderPipeline:
|
|||||||
# leader's via TCP image transfer at each new_cycle) at scroll_x ± display_width.
|
# leader's via TCP image transfer at each new_cycle) at scroll_x ± display_width.
|
||||||
if self.sync_manager:
|
if self.sync_manager:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_sync_send >= self._sync_send_interval:
|
if now - self._last_sync_send >= SYNC_SEND_INTERVAL:
|
||||||
self._last_sync_send = now
|
self._last_sync_send = now
|
||||||
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
|
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
|
||||||
|
|
||||||
@@ -603,7 +580,6 @@ class RenderPipeline:
|
|||||||
|
|
||||||
Returns True when:
|
Returns True when:
|
||||||
- Cycle is complete and we should start fresh
|
- Cycle is complete and we should start fresh
|
||||||
- Staging buffer has new content
|
|
||||||
- A plugin currently visible in the scroll has pending updated data
|
- A plugin currently visible in the scroll has pending updated data
|
||||||
(e.g. a live score changed) — standalone (non-sync) mode only
|
(e.g. a live score changed) — standalone (non-sync) mode only
|
||||||
"""
|
"""
|
||||||
@@ -613,16 +589,11 @@ class RenderPipeline:
|
|||||||
# When multi-display sync is active, defer mid-cycle hot swaps until the
|
# When multi-display sync is active, defer mid-cycle hot swaps until the
|
||||||
# cycle ends naturally. Hot swaps block the render loop for 15-30ms while
|
# cycle ends naturally. Hot swaps block the render loop for 15-30ms while
|
||||||
# the image is rebuilt, causing a freeze+jump that the follower perceives
|
# the image is rebuilt, causing a freeze+jump that the follower perceives
|
||||||
# as a speed-up. Deferring to cycle boundaries keeps transitions clean.
|
# as a speed-up. Deferring to cycle boundaries keeps transitions clean;
|
||||||
# Staging buffer content is still pre-loaded; it just applies at cycle end.
|
# the pending updates are still applied, by the recompose at cycle end.
|
||||||
if self.sync_manager is not None:
|
if self.sync_manager is not None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if we need more content in the buffer
|
|
||||||
buffer_status = self.stream_manager.get_buffer_status()
|
|
||||||
if buffer_status['staging_count'] > 0:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Trigger recompose when pending updates affect visible segments, so
|
# Trigger recompose when pending updates affect visible segments, so
|
||||||
# live score/status changes reach the display within a few seconds
|
# live score/status changes reach the display within a few seconds
|
||||||
# instead of waiting for the next full cycle.
|
# instead of waiting for the next full cycle.
|
||||||
@@ -652,10 +623,12 @@ class RenderPipeline:
|
|||||||
|
|
||||||
def hot_swap_content(self) -> bool:
|
def hot_swap_content(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Hot-swap to new composed content.
|
Refetch the plugins with pending updates and recompose the strip.
|
||||||
|
|
||||||
Called when staging buffer has updated content.
|
Swap mode only (continuous mode uses :meth:`refresh_updated_plugins`).
|
||||||
Swaps atomically to prevent visual glitches.
|
Called when :meth:`should_recompose` finds a visible plugin with
|
||||||
|
pending updates. The scroll resumes at the same relative position in
|
||||||
|
the rebuilt strip.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if swap occurred
|
True if swap occurred
|
||||||
@@ -668,9 +641,7 @@ class RenderPipeline:
|
|||||||
old_width = self.scroll_helper.total_scroll_width
|
old_width = self.scroll_helper.total_scroll_width
|
||||||
old_pos = self.scroll_helper.scroll_position
|
old_pos = self.scroll_helper.scroll_position
|
||||||
|
|
||||||
# Process any pending updates
|
|
||||||
self.stream_manager.process_updates()
|
self.stream_manager.process_updates()
|
||||||
self.stream_manager.swap_buffers()
|
|
||||||
|
|
||||||
# Recompose with updated content
|
# Recompose with updated content
|
||||||
if self.compose_scroll_content():
|
if self.compose_scroll_content():
|
||||||
@@ -729,21 +700,17 @@ class RenderPipeline:
|
|||||||
result = self.compose_scroll_content()
|
result = self.compose_scroll_content()
|
||||||
|
|
||||||
if result and self.sync_manager:
|
if result and self.sync_manager:
|
||||||
# When sync is active, start the leader past the lead-in gap so it
|
# Start the leader past the lead-in gap so it immediately shows
|
||||||
# immediately shows content, leaving the follower on the blank gap
|
# content, leaving the follower on the blank gap for a clean
|
||||||
# for a clean transition rather than near-end content wrapping
|
# transition rather than near-end content wrapping around.
|
||||||
# around. This tracks lead_in_width rather than assuming a full
|
|
||||||
# display width of gap, which is no longer the default.
|
|
||||||
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
||||||
|
|
||||||
if result and self.sync_manager:
|
|
||||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||||
self.sync_manager.send_new_cycle()
|
self.sync_manager.send_new_cycle()
|
||||||
# Push the actual scroll image over TCP so follower has identical pixels.
|
# Push the actual scroll image over TCP so follower has identical pixels.
|
||||||
# Done in a background thread to not block the render loop (~15ms transfer).
|
# Done in a background thread to not block the render loop (~15ms transfer).
|
||||||
if self.scroll_helper.cached_image is not None:
|
if self.scroll_helper.cached_image is not None:
|
||||||
import threading as _t
|
threading.Thread(
|
||||||
_t.Thread(
|
|
||||||
target=self.sync_manager.send_scroll_image,
|
target=self.sync_manager.send_scroll_image,
|
||||||
args=(self.scroll_helper.cached_image,),
|
args=(self.scroll_helper.cached_image,),
|
||||||
daemon=True, name="sync-image-push"
|
daemon=True, name="sync-image-push"
|
||||||
@@ -792,7 +759,6 @@ class RenderPipeline:
|
|||||||
"""
|
"""
|
||||||
old_fps = self.config.target_fps
|
old_fps = self.config.target_fps
|
||||||
self.config = new_config
|
self.config = new_config
|
||||||
self._frame_interval = new_config.get_frame_interval()
|
|
||||||
|
|
||||||
# Reconfigure scroll helper
|
# Reconfigure scroll helper
|
||||||
self._configure_scroll_helper()
|
self._configure_scroll_helper()
|
||||||
@@ -805,10 +771,6 @@ class RenderPipeline:
|
|||||||
self.scroll_helper.reset_scroll()
|
self.scroll_helper.reset_scroll()
|
||||||
self.scroll_helper.clear_cache()
|
self.scroll_helper.clear_cache()
|
||||||
|
|
||||||
with self._buffer_lock:
|
|
||||||
self._active_scroll_image = None
|
|
||||||
self._staging_scroll_image = None
|
|
||||||
|
|
||||||
self._cycle_complete = False
|
self._cycle_complete = False
|
||||||
self._segments_in_scroll = []
|
self._segments_in_scroll = []
|
||||||
self._frame_times = deque(maxlen=100)
|
self._frame_times = deque(maxlen=100)
|
||||||
|
|||||||
@@ -31,22 +31,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ContentSegment:
|
class ContentSegment:
|
||||||
"""Represents a segment of scrollable content from a plugin."""
|
"""One plugin's content for a cycle.
|
||||||
|
|
||||||
|
A STATIC segment carries no images: it marks where the coordinator pauses
|
||||||
|
the scroll to show the plugin full-screen.
|
||||||
|
"""
|
||||||
plugin_id: str
|
plugin_id: str
|
||||||
images: List[Image.Image]
|
images: List[Image.Image]
|
||||||
total_width: int
|
|
||||||
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT)
|
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT)
|
||||||
fetched_at: float = field(default_factory=time.time)
|
|
||||||
is_stale: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def image_count(self) -> int:
|
|
||||||
return len(self.images)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_static(self) -> bool:
|
|
||||||
"""Check if this segment should trigger a static pause."""
|
|
||||||
return self.display_mode == VegasDisplayMode.STATIC
|
|
||||||
|
|
||||||
|
|
||||||
class StreamManager:
|
class StreamManager:
|
||||||
@@ -54,10 +46,12 @@ class StreamManager:
|
|||||||
Manages streaming of plugin content for Vegas scroll mode.
|
Manages streaming of plugin content for Vegas scroll mode.
|
||||||
|
|
||||||
Key responsibilities:
|
Key responsibilities:
|
||||||
- Maintain ordered list of plugins to stream
|
- Maintain the ordered (and priority-weighted) rotation of plugins
|
||||||
- Prefetch content 1-2 plugins ahead of current position
|
- Fill the active buffer with a cycle's worth of segments (swap mode), or
|
||||||
- Handle plugin data updates via double-buffer swap
|
hand out the next group of plugins directly (continuous mode)
|
||||||
- Manage content lifecycle and staleness
|
- Track plugins whose data changed in ``_pending_updates``, which
|
||||||
|
:meth:`process_updates` (swap mode) or
|
||||||
|
:meth:`invalidate_pending_updates` (continuous mode) consumes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -78,14 +72,14 @@ class StreamManager:
|
|||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
self.plugin_adapter = plugin_adapter
|
self.plugin_adapter = plugin_adapter
|
||||||
|
|
||||||
# Content queue (double-buffered)
|
# Segments composed into the current cycle (swap mode only).
|
||||||
self._active_buffer: Deque[ContentSegment] = deque()
|
self._active_buffer: Deque[ContentSegment] = deque()
|
||||||
self._staging_buffer: Deque[ContentSegment] = deque()
|
# Reentrant: _prefetch_content releases and re-acquires it around the
|
||||||
self._buffer_lock = threading.RLock() # RLock for reentrant access
|
# slow fetch while a caller may already hold it.
|
||||||
|
self._buffer_lock = threading.RLock()
|
||||||
|
|
||||||
# Plugin rotation state
|
# Plugin rotation, and the position of the next plugin to fetch in it.
|
||||||
self._ordered_plugins: List[str] = []
|
self._ordered_plugins: List[str] = []
|
||||||
self._current_index: int = 0
|
|
||||||
self._prefetch_index: int = 0
|
self._prefetch_index: int = 0
|
||||||
|
|
||||||
# Update tracking
|
# Update tracking
|
||||||
@@ -97,7 +91,6 @@ class StreamManager:
|
|||||||
self.stats = {
|
self.stats = {
|
||||||
'segments_fetched': 0,
|
'segments_fetched': 0,
|
||||||
'segments_served': 0,
|
'segments_served': 0,
|
||||||
'buffer_swaps': 0,
|
|
||||||
'fetch_errors': 0,
|
'fetch_errors': 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +160,7 @@ class StreamManager:
|
|||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
return {
|
return {
|
||||||
'active_count': len(self._active_buffer),
|
'active_count': len(self._active_buffer),
|
||||||
'staging_count': len(self._staging_buffer),
|
|
||||||
'total_plugins': len(self._ordered_plugins),
|
'total_plugins': len(self._ordered_plugins),
|
||||||
'current_index': self._current_index,
|
|
||||||
'prefetch_index': self._prefetch_index,
|
'prefetch_index': self._prefetch_index,
|
||||||
'stats': self.stats.copy(),
|
'stats': self.stats.copy(),
|
||||||
}
|
}
|
||||||
@@ -190,8 +181,9 @@ class StreamManager:
|
|||||||
"""
|
"""
|
||||||
Mark a plugin as having updated data.
|
Mark a plugin as having updated data.
|
||||||
|
|
||||||
Called when a plugin's data changes. Triggers content refresh
|
Only records it in ``_pending_updates``. The refetch happens later,
|
||||||
for that plugin in the staging buffer.
|
in :meth:`process_updates` (swap mode) or by dropping the plugin's
|
||||||
|
caches in :meth:`invalidate_pending_updates` (continuous mode).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin that was updated
|
plugin_id: Plugin that was updated
|
||||||
@@ -242,11 +234,6 @@ class StreamManager:
|
|||||||
)
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
def has_pending_updates(self) -> bool:
|
|
||||||
"""Check if any plugins have pending updates awaiting processing."""
|
|
||||||
with self._buffer_lock:
|
|
||||||
return len(self._pending_updates) > 0
|
|
||||||
|
|
||||||
def has_pending_updates_for_visible_segments(self) -> bool:
|
def has_pending_updates_for_visible_segments(self) -> bool:
|
||||||
"""Check if pending updates affect plugins currently in the active buffer."""
|
"""Check if pending updates affect plugins currently in the active buffer."""
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
@@ -309,19 +296,6 @@ class StreamManager:
|
|||||||
|
|
||||||
logger.debug("Processed in-place updates for %d plugins", len(updated_plugins))
|
logger.debug("Processed in-place updates for %d plugins", len(updated_plugins))
|
||||||
|
|
||||||
def swap_buffers(self) -> None:
|
|
||||||
"""
|
|
||||||
Swap active and staging buffers.
|
|
||||||
|
|
||||||
Called when staging buffer has updated content ready.
|
|
||||||
"""
|
|
||||||
with self._buffer_lock:
|
|
||||||
if self._staging_buffer:
|
|
||||||
# True swap: staging becomes active, old active is discarded
|
|
||||||
self._active_buffer, self._staging_buffer = self._staging_buffer, deque()
|
|
||||||
self.stats['buffer_swaps'] += 1
|
|
||||||
logger.debug("Swapped buffers, active now has %d segments", len(self._active_buffer))
|
|
||||||
|
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
"""
|
"""
|
||||||
Refresh the plugin list and content.
|
Refresh the plugin list and content.
|
||||||
@@ -337,61 +311,49 @@ class StreamManager:
|
|||||||
self._refresh_plugin_list()
|
self._refresh_plugin_list()
|
||||||
|
|
||||||
if len(self._ordered_plugins) != old_count:
|
if len(self._ordered_plugins) != old_count:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Plugin list refreshed: %d -> %d plugins",
|
"Plugin list refreshed: %d -> %d plugins",
|
||||||
old_count, len(self._ordered_plugins)
|
old_count, len(self._ordered_plugins)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _refresh_plugin_list(self) -> None:
|
def _refresh_plugin_list(self) -> None:
|
||||||
"""Refresh the ordered list of plugins from plugin manager."""
|
"""Refresh the ordered list of plugins from plugin manager.
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("REFRESHING PLUGIN LIST FOR VEGAS SCROLL")
|
|
||||||
logger.info("=" * 60)
|
|
||||||
|
|
||||||
# Get all enabled plugins
|
Runs at every cycle start and every ``_refresh_interval`` seconds, so
|
||||||
|
it logs one INFO summary; the per-plugin decisions are at DEBUG.
|
||||||
|
"""
|
||||||
available_plugins = []
|
available_plugins = []
|
||||||
|
loaded = 0
|
||||||
|
|
||||||
if hasattr(self.plugin_manager, 'plugins'):
|
if hasattr(self.plugin_manager, 'plugins'):
|
||||||
logger.info(
|
loaded = len(self.plugin_manager.plugins)
|
||||||
"Checking %d loaded plugins for Vegas scroll",
|
|
||||||
len(self.plugin_manager.plugins)
|
|
||||||
)
|
|
||||||
for plugin_id, plugin in self.plugin_manager.plugins.items():
|
for plugin_id, plugin in self.plugin_manager.plugins.items():
|
||||||
has_enabled = hasattr(plugin, 'enabled')
|
if not getattr(plugin, 'enabled', False):
|
||||||
is_enabled = getattr(plugin, 'enabled', False)
|
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
|
||||||
logger.info(
|
continue
|
||||||
"[%s] class=%s, has_enabled=%s, enabled=%s",
|
|
||||||
plugin_id, plugin.__class__.__name__, has_enabled, is_enabled
|
|
||||||
)
|
|
||||||
if has_enabled and is_enabled:
|
|
||||||
# Check vegas content type - skip 'none' unless in STATIC mode
|
|
||||||
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
|
|
||||||
|
|
||||||
# Also check display mode - STATIC plugins should be included
|
# Content type 'none' is left out, except for STATIC plugins,
|
||||||
# even if their content_type is 'none'
|
# which pause the scroll rather than contributing to it.
|
||||||
display_mode = VegasDisplayMode.FIXED_SEGMENT
|
content_type = self.plugin_adapter.get_content_type(plugin, plugin_id)
|
||||||
try:
|
display_mode = VegasDisplayMode.FIXED_SEGMENT
|
||||||
display_mode = plugin.get_vegas_display_mode()
|
try:
|
||||||
except Exception:
|
display_mode = plugin.get_vegas_display_mode()
|
||||||
# Plugin error should not abort refresh; use default mode
|
except Exception:
|
||||||
logger.exception(
|
# Plugin error should not abort refresh; use default mode
|
||||||
"[%s] (%s) get_vegas_display_mode() failed, using default",
|
logger.exception(
|
||||||
plugin_id, plugin.__class__.__name__
|
"[%s] (%s) get_vegas_display_mode() failed, using default",
|
||||||
)
|
plugin_id, plugin.__class__.__name__
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[%s] content_type=%s, display_mode=%s",
|
|
||||||
plugin_id, content_type, display_mode.value
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if content_type != 'none' or display_mode == VegasDisplayMode.STATIC:
|
included = (content_type != 'none'
|
||||||
available_plugins.append(plugin_id)
|
or display_mode == VegasDisplayMode.STATIC)
|
||||||
logger.info("[%s] --> INCLUDED in Vegas scroll", plugin_id)
|
logger.debug(
|
||||||
else:
|
"[%s] Vegas: %s (content_type=%s, display_mode=%s)",
|
||||||
logger.info("[%s] --> EXCLUDED from Vegas scroll", plugin_id)
|
plugin_id, "included" if included else "excluded",
|
||||||
else:
|
content_type, display_mode.value
|
||||||
logger.info("[%s] --> SKIPPED (not enabled)", plugin_id)
|
)
|
||||||
|
if included:
|
||||||
|
available_plugins.append(plugin_id)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"plugin_manager does not have plugins attribute: %s",
|
"plugin_manager does not have plugins attribute: %s",
|
||||||
@@ -400,24 +362,20 @@ class StreamManager:
|
|||||||
|
|
||||||
# Apply ordering from config (outside lock for potentially slow operation)
|
# Apply ordering from config (outside lock for potentially slow operation)
|
||||||
ordered_plugins = self.config.get_ordered_plugins(available_plugins)
|
ordered_plugins = self.config.get_ordered_plugins(available_plugins)
|
||||||
logger.info(
|
|
||||||
"Vegas scroll plugin list: %d available -> %d ordered",
|
|
||||||
len(available_plugins), len(ordered_plugins)
|
|
||||||
)
|
|
||||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
|
||||||
|
|
||||||
ordered_plugins = self._apply_priority_weights(ordered_plugins)
|
ordered_plugins = self._apply_priority_weights(ordered_plugins)
|
||||||
|
|
||||||
# Atomically update shared state under lock to avoid races with prefetchers
|
# Atomically update shared state under lock to avoid races with prefetchers
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
self._ordered_plugins = ordered_plugins
|
self._ordered_plugins = ordered_plugins
|
||||||
# Reset indices if needed
|
|
||||||
if self._current_index >= len(self._ordered_plugins):
|
|
||||||
self._current_index = 0
|
|
||||||
if self._prefetch_index >= len(self._ordered_plugins):
|
if self._prefetch_index >= len(self._ordered_plugins):
|
||||||
self._prefetch_index = 0
|
self._prefetch_index = 0
|
||||||
|
|
||||||
logger.info("=" * 60)
|
slots = (f", {len(ordered_plugins)} slots"
|
||||||
|
if len(ordered_plugins) != len(set(ordered_plugins)) else "")
|
||||||
|
logger.info(
|
||||||
|
"Vegas rotation: %d of %d loaded plugin(s)%s: %s",
|
||||||
|
len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins)
|
||||||
|
)
|
||||||
|
|
||||||
def _plugin_weight(self, plugin_id: str) -> int:
|
def _plugin_weight(self, plugin_id: str) -> int:
|
||||||
"""Slots per cycle for one plugin.
|
"""Slots per cycle for one plugin.
|
||||||
@@ -490,7 +448,7 @@ class StreamManager:
|
|||||||
schedule = self._unclump_seam(schedule)
|
schedule = self._unclump_seam(schedule)
|
||||||
|
|
||||||
boosted = {p: w for p, w in weights.items() if w > 1}
|
boosted = {p: w for p, w in weights.items() if w > 1}
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
|
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
|
||||||
len(schedule), len(ordered), boosted)
|
len(schedule), len(ordered), boosted)
|
||||||
return schedule
|
return schedule
|
||||||
@@ -607,11 +565,6 @@ class StreamManager:
|
|||||||
ContentSegment or None if fetch failed
|
ContentSegment or None if fetch failed
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("=" * 60)
|
|
||||||
logger.info("[%s] FETCHING CONTENT", plugin_id)
|
|
||||||
logger.info("=" * 60)
|
|
||||||
|
|
||||||
# Get plugin instance
|
|
||||||
if not hasattr(self.plugin_manager, 'plugins'):
|
if not hasattr(self.plugin_manager, 'plugins'):
|
||||||
logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id)
|
logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id)
|
||||||
return None
|
return None
|
||||||
@@ -621,18 +574,11 @@ class StreamManager:
|
|||||||
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
|
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[%s] Plugin found: class=%s, enabled=%s",
|
|
||||||
plugin_id, plugin.__class__.__name__, getattr(plugin, 'enabled', 'N/A')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get display mode from plugin
|
|
||||||
display_mode = VegasDisplayMode.FIXED_SEGMENT
|
display_mode = VegasDisplayMode.FIXED_SEGMENT
|
||||||
try:
|
try:
|
||||||
display_mode = plugin.get_vegas_display_mode()
|
display_mode = plugin.get_vegas_display_mode()
|
||||||
logger.info("[%s] Display mode: %s", plugin_id, display_mode.value)
|
|
||||||
except (AttributeError, TypeError) as e:
|
except (AttributeError, TypeError) as e:
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)",
|
"[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)",
|
||||||
plugin_id, e
|
plugin_id, e
|
||||||
)
|
)
|
||||||
@@ -644,21 +590,21 @@ class StreamManager:
|
|||||||
segment = ContentSegment(
|
segment = ContentSegment(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
images=[], # No images needed for static pause
|
images=[], # No images needed for static pause
|
||||||
total_width=0,
|
|
||||||
display_mode=display_mode
|
display_mode=display_mode
|
||||||
)
|
)
|
||||||
self.stats['segments_fetched'] += 1
|
self.stats['segments_fetched'] += 1
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] Created STATIC placeholder (pause trigger)",
|
"[%s] Created STATIC placeholder (pause trigger)",
|
||||||
plugin_id
|
plugin_id
|
||||||
)
|
)
|
||||||
return segment
|
return segment
|
||||||
|
|
||||||
# Get content via adapter for SCROLL/FIXED_SEGMENT modes
|
# Get content via adapter for SCROLL/FIXED_SEGMENT modes
|
||||||
logger.info("[%s] Calling plugin_adapter.get_content()...", plugin_id)
|
|
||||||
images = self.plugin_adapter.get_content(plugin, plugin_id)
|
images = self.plugin_adapter.get_content(plugin, plugin_id)
|
||||||
if not images:
|
if not images:
|
||||||
logger.warning("[%s] NO CONTENT RETURNED from plugin_adapter", plugin_id)
|
# The adapter already warns when every content path failed;
|
||||||
|
# an empty result is otherwise routine (nothing scheduled).
|
||||||
|
logger.debug("[%s] No Vegas content this cycle", plugin_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Calculate total width
|
# Calculate total width
|
||||||
@@ -667,17 +613,14 @@ class StreamManager:
|
|||||||
segment = ContentSegment(
|
segment = ContentSegment(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
images=images,
|
images=images,
|
||||||
total_width=total_width,
|
|
||||||
display_mode=display_mode
|
display_mode=display_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
self.stats['segments_fetched'] += 1
|
self.stats['segments_fetched'] += 1
|
||||||
logger.info(
|
logger.debug(
|
||||||
"[%s] SEGMENT CREATED: %d images, %dpx total, mode=%s",
|
"[%s] Segment: %d image(s), %dpx, mode=%s",
|
||||||
plugin_id, len(images), total_width, display_mode.value
|
plugin_id, len(images), total_width, display_mode.value
|
||||||
)
|
)
|
||||||
logger.info("=" * 60)
|
|
||||||
|
|
||||||
return segment
|
return segment
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -696,24 +639,6 @@ class StreamManager:
|
|||||||
if len(self._active_buffer) < low_water:
|
if len(self._active_buffer) < low_water:
|
||||||
self._prefetch_content(count=low_water - len(self._active_buffer))
|
self._prefetch_content(count=low_water - len(self._active_buffer))
|
||||||
|
|
||||||
def get_all_content_for_composition(self) -> List[Image.Image]:
|
|
||||||
"""
|
|
||||||
Get all buffered content as a flat list of images.
|
|
||||||
|
|
||||||
Skips STATIC segments as they don't have images to compose.
|
|
||||||
|
|
||||||
Prefer get_grouped_content_for_composition(): flattening loses the
|
|
||||||
plugin boundaries, which is what tells the compositor where a
|
|
||||||
separator belongs and where it does not.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of all images in buffer order
|
|
||||||
"""
|
|
||||||
all_images = []
|
|
||||||
for _plugin_id, images in self.get_grouped_content_for_composition():
|
|
||||||
all_images.extend(images)
|
|
||||||
return all_images
|
|
||||||
|
|
||||||
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
|
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
|
||||||
"""
|
"""
|
||||||
Get buffered content grouped by the plugin that produced it.
|
Get buffered content grouped by the plugin that produced it.
|
||||||
@@ -816,8 +741,6 @@ class StreamManager:
|
|||||||
"""Reset the stream manager state."""
|
"""Reset the stream manager state."""
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
self._active_buffer.clear()
|
self._active_buffer.clear()
|
||||||
self._staging_buffer.clear()
|
|
||||||
self._current_index = 0
|
|
||||||
self._prefetch_index = 0
|
self._prefetch_index = 0
|
||||||
self._pending_updates.clear()
|
self._pending_updates.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ def _reset_display_manager_globals():
|
|||||||
# An instance left here is what the next module's DisplayManager() call
|
# An instance left here is what the next module's DisplayManager() call
|
||||||
# gets back -- potentially one built against a MagicMock matrix.
|
# gets back -- potentially one built against a MagicMock matrix.
|
||||||
dm_mod.DisplayManager._instance = None
|
dm_mod.DisplayManager._instance = None
|
||||||
dm_mod.DisplayManager._initialized = False
|
|
||||||
|
|
||||||
# Put a binding back if a patch outlived the module that started it.
|
# Put a binding back if a patch outlived the module that started it.
|
||||||
# Restoring rather than failing: a leak reported against an innocent module
|
# Restoring rather than failing: a leak reported against an innocent module
|
||||||
|
|||||||
@@ -43,12 +43,7 @@ function get(path) {
|
|||||||
window.debugLog = () => {};
|
window.debugLog = () => {};
|
||||||
window.PLUGIN_DEBUG = false;
|
window.PLUGIN_DEBUG = false;
|
||||||
window.installedPlugins = installed;
|
window.installedPlugins = installed;
|
||||||
window.escapeHtml = function (text) {
|
require('../led_escape').install(window);
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load the helper as a real <script>.
|
// Load the helper as a real <script>.
|
||||||
const s = document.createElement('script');
|
const s = document.createElement('script');
|
||||||
@@ -69,7 +64,8 @@ function get(path) {
|
|||||||
const s2 = document.createElement('script');
|
const s2 = document.createElement('script');
|
||||||
s2.textContent = `
|
s2.textContent = `
|
||||||
var installedPlugins = window.installedPlugins;
|
var installedPlugins = window.installedPlugins;
|
||||||
var escapeHtml = window.escapeHtml, pluginLog = window.pluginLog, debugLog = window.debugLog;
|
var escapeHtml = window.LEDEscape.html, escapeAttribute = window.LEDEscape.attr;
|
||||||
|
var pluginLog = window.pluginLog, debugLog = window.debugLog;
|
||||||
var PLUGIN_DEBUG = false;
|
var PLUGIN_DEBUG = false;
|
||||||
${src.slice(a, b)}
|
${src.slice(a, b)}
|
||||||
${src.slice(b, c)}
|
${src.slice(b, c)}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const get = p => new Promise((res, rej) =>
|
|||||||
const s0 = window.document.createElement('script');
|
const s0 = window.document.createElement('script');
|
||||||
s0.textContent = `
|
s0.textContent = `
|
||||||
window.debugLog = function () {};
|
window.debugLog = function () {};
|
||||||
|
${require('../led_escape').source}
|
||||||
window.showNotification = function () {};
|
window.showNotification = function () {};
|
||||||
window.showError = function () {};
|
window.showError = function () {};
|
||||||
window.updateSystemStatus = function () {};
|
window.updateSystemStatus = function () {};
|
||||||
|
|||||||
@@ -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_columns.js',
|
||||||
'unit/test_style_editor_layout_leaf_collision.js',
|
'unit/test_style_editor_layout_leaf_collision.js',
|
||||||
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
|
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
|
||||||
'unit/test_plugin_action_delegation.js'];
|
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
'dom/test_tools_sections.js'];
|
'dom/test_tools_sections.js'];
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
// name" onfocus="alert(1)
|
||||||
//
|
//
|
||||||
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
|
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
|
||||||
// exactly this. This suite pins the fix at the source: it reads each real
|
// exactly this. The web UI now has one implementation, window.LEDEscape in
|
||||||
// implementation out of the shipped file and runs it, so an escaper that loses
|
// app-early.js, and the old per-file escapers are one-line names for it. This
|
||||||
// its quote handling again fails here rather than in a scanner run weeks later.
|
// suite runs LEDEscape and every one of those names as shipped, and fails if a
|
||||||
|
// hand-rolled escaper appears anywhere else in web_interface/.
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -41,6 +42,7 @@ class FakeEl {
|
|||||||
}
|
}
|
||||||
global.document = { createElement: () => new FakeEl() };
|
global.document = { createElement: () => new FakeEl() };
|
||||||
global.window = global;
|
global.window = global;
|
||||||
|
const LEDEscape = require('../led_escape').install(window);
|
||||||
|
|
||||||
// ── source extraction ──────────────────────────────────────────────────────
|
// ── source extraction ──────────────────────────────────────────────────────
|
||||||
// Pull a function out of a real source file by its opening line and balanced
|
// Pull a function out of a real source file by its opening line and balanced
|
||||||
@@ -66,6 +68,7 @@ function extract(file, opener) {
|
|||||||
|
|
||||||
// Evaluate an extracted escaper and return it as a callable.
|
// Evaluate an extracted escaper and return it as a callable.
|
||||||
function loadFn(file, opener, name, { method = false } = {}) {
|
function loadFn(file, opener, name, { method = false } = {}) {
|
||||||
|
if (file === null) return LEDEscape[name];
|
||||||
const body = extract(file, opener);
|
const body = extract(file, opener);
|
||||||
// Class/object methods (`escapeHtml(text) {...}`) are not valid statements on
|
// Class/object methods (`escapeHtml(text) {...}`) are not valid statements on
|
||||||
// their own -- wrap them in an object literal so they can be evaluated.
|
// their own -- wrap them in an object literal so they can be evaluated.
|
||||||
@@ -78,6 +81,8 @@ function loadFn(file, opener, name, { method = false } = {}) {
|
|||||||
|
|
||||||
// ── the escapers, as shipped ───────────────────────────────────────────────
|
// ── the escapers, as shipped ───────────────────────────────────────────────
|
||||||
const ESCAPERS = [
|
const ESCAPERS = [
|
||||||
|
['app-early.js (LEDEscape.html)', null, null, 'html'],
|
||||||
|
['app-early.js (LEDEscape.attr)', null, null, 'attr'],
|
||||||
['base-widget.js (BaseWidget.escapeHtml)',
|
['base-widget.js (BaseWidget.escapeHtml)',
|
||||||
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
|
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
|
||||||
['plugins_manager.js (top-level escapeHtml)',
|
['plugins_manager.js (top-level escapeHtml)',
|
||||||
@@ -90,8 +95,26 @@ const ESCAPERS = [
|
|||||||
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
|
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
|
||||||
['plugin-file-manager.js (escHtml)',
|
['plugin-file-manager.js (escHtml)',
|
||||||
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
|
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
|
||||||
['app-shell.js (escapeHtml)',
|
['plugins_manager.js (escapeAttribute)',
|
||||||
'static/v3/js/app-shell.js', 'escapeHtml(text) {', 'escapeHtml', true],
|
'static/v3/plugins_manager.js', 'function escapeAttribute(text) {', 'escapeAttribute', false],
|
||||||
|
['notification.js (escapeHtml)',
|
||||||
|
'static/v3/js/widgets/notification.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
|
['google-calendar-picker.js (escapeHtml)',
|
||||||
|
'static/v3/js/widgets/google-calendar-picker.js', 'function escapeHtml(str) {', 'escapeHtml', false],
|
||||||
|
['text-input.js (escapeHtml)',
|
||||||
|
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
|
['slider.js (escapeAttr)',
|
||||||
|
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||||
|
['display.html (escapeAttr)',
|
||||||
|
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||||
|
['backup_restore.html (escapeHtml)',
|
||||||
|
'templates/v3/partials/backup_restore.html', 'function escapeHtml(value) {', 'escapeHtml', false],
|
||||||
|
['operation_history.html (escapeHtml)',
|
||||||
|
'templates/v3/partials/operation_history.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
|
['tools.html (escHtml)',
|
||||||
|
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
|
||||||
|
['tools.html (phEscape)',
|
||||||
|
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
|
||||||
['logs.html (escapeHtml)',
|
['logs.html (escapeHtml)',
|
||||||
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||||
['cache.html (escapeHtml)',
|
['cache.html (escapeHtml)',
|
||||||
@@ -135,6 +158,51 @@ for (const [label, file, opener, name, method] of ESCAPERS) {
|
|||||||
ok(`${label}: " input stays inert`, out === '&quot;', out);
|
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) ─────────────────────────
|
// ── url-input scheme handling (js/xss-through-dom) ─────────────────────────
|
||||||
console.log('\n5. url-input never treats a scriptable scheme as a valid URL');
|
console.log('\n5. url-input never treats a scriptable scheme as a valid URL');
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,9 +7,8 @@
|
|||||||
//
|
//
|
||||||
// JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id
|
// JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id
|
||||||
// of x' onmouseover='alert(1) closed the single-quoted attribute and added a
|
// of x' onmouseover='alert(1) closed the single-quoted attribute and added a
|
||||||
// handler of its own. The live window.updateImageList (plugins_manager.js loads
|
// handler of its own. (The uploaded-image list is no longer built here: the
|
||||||
// last, so its copy beats the file-upload widget's) put the uploaded file's
|
// file-upload widget owns it, and test_file_upload_widget.js covers it.)
|
||||||
// original name into the markup unescaped.
|
|
||||||
//
|
//
|
||||||
// Each case renders with the shipped function, parses the tag the way a browser
|
// Each case renders with the shipped function, parses the tag the way a browser
|
||||||
// does (quoted attribute values, entities decoded), and checks two things: no
|
// does (quoted attribute values, entities decoded), and checks two things: no
|
||||||
@@ -54,6 +53,7 @@ global.document = {
|
|||||||
createElement: () => new TextEl(),
|
createElement: () => new TextEl(),
|
||||||
};
|
};
|
||||||
global.window = global;
|
global.window = global;
|
||||||
|
require('../led_escape').install(window);
|
||||||
global.pluginLog = () => {};
|
global.pluginLog = () => {};
|
||||||
global.isStorePluginInstalled = () => false;
|
global.isStorePluginInstalled = () => false;
|
||||||
global.isNewPlugin = () => false;
|
global.isNewPlugin = () => false;
|
||||||
@@ -68,8 +68,6 @@ eval([
|
|||||||
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
|
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
|
||||||
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
|
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
|
||||||
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
|
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
|
||||||
// eslint-disable-next-line no-eval
|
|
||||||
eval(extract('window.updateImageList = function(fieldId, images) {'));
|
|
||||||
|
|
||||||
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
|
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
|
||||||
function decodeEntities(s) {
|
function decodeEntities(s) {
|
||||||
@@ -190,26 +188,5 @@ for (const hostile of [SQ, DQ, AMP]) {
|
|||||||
calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls);
|
calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n5. live window.updateImageList escapes the uploaded file name');
|
|
||||||
window.getUploadConfig = () => ({ plugin_id: SQ });
|
|
||||||
window.currentPluginConfig = null;
|
|
||||||
{
|
|
||||||
const name = '<img src=x onerror=alert(1)>' + DQ + '.png';
|
|
||||||
window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]);
|
|
||||||
const html = els['f_image_list'].innerHTML;
|
|
||||||
ok('no raw markup from original_filename', !html.includes('<img src=x onerror'), html);
|
|
||||||
const imgs = tags(html, 'img');
|
|
||||||
ok('one <img>, only the template attributes',
|
|
||||||
imgs.length === 1 && names(imgs[0]).join(',') === 'src,alt,loading,decoding,class,onerror', imgs.map(names));
|
|
||||||
ok('alt carries the stored filename as text', attr(imgs[0], 'alt') === DQ, imgs[0]);
|
|
||||||
const buttons = tags(html, 'button');
|
|
||||||
ok('two buttons, no extra attributes',
|
|
||||||
buttons.length === 2 && buttons.every(b => names(b).join(',') === 'type,onclick,class,title,aria-label'),
|
|
||||||
buttons.map(names));
|
|
||||||
const del = runHandler(attr(buttons[1], 'onclick'));
|
|
||||||
ok('delete handler gets field, image and plugin ids intact',
|
|
||||||
del.length === 1 && del[0][0] === 'deleteUploadedImage' && del[0][1] === 'f' && del[0][2] === SQ && del[0][3] === SQ, del);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function slice(startMarker, endMarker) {
|
|||||||
return SRC.slice(a, b);
|
return SRC.slice(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// Note: configurePlugin');
|
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// GitHub Token Collapse Handler');
|
||||||
const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)');
|
const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)');
|
||||||
|
|
||||||
let pass = 0, fail = 0;
|
let pass = 0, fail = 0;
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ global.document = {
|
|||||||
createElement: () => new FakeEl(),
|
createElement: () => new FakeEl(),
|
||||||
};
|
};
|
||||||
global.window = global;
|
global.window = global;
|
||||||
|
require('../led_escape').install(window);
|
||||||
global.pluginLog = () => {};
|
global.pluginLog = () => {};
|
||||||
global.PLUGIN_DEBUG = false;
|
global.PLUGIN_DEBUG = false;
|
||||||
global.debugLog = () => {};
|
global.debugLog = () => {};
|
||||||
function setupInstalledEventDelegation() {} // stubbed; tested separately
|
function setupInstalledEventDelegation() {} // stubbed; tested separately
|
||||||
|
|
||||||
eval(slice('function escapeHtml(text)', '\nfunction ', ));
|
eval(slice('function escapeHtml(text)', '\nfunction isNewPlugin'));
|
||||||
eval(slice('function renderInstalledCards(plugins, total)',
|
eval(slice('function renderInstalledCards(plugins, total)',
|
||||||
'// Set up event delegation for plugin action buttons'));
|
'// Set up event delegation for plugin action buttons'));
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
fake_coordinator = MagicMock()
|
||||||
|
|
||||||
import src.display_controller as dc_module
|
from unittest.mock import patch
|
||||||
original_imported = dc_module._vegas_mode_imported
|
with patch('src.vegas_mode.VegasModeCoordinator',
|
||||||
original_class = dc_module.VegasModeCoordinator
|
MagicMock(return_value=fake_coordinator)):
|
||||||
try:
|
|
||||||
dc_module._vegas_mode_imported = True
|
|
||||||
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
|
|
||||||
dc._initialize_vegas_mode()
|
dc._initialize_vegas_mode()
|
||||||
finally:
|
|
||||||
dc_module._vegas_mode_imported = original_imported
|
|
||||||
dc_module.VegasModeCoordinator = original_class
|
|
||||||
|
|
||||||
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
|
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ def dm(tmp_path_factory):
|
|||||||
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
||||||
from src.display_manager import DisplayManager
|
from src.display_manager import DisplayManager
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
|
||||||
manager = DisplayManager({
|
manager = DisplayManager({
|
||||||
"display": {
|
"display": {
|
||||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
||||||
@@ -60,7 +59,6 @@ def dm(tmp_path_factory):
|
|||||||
"test module.")
|
"test module.")
|
||||||
yield manager
|
yield manager
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
class _SwapSpy:
|
class _SwapSpy:
|
||||||
@@ -230,7 +228,6 @@ class TestKillSwitch:
|
|||||||
def test_config_flag_wires_through(self):
|
def test_config_flag_wires_through(self):
|
||||||
from src.display_manager import DisplayManager
|
from src.display_manager import DisplayManager
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
|
||||||
try:
|
try:
|
||||||
manager = DisplayManager({
|
manager = DisplayManager({
|
||||||
"display": {
|
"display": {
|
||||||
@@ -243,7 +240,6 @@ class TestKillSwitch:
|
|||||||
assert manager._dirty_tracking_enabled is False
|
assert manager._dirty_tracking_enabled is False
|
||||||
finally:
|
finally:
|
||||||
DisplayManager._instance = None
|
DisplayManager._instance = None
|
||||||
DisplayManager._initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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_callback = None
|
||||||
coord._update_tick_running = False
|
coord._update_tick_running = False
|
||||||
coord.sync_manager = None
|
coord.sync_manager = None
|
||||||
coord._update_static_mode_plugins = lambda: None
|
|
||||||
coord._check_static_plugin_trigger = lambda: None
|
coord._check_static_plugin_trigger = lambda: None
|
||||||
coord.frames = 0
|
coord.frames = 0
|
||||||
|
|
||||||
|
|||||||
+24
-19
@@ -2,7 +2,7 @@
|
|||||||
Tests for src/vegas_mode/config.py
|
Tests for src/vegas_mode/config.py
|
||||||
|
|
||||||
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
|
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
|
||||||
is_plugin_included, get_ordered_plugins, validate.
|
get_ordered_plugins, validate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -38,6 +38,29 @@ class TestVegasModeConfigDefaults:
|
|||||||
cfg = VegasModeConfig()
|
cfg = VegasModeConfig()
|
||||||
assert len(cfg.excluded_plugins) == 0
|
assert len(cfg.excluded_plugins) == 0
|
||||||
|
|
||||||
|
def test_defaults_match_the_shipped_template(self):
|
||||||
|
# The template, the web UI help and CONFIG_REFERENCE all document
|
||||||
|
# these values. max_cycle_duration defaulted to 600 in code while all
|
||||||
|
# three said 240, so an install without the key ran iterations 2.5x
|
||||||
|
# longer than documented.
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
template = json.loads(
|
||||||
|
(Path(__file__).resolve().parent.parent / "config"
|
||||||
|
/ "config.template.json").read_text(encoding="utf-8"))
|
||||||
|
shipped = template["display"]["vegas_scroll"]
|
||||||
|
defaults = VegasModeConfig().to_dict()
|
||||||
|
mismatched = {k: (v, defaults[k]) for k, v in shipped.items()
|
||||||
|
if k in defaults and defaults[k] != v}
|
||||||
|
assert not mismatched, f"template vs code default: {mismatched}"
|
||||||
|
|
||||||
|
def test_missing_keys_read_the_field_defaults(self):
|
||||||
|
# from_config used to repeat every default; with no keys set it must
|
||||||
|
# produce exactly the dataclass defaults.
|
||||||
|
assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict()
|
||||||
|
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict()
|
||||||
|
== VegasModeConfig().to_dict())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# from_config
|
# from_config
|
||||||
@@ -147,24 +170,6 @@ class TestGetFrameInterval:
|
|||||||
assert result == 1.0
|
assert result == 1.0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# is_plugin_included
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestIsPluginIncluded:
|
|
||||||
def test_not_excluded_is_included(self):
|
|
||||||
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
|
|
||||||
assert cfg.is_plugin_included("good_plugin") is True
|
|
||||||
|
|
||||||
def test_excluded_plugin_not_included(self):
|
|
||||||
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
|
|
||||||
assert cfg.is_plugin_included("bad_plugin") is False
|
|
||||||
|
|
||||||
def test_empty_exclusions_all_included(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.is_plugin_included("anything") is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# get_ordered_plugins
|
# get_ordered_plugins
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ class TestInvalidatePendingUpdates:
|
|||||||
# Left unconsumed they accumulate forever and nothing ever refreshes.
|
# Left unconsumed they accumulate forever and nothing ever refreshes.
|
||||||
stream = self._manager({'baseball': SimpleNamespace()})
|
stream = self._manager({'baseball': SimpleNamespace()})
|
||||||
stream.mark_plugin_updated('baseball')
|
stream.mark_plugin_updated('baseball')
|
||||||
assert stream.has_pending_updates() is True
|
assert stream._pending_updates
|
||||||
|
|
||||||
stream.invalidate_pending_updates()
|
stream.invalidate_pending_updates()
|
||||||
assert stream.has_pending_updates() is False
|
assert not stream._pending_updates
|
||||||
assert stream.invalidate_pending_updates() == []
|
assert stream.invalidate_pending_updates() == []
|
||||||
|
|
||||||
def test_no_pending_updates_does_no_work(self):
|
def test_no_pending_updates_does_no_work(self):
|
||||||
@@ -133,7 +133,6 @@ class TestInvalidatePendingUpdates:
|
|||||||
class TestContinuousModeReachesTheRefresh:
|
class TestContinuousModeReachesTheRefresh:
|
||||||
def _pipeline(self):
|
def _pipeline(self):
|
||||||
stream = MagicMock()
|
stream = MagicMock()
|
||||||
stream.get_buffer_status.return_value = {'staging_count': 0}
|
|
||||||
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
|
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
|
||||||
|
|
||||||
def test_refresh_delegates_to_the_stream_manager(self):
|
def test_refresh_delegates_to_the_stream_manager(self):
|
||||||
@@ -161,7 +160,6 @@ class TestContinuousModeReachesTheRefresh:
|
|||||||
pipeline.refresh_updated_plugins()
|
pipeline.refresh_updated_plugins()
|
||||||
|
|
||||||
assert pipeline.scroll_helper.scroll_position == 1234
|
assert pipeline.scroll_helper.scroll_position == 1234
|
||||||
stream.swap_buffers.assert_not_called()
|
|
||||||
stream.process_updates.assert_not_called()
|
stream.process_updates.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||||
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
|
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
|
||||||
return ContentSegment(
|
return ContentSegment(
|
||||||
plugin_id=plugin_id, images=imgs, total_width=10 * count,
|
plugin_id=plugin_id, images=imgs,
|
||||||
display_mode=mode or VegasDisplayMode.SCROLL)
|
display_mode=mode or VegasDisplayMode.SCROLL)
|
||||||
|
|
||||||
def test_grouping_preserves_plugin_boundaries(self):
|
def test_grouping_preserves_plugin_boundaries(self):
|
||||||
@@ -437,10 +437,6 @@ class TestStreamGrouping:
|
|||||||
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
|
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
|
||||||
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
|
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
|
||||||
|
|
||||||
def test_flat_accessor_still_matches_grouped_total(self):
|
|
||||||
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
|
|
||||||
assert len(sm.get_all_content_for_composition()) == 5
|
|
||||||
|
|
||||||
|
|
||||||
class TestApiBoundsMatchValidate:
|
class TestApiBoundsMatchValidate:
|
||||||
"""
|
"""
|
||||||
@@ -980,8 +976,8 @@ class TestRotationAcrossMultipleCycles:
|
|||||||
"""
|
"""
|
||||||
The single-image crop advances a window across cycles. The second and later
|
The single-image crop advances a window across cycles. The second and later
|
||||||
passes are where start + budget can land exactly on the image width, which
|
passes are where start + budget can land exactly on the image width, which
|
||||||
crashed find_blank_cut in the field and lost that plugin's content for the
|
once crashed the cut and lost that plugin's content for the cycle.
|
||||||
cycle. First-pass-only tests never reach it.
|
First-pass-only tests never reach it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_window_advances_over_many_cycles_without_error(self):
|
def test_window_advances_over_many_cycles_without_error(self):
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from src.vegas_mode.geometry import (
|
|||||||
content_bounds,
|
content_bounds,
|
||||||
dead_window_stats,
|
dead_window_stats,
|
||||||
edge_blank,
|
edge_blank,
|
||||||
find_blank_cut,
|
|
||||||
separation_gap,
|
separation_gap,
|
||||||
trim_to_content,
|
trim_to_content,
|
||||||
window_coverage_stats,
|
window_coverage_stats,
|
||||||
@@ -320,51 +319,3 @@ class TestSeparationGap:
|
|||||||
# flat gap; measured separation lifts them to the 24px target.
|
# flat gap; measured separation lifts them to the 24px target.
|
||||||
card = paint(make_img(150), 0, 150)
|
card = paint(make_img(150), 0, 150)
|
||||||
assert separation_gap(card, card, target=24, minimum=8) == 24
|
assert separation_gap(card, card, target=24, minimum=8) == 24
|
||||||
|
|
||||||
|
|
||||||
class TestFindBlankCut:
|
|
||||||
def test_snaps_to_the_nearest_gap(self):
|
|
||||||
img = paint(make_img(200), 0, 90)
|
|
||||||
paint(img, 110, 200)
|
|
||||||
# 100 is inside the 90..110 gap already.
|
|
||||||
assert find_blank_cut(img, 100, 20) == 100
|
|
||||||
|
|
||||||
def test_walks_outwards_to_find_a_gap(self):
|
|
||||||
img = paint(make_img(200), 0, 95)
|
|
||||||
paint(img, 105, 200)
|
|
||||||
cut = find_blank_cut(img, 90, 20)
|
|
||||||
assert 95 <= cut < 105
|
|
||||||
|
|
||||||
def test_solid_ink_returns_the_target(self):
|
|
||||||
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
|
|
||||||
|
|
||||||
def test_target_at_image_width_does_not_index_past_the_end(self):
|
|
||||||
# A cut after the last column is legal. Indexing ink[width] raised
|
|
||||||
# IndexError in the field, losing that plugin's content for the cycle.
|
|
||||||
# Reached once the rotation offset advances so start + budget lands
|
|
||||||
# exactly on the image width.
|
|
||||||
img = paint(make_img(1840), 0, 1840)
|
|
||||||
assert find_blank_cut(img, 1840, 32) == 1840
|
|
||||||
|
|
||||||
def test_target_past_image_width_is_clamped(self):
|
|
||||||
img = paint(make_img(100), 0, 100)
|
|
||||||
assert find_blank_cut(img, 500, 32) == 100
|
|
||||||
|
|
||||||
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
|
|
||||||
# Content 0..179, blank 180..199. The nearest blank column to 200 is
|
|
||||||
# 199, not the start of the gap — nearest is what keeps the cut as
|
|
||||||
# close as possible to the requested budget.
|
|
||||||
img = paint(make_img(200), 0, 180)
|
|
||||||
assert find_blank_cut(img, 200, 32) == 199
|
|
||||||
|
|
||||||
def test_zero_radius_returns_the_target(self):
|
|
||||||
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
|
|
||||||
|
|
||||||
def test_negative_target_is_clamped_to_zero(self):
|
|
||||||
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
|
|
||||||
def test_never_raises_across_the_range(self, target):
|
|
||||||
img = paint(make_img(100), 0, 100)
|
|
||||||
cut = find_blank_cut(img, target, 16)
|
|
||||||
assert 0 <= cut <= 100
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class FakeDisplayManager:
|
|||||||
|
|
||||||
def _make_pipeline(sync_manager=None):
|
def _make_pipeline(sync_manager=None):
|
||||||
stream_manager = MagicMock()
|
stream_manager = MagicMock()
|
||||||
stream_manager.get_buffer_status.return_value = {'staging_count': 0}
|
|
||||||
pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager)
|
pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager)
|
||||||
pipeline.sync_manager = sync_manager
|
pipeline.sync_manager = sync_manager
|
||||||
return pipeline, stream_manager
|
return pipeline, stream_manager
|
||||||
@@ -37,7 +36,7 @@ class TestShouldRecompose:
|
|||||||
stream_manager.has_pending_updates_for_visible_segments.return_value = False
|
stream_manager.has_pending_updates_for_visible_segments.return_value = False
|
||||||
assert pipeline.should_recompose() is True
|
assert pipeline.should_recompose() is True
|
||||||
|
|
||||||
def test_no_pending_updates_no_staging_does_not_recompose(self):
|
def test_no_pending_updates_does_not_recompose(self):
|
||||||
pipeline, stream_manager = _make_pipeline()
|
pipeline, stream_manager = _make_pipeline()
|
||||||
stream_manager.has_pending_updates_for_visible_segments.return_value = False
|
stream_manager.has_pending_updates_for_visible_segments.return_value = False
|
||||||
assert pipeline.should_recompose() is False
|
assert pipeline.should_recompose() is False
|
||||||
@@ -49,12 +48,6 @@ class TestShouldRecompose:
|
|||||||
stream_manager.has_pending_updates_for_visible_segments.return_value = True
|
stream_manager.has_pending_updates_for_visible_segments.return_value = True
|
||||||
assert pipeline.should_recompose() is True
|
assert pipeline.should_recompose() is True
|
||||||
|
|
||||||
def test_staging_buffer_content_triggers_recompose(self):
|
|
||||||
pipeline, stream_manager = _make_pipeline()
|
|
||||||
stream_manager.get_buffer_status.return_value = {'staging_count': 1}
|
|
||||||
stream_manager.has_pending_updates_for_visible_segments.return_value = False
|
|
||||||
assert pipeline.should_recompose() is True
|
|
||||||
|
|
||||||
def test_sync_active_defers_pending_updates_to_cycle_boundary(self):
|
def test_sync_active_defers_pending_updates_to_cycle_boundary(self):
|
||||||
"""Sync-mode deferral (PR #330's actual intent) must still hold:
|
"""Sync-mode deferral (PR #330's actual intent) must still hold:
|
||||||
pending updates alone must NOT trigger a mid-cycle hot-swap when a
|
pending updates alone must NOT trigger a mid-cycle hot-swap when a
|
||||||
|
|||||||
@@ -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 */
|
/* global showNotification */
|
||||||
// LED Matrix v3 JavaScript
|
/*
|
||||||
// Additional helpers for HTMX and Alpine.js integration
|
* app.js -- page-wide behaviour that is not the Alpine app itself.
|
||||||
|
*
|
||||||
|
* Deferred, first of the scripts at the end of <body>; after app-shell.js
|
||||||
|
* and Alpine.
|
||||||
|
*
|
||||||
|
* Load order (templates/v3/base.html):
|
||||||
|
* <head>, blocking: debugLog and theme inline scripts; the htmx loader
|
||||||
|
* (injects htmx.min.js with a dynamic <script>);
|
||||||
|
* js/htmx-config.js; the loadPartialDirect fallback;
|
||||||
|
* js/app-early.js
|
||||||
|
* <head>, defer: js/app-shell.js, then js/alpinejs.min.js (Alpine
|
||||||
|
* starts as soon as it runs, so app-shell.js's app()
|
||||||
|
* is the one Alpine uses)
|
||||||
|
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
||||||
|
* js/settings-search.js, js/utils/dialog.js,
|
||||||
|
* js/utils/error_handler.js, js/plugins/api_client.js,
|
||||||
|
* state_manager.js, install_manager.js, list_filter.js,
|
||||||
|
* the widget bundle (web_interface/widget_bundle.py),
|
||||||
|
* plugins_manager.js
|
||||||
|
* Tab partials arrive later through htmx; their inline scripts run on
|
||||||
|
* htmx:afterSwap (js/htmx-config.js).
|
||||||
|
*
|
||||||
|
* Owns: button loading states and the fallback result toast for htmx
|
||||||
|
* requests; the unsaved-changes guard for plugin config forms; the "restart
|
||||||
|
* the display" banner; the floating live preview; aria-current on the nav;
|
||||||
|
* the mobile nav drawer's keyboard handling; header widget placement.
|
||||||
|
*
|
||||||
|
* Globals: showSaveResult, showRestartPending, dismissRestartPending,
|
||||||
|
* restartPendingNow, toggleFloatingPreview, applyFloatingPreviewSize,
|
||||||
|
* cycleFloatingPreviewSize, updateFloatingPreviewVisibility,
|
||||||
|
* previewPluginNow, updateNavAriaCurrent, placeHeaderWidgets.
|
||||||
|
*/
|
||||||
|
|
||||||
// HTMX response handlers
|
// HTMX response handlers
|
||||||
document.body.addEventListener('htmx:beforeRequest', function(event) {
|
document.body.addEventListener('htmx:beforeRequest', function(event) {
|
||||||
@@ -22,9 +53,15 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
|||||||
if (textEl) textEl.style.opacity = '1';
|
if (textEl) textEl.style.opacity = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle response notifications
|
// Show the server's message, unless the element that made the request
|
||||||
|
// (or its form) has its own after-request handler: every such handler in
|
||||||
|
// the templates reports the result itself, and this used to repeat it,
|
||||||
|
// so each save showed two toasts.
|
||||||
const response = event.detail.xhr;
|
const response = event.detail.xhr;
|
||||||
if (response && response.responseText) {
|
const elt = event.detail.elt;
|
||||||
|
const reportsItself = elt && elt.closest &&
|
||||||
|
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request]');
|
||||||
|
if (!reportsItself && response && response.responseText) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(response.responseText);
|
const data = JSON.parse(response.responseText);
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
@@ -48,6 +85,30 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
|||||||
} catch { /* banner is best-effort */ }
|
} catch { /* banner is best-effort */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the outcome of a settings form save as one notification. Used by the
|
||||||
|
* hx-on:htmx:after-request of the Display, Rotation & Durations and General
|
||||||
|
* forms. Only a 2xx response counts as saved (a network failure is status 0);
|
||||||
|
* the server's message is shown when there is one, and its status can refine
|
||||||
|
* a success but never overturn a failure.
|
||||||
|
* @param {XMLHttpRequest} xhr - event.detail.xhr
|
||||||
|
* @param {string} savedText - message for a success without one
|
||||||
|
* @param {string} failedText - message for a failure without one
|
||||||
|
*/
|
||||||
|
window.showSaveResult = function(xhr, savedText, failedText) {
|
||||||
|
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
|
||||||
|
let message = httpSuccess ? savedText : failedText;
|
||||||
|
let status = httpSuccess ? 'success' : 'error';
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (data.message) message = data.message;
|
||||||
|
if (httpSuccess && data.status) status = data.status;
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body: keep the status-code verdict.
|
||||||
|
}
|
||||||
|
showNotification(message, status);
|
||||||
|
};
|
||||||
|
|
||||||
// ===== Unsaved-changes guard =====
|
// ===== Unsaved-changes guard =====
|
||||||
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS
|
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS
|
||||||
// the panel and revisiting re-fetches it, silently discarding any edits.
|
// the panel and revisiting re-fetches it, silently discarding any edits.
|
||||||
@@ -242,9 +303,8 @@ window.updateFloatingPreviewVisibility = function(tab) {
|
|||||||
if (!panel || !toggle) return;
|
if (!panel || !toggle) return;
|
||||||
let active = tab;
|
let active = tab;
|
||||||
if (!active) {
|
if (!active) {
|
||||||
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
const app = window.getApp();
|
||||||
const data = el && el._x_dataStack && el._x_dataStack[0];
|
active = app && app.activeTab;
|
||||||
active = data && data.activeTab;
|
|
||||||
}
|
}
|
||||||
const onOverview = active === 'overview';
|
const onOverview = active === 'overview';
|
||||||
let open = false;
|
let open = false;
|
||||||
@@ -308,13 +368,9 @@ window.updateNavAriaCurrent = function(tab) {
|
|||||||
// Escape closes the mobile nav drawer and returns focus to the hamburger;
|
// Escape closes the mobile nav drawer and returns focus to the hamburger;
|
||||||
// opening the drawer moves focus to its first tab.
|
// opening the drawer moves focus to its first tab.
|
||||||
(function() {
|
(function() {
|
||||||
function appData() {
|
|
||||||
const el = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
|
||||||
return el && el._x_dataStack && el._x_dataStack[0];
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key !== 'Escape') return;
|
if (e.key !== 'Escape') return;
|
||||||
const data = appData();
|
const data = window.getApp();
|
||||||
if (data && data.mobileNavOpen) {
|
if (data && data.mobileNavOpen) {
|
||||||
data.mobileNavOpen = false;
|
data.mobileNavOpen = false;
|
||||||
const burger = document.querySelector('[aria-controls="site-nav"]');
|
const burger = document.querySelector('[aria-controls="site-nav"]');
|
||||||
@@ -328,7 +384,7 @@ window.updateNavAriaCurrent = function(tab) {
|
|||||||
// The click handler toggles mobileNavOpen; focus the first tab once
|
// The click handler toggles mobileNavOpen; focus the first tab once
|
||||||
// the drawer has slid in (matches the CSS transition timing).
|
// the drawer has slid in (matches the CSS transition timing).
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
const data = appData();
|
const data = window.getApp();
|
||||||
if (data && data.mobileNavOpen) {
|
if (data && data.mobileNavOpen) {
|
||||||
const first = document.querySelector('#site-nav .nav-tab');
|
const first = document.querySelector('#site-nav .nav-tab');
|
||||||
if (first) first.focus();
|
if (first) first.focus();
|
||||||
|
|||||||
@@ -1,8 +1,89 @@
|
|||||||
/* global debugLog */
|
/* global debugLog */
|
||||||
// Early helpers and the app() stub (must run before Alpine init)
|
/*
|
||||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
* app-early.js -- helpers every other script relies on, and the app() stub.
|
||||||
// Helper function to get installed plugins with fallback
|
*
|
||||||
// Must be defined before app() function that uses it
|
* A blocking <script> in <head>, so everything here exists before any other
|
||||||
|
* script, widget or partial runs.
|
||||||
|
*
|
||||||
|
* Load order (templates/v3/base.html):
|
||||||
|
* <head>, blocking: debugLog and theme inline scripts; the htmx loader
|
||||||
|
* (injects htmx.min.js with a dynamic <script>);
|
||||||
|
* js/htmx-config.js; the loadPartialDirect fallback;
|
||||||
|
* js/app-early.js
|
||||||
|
* <head>, defer: js/app-shell.js, then js/alpinejs.min.js (Alpine
|
||||||
|
* starts as soon as it runs, so app-shell.js's app()
|
||||||
|
* is the one Alpine uses)
|
||||||
|
* end of <body>, defer, in this order: app.js, js/tooltips.js,
|
||||||
|
* js/settings-search.js, js/utils/dialog.js,
|
||||||
|
* js/utils/error_handler.js, js/plugins/api_client.js,
|
||||||
|
* state_manager.js, install_manager.js, list_filter.js,
|
||||||
|
* the widget bundle (web_interface/widget_bundle.py),
|
||||||
|
* plugins_manager.js
|
||||||
|
* Tab partials arrive later through htmx; their inline scripts run on
|
||||||
|
* htmx:afterSwap (js/htmx-config.js).
|
||||||
|
*
|
||||||
|
* Globals:
|
||||||
|
* window.LEDEscape html / attr / jsStringAttr, the only HTML escaper
|
||||||
|
* window.escapeHtml / window.escapeAttribute aliases of LEDEscape.html /
|
||||||
|
* .attr, kept for plugin pages
|
||||||
|
* window.getApp() the root Alpine component (<body x-data="app()">)
|
||||||
|
* window.app a stub app() so Alpine can start before app-shell.js
|
||||||
|
* has run; app-shell.js replaces it with the full one
|
||||||
|
* (in the normal load order Alpine only ever sees the
|
||||||
|
* full one, see the note on the stub below)
|
||||||
|
* getInstalledPluginsSafe() installed list via PluginAPI or fetch
|
||||||
|
* a pluginsUpdated listener that draws the plugin tab row while the app is
|
||||||
|
* not the full implementation yet
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== window.LEDEscape: the web UI's HTML escaping =====
|
||||||
|
// This file is a blocking <script> in <head>, so every later script,
|
||||||
|
// widget and partial can call these directly.
|
||||||
|
// html(v) text for element content or a quoted attribute
|
||||||
|
// value: & < > " ' become entities, null and
|
||||||
|
// undefined become ''. Escaping quotes is what makes
|
||||||
|
// it attribute-safe; a textContent/innerHTML round
|
||||||
|
// trip only escapes & < and >.
|
||||||
|
// attr(v) the same function, for call sites that want the
|
||||||
|
// attribute context to read explicitly.
|
||||||
|
// jsStringAttr(v) a quoted JS string literal for an inline handler
|
||||||
|
// attribute: onclick='f(${jsStringAttr(id)})'.
|
||||||
|
// JSON.stringify alone leaves ' and & untouched, so a
|
||||||
|
// value containing ' could close a single-quoted
|
||||||
|
// attribute. The browser decodes the entities before
|
||||||
|
// it parses the handler, so the JS sees the literal.
|
||||||
|
window.LEDEscape = (function() {
|
||||||
|
const ENTITIES = new Map([['&', '&'], ['<', '<'], ['>', '>'], ['"', '"'], ["'", ''']]);
|
||||||
|
function html(value) {
|
||||||
|
return value == null ? '' : String(value).replace(/[&<>"']/g, c => ENTITIES.get(c));
|
||||||
|
}
|
||||||
|
function jsStringAttr(value) {
|
||||||
|
return html(JSON.stringify(value == null ? '' : String(value)));
|
||||||
|
}
|
||||||
|
// An attribute value needs no more than html(): it escapes both quote characters.
|
||||||
|
function attr(value) {
|
||||||
|
return html(value);
|
||||||
|
}
|
||||||
|
return Object.freeze({ html: html, attr: attr, jsStringAttr: jsStringAttr });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Kept for plugin web UIs and third-party plugin pages that call them.
|
||||||
|
window.escapeHtml = window.LEDEscape.html;
|
||||||
|
window.escapeAttribute = window.LEDEscape.attr;
|
||||||
|
|
||||||
|
// ===== window.getApp(): the root Alpine component =====
|
||||||
|
// The data of <body x-data="app()"> (activeTab, installedPlugins, ...),
|
||||||
|
// read through Alpine's public Alpine.$data rather than the private
|
||||||
|
// el._x_dataStack. Null until Alpine has initialised the app.
|
||||||
|
window.getApp = function() {
|
||||||
|
const el = document.querySelector('[x-data="app()"]');
|
||||||
|
if (!el || !window.Alpine) return null;
|
||||||
|
const data = window.Alpine.$data(el);
|
||||||
|
return data && 'activeTab' in data ? data : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The installed-plugin list through PluginAPI, or a plain fetch if
|
||||||
|
// PluginAPI (loaded later, deferred) is not there yet.
|
||||||
async function getInstalledPluginsSafe() {
|
async function getInstalledPluginsSafe() {
|
||||||
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
|
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
|
||||||
try {
|
try {
|
||||||
@@ -20,32 +101,19 @@
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
|
// Builds the plugin tab row from a pluginsUpdated event while the app is
|
||||||
// This ensures tabs update when plugins_manager.js loads plugins
|
// not app-shell.js's full implementation (Alpine not started yet, or
|
||||||
|
// still on the stub below). The full app() has its own pluginsUpdated
|
||||||
|
// listener, registered in its init().
|
||||||
document.addEventListener('pluginsUpdated', function(event) {
|
document.addEventListener('pluginsUpdated', function(event) {
|
||||||
|
const appComponent = window.getApp();
|
||||||
|
if (appComponent && typeof appComponent._doUpdatePluginTabs === 'function') return;
|
||||||
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
||||||
const plugins = event.detail?.plugins || [];
|
const plugins = event.detail?.plugins || [];
|
||||||
|
if (appComponent) {
|
||||||
// Update window.installedPlugins
|
appComponent.installedPlugins = plugins;
|
||||||
window.installedPlugins = plugins;
|
|
||||||
|
|
||||||
// Try to update Alpine component if it exists (only if using full implementation)
|
|
||||||
if (window.Alpine) {
|
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
|
||||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
||||||
const appComponent = appElement._x_dataStack[0];
|
|
||||||
appComponent.installedPlugins = plugins;
|
|
||||||
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
|
|
||||||
if (typeof appComponent.updatePluginTabs === 'function' &&
|
|
||||||
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
|
||||||
debugLog('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
|
|
||||||
appComponent.updatePluginTabs();
|
|
||||||
return; // Full implementation handles it, don't do direct update
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only do direct DOM update if full implementation isn't available yet
|
|
||||||
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
||||||
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
||||||
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
|
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
|
||||||
@@ -60,15 +128,11 @@
|
|||||||
tabButton.setAttribute('data-plugin-id', plugin.id);
|
tabButton.setAttribute('data-plugin-id', plugin.id);
|
||||||
tabButton.className = `plugin-tab nav-tab`;
|
tabButton.className = `plugin-tab nav-tab`;
|
||||||
tabButton.onclick = function() {
|
tabButton.onclick = function() {
|
||||||
// Try to set activeTab via Alpine if available
|
const app = window.getApp();
|
||||||
if (window.Alpine) {
|
if (app) {
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
app.activeTab = plugin.id;
|
||||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
if (typeof app.updatePluginTabStates === 'function') {
|
||||||
appElement._x_dataStack[0].activeTab = plugin.id;
|
app.updatePluginTabStates();
|
||||||
// Only call updatePluginTabStates if it exists
|
|
||||||
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
|
|
||||||
appElement._x_dataStack[0].updatePluginTabStates();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -89,8 +153,13 @@
|
|||||||
// Guard flag to prevent duplicate stub-to-full enhancement
|
// Guard flag to prevent duplicate stub-to-full enhancement
|
||||||
window._appEnhanced = false;
|
window._appEnhanced = false;
|
||||||
|
|
||||||
// Define app() function early so Alpine can find it when it initializes
|
// The stub app(). If Alpine initialises before app-shell.js has run,
|
||||||
// This is a complete implementation that will work immediately
|
// this object is what it gets; its init() copies the full
|
||||||
|
// implementation in as soon as app-shell.js has replaced window.app
|
||||||
|
// (the "enhancement", guarded by window._appEnhanced), and draws the
|
||||||
|
// plugin tabs itself until then. base.html now loads app-shell.js
|
||||||
|
// before Alpine, so in practice Alpine calls the full app() directly
|
||||||
|
// and this stub never runs.
|
||||||
(function() {
|
(function() {
|
||||||
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
||||||
window.location.hostname.startsWith('192.168.4.');
|
window.location.hostname.startsWith('192.168.4.');
|
||||||
@@ -349,8 +418,7 @@
|
|||||||
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
|
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
showNotification: function(message, type) {},
|
showNotification: function(message, type) {}
|
||||||
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+397
-1634
File diff suppressed because it is too large
Load Diff
@@ -113,12 +113,15 @@
|
|||||||
// that same partial would have that script still un-run
|
// that same partial would have that script still un-run
|
||||||
// when Alpine evaluates x-data, permanently failing with
|
// when Alpine evaluates x-data, permanently failing with
|
||||||
// "wifiSetup is not defined" (Alpine does not retry).
|
// "wifiSetup is not defined" (Alpine does not retry).
|
||||||
// Disable htmx's own native script re-execution so the
|
// htmx's own script handling is switched off here, in the
|
||||||
// same script doesn't also run a second time via settle.
|
// handler, rather than once at setup: base.html injects
|
||||||
if (typeof htmx !== 'undefined' && htmx.config) {
|
// htmx with a dynamic <script>, so at setup htmx is usually
|
||||||
htmx.config.allowScriptTags = false;
|
// not defined yet and the switch never took effect. htmx
|
||||||
}
|
// then tried to run each script again in its settle phase,
|
||||||
|
// found it already replaced (no parent) and threw, which
|
||||||
|
// also skipped the rest of that swap's settle tasks.
|
||||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
htmx.config.allowScriptTags = false;
|
||||||
const target = event.detail && event.detail.target;
|
const target = event.detail && event.detail.target;
|
||||||
if (!target || !(target instanceof Element)) return;
|
if (!target || !(target instanceof Element)) return;
|
||||||
target.querySelectorAll('script').forEach(function(oldScript) {
|
target.querySelectorAll('script').forEach(function(oldScript) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* global debugLog */
|
||||||
/**
|
/**
|
||||||
* API client for plugin operations.
|
* API client for plugin operations.
|
||||||
*
|
*
|
||||||
@@ -10,7 +11,6 @@ const RequestThrottler = {
|
|||||||
pending: new Map(),
|
pending: new Map(),
|
||||||
cache: new Map(),
|
cache: new Map(),
|
||||||
cacheTTL: 5000, // 5 seconds cache for GET requests
|
cacheTTL: 5000, // 5 seconds cache for GET requests
|
||||||
debug: false, // Set to true to enable logging
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throttle a request to prevent rapid-fire calls
|
* Throttle a request to prevent rapid-fire calls
|
||||||
@@ -19,23 +19,17 @@ const RequestThrottler = {
|
|||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = this.cache.get(key);
|
const cached = this.cache.get(key);
|
||||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) {
|
if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) {
|
||||||
if (this.debug) {
|
debugLog('[RequestThrottler] Cache hit for:', key);
|
||||||
console.log('[RequestThrottler] Cache hit for:', key);
|
|
||||||
}
|
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if request is already pending
|
// Check if request is already pending
|
||||||
if (this.pending.has(key)) {
|
if (this.pending.has(key)) {
|
||||||
if (this.debug) {
|
debugLog('[RequestThrottler] Reusing pending request for:', key);
|
||||||
console.log('[RequestThrottler] Reusing pending request for:', key);
|
|
||||||
}
|
|
||||||
return this.pending.get(key);
|
return this.pending.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) {
|
debugLog('[RequestThrottler] Creating new request for:', key);
|
||||||
console.log('[RequestThrottler] Creating new request for:', key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create throttled request with abort support
|
// Create throttled request with abort support
|
||||||
let abortController = null;
|
let abortController = null;
|
||||||
@@ -49,16 +43,12 @@ const RequestThrottler = {
|
|||||||
data: result,
|
data: result,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
if (this.debug) {
|
debugLog('[RequestThrottler] Cached response for:', key);
|
||||||
console.log('[RequestThrottler] Cached response for:', key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't cache errors
|
// Don't cache errors
|
||||||
if (this.debug) {
|
debugLog('[RequestThrottler] Request failed for:', key, error);
|
||||||
console.error('[RequestThrottler] Request failed for:', key, error);
|
|
||||||
}
|
|
||||||
reject(error);
|
reject(error);
|
||||||
} finally {
|
} finally {
|
||||||
this.pending.delete(key);
|
this.pending.delete(key);
|
||||||
@@ -74,9 +64,7 @@ const RequestThrottler = {
|
|||||||
// Add abort method if available
|
// Add abort method if available
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
promise.abort = () => {
|
promise.abort = () => {
|
||||||
if (this.debug) {
|
debugLog('[RequestThrottler] Aborting request for:', key);
|
||||||
console.log('[RequestThrottler] Aborting request for:', key);
|
|
||||||
}
|
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
this.pending.delete(key);
|
this.pending.delete(key);
|
||||||
};
|
};
|
||||||
@@ -97,13 +85,6 @@ const RequestThrottler = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable or disable debug logging
|
|
||||||
*/
|
|
||||||
setDebug(enabled) {
|
|
||||||
this.debug = enabled;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get statistics about pending requests and cache
|
* Get statistics about pending requests and cache
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* global debugLog */
|
||||||
/*
|
/*
|
||||||
* settings-search.js — global settings search + per-tab filter for the v3 UI.
|
* settings-search.js — global settings search + per-tab filter for the v3 UI.
|
||||||
*
|
*
|
||||||
@@ -162,11 +163,7 @@
|
|||||||
// --- Navigation to a setting ---------------------------------------------
|
// --- Navigation to a setting ---------------------------------------------
|
||||||
|
|
||||||
function getAppData() {
|
function getAppData() {
|
||||||
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
return window.getApp();
|
||||||
if (!appEl) return null;
|
|
||||||
if (appEl._x_dataStack && appEl._x_dataStack[0]) return appEl._x_dataStack[0];
|
|
||||||
if (appEl.__x && appEl.__x.$data) return appEl.__x.$data;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveTab(tab) {
|
function setActiveTab(tab) {
|
||||||
@@ -478,5 +475,5 @@
|
|||||||
navigateToSetting: navigateToSetting
|
navigateToSetting: navigateToSetting
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[SettingsSearch] registered');
|
debugLog('[SettingsSearch] registered');
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* global debugLog */
|
||||||
/*
|
/*
|
||||||
* tooltips.js — accessible, delegated tooltip controller for the v3 web UI.
|
* tooltips.js — accessible, delegated tooltip controller for the v3 web UI.
|
||||||
*
|
*
|
||||||
@@ -157,5 +158,5 @@
|
|||||||
if (currentTrigger && !document.body.contains(currentTrigger)) hide();
|
if (currentTrigger && !document.body.contains(currentTrigger)) hide();
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Tooltips] controller registered');
|
debugLog('[Tooltips] controller registered');
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -139,12 +139,7 @@ function displayError(error, context = null, options = {}) {
|
|||||||
showErrorModal(error, context, message, suggestions, docLink);
|
showErrorModal(error, context, message, suggestions, docLink);
|
||||||
} else {
|
} else {
|
||||||
// Simple notification
|
// Simple notification
|
||||||
if (typeof showNotification === 'function') {
|
showNotification(fullMessage, 'error');
|
||||||
showNotification(fullMessage, 'error');
|
|
||||||
} else {
|
|
||||||
console.error('Error:', fullMessage);
|
|
||||||
alert(fullMessage);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,22 +284,7 @@ function closeErrorModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
* Escape HTML to prevent XSS.
|
|
||||||
*
|
|
||||||
* Quotes are escaped as well so the result is safe inside a quoted attribute
|
|
||||||
* value -- the textContent/innerHTML round-trip alone only escapes &, < and >.
|
|
||||||
*/
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (typeof text !== 'string') {
|
|
||||||
text = String(text);
|
|
||||||
}
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy error details to clipboard.
|
* Copy error details to clipboard.
|
||||||
@@ -315,16 +295,12 @@ function copyErrorDetails(error) {
|
|||||||
const errorText = JSON.stringify(error, null, 2);
|
const errorText = JSON.stringify(error, null, 2);
|
||||||
function copyFailed(err) {
|
function copyFailed(err) {
|
||||||
console.error('Failed to copy error details:', err);
|
console.error('Failed to copy error details:', err);
|
||||||
if (typeof showNotification === 'function') {
|
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
|
||||||
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
navigator.clipboard.writeText(errorText).then(() => {
|
navigator.clipboard.writeText(errorText).then(() => {
|
||||||
if (typeof showNotification === 'function') {
|
showNotification('Error details copied to clipboard', 'success');
|
||||||
showNotification('Error details copied to clipboard', 'success');
|
|
||||||
}
|
|
||||||
}).catch(copyFailed);
|
}).catch(copyFailed);
|
||||||
} else {
|
} else {
|
||||||
// Fallback for older browsers
|
// Fallback for older browsers
|
||||||
@@ -340,9 +316,7 @@ function copyErrorDetails(error) {
|
|||||||
textArea.select();
|
textArea.select();
|
||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
if (typeof showNotification === 'function') {
|
showNotification('Error details copied to clipboard', 'success');
|
||||||
showNotification('Error details copied to clipboard', 'success');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
copyFailed(err);
|
copyFailed(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,11 +227,10 @@ directory is the only place the core serves plugin widgets from.
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
const escapeHtml = (text) => {
|
// The page's shared escaper covers HTML content and quoted attribute
|
||||||
const div = document.createElement('div');
|
// values. A textContent/innerHTML round trip leaves quotes alone, so it
|
||||||
div.textContent = text;
|
// is not safe inside value="...".
|
||||||
return div.innerHTML;
|
const escapeHtml = (text) => window.LEDEscape.html(text);
|
||||||
};
|
|
||||||
|
|
||||||
window.LEDMatrixWidgets.register('my-custom-widget', {
|
window.LEDMatrixWidgets.register('my-custom-widget', {
|
||||||
name: 'My Custom Widget',
|
name: 'My Custom Widget',
|
||||||
|
|||||||
@@ -806,14 +806,13 @@
|
|||||||
const file = event.target.files && event.target.files[0];
|
const file = event.target.files && event.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const notifyFn = window.showNotification || console.log;
|
|
||||||
const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
||||||
if (!allowed.includes(file.type)) {
|
if (!allowed.includes(file.type)) {
|
||||||
notifyFn(`File type "${file.type}" not allowed`, 'error');
|
window.showNotification(`File type "${file.type}" not allowed`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
notifyFn('File exceeds 5MB limit', 'error');
|
window.showNotification('File exceeds 5MB limit', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,12 +828,12 @@
|
|||||||
const path = data.uploaded_files[0].path;
|
const path = data.uploaded_files[0].path;
|
||||||
pathInput.value = path;
|
pathInput.value = path;
|
||||||
if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; }
|
if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; }
|
||||||
notifyFn('Image uploaded', 'success');
|
window.showNotification('Image uploaded', 'success');
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.message || 'Upload failed');
|
throw new Error(data.message || 'Upload failed');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyFn('Upload error: ' + err.message, 'error');
|
window.showNotification('Upload error: ' + err.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
@@ -853,7 +852,6 @@
|
|||||||
addButton.style.opacity = isAtMax ? '0.5' : '';
|
addButton.style.opacity = isAtMax ? '0.5' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.updateArrayTableAddButtonState = updateAddButtonState;
|
|
||||||
|
|
||||||
window.addArrayTableRow = function(button) {
|
window.addArrayTableRow = function(button) {
|
||||||
const fieldId = button.getAttribute('data-field-id');
|
const fieldId = button.getAttribute('data-field-id');
|
||||||
@@ -874,7 +872,7 @@
|
|||||||
|
|
||||||
const currentRows = tbody.querySelectorAll('.array-table-row').length;
|
const currentRows = tbody.querySelectorAll('.array-table-row').length;
|
||||||
if (currentRows >= maxItems) {
|
if (currentRows >= maxItems) {
|
||||||
(window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error');
|
window.showNotification(`Maximum ${maxItems} items allowed`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,29 +116,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape HTML to prevent XSS
|
* Escape text for HTML content or a quoted attribute value.
|
||||||
* Always escapes the input, even for non-strings, by coercing to string first
|
* Kept for widgets that call it as a method; it is window.LEDEscape.html
|
||||||
*
|
* (app-early.js).
|
||||||
* The result is safe in text content AND inside quoted attribute values.
|
* @param {*} text - Text to escape (null/undefined become '')
|
||||||
* The textContent/innerHTML round-trip only escapes `&`, `<` and `>` --
|
|
||||||
* the HTML serializer leaves quotes alone because they are harmless in a
|
|
||||||
* text node. Every widget here interpolates the result into attributes
|
|
||||||
* (`value="${escapeHtml(v)}"`), where an unescaped `"` closes the
|
|
||||||
* attribute and lets the value inject its own, so the quotes have to go
|
|
||||||
* too. Each widget's standalone fallback already did this; the shared
|
|
||||||
* implementation they all prefer did not.
|
|
||||||
*
|
|
||||||
* @param {*} text - Text to escape (will be coerced to string)
|
|
||||||
* @returns {string} Escaped text, safe for text and attribute contexts
|
* @returns {string} Escaped text, safe for text and attribute contexts
|
||||||
*/
|
*/
|
||||||
escapeHtml(text) {
|
escapeHtml(text) {
|
||||||
// Always coerce to string first, then escape
|
return window.LEDEscape.html(text);
|
||||||
const textStr = String(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = textStr;
|
|
||||||
return div.innerHTML
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,9 +158,7 @@
|
|||||||
* @returns {Function|null} Notification function or null
|
* @returns {Function|null} Notification function or null
|
||||||
*/
|
*/
|
||||||
getNotificationFunction() {
|
getNotificationFunction() {
|
||||||
if (typeof window.showNotification === 'function') {
|
return window.showNotification;
|
||||||
return window.showNotification;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,10 @@
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the checkbox group widget
|
* No-op: plugin_config.html renders this widget server-side. The
|
||||||
* Note: This widget is currently server-side rendered via Jinja2 template.
|
* registration exists for getValue/setValue and updateCheckboxGroupData.
|
||||||
* This registration ensures the handlers are available globally.
|
|
||||||
*/
|
*/
|
||||||
render: function(container, config, value, options) {
|
render: function() {},
|
||||||
// For now, widgets are server-side rendered
|
|
||||||
// This function is a placeholder for future client-side rendering
|
|
||||||
if (window.debugLog) window.debugLog('[CheckboxGroupWidget] Render called (server-side rendered)');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current value from widget
|
* Get current value from widget
|
||||||
|
|||||||
@@ -25,12 +25,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -24,15 +24,10 @@
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the custom feeds widget
|
* No-op: plugin_config.html renders this widget server-side. The
|
||||||
* Note: This widget is currently server-side rendered via Jinja2 template.
|
* registration exists for getValue/setValue and the window.* feed-table handlers.
|
||||||
* This registration ensures the handlers are available globally.
|
|
||||||
*/
|
*/
|
||||||
render: function(container, config, value, options) {
|
render: function() {},
|
||||||
// For now, widgets are server-side rendered
|
|
||||||
// This function is a placeholder for future client-side rendering
|
|
||||||
if (window.debugLog) window.debugLog('[CustomFeedsWidget] Render called (server-side rendered)');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current value from widget
|
* Get current value from widget
|
||||||
@@ -246,8 +241,7 @@
|
|||||||
|
|
||||||
const currentRows = tbody.querySelectorAll('.custom-feed-row');
|
const currentRows = tbody.querySelectorAll('.custom-feed-row');
|
||||||
if (currentRows.length >= maxItems) {
|
if (currentRows.length >= maxItems) {
|
||||||
const notifyFn = window.showNotification || alert;
|
window.showNotification(`Maximum ${maxItems} feeds allowed`, 'error');
|
||||||
notifyFn(`Maximum ${maxItems} feeds allowed`, 'error');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,14 +501,12 @@
|
|||||||
logoCell.appendChild(container);
|
logoCell.appendChild(container);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const notifyFn = window.showNotification || alert;
|
window.showNotification('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
||||||
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
const notifyFn = window.showNotification || alert;
|
window.showNotification('Upload failed: ' + error.message, 'error');
|
||||||
notifyFn('Upload failed: ' + error.message, 'error');
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Reset regardless of outcome, so the same file can be re-selected
|
// Reset regardless of outcome, so the same file can be re-selected
|
||||||
|
|||||||
@@ -26,12 +26,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -50,12 +50,7 @@
|
|||||||
// Use BaseWidget utilities if available
|
// Use BaseWidget utilities if available
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('DaySelector', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('DaySelector', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -24,12 +24,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('EmailInput', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('EmailInput', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -33,12 +33,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
@@ -199,7 +194,6 @@
|
|||||||
const safeId = sanitizeId(fieldId);
|
const safeId = sanitizeId(fieldId);
|
||||||
const fileInput = document.getElementById(`${safeId}_file_input`);
|
const fileInput = document.getElementById(`${safeId}_file_input`);
|
||||||
const statusDiv = document.getElementById(`${safeId}_status`);
|
const statusDiv = document.getElementById(`${safeId}_status`);
|
||||||
const notifyFn = window.showNotification || console.log;
|
|
||||||
|
|
||||||
// Read config from the file input data attributes
|
// Read config from the file input data attributes
|
||||||
const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
|
const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
|
||||||
@@ -208,19 +202,19 @@
|
|||||||
.split(',').map(t => t.trim());
|
.split(',').map(t => t.trim());
|
||||||
|
|
||||||
if (!pluginId) {
|
if (!pluginId) {
|
||||||
notifyFn('Plugin ID not set — cannot upload', 'error');
|
window.showNotification('Plugin ID not set — cannot upload', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate type
|
// Validate type
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
notifyFn(`File type "${file.type}" not allowed`, 'error');
|
window.showNotification(`File type "${file.type}" not allowed`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate size
|
// Validate size
|
||||||
if (file.size > maxSizeMb * 1024 * 1024) {
|
if (file.size > maxSizeMb * 1024 * 1024) {
|
||||||
notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error');
|
window.showNotification(`File exceeds ${maxSizeMb}MB limit`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +260,7 @@
|
|||||||
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
|
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
|
||||||
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
|
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
|
||||||
}
|
}
|
||||||
notifyFn('Image uploaded successfully', 'success');
|
window.showNotification('Image uploaded successfully', 'success');
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.message || 'Upload failed');
|
throw new Error(data.message || 'Upload failed');
|
||||||
}
|
}
|
||||||
@@ -279,7 +273,7 @@
|
|||||||
statusDiv.appendChild(errIcon);
|
statusDiv.appendChild(errIcon);
|
||||||
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
|
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
|
||||||
}
|
}
|
||||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
window.showNotification(`Upload error: ${error.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
if (fileInput) fileInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,11 @@
|
|||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the file upload widget
|
* No-op: plugin_config.html renders this widget server-side. The
|
||||||
* Note: This widget is currently server-side rendered via Jinja2 template.
|
* registration exists for getValue/setValue and the window.* handlers
|
||||||
* This registration ensures the handlers are available globally.
|
* below.
|
||||||
* Future enhancement: Full client-side rendering support.
|
|
||||||
*/
|
*/
|
||||||
render: function(container, config, value, options) {
|
render: function() {},
|
||||||
// For now, widgets are server-side rendered
|
|
||||||
// This function is a placeholder for future client-side rendering
|
|
||||||
if (window.debugLog) window.debugLog('[FileUploadWidget] Render called (server-side rendered)');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current value from widget
|
* Get current value from widget
|
||||||
@@ -142,24 +137,23 @@
|
|||||||
.split(',').map(e => e.trim().toLowerCase());
|
.split(',').map(e => e.trim().toLowerCase());
|
||||||
|
|
||||||
const statusDiv = document.getElementById(`${fieldId}_upload_status`);
|
const statusDiv = document.getElementById(`${fieldId}_upload_status`);
|
||||||
const notifyFn = window.showNotification || console.log;
|
|
||||||
|
|
||||||
// Guard: endpoint must be configured
|
// Guard: endpoint must be configured
|
||||||
if (!uploadEndpoint) {
|
if (!uploadEndpoint) {
|
||||||
notifyFn('No upload endpoint configured for this field', 'error');
|
window.showNotification('No upload endpoint configured for this field', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate extension
|
// Validate extension
|
||||||
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
|
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
|
||||||
if (!allowedExtensions.includes(fileExt)) {
|
if (!allowedExtensions.includes(fileExt)) {
|
||||||
notifyFn(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
|
window.showNotification(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate size
|
// Validate size
|
||||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||||
notifyFn(`File exceeds ${maxSizeMB}MB limit`, 'error');
|
window.showNotification(`File exceeds ${maxSizeMB}MB limit`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +192,7 @@
|
|||||||
// Update hidden input with the target filename
|
// Update hidden input with the target filename
|
||||||
const hiddenInput = document.getElementById(fieldId);
|
const hiddenInput = document.getElementById(fieldId);
|
||||||
if (hiddenInput) hiddenInput.value = targetFilename;
|
if (hiddenInput) hiddenInput.value = targetFilename;
|
||||||
notifyFn(`${targetFilename} uploaded successfully`, 'success');
|
window.showNotification(`${targetFilename} uploaded successfully`, 'success');
|
||||||
} else {
|
} else {
|
||||||
if (statusDiv) {
|
if (statusDiv) {
|
||||||
statusDiv.className = 'mt-2 text-xs text-red-600';
|
statusDiv.className = 'mt-2 text-xs text-red-600';
|
||||||
@@ -208,7 +202,7 @@
|
|||||||
statusDiv.appendChild(icon);
|
statusDiv.appendChild(icon);
|
||||||
statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`));
|
statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`));
|
||||||
}
|
}
|
||||||
notifyFn(`Upload failed: ${data.message}`, 'error');
|
window.showNotification(`Upload failed: ${data.message}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (statusDiv) {
|
if (statusDiv) {
|
||||||
@@ -219,7 +213,7 @@
|
|||||||
statusDiv.appendChild(icon);
|
statusDiv.appendChild(icon);
|
||||||
statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`));
|
statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`));
|
||||||
}
|
}
|
||||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
window.showNotification(`Upload error: ${error.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
||||||
if (fileInput) fileInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
@@ -249,23 +243,20 @@
|
|||||||
const validFiles = [];
|
const validFiles = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||||
const notifyFn = window.showNotification || console.error;
|
window.showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
|
||||||
notifyFn(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileType === 'json') {
|
if (fileType === 'json') {
|
||||||
// Validate JSON files
|
// Validate JSON files
|
||||||
if (!file.name.toLowerCase().endsWith('.json')) {
|
if (!file.name.toLowerCase().endsWith('.json')) {
|
||||||
const notifyFn = window.showNotification || console.error;
|
window.showNotification(`File ${file.name} must be a JSON file (.json)`, 'error');
|
||||||
notifyFn(`File ${file.name} must be a JSON file (.json)`, 'error');
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Validate image files using allowedTypes from config
|
// Validate image files using allowedTypes from config
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) {
|
||||||
const notifyFn = window.showNotification || console.error;
|
window.showNotification(`File ${file.name} is not a valid image type`, 'error');
|
||||||
notifyFn(`File ${file.name} is not a valid image type`, 'error');
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,8 +266,7 @@
|
|||||||
|
|
||||||
// Check max files AFTER building validFiles
|
// Check max files AFTER building validFiles
|
||||||
if (currentFiles.length + validFiles.length > maxFiles) {
|
if (currentFiles.length + validFiles.length > maxFiles) {
|
||||||
const notifyFn = window.showNotification || console.error;
|
window.showNotification(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
|
||||||
notifyFn(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,16 +307,13 @@
|
|||||||
window.updateImageList(fieldId, newFiles);
|
window.updateImageList(fieldId, newFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyFn = window.showNotification || console.log;
|
window.showNotification(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
|
||||||
notifyFn(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
|
|
||||||
} else {
|
} else {
|
||||||
const notifyFn = window.showNotification || console.error;
|
window.showNotification(`Upload failed: ${data.message}`, 'error');
|
||||||
notifyFn(`Upload failed: ${data.message}`, 'error');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
const notifyFn = window.showNotification || console.error;
|
window.showNotification(`Upload error: ${error.message}`, 'error');
|
||||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
|
||||||
} finally {
|
} finally {
|
||||||
if (window.hideUploadProgress) {
|
if (window.hideUploadProgress) {
|
||||||
window.hideUploadProgress(fieldId);
|
window.hideUploadProgress(fieldId);
|
||||||
@@ -394,16 +381,13 @@
|
|||||||
window.updateImageList(fieldId, newFiles);
|
window.updateImageList(fieldId, newFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyFn = window.showNotification || console.log;
|
window.showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
|
||||||
notifyFn(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
|
|
||||||
} else {
|
} else {
|
||||||
const notifyFn = window.showNotification || console.error;
|
window.showNotification(`Delete failed: ${data.message}`, 'error');
|
||||||
notifyFn(`Delete failed: ${data.message}`, 'error');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete error:', error);
|
console.error('Delete error:', error);
|
||||||
const notifyFn = window.showNotification || console.error;
|
window.showNotification(`Delete error: ${error.message}`, 'error');
|
||||||
notifyFn(`Delete error: ${error.message}`, 'error');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -492,9 +476,18 @@
|
|||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DOM id suffix for an image's card and schedule editor. Matches the
|
||||||
|
// template's img_id|replace('.', '_')|replace('-', '_') in
|
||||||
|
// plugin_config.html, so server-rendered cards (UUID ids contain '-') and
|
||||||
|
// cards rendered here are found by the same lookup.
|
||||||
|
function imageDomId(imageId) {
|
||||||
|
return String(imageId).replace(/[^a-zA-Z0-9_]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update image list display and hidden input
|
* Replace the image list: writes the hidden input the form saves and
|
||||||
* Uses DOM creation to prevent XSS and preserves open schedule editors
|
* re-renders the cards. A schedule editor that was open stays open,
|
||||||
|
* rebuilt from the new data.
|
||||||
* @param {string} fieldId - Field ID
|
* @param {string} fieldId - Field ID
|
||||||
* @param {Array} images - Array of image objects
|
* @param {Array} images - Array of image objects
|
||||||
*/
|
*/
|
||||||
@@ -503,64 +496,41 @@
|
|||||||
if (hiddenInput) {
|
if (hiddenInput) {
|
||||||
hiddenInput.value = JSON.stringify(images);
|
hiddenInput.value = JSON.stringify(images);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the display
|
|
||||||
const imageList = document.getElementById(`${fieldId}_image_list`);
|
const imageList = document.getElementById(`${fieldId}_image_list`);
|
||||||
if (!imageList) return;
|
if (!imageList) return;
|
||||||
|
|
||||||
const uploadConfig = window.getUploadConfig(fieldId);
|
const uploadConfig = window.getUploadConfig(fieldId);
|
||||||
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
|
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
|
||||||
|
|
||||||
// Detect which schedule is currently open (if any)
|
const openEditor = imageList.querySelector('[id^="schedule_"]:not(.hidden)');
|
||||||
const openScheduleId = (() => {
|
const openScheduleId = openEditor ? openEditor.id.slice('schedule_'.length) : null;
|
||||||
const existingItems = imageList.querySelectorAll('[id^="img_"]');
|
|
||||||
for (const item of existingItems) {
|
|
||||||
const scheduleDiv = item.querySelector('[id^="schedule_"]');
|
|
||||||
if (scheduleDiv && !scheduleDiv.classList.contains('hidden')) {
|
|
||||||
// Extract the ID from schedule_<id>
|
|
||||||
const match = scheduleDiv.id.match(/^schedule_(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Preserve open schedule content if it exists
|
|
||||||
const preservedScheduleContent = openScheduleId ? (() => {
|
|
||||||
const scheduleDiv = document.getElementById(`schedule_${openScheduleId}`);
|
|
||||||
return scheduleDiv ? scheduleDiv.innerHTML : null;
|
|
||||||
})() : null;
|
|
||||||
|
|
||||||
// Clear and rebuild using DOM creation
|
|
||||||
imageList.innerHTML = '';
|
imageList.innerHTML = '';
|
||||||
|
|
||||||
images.forEach((img, idx) => {
|
images.forEach((img, idx) => {
|
||||||
const imgId = img.id || idx;
|
const imgId = img.id || idx;
|
||||||
const sanitizedId = String(imgId).replace(/[^a-zA-Z0-9_-]/g, '_');
|
const domId = imageDomId(imgId);
|
||||||
const imgSchedule = img.schedule || {};
|
const label = img.original_filename || img.filename || '';
|
||||||
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
|
|
||||||
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
|
|
||||||
|
|
||||||
// Create container div
|
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.id = `img_${sanitizedId}`;
|
container.id = `img_${domId}`;
|
||||||
container.className = 'bg-gray-50 p-3 rounded-lg border border-gray-200';
|
container.className = 'bg-gray-50 p-3 rounded-lg border border-gray-200';
|
||||||
|
|
||||||
// Create main content div
|
|
||||||
const mainDiv = document.createElement('div');
|
const mainDiv = document.createElement('div');
|
||||||
mainDiv.className = 'flex items-center justify-between mb-2';
|
mainDiv.className = 'flex items-center justify-between mb-2';
|
||||||
|
|
||||||
// Create left section with image and info
|
|
||||||
const leftSection = document.createElement('div');
|
const leftSection = document.createElement('div');
|
||||||
leftSection.className = 'flex items-center space-x-3 flex-1';
|
leftSection.className = 'flex items-center space-x-3 flex-1';
|
||||||
|
|
||||||
// Create image element
|
|
||||||
const imgEl = document.createElement('img');
|
const imgEl = document.createElement('img');
|
||||||
const imgPath = String(img.path || '').replace(/^\/+/, '');
|
// A stored path names a file under the project root. Encoding each
|
||||||
imgEl.src = '/' + imgPath;
|
// segment keeps it a same-origin path whatever characters it holds.
|
||||||
|
imgEl.src = '/' + String(img.path || '').replace(/^\/+/, '')
|
||||||
|
.split('/').map(encodeURIComponent).join('/');
|
||||||
imgEl.alt = String(img.filename || '');
|
imgEl.alt = String(img.filename || '');
|
||||||
|
imgEl.loading = 'lazy';
|
||||||
|
imgEl.decoding = 'async';
|
||||||
imgEl.className = 'w-16 h-16 object-cover rounded';
|
imgEl.className = 'w-16 h-16 object-cover rounded';
|
||||||
imgEl.addEventListener('error', function() {
|
imgEl.addEventListener('error', function() {
|
||||||
this.style.display = 'none';
|
this.style.display = 'none';
|
||||||
@@ -568,104 +538,120 @@
|
|||||||
this.nextElementSibling.style.display = 'block';
|
this.nextElementSibling.style.display = 'block';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create placeholder div for broken images
|
// Shown in place of a thumbnail that fails to load
|
||||||
const placeholderDiv = document.createElement('div');
|
const placeholderDiv = document.createElement('div');
|
||||||
placeholderDiv.style.display = 'none';
|
placeholderDiv.style.display = 'none';
|
||||||
placeholderDiv.className = 'w-16 h-16 bg-gray-200 rounded flex items-center justify-center';
|
placeholderDiv.className = 'w-16 h-16 bg-gray-200 rounded flex items-center justify-center';
|
||||||
const placeholderIcon = document.createElement('i');
|
const placeholderIcon = document.createElement('i');
|
||||||
placeholderIcon.className = 'fas fa-image text-gray-400';
|
placeholderIcon.className = 'fas fa-image text-gray-400';
|
||||||
placeholderDiv.appendChild(placeholderIcon);
|
placeholderDiv.appendChild(placeholderIcon);
|
||||||
|
|
||||||
// Create info div
|
|
||||||
const infoDiv = document.createElement('div');
|
const infoDiv = document.createElement('div');
|
||||||
infoDiv.className = 'flex-1 min-w-0';
|
infoDiv.className = 'flex-1 min-w-0';
|
||||||
|
|
||||||
// Filename
|
|
||||||
const filenameP = document.createElement('p');
|
const filenameP = document.createElement('p');
|
||||||
filenameP.className = 'text-sm font-medium text-gray-900 truncate';
|
filenameP.className = 'text-sm font-medium text-gray-900 truncate';
|
||||||
filenameP.textContent = img.original_filename || img.filename || 'Image';
|
filenameP.textContent = label || 'Image';
|
||||||
|
|
||||||
// Size and date
|
|
||||||
const sizeDateP = document.createElement('p');
|
const sizeDateP = document.createElement('p');
|
||||||
sizeDateP.className = 'text-xs text-gray-500';
|
sizeDateP.className = 'text-xs text-gray-500';
|
||||||
const fileSize = window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB');
|
sizeDateP.textContent = `${window.formatFileSize(img.size || 0)} • ${window.formatDate(img.uploaded_at)}`;
|
||||||
const uploadedDate = window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '');
|
|
||||||
sizeDateP.textContent = `${fileSize} • ${uploadedDate}`;
|
|
||||||
|
|
||||||
// Schedule summary
|
|
||||||
const scheduleP = document.createElement('p');
|
const scheduleP = document.createElement('p');
|
||||||
scheduleP.className = 'text-xs text-blue-600 mt-1';
|
scheduleP.className = 'text-xs text-blue-600 mt-1 image-schedule-summary';
|
||||||
const clockIcon = document.createElement('i');
|
renderScheduleSummary(scheduleP, img.schedule);
|
||||||
clockIcon.className = 'fas fa-clock mr-1';
|
|
||||||
scheduleP.appendChild(clockIcon);
|
|
||||||
scheduleP.appendChild(document.createTextNode(scheduleSummary));
|
|
||||||
|
|
||||||
infoDiv.appendChild(filenameP);
|
infoDiv.appendChild(filenameP);
|
||||||
infoDiv.appendChild(sizeDateP);
|
infoDiv.appendChild(sizeDateP);
|
||||||
infoDiv.appendChild(scheduleP);
|
infoDiv.appendChild(scheduleP);
|
||||||
|
|
||||||
leftSection.appendChild(imgEl);
|
leftSection.appendChild(imgEl);
|
||||||
leftSection.appendChild(placeholderDiv);
|
leftSection.appendChild(placeholderDiv);
|
||||||
leftSection.appendChild(infoDiv);
|
leftSection.appendChild(infoDiv);
|
||||||
|
|
||||||
// Create right section with buttons
|
|
||||||
const rightSection = document.createElement('div');
|
const rightSection = document.createElement('div');
|
||||||
rightSection.className = 'flex items-center space-x-2 ml-4';
|
rightSection.className = 'flex items-center space-x-2 ml-4';
|
||||||
|
|
||||||
// Schedule button
|
|
||||||
const scheduleBtn = document.createElement('button');
|
const scheduleBtn = document.createElement('button');
|
||||||
scheduleBtn.type = 'button';
|
scheduleBtn.type = 'button';
|
||||||
scheduleBtn.className = 'text-blue-600 hover:text-blue-800 p-2';
|
scheduleBtn.className = 'text-blue-600 hover:text-blue-800 p-2';
|
||||||
scheduleBtn.title = 'Schedule this image';
|
scheduleBtn.title = 'Schedule this image';
|
||||||
|
scheduleBtn.setAttribute('aria-label', `Schedule image ${label}`);
|
||||||
scheduleBtn.dataset.fieldId = fieldId;
|
scheduleBtn.dataset.fieldId = fieldId;
|
||||||
scheduleBtn.dataset.imageId = String(imgId);
|
scheduleBtn.dataset.imageId = String(imgId);
|
||||||
scheduleBtn.dataset.imageIdx = String(idx);
|
scheduleBtn.dataset.imageIdx = String(idx);
|
||||||
scheduleBtn.addEventListener('click', function() {
|
scheduleBtn.addEventListener('click', function() {
|
||||||
window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId, parseInt(this.dataset.imageIdx, 10));
|
window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId, parseInt(this.dataset.imageIdx, 10));
|
||||||
});
|
});
|
||||||
const scheduleIcon = document.createElement('i');
|
scheduleBtn.appendChild(iconEl('fas fa-calendar-alt'));
|
||||||
scheduleIcon.className = 'fas fa-calendar-alt';
|
|
||||||
scheduleBtn.appendChild(scheduleIcon);
|
|
||||||
|
|
||||||
// Delete button
|
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.type = 'button';
|
deleteBtn.type = 'button';
|
||||||
deleteBtn.className = 'text-red-600 hover:text-red-800 p-2';
|
deleteBtn.className = 'text-red-600 hover:text-red-800 p-2';
|
||||||
deleteBtn.title = 'Delete image';
|
deleteBtn.title = 'Delete image';
|
||||||
|
deleteBtn.setAttribute('aria-label', `Delete image ${label}`);
|
||||||
deleteBtn.dataset.fieldId = fieldId;
|
deleteBtn.dataset.fieldId = fieldId;
|
||||||
deleteBtn.dataset.imageId = String(imgId);
|
deleteBtn.dataset.imageId = String(imgId);
|
||||||
deleteBtn.dataset.pluginId = pluginId;
|
deleteBtn.dataset.pluginId = pluginId;
|
||||||
deleteBtn.addEventListener('click', function() {
|
deleteBtn.addEventListener('click', function() {
|
||||||
window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId);
|
window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId);
|
||||||
});
|
});
|
||||||
const deleteIcon = document.createElement('i');
|
deleteBtn.appendChild(iconEl('fas fa-trash'));
|
||||||
deleteIcon.className = 'fas fa-trash';
|
|
||||||
deleteBtn.appendChild(deleteIcon);
|
|
||||||
|
|
||||||
rightSection.appendChild(scheduleBtn);
|
rightSection.appendChild(scheduleBtn);
|
||||||
rightSection.appendChild(deleteBtn);
|
rightSection.appendChild(deleteBtn);
|
||||||
|
|
||||||
mainDiv.appendChild(leftSection);
|
mainDiv.appendChild(leftSection);
|
||||||
mainDiv.appendChild(rightSection);
|
mainDiv.appendChild(rightSection);
|
||||||
|
|
||||||
// Create schedule container
|
|
||||||
const scheduleContainer = document.createElement('div');
|
const scheduleContainer = document.createElement('div');
|
||||||
scheduleContainer.id = `schedule_${sanitizedId}`;
|
scheduleContainer.id = `schedule_${domId}`;
|
||||||
scheduleContainer.className = 'hidden mt-3 pt-3 border-t border-gray-300';
|
scheduleContainer.className = 'hidden mt-3 pt-3 border-t border-gray-300';
|
||||||
|
|
||||||
// Restore preserved schedule content if this is the open one
|
|
||||||
if (openScheduleId === sanitizedId && preservedScheduleContent) {
|
|
||||||
scheduleContainer.innerHTML = preservedScheduleContent;
|
|
||||||
scheduleContainer.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
container.appendChild(mainDiv);
|
container.appendChild(mainDiv);
|
||||||
container.appendChild(scheduleContainer);
|
container.appendChild(scheduleContainer);
|
||||||
imageList.appendChild(container);
|
imageList.appendChild(container);
|
||||||
|
|
||||||
|
if (openScheduleId === domId) {
|
||||||
|
renderScheduleEditor(scheduleContainer, fieldId, imgId, idx, img.schedule);
|
||||||
|
scheduleContainer.classList.remove('hidden');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Decorative icon, hidden from screen readers (the button has a label)
|
||||||
|
function iconEl(className) {
|
||||||
|
const i = document.createElement('i');
|
||||||
|
i.className = className;
|
||||||
|
i.setAttribute('aria-hidden', 'true');
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScheduleSummary(el, schedule) {
|
||||||
|
el.textContent = '';
|
||||||
|
const clock = document.createElement('i');
|
||||||
|
clock.className = 'fas fa-clock mr-1';
|
||||||
|
el.appendChild(clock);
|
||||||
|
el.appendChild(document.createTextNode(window.getScheduleSummary(schedule || {})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saves a schedule edit: writes the hidden input and refreshes that
|
||||||
|
// card's summary line in place. The list is not re-rendered, so the open
|
||||||
|
// editor keeps its state and the control being edited keeps focus.
|
||||||
|
function commitScheduleEdit(fieldId, images, imageId) {
|
||||||
|
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
|
||||||
|
if (hiddenInput) {
|
||||||
|
hiddenInput.value = JSON.stringify(images);
|
||||||
|
}
|
||||||
|
const card = document.getElementById(`img_${imageDomId(imageId)}`);
|
||||||
|
const image = images.find((img, idx) => String(img.id || idx) === String(imageId));
|
||||||
|
const summary = card && card.querySelector('.image-schedule-summary');
|
||||||
|
if (summary && image) {
|
||||||
|
renderScheduleSummary(summary, image.schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show upload progress
|
* Show upload progress
|
||||||
* @param {string} fieldId - Field ID
|
* @param {string} fieldId - Field ID
|
||||||
@@ -776,364 +762,242 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open image schedule editor
|
* Show or hide an image's schedule editor.
|
||||||
* @param {string} fieldId - Field ID
|
* @param {string} fieldId - Field ID
|
||||||
* @param {string|number} imageId - Image ID
|
* @param {string|number} imageId - Image ID (the index when the image has none)
|
||||||
* @param {number} imageIdx - Image index
|
* @param {number} imageIdx - Image index
|
||||||
*/
|
*/
|
||||||
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
||||||
const currentImages = window.getCurrentImages(fieldId);
|
const idx = Number(imageIdx);
|
||||||
const image = currentImages[imageIdx];
|
const image = Number.isInteger(idx) && idx >= 0 ? window.getCurrentImages(fieldId).at(idx) : undefined;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
// Sanitize imageId to match updateImageList's sanitization
|
const scheduleContainer = document.getElementById(`schedule_${imageDomId(imageId || imageIdx)}`);
|
||||||
const sanitizedId = (imageId || imageIdx).toString().replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
const scheduleContainer = document.getElementById(`schedule_${sanitizedId}`);
|
|
||||||
if (!scheduleContainer) return;
|
if (!scheduleContainer) return;
|
||||||
|
|
||||||
// Toggle visibility
|
if (!scheduleContainer.classList.contains('hidden')) {
|
||||||
const isVisible = !scheduleContainer.classList.contains('hidden');
|
|
||||||
|
|
||||||
if (isVisible) {
|
|
||||||
scheduleContainer.classList.add('hidden');
|
scheduleContainer.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
renderScheduleEditor(scheduleContainer, fieldId, imageId || imageIdx, imageIdx, image.schedule);
|
||||||
scheduleContainer.classList.remove('hidden');
|
scheduleContainer.classList.remove('hidden');
|
||||||
|
|
||||||
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
|
||||||
|
|
||||||
// Escape HTML helper
|
|
||||||
// Quotes too: the result lands in quoted attribute values below, and the
|
|
||||||
// textContent/innerHTML round-trip only escapes &, < and >.
|
|
||||||
const escapeHtml = (text) => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text ?? '');
|
|
||||||
return div.innerHTML
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.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>
|
|
||||||
|
|
||||||
<!-- Enable Schedule -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="flex items-center">
|
|
||||||
<input type="checkbox"
|
|
||||||
id="schedule_enabled_${sanitizedId}"
|
|
||||||
data-field-id="${escapeHtml(fieldId)}"
|
|
||||||
data-image-id="${sanitizedId}"
|
|
||||||
data-image-idx="${imageIdx}"
|
|
||||||
${schedule.enabled ? 'checked' : ''}
|
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
||||||
<span class="ml-2 text-sm font-medium text-gray-700">Enable schedule for this image</span>
|
|
||||||
</label>
|
|
||||||
<p class="ml-6 text-xs text-gray-500 mt-1">When enabled, this image will only display during scheduled times</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schedule Mode -->
|
|
||||||
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
|
|
||||||
<div>
|
|
||||||
<label for="schedule_mode_${sanitizedId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
|
||||||
<select id="schedule_mode_${sanitizedId}"
|
|
||||||
data-field-id="${escapeHtml(fieldId)}"
|
|
||||||
data-image-id="${sanitizedId}"
|
|
||||||
data-image-idx="${imageIdx}"
|
|
||||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
|
||||||
<option value="always" ${schedule.mode === 'always' ? 'selected' : ''}>Always Show (No Schedule)</option>
|
|
||||||
<option value="time_range" ${schedule.mode === 'time_range' ? 'selected' : ''}>Same Time Every Day</option>
|
|
||||||
<option value="per_day" ${schedule.mode === 'per_day' ? 'selected' : ''}>Different Times Per Day</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Time Range Mode -->
|
|
||||||
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
|
|
||||||
<div>
|
|
||||||
<label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
|
||||||
<input type="time"
|
|
||||||
id="schedule_start_${sanitizedId}"
|
|
||||||
data-field-id="${escapeHtml(fieldId)}"
|
|
||||||
data-image-id="${sanitizedId}"
|
|
||||||
data-image-idx="${imageIdx}"
|
|
||||||
value="${escapeHtml(schedule.start_time || '08:00')}"
|
|
||||||
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
|
||||||
<input type="time"
|
|
||||||
id="schedule_end_${sanitizedId}"
|
|
||||||
data-field-id="${escapeHtml(fieldId)}"
|
|
||||||
data-image-id="${sanitizedId}"
|
|
||||||
data-image-idx="${imageIdx}"
|
|
||||||
value="${escapeHtml(schedule.end_time || '18:00')}"
|
|
||||||
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Per-Day Mode -->
|
|
||||||
<div id="per_day_${sanitizedId}" style="display: ${schedule.mode === 'per_day' ? 'block' : 'none'};">
|
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-2">Day-Specific Times</label>
|
|
||||||
<div class="bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto">
|
|
||||||
${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => {
|
|
||||||
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
|
|
||||||
return `
|
|
||||||
<div class="bg-white rounded p-2 border border-gray-200">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<label class="flex items-center">
|
|
||||||
<input type="checkbox"
|
|
||||||
id="day_${day}_${sanitizedId}"
|
|
||||||
data-field-id="${escapeHtml(fieldId)}"
|
|
||||||
data-image-id="${sanitizedId}"
|
|
||||||
data-image-idx="${imageIdx}"
|
|
||||||
data-day="${day}"
|
|
||||||
${dayConfig.enabled ? 'checked' : ''}
|
|
||||||
class="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
||||||
<span class="ml-2 text-xs font-medium text-gray-700 capitalize">${day}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
|
|
||||||
<input type="time"
|
|
||||||
id="day_${day}_start_${sanitizedId}"
|
|
||||||
aria-label="${day} start time"
|
|
||||||
data-field-id="${escapeHtml(fieldId)}"
|
|
||||||
data-image-id="${sanitizedId}"
|
|
||||||
data-image-idx="${imageIdx}"
|
|
||||||
data-day="${day}"
|
|
||||||
value="${escapeHtml(dayConfig.start_time || '08:00')}"
|
|
||||||
class="text-xs px-2 py-1 border border-gray-300 rounded"
|
|
||||||
${!dayConfig.enabled ? 'disabled' : ''}>
|
|
||||||
<input type="time"
|
|
||||||
id="day_${day}_end_${sanitizedId}"
|
|
||||||
aria-label="${day} end time"
|
|
||||||
data-field-id="${escapeHtml(fieldId)}"
|
|
||||||
data-image-id="${sanitizedId}"
|
|
||||||
data-image-idx="${imageIdx}"
|
|
||||||
data-day="${day}"
|
|
||||||
value="${escapeHtml(dayConfig.end_time || '18:00')}"
|
|
||||||
class="text-xs px-2 py-1 border border-gray-300 rounded"
|
|
||||||
${!dayConfig.enabled ? 'disabled' : ''}>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Attach event listeners using data attributes (prevents JS injection)
|
|
||||||
const enabledCheckbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
|
|
||||||
if (enabledCheckbox) {
|
|
||||||
enabledCheckbox.addEventListener('change', function() {
|
|
||||||
const fieldId = this.dataset.fieldId;
|
|
||||||
const imageId = this.dataset.imageId;
|
|
||||||
const imageIdx = parseInt(this.dataset.imageIdx, 10);
|
|
||||||
window.toggleImageScheduleEnabled(fieldId, imageId, imageIdx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`);
|
|
||||||
if (modeSelect) {
|
|
||||||
modeSelect.addEventListener('change', function() {
|
|
||||||
const fieldId = this.dataset.fieldId;
|
|
||||||
const imageId = this.dataset.imageId;
|
|
||||||
const imageIdx = parseInt(this.dataset.imageIdx, 10);
|
|
||||||
window.updateImageScheduleMode(fieldId, imageId, imageIdx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const startInput = document.getElementById(`schedule_start_${sanitizedId}`);
|
|
||||||
if (startInput) {
|
|
||||||
startInput.addEventListener('change', function() {
|
|
||||||
const fieldId = this.dataset.fieldId;
|
|
||||||
const imageId = this.dataset.imageId;
|
|
||||||
const imageIdx = parseInt(this.dataset.imageIdx, 10);
|
|
||||||
window.updateImageScheduleTime(fieldId, imageId, imageIdx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const endInput = document.getElementById(`schedule_end_${sanitizedId}`);
|
|
||||||
if (endInput) {
|
|
||||||
endInput.addEventListener('change', function() {
|
|
||||||
const fieldId = this.dataset.fieldId;
|
|
||||||
const imageId = this.dataset.imageId;
|
|
||||||
const imageIdx = parseInt(this.dataset.imageIdx, 10);
|
|
||||||
window.updateImageScheduleTime(fieldId, imageId, imageIdx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach listeners for per-day inputs
|
|
||||||
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].forEach(day => {
|
|
||||||
const dayCheckbox = document.getElementById(`day_${day}_${sanitizedId}`);
|
|
||||||
if (dayCheckbox) {
|
|
||||||
dayCheckbox.addEventListener('change', function() {
|
|
||||||
const fieldId = this.dataset.fieldId;
|
|
||||||
const imageId = this.dataset.imageId;
|
|
||||||
const imageIdx = parseInt(this.dataset.imageIdx, 10);
|
|
||||||
const day = this.dataset.day;
|
|
||||||
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayStartInput = document.getElementById(`day_${day}_start_${sanitizedId}`);
|
|
||||||
if (dayStartInput) {
|
|
||||||
dayStartInput.addEventListener('change', function() {
|
|
||||||
const fieldId = this.dataset.fieldId;
|
|
||||||
const imageId = this.dataset.imageId;
|
|
||||||
const imageIdx = parseInt(this.dataset.imageIdx, 10);
|
|
||||||
const day = this.dataset.day;
|
|
||||||
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayEndInput = document.getElementById(`day_${day}_end_${sanitizedId}`);
|
|
||||||
if (dayEndInput) {
|
|
||||||
dayEndInput.addEventListener('change', function() {
|
|
||||||
const fieldId = this.dataset.fieldId;
|
|
||||||
const imageId = this.dataset.imageId;
|
|
||||||
const imageIdx = parseInt(this.dataset.imageIdx, 10);
|
|
||||||
const day = this.dataset.day;
|
|
||||||
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||||
* Toggle image schedule enabled state
|
|
||||||
*/
|
// Builds a DOM element. Attribute values and text children go through the
|
||||||
|
// DOM APIs, so nothing passed in is ever parsed as HTML. `true` sets an
|
||||||
|
// empty (boolean) attribute; `false`, null and undefined leave it off.
|
||||||
|
function h(tag, attrs, children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [name, value] of Object.entries(attrs || {})) {
|
||||||
|
if (value === false || value == null) continue;
|
||||||
|
node.setAttribute(name, value === true ? '' : String(value));
|
||||||
|
}
|
||||||
|
for (const child of children || []) node.append(child);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayStyle(visible, shown) {
|
||||||
|
return `display: ${visible ? shown : 'none'};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleModeOption(schedule, value, label) {
|
||||||
|
return h('option', { value: value, selected: schedule.mode === value }, [label]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRangeTime(which, label, value, domId, ids) {
|
||||||
|
return h('div', {}, [
|
||||||
|
h('label', { for: `schedule_${which}_${domId}`, class: 'block text-xs font-medium text-gray-700 mb-1' }, [label]),
|
||||||
|
h('input', {
|
||||||
|
type: 'time', id: `schedule_${which}_${domId}`, 'data-schedule-control': 'time', ...ids,
|
||||||
|
value: value, class: 'block w-full px-2 py-1 text-sm border border-gray-300 rounded-md',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDayTime(day, which, value, enabled, domId, dayIds) {
|
||||||
|
return h('input', {
|
||||||
|
type: 'time', id: `day_${day}_${which}_${domId}`, 'aria-label': `${day} ${which} time`, ...dayIds,
|
||||||
|
value: value, class: 'text-xs px-2 py-1 border border-gray-300 rounded', disabled: !enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDayRow(schedule, day, domId, ids) {
|
||||||
|
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
|
||||||
|
const dayIds = { 'data-schedule-control': 'day', 'data-day': day, ...ids };
|
||||||
|
return h('div', { class: 'bg-white rounded p-2 border border-gray-200' }, [
|
||||||
|
h('div', { class: 'flex items-center justify-between mb-2' }, [
|
||||||
|
h('label', { class: 'flex items-center' }, [
|
||||||
|
h('input', {
|
||||||
|
type: 'checkbox', id: `day_${day}_${domId}`, ...dayIds, checked: !!dayConfig.enabled,
|
||||||
|
class: 'h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded',
|
||||||
|
}),
|
||||||
|
h('span', { class: 'ml-2 text-xs font-medium text-gray-700 capitalize' }, [day]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
h('div', { class: 'grid grid-cols-2 gap-2 ml-5', id: `day_times_${day}_${domId}`, style: displayStyle(dayConfig.enabled, 'grid') }, [
|
||||||
|
scheduleDayTime(day, 'start', dayConfig.start_time || '08:00', dayConfig.enabled, domId, dayIds),
|
||||||
|
scheduleDayTime(day, 'end', dayConfig.end_time || '18:00', dayConfig.enabled, domId, dayIds),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the schedule editor. Controls carry data-schedule-control and
|
||||||
|
// their ids as data attributes; one delegated change listener (below)
|
||||||
|
// routes them, so the editor needs no per-element listeners.
|
||||||
|
function renderScheduleEditor(container, fieldId, imageId, imageIdx, savedSchedule) {
|
||||||
|
const schedule = savedSchedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
||||||
|
const domId = imageDomId(imageId);
|
||||||
|
const ids = { 'data-field-id': fieldId, 'data-image-id': imageId, 'data-image-idx': Number(imageIdx) };
|
||||||
|
const dayRows = [];
|
||||||
|
for (const day of DAYS) dayRows.push(scheduleDayRow(schedule, day, domId, ids));
|
||||||
|
|
||||||
|
container.replaceChildren(h('div', { class: 'bg-white rounded-lg border border-blue-200 p-4' }, [
|
||||||
|
h('h4', { class: 'text-sm font-semibold text-gray-900 mb-3' }, [
|
||||||
|
h('i', { class: 'fas fa-clock mr-2' }), 'Schedule Settings',
|
||||||
|
]),
|
||||||
|
h('div', { class: 'mb-4' }, [
|
||||||
|
h('label', { class: 'flex items-center' }, [
|
||||||
|
h('input', {
|
||||||
|
type: 'checkbox', id: `schedule_enabled_${domId}`, 'data-schedule-control': 'enabled', ...ids,
|
||||||
|
checked: !!schedule.enabled, class: 'h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded',
|
||||||
|
}),
|
||||||
|
h('span', { class: 'ml-2 text-sm font-medium text-gray-700' }, ['Enable schedule for this image']),
|
||||||
|
]),
|
||||||
|
h('p', { class: 'ml-6 text-xs text-gray-500 mt-1' }, ['When enabled, this image will only display during scheduled times']),
|
||||||
|
]),
|
||||||
|
h('div', { id: `schedule_options_${domId}`, class: 'space-y-4', style: displayStyle(schedule.enabled, 'block') }, [
|
||||||
|
h('div', {}, [
|
||||||
|
h('label', { for: `schedule_mode_${domId}`, class: 'block text-sm font-medium text-gray-700 mb-2' }, ['Schedule Type']),
|
||||||
|
h('select', {
|
||||||
|
id: `schedule_mode_${domId}`, 'data-schedule-control': 'mode', ...ids,
|
||||||
|
class: 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm',
|
||||||
|
}, [
|
||||||
|
scheduleModeOption(schedule, 'always', 'Always Show (No Schedule)'),
|
||||||
|
scheduleModeOption(schedule, 'time_range', 'Same Time Every Day'),
|
||||||
|
scheduleModeOption(schedule, 'per_day', 'Different Times Per Day'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
h('div', { id: `time_range_${domId}`, class: 'grid grid-cols-2 gap-4', style: displayStyle(schedule.mode === 'time_range', 'grid') }, [
|
||||||
|
scheduleRangeTime('start', 'Start Time', schedule.start_time || '08:00', domId, ids),
|
||||||
|
scheduleRangeTime('end', 'End Time', schedule.end_time || '18:00', domId, ids),
|
||||||
|
]),
|
||||||
|
h('div', { id: `per_day_${domId}`, style: displayStyle(schedule.mode === 'per_day', 'block') }, [
|
||||||
|
h('label', { class: 'block text-xs font-medium text-gray-700 mb-2' }, ['Day-Specific Times']),
|
||||||
|
h('div', { class: 'bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto' }, dayRows),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('change', function(event) {
|
||||||
|
const el = event.target && event.target.closest && event.target.closest('[data-schedule-control]');
|
||||||
|
if (!el) return;
|
||||||
|
const { fieldId, imageId, day } = el.dataset;
|
||||||
|
const imageIdx = parseInt(el.dataset.imageIdx, 10);
|
||||||
|
switch (el.dataset.scheduleControl) {
|
||||||
|
case 'enabled': window.toggleImageScheduleEnabled(fieldId, imageId, imageIdx); break;
|
||||||
|
case 'mode': window.updateImageScheduleMode(fieldId, imageId, imageIdx); break;
|
||||||
|
case 'time': window.updateImageScheduleTime(fieldId, imageId, imageIdx); break;
|
||||||
|
case 'day': window.updateImageScheduleDay(fieldId, imageId, imageIdx, day); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) {
|
window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) {
|
||||||
const currentImages = window.getCurrentImages(fieldId);
|
const currentImages = window.getCurrentImages(fieldId);
|
||||||
const image = currentImages[imageIdx];
|
const image = currentImages[imageIdx];
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
// Sanitize imageId for DOM lookup
|
const domId = imageDomId(imageId);
|
||||||
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
|
const checkbox = document.getElementById(`schedule_enabled_${domId}`);
|
||||||
const checkbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
|
|
||||||
const enabled = checkbox ? checkbox.checked : false;
|
const enabled = checkbox ? checkbox.checked : false;
|
||||||
|
|
||||||
if (!image.schedule) {
|
if (!image.schedule) {
|
||||||
image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
image.schedule.enabled = enabled;
|
image.schedule.enabled = enabled;
|
||||||
|
|
||||||
const optionsDiv = document.getElementById(`schedule_options_${sanitizedId}`);
|
const optionsDiv = document.getElementById(`schedule_options_${domId}`);
|
||||||
if (optionsDiv) {
|
if (optionsDiv) {
|
||||||
optionsDiv.style.display = enabled ? 'block' : 'none';
|
optionsDiv.style.display = enabled ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.updateImageList) {
|
commitScheduleEdit(fieldId, currentImages, imageId);
|
||||||
window.updateImageList(fieldId, currentImages);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update image schedule mode
|
|
||||||
*/
|
|
||||||
window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) {
|
window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) {
|
||||||
const currentImages = window.getCurrentImages(fieldId);
|
const currentImages = window.getCurrentImages(fieldId);
|
||||||
const image = currentImages[imageIdx];
|
const image = currentImages[imageIdx];
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
// Sanitize imageId for DOM lookup
|
const domId = imageDomId(imageId);
|
||||||
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
|
|
||||||
if (!image.schedule) {
|
if (!image.schedule) {
|
||||||
image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`);
|
const modeSelect = document.getElementById(`schedule_mode_${domId}`);
|
||||||
const mode = modeSelect ? modeSelect.value : 'always';
|
const mode = modeSelect ? modeSelect.value : 'always';
|
||||||
|
|
||||||
image.schedule.mode = mode;
|
image.schedule.mode = mode;
|
||||||
|
|
||||||
const timeRangeDiv = document.getElementById(`time_range_${sanitizedId}`);
|
const timeRangeDiv = document.getElementById(`time_range_${domId}`);
|
||||||
const perDayDiv = document.getElementById(`per_day_${sanitizedId}`);
|
const perDayDiv = document.getElementById(`per_day_${domId}`);
|
||||||
|
|
||||||
if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none';
|
if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none';
|
||||||
if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none';
|
if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none';
|
||||||
|
|
||||||
if (window.updateImageList) {
|
commitScheduleEdit(fieldId, currentImages, imageId);
|
||||||
window.updateImageList(fieldId, currentImages);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update image schedule time
|
|
||||||
*/
|
|
||||||
window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) {
|
window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) {
|
||||||
const currentImages = window.getCurrentImages(fieldId);
|
const currentImages = window.getCurrentImages(fieldId);
|
||||||
const image = currentImages[imageIdx];
|
const image = currentImages[imageIdx];
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
// Sanitize imageId for DOM lookup
|
const domId = imageDomId(imageId);
|
||||||
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
|
|
||||||
if (!image.schedule) {
|
if (!image.schedule) {
|
||||||
image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' };
|
image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const startInput = document.getElementById(`schedule_start_${sanitizedId}`);
|
const startInput = document.getElementById(`schedule_start_${domId}`);
|
||||||
const endInput = document.getElementById(`schedule_end_${sanitizedId}`);
|
const endInput = document.getElementById(`schedule_end_${domId}`);
|
||||||
|
|
||||||
if (startInput) image.schedule.start_time = startInput.value || '08:00';
|
if (startInput) image.schedule.start_time = startInput.value || '08:00';
|
||||||
if (endInput) image.schedule.end_time = endInput.value || '18:00';
|
if (endInput) image.schedule.end_time = endInput.value || '18:00';
|
||||||
|
|
||||||
if (window.updateImageList) {
|
commitScheduleEdit(fieldId, currentImages, imageId);
|
||||||
window.updateImageList(fieldId, currentImages);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Update image schedule day
|
|
||||||
*/
|
|
||||||
window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
|
window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
|
||||||
const currentImages = window.getCurrentImages(fieldId);
|
const currentImages = window.getCurrentImages(fieldId);
|
||||||
const image = currentImages[imageIdx];
|
const image = currentImages[imageIdx];
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|
||||||
// Sanitize imageId for DOM lookup
|
const domId = imageDomId(imageId);
|
||||||
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
|
|
||||||
if (!image.schedule) {
|
if (!image.schedule) {
|
||||||
image.schedule = { enabled: true, mode: 'per_day', days: {} };
|
image.schedule = { enabled: true, mode: 'per_day', days: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!image.schedule.days) {
|
if (!image.schedule.days) {
|
||||||
image.schedule.days = {};
|
image.schedule.days = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkbox = document.getElementById(`day_${day}_${sanitizedId}`);
|
const checkbox = document.getElementById(`day_${day}_${domId}`);
|
||||||
const startInput = document.getElementById(`day_${day}_start_${sanitizedId}`);
|
const startInput = document.getElementById(`day_${day}_start_${domId}`);
|
||||||
const endInput = document.getElementById(`day_${day}_end_${sanitizedId}`);
|
const endInput = document.getElementById(`day_${day}_end_${domId}`);
|
||||||
|
|
||||||
const enabled = checkbox ? checkbox.checked : true;
|
const enabled = checkbox ? checkbox.checked : true;
|
||||||
|
|
||||||
if (!image.schedule.days[day]) {
|
if (!image.schedule.days[day]) {
|
||||||
image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' };
|
image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' };
|
||||||
}
|
}
|
||||||
|
|
||||||
image.schedule.days[day].enabled = enabled;
|
image.schedule.days[day].enabled = enabled;
|
||||||
if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00';
|
if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00';
|
||||||
if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00';
|
if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00';
|
||||||
|
|
||||||
const dayTimesDiv = document.getElementById(`day_times_${day}_${sanitizedId}`);
|
const dayTimesDiv = document.getElementById(`day_times_${day}_${domId}`);
|
||||||
if (dayTimesDiv) {
|
if (dayTimesDiv) {
|
||||||
dayTimesDiv.style.display = enabled ? 'grid' : 'none';
|
dayTimesDiv.style.display = enabled ? 'grid' : 'none';
|
||||||
}
|
}
|
||||||
if (startInput) startInput.disabled = !enabled;
|
if (startInput) startInput.disabled = !enabled;
|
||||||
if (endInput) endInput.disabled = !enabled;
|
if (endInput) endInput.disabled = !enabled;
|
||||||
|
|
||||||
if (window.updateImageList) {
|
commitScheduleEdit(fieldId, currentImages, imageId);
|
||||||
window.updateImageList(fieldId, currentImages);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -31,12 +31,7 @@
|
|||||||
let fontCatalogCache = null;
|
let fontCatalogCache = null;
|
||||||
let fontCatalogPromise = null;
|
let fontCatalogPromise = null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -152,9 +152,7 @@
|
|||||||
if (selectedIds.length === 0) {
|
if (selectedIds.length === 0) {
|
||||||
checkbox.checked = true;
|
checkbox.checked = true;
|
||||||
selectedIds.push(cal.id);
|
selectedIds.push(cal.id);
|
||||||
if (window.showNotification) {
|
window.showNotification('At least one calendar must be selected.', 'warning');
|
||||||
window.showNotification('At least one calendar must be selected.', 'warning');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
syncHiddenAndSummary();
|
syncHiddenAndSummary();
|
||||||
});
|
});
|
||||||
@@ -185,11 +183,5 @@
|
|||||||
container.appendChild(p);
|
container.appendChild(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) { return window.LEDEscape.html(str); }
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -760,11 +760,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_notify(msg, type) {
|
_notify(msg, type) {
|
||||||
if (typeof window.showNotification === 'function') {
|
window.showNotification(msg, type || 'info');
|
||||||
window.showNotification(msg, type || 'info');
|
|
||||||
} else {
|
|
||||||
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_busy(btn, label) {
|
_busy(btn, label) {
|
||||||
@@ -785,15 +781,8 @@
|
|||||||
delete btn._jfmOrigText;
|
delete btn._jfmOrigText;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quotes too: the result lands in quoted attribute values (title=,
|
|
||||||
// data-cat=, pattern=, ...), and the textContent/innerHTML round-trip
|
|
||||||
// only escapes &, < and >.
|
|
||||||
_esc(str) {
|
_esc(str) {
|
||||||
const d = document.createElement('div');
|
return window.LEDEscape.html(str);
|
||||||
d.textContent = String(str ?? '');
|
|
||||||
return d.innerHTML
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_fmtSize(bytes) {
|
_fmtSize(bytes) {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
/* global debugLog */
|
||||||
/**
|
/**
|
||||||
* LEDMatrix Notification Widget
|
* LEDMatrix Notification Widget
|
||||||
*
|
*
|
||||||
* Global notification/toast system for displaying messages to users.
|
* Global notification/toast system for displaying messages to users.
|
||||||
* This is the single implementation: the early fallbacks in app-shell.js,
|
* This is the single implementation. app-shell.js defines a stand-in first
|
||||||
* app.js and partials/fonts.html only queue messages until this widget has
|
* (it runs before every other script that notifies), which queues messages
|
||||||
* loaded (see window.__pendingNotifications) and then delegate to it.
|
* in window.__pendingNotifications until this widget loads, shows the queue
|
||||||
|
* and replaces it. So window.showNotification always exists: call it
|
||||||
|
* directly, without a typeof check or a fallback.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* window.showNotification('Message here', 'success');
|
* window.showNotification('Message here', 'success');
|
||||||
@@ -158,16 +161,7 @@
|
|||||||
setTimeout(() => { region.textContent = text; }, 50);
|
setTimeout(() => { region.textContent = text; }, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
* Escape HTML to prevent XSS
|
|
||||||
* @param {string} text - Text to escape
|
|
||||||
* @returns {string} Escaped text
|
|
||||||
*/
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearTimer(notificationId) {
|
function clearTimer(notificationId) {
|
||||||
const t = timers.get(notificationId);
|
const t = timers.get(notificationId);
|
||||||
@@ -360,8 +354,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log for debugging
|
debugLog(`[${type.toUpperCase()}]`, message);
|
||||||
console.log(`[${type.toUpperCase()}]`, message);
|
|
||||||
|
|
||||||
return notificationId;
|
return notificationId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('NumberInput', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('NumberInput', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -28,12 +28,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -235,20 +235,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notify(msg, type) {
|
function notify(msg, type) {
|
||||||
if (window.showNotification) window.showNotification(msg, type);
|
window.showNotification(msg, type);
|
||||||
else console.log(`[PFM][${type}] ${msg}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quotes too: the result lands in quoted attribute values (id=, value=,
|
function escHtml(s) { return window.LEDEscape.html(s); }
|
||||||
// data-col=), and the textContent/innerHTML round-trip only escapes
|
|
||||||
// &, < and >.
|
|
||||||
function escHtml(s) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = String(s ?? '');
|
|
||||||
return d.innerHTML
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||||
@@ -745,13 +735,12 @@
|
|||||||
|
|
||||||
window._pfmUpload = async function (fieldId, file) {
|
window._pfmUpload = async function (fieldId, file) {
|
||||||
const st = getState(fieldId);
|
const st = getState(fieldId);
|
||||||
const notifyFn = window.showNotification || console.log;
|
|
||||||
if (!file.name.toLowerCase().endsWith('.json')) {
|
if (!file.name.toLowerCase().endsWith('.json')) {
|
||||||
notifyFn('Only .json files can be uploaded', 'error'); return;
|
window.showNotification('Only .json files can be uploaded', 'error'); return;
|
||||||
}
|
}
|
||||||
let content;
|
let content;
|
||||||
try { content = await file.text(); JSON.parse(content); }
|
try { content = await file.text(); JSON.parse(content); }
|
||||||
catch { notifyFn('File contains invalid JSON', 'error'); return; }
|
catch { window.showNotification('File contains invalid JSON', 'error'); return; }
|
||||||
|
|
||||||
const result = await callAction(st.pluginId, st.actions.upload, {
|
const result = await callAction(st.pluginId, st.actions.upload, {
|
||||||
filename: file.name, content
|
filename: file.name, content
|
||||||
|
|||||||
@@ -33,12 +33,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('RadioGroup', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('RadioGroup', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* global debugLog */
|
||||||
/**
|
/**
|
||||||
* LEDMatrix Widget Registry
|
* LEDMatrix Widget Registry
|
||||||
*
|
*
|
||||||
@@ -183,7 +184,7 @@
|
|||||||
const removed = this._widgets.delete(widgetName);
|
const removed = this._widgets.delete(widgetName);
|
||||||
this._handlers.delete(widgetName);
|
this._handlers.delete(widgetName);
|
||||||
if (removed) {
|
if (removed) {
|
||||||
console.log(`[WidgetRegistry] Unregistered widget: ${widgetName}`);
|
debugLog(`[WidgetRegistry] Unregistered widget: ${widgetName}`);
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
},
|
},
|
||||||
@@ -194,7 +195,7 @@
|
|||||||
clear: function() {
|
clear: function() {
|
||||||
this._widgets.clear();
|
this._widgets.clear();
|
||||||
this._handlers.clear();
|
this._handlers.clear();
|
||||||
console.log('[WidgetRegistry] Cleared all widgets');
|
debugLog('[WidgetRegistry] Cleared all widgets');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,7 @@
|
|||||||
// Use BaseWidget utilities if available
|
// Use BaseWidget utilities if available
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -28,12 +28,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('SelectDropdown', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('SelectDropdown', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -28,17 +28,9 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('Slider', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('Slider', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape for use in HTML attributes (also escapes quotes)
|
function escapeAttr(text) { return window.LEDEscape.attr(text); }
|
||||||
function escapeAttr(text) {
|
|
||||||
return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -29,12 +29,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('TextInput', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('TextInput', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -26,12 +26,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('Textarea', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('Textarea', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -25,12 +25,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -32,12 +32,7 @@
|
|||||||
// Use BaseWidget utilities if available
|
// Use BaseWidget utilities if available
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('TimeRange', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('TimeRange', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -23,12 +23,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('TimezoneSelector', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('TimezoneSelector', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -26,12 +26,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('ToggleSwitch', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('ToggleSwitch', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
@@ -26,12 +26,7 @@
|
|||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('UrlInput', '1.0.0') : null;
|
const base = window.BaseWidget ? new window.BaseWidget('UrlInput', '1.0.0') : null;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
function sanitizeId(id) {
|
||||||
if (base) return base.sanitizeId(id);
|
if (base) return base.sanitizeId(id);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -168,54 +168,109 @@
|
|||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/htmx-config.js') }}"></script>
|
<script src="{{ url_for('static', filename='v3/js/htmx-config.js') }}"></script>
|
||||||
|
|
||||||
<!-- Fallback for loading plugins if HTMX fails -->
|
<!-- Direct-fetch fallback for tab panels. Used when HTMX never loads, and
|
||||||
|
by the hx-on::response-error handlers on the Overview, WiFi and
|
||||||
|
Plugin Manager panels when HTMX's own request fails. -->
|
||||||
<script>
|
<script>
|
||||||
function loadPluginsDirect() {
|
(function() {
|
||||||
const content = document.getElementById('plugins-content');
|
const PANEL_LABELS = {
|
||||||
if (content && !content.hasAttribute('data-loaded')) {
|
'overview-content': 'the Overview',
|
||||||
content.setAttribute('data-loaded', 'true');
|
'wifi-content': 'WiFi setup',
|
||||||
debugLog('Loading plugins directly via fetch (HTMX fallback)...');
|
'plugins-content': 'the Plugin Manager'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetches `url` into the element `id`, runs the partial's inline
|
||||||
|
// scripts, and lets HTMX and Alpine process the new markup. The
|
||||||
|
// scripts run before Alpine sees the content for the same reason
|
||||||
|
// htmx-config.js runs them on htmx:afterSwap: partials define their
|
||||||
|
// x-data component functions in those scripts.
|
||||||
|
window.loadPartialDirect = function(id, url) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el || !url || el.hasAttribute('data-loaded') || el.hasAttribute('data-direct-loading')) return;
|
||||||
|
el.setAttribute('data-direct-loading', 'true');
|
||||||
|
debugLog('Loading ' + url + ' directly via fetch (HTMX fallback)');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
fetch('/v3/partials/plugins', { signal: controller.signal })
|
fetch(url, { signal: controller.signal })
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
||||||
return r.text();
|
return r.text();
|
||||||
})
|
})
|
||||||
.then(html => {
|
.then(html => {
|
||||||
clearTimeout(timeout);
|
el.innerHTML = html;
|
||||||
content.innerHTML = html;
|
el.querySelectorAll('script').forEach(oldScript => {
|
||||||
if (typeof htmx !== 'undefined') {
|
const script = document.createElement('script');
|
||||||
htmx.process(content);
|
for (const attr of oldScript.attributes) script.setAttribute(attr.name, attr.value);
|
||||||
}
|
script.textContent = oldScript.textContent;
|
||||||
// Trigger full initialization chain
|
oldScript.replaceWith(script);
|
||||||
if (window.pluginManager) {
|
});
|
||||||
window.pluginManager.initialized = false;
|
el.setAttribute('data-loaded', 'true');
|
||||||
window.pluginManager.initializing = false;
|
if (typeof htmx !== 'undefined') htmx.process(el);
|
||||||
}
|
if (window.Alpine) window.Alpine.initTree(el);
|
||||||
if (window.initPluginsPage) {
|
if (id === 'plugins-content' && window.initPluginsPage) {
|
||||||
|
if (window.pluginManager) {
|
||||||
|
window.pluginManager.initialized = false;
|
||||||
|
window.pluginManager.initializing = false;
|
||||||
|
}
|
||||||
window.initPluginsPage();
|
window.initPluginsPage();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
console.error('Failed to load ' + url + ':', err);
|
||||||
|
const label = PANEL_LABELS[id] || 'this page';
|
||||||
|
el.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load ' + label + '. Check that the device is still reachable, then try again.</p><button type="button" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
|
||||||
|
el.querySelector('button').addEventListener('click', () => window.loadPartialDirect(id, url));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
console.error('Failed to load plugins:', err);
|
el.removeAttribute('data-direct-loading');
|
||||||
content.removeAttribute('data-loaded');
|
|
||||||
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Plugin Manager. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadPluginsDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
|
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if HTMX doesn't load within 5 seconds
|
function activeTab() {
|
||||||
var _pluginsFallbackTimer = setTimeout(() => {
|
const appEl = document.querySelector('[x-data="app()"]');
|
||||||
if (typeof htmx === 'undefined') {
|
try {
|
||||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
return (window.Alpine && appEl) ? window.Alpine.$data(appEl).activeTab : null;
|
||||||
// Load plugins tab content directly regardless of active tab,
|
} catch (e) {
|
||||||
// so it's ready when the user navigates to it
|
return null; // Alpine has not initialised the app yet
|
||||||
loadPluginsDirect();
|
}
|
||||||
}
|
}
|
||||||
}, 5000);
|
|
||||||
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
|
function loadActiveTabDirect() {
|
||||||
|
const el = document.getElementById((activeTab() || 'overview') + '-content');
|
||||||
|
if (el) window.loadPartialDirect(el.id, el.getAttribute('hx-get'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX loaded but never initialised, even from the CDN rescue.
|
||||||
|
window.addEventListener('htmx-load-failed', function() {
|
||||||
|
console.warn('HTMX failed to load, loading the active tab directly');
|
||||||
|
setTimeout(loadActiveTabDirect, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTMX still missing after 5 seconds: load the active tab, and the
|
||||||
|
// Plugin Manager regardless of tab so the plugin tabs work.
|
||||||
|
const fallbackTimer = setTimeout(function() {
|
||||||
|
if (typeof htmx !== 'undefined') return;
|
||||||
|
console.warn('HTMX not loaded after 5 seconds, loading tabs directly');
|
||||||
|
window.loadPartialDirect('plugins-content', '/v3/partials/plugins');
|
||||||
|
loadActiveTabDirect();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// AP mode (setting up WiFi) opens on the WiFi tab and can least
|
||||||
|
// afford to wait, so it gets an earlier check.
|
||||||
|
const apTimer = setTimeout(function() {
|
||||||
|
const host = window.location.hostname;
|
||||||
|
if (typeof htmx === 'undefined' && host.startsWith('192.168.4.')) {
|
||||||
|
console.warn('HTMX not loaded, loading WiFi setup directly');
|
||||||
|
window.loadPartialDirect('wifi-content', '/v3/partials/wifi');
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
window.addEventListener('htmx:ready', function() {
|
||||||
|
clearTimeout(fallbackTimer);
|
||||||
|
clearTimeout(apTimer);
|
||||||
|
}, { once: true });
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
||||||
<script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script>
|
<script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script>
|
||||||
@@ -246,45 +301,6 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
|
||||||
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
||||||
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}"></noscript>
|
|
||||||
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
||||||
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}"></noscript>
|
|
||||||
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
|
|
||||||
<script>
|
|
||||||
// Lazy load CodeMirror when needed
|
|
||||||
window.loadCodeMirror = function() {
|
|
||||||
if (window.CodeMirror) return Promise.resolve();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Vendored CodeMirror 5.65.2 (note: the previously listed CDN
|
|
||||||
// mode/json/json.min.js never existed — it 404'd on every
|
|
||||||
// editor open; JSON highlighting comes from the javascript
|
|
||||||
// mode, so it's simply dropped here)
|
|
||||||
const scripts = [
|
|
||||||
'/static/v3/vendor/codemirror/codemirror.min.js',
|
|
||||||
'/static/v3/vendor/codemirror/mode/javascript/javascript.min.js',
|
|
||||||
'/static/v3/vendor/codemirror/addon/edit/closebrackets.min.js',
|
|
||||||
'/static/v3/vendor/codemirror/addon/edit/matchbrackets.min.js'
|
|
||||||
];
|
|
||||||
|
|
||||||
let loaded = 0;
|
|
||||||
scripts.forEach((src, index) => {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = src;
|
|
||||||
script.defer = true;
|
|
||||||
script.onload = () => {
|
|
||||||
loaded++;
|
|
||||||
if (loaded === scripts.length) resolve();
|
|
||||||
};
|
|
||||||
script.onerror = reject;
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Font Awesome icons (vendored 6.0.0 — css/all.min.css references
|
<!-- Font Awesome icons (vendored 6.0.0 — css/all.min.css references
|
||||||
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
|
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
|
||||||
@@ -548,7 +564,7 @@
|
|||||||
<!-- Second row - Plugin tabs (populated dynamically) -->
|
<!-- Second row - Plugin tabs (populated dynamically) -->
|
||||||
<div id="plugin-tabs-row" class="border-b border-gray-200">
|
<div id="plugin-tabs-row" class="border-b border-gray-200">
|
||||||
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
|
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
|
||||||
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
|
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPartialDirect('plugins-content', '/v3/partials/plugins')); }"
|
||||||
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
|
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
|
||||||
class="nav-tab">
|
class="nav-tab">
|
||||||
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
|
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
|
||||||
@@ -592,7 +608,7 @@
|
|||||||
<div id="tab-content" class="space-y-6" tabindex="-1">
|
<div id="tab-content" class="space-y-6" tabindex="-1">
|
||||||
<!-- Overview tab -->
|
<!-- Overview tab -->
|
||||||
<div x-show="activeTab === 'overview'" x-transition>
|
<div x-show="activeTab === 'overview'" x-transition>
|
||||||
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -601,65 +617,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Fallback: Load overview content directly if HTMX fails
|
|
||||||
function loadOverviewDirect() {
|
|
||||||
const overviewContent = document.getElementById('overview-content');
|
|
||||||
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
|
|
||||||
fetch('/v3/partials/overview')
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
|
|
||||||
return response.text();
|
|
||||||
})
|
|
||||||
.then(html => {
|
|
||||||
overviewContent.innerHTML = html;
|
|
||||||
overviewContent.setAttribute('data-loaded', 'true');
|
|
||||||
if (typeof htmx !== 'undefined') {
|
|
||||||
htmx.process(overviewContent);
|
|
||||||
}
|
|
||||||
// Re-initialize Alpine.js for the new content
|
|
||||||
if (window.Alpine) {
|
|
||||||
window.Alpine.initTree(overviewContent);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to load overview content:', err);
|
|
||||||
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Overview. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadOverviewDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for HTMX load failure
|
|
||||||
window.addEventListener('htmx-load-failed', function() {
|
|
||||||
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
|
|
||||||
// Try to load content directly after a delay
|
|
||||||
setTimeout(() => {
|
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
|
||||||
if (appElement && appElement.__x) {
|
|
||||||
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
||||||
if (activeTab === 'overview') {
|
|
||||||
loadOverviewDirect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also try direct load if HTMX doesn't load within 5 seconds
|
|
||||||
var _overviewFallbackTimer = setTimeout(() => {
|
|
||||||
if (typeof htmx === 'undefined') {
|
|
||||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
|
||||||
if (appElement && appElement.__x) {
|
|
||||||
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
||||||
if (activeTab === 'overview') {
|
|
||||||
loadOverviewDirect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- General tab -->
|
<!-- General tab -->
|
||||||
<div x-show="activeTab === 'general'" x-transition>
|
<div x-show="activeTab === 'general'" x-transition>
|
||||||
@@ -683,7 +640,7 @@
|
|||||||
hx-get="/v3/partials/wifi"
|
hx-get="/v3/partials/wifi"
|
||||||
hx-trigger="loadtab"
|
hx-trigger="loadtab"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-on::htmx:response-error="loadWifiDirect()">
|
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -696,42 +653,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Fallback: Load WiFi content directly if HTMX fails
|
|
||||||
function loadWifiDirect() {
|
|
||||||
const wifiContent = document.getElementById('wifi-content');
|
|
||||||
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
|
|
||||||
fetch('/v3/partials/wifi')
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
|
|
||||||
return response.text();
|
|
||||||
})
|
|
||||||
.then(html => {
|
|
||||||
wifiContent.innerHTML = html;
|
|
||||||
wifiContent.setAttribute('data-loaded', 'true');
|
|
||||||
// Re-initialize Alpine.js for the new content
|
|
||||||
if (window.Alpine) {
|
|
||||||
window.Alpine.initTree(wifiContent);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to load WiFi content:', err);
|
|
||||||
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load WiFi setup. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadWifiDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
|
|
||||||
setTimeout(() => {
|
|
||||||
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
||||||
window.location.hostname.startsWith('192.168.4.');
|
|
||||||
if (isAPMode && typeof htmx === 'undefined') {
|
|
||||||
console.warn('HTMX not loaded, using direct fetch for WiFi content');
|
|
||||||
loadWifiDirect();
|
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Schedule tab -->
|
<!-- Schedule tab -->
|
||||||
<div x-show="activeTab === 'schedule'" x-transition>
|
<div x-show="activeTab === 'schedule'" x-transition>
|
||||||
@@ -806,7 +727,7 @@
|
|||||||
<!-- Plugins tab -->
|
<!-- Plugins tab -->
|
||||||
<div x-show="activeTab === 'plugins'" x-transition>
|
<div x-show="activeTab === 'plugins'" x-transition>
|
||||||
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
|
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
|
||||||
hx-on::response-error="loadPluginsDirect()">
|
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
@@ -986,11 +907,10 @@
|
|||||||
their own URLs for plugin-loader.js and debugging. -->
|
their own URLs for plugin-loader.js and debugging. -->
|
||||||
<script src="{{ widgets_bundle_url() }}" defer></script>
|
<script src="{{ widgets_bundle_url() }}" defer></script>
|
||||||
|
|
||||||
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
|
<!-- plugins_manager.js: loaded last. It uses PluginAPI, ListFilter,
|
||||||
window.* implementations for several plugin actions (installPlugin,
|
LEDDialog, the widgets and app-shell.js's updatePlugin at load time
|
||||||
uninstallPlugin, executePluginAction, ...) and intentionally wins
|
or when a card is clicked, so it must follow all of them. No other
|
||||||
over same-named definitions in js/app.js and js/app-shell.js. Do not
|
script defines the same globals. -->
|
||||||
remove or reorder without resolving that overlap first. -->
|
|
||||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
||||||
|
|
||||||
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
|
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
|
||||||
@@ -1147,16 +1067,12 @@
|
|||||||
'services to run the new code');
|
'services to run the new code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof showNotification === 'function') {
|
showNotification(data.message || 'Update complete', data.status || 'success');
|
||||||
showNotification(data.message || 'Update complete', data.status || 'success');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
btn.innerHTML = originalHTML;
|
btn.innerHTML = originalHTML;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
if (typeof showNotification === 'function') {
|
showNotification('Update failed — check your connection', 'error');
|
||||||
showNotification('Update failed — check your connection', 'error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -121,11 +121,7 @@
|
|||||||
let inspectedFile = null;
|
let inspectedFile = null;
|
||||||
|
|
||||||
function notify(message, kind) {
|
function notify(message, kind) {
|
||||||
if (typeof showNotification === 'function') {
|
showNotification(message, kind || 'info');
|
||||||
showNotification(message, kind || 'info');
|
|
||||||
} else {
|
|
||||||
console.log('[backup]', kind || 'info', message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
@@ -136,11 +132,7 @@
|
|||||||
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) { return window.LEDEscape.html(value); }
|
||||||
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
|
|
||||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPreview() {
|
async function loadPreview() {
|
||||||
const el = document.getElementById('export-preview');
|
const el = document.getElementById('export-preview');
|
||||||
|
|||||||
@@ -55,13 +55,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Initialize cache management when partial loads
|
// Scoped: this script runs at global scope after every HTMX swap, and the Logs
|
||||||
|
// partial has helpers with the same names (showError, escapeHtml). Only
|
||||||
|
// deleteCacheFile, which the row buttons call from onclick, is exported.
|
||||||
(function() {
|
(function() {
|
||||||
loadCacheFiles();
|
loadCacheFiles();
|
||||||
|
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
|
||||||
// Event listeners
|
|
||||||
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
|
|
||||||
})();
|
|
||||||
|
|
||||||
function loadCacheFiles() {
|
function loadCacheFiles() {
|
||||||
const tbody = document.getElementById('cache-files-tbody');
|
const tbody = document.getElementById('cache-files-tbody');
|
||||||
@@ -190,21 +189,15 @@ function deleteCacheFile(key) {
|
|||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
if (typeof showNotification !== 'undefined') {
|
showNotification(data.message || 'Cache file deleted successfully', 'success');
|
||||||
showNotification(data.message || 'Cache file deleted successfully', 'success');
|
|
||||||
}
|
|
||||||
// Reload cache files list
|
// Reload cache files list
|
||||||
loadCacheFiles();
|
loadCacheFiles();
|
||||||
} else {
|
} else {
|
||||||
if (typeof showNotification !== 'undefined') {
|
showNotification(data.message || 'Failed to delete cache file', 'error');
|
||||||
showNotification(data.message || 'Failed to delete cache file', 'error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
if (typeof showNotification !== 'undefined') {
|
showNotification('Error deleting cache file: ' + error.message, 'error');
|
||||||
showNotification('Error deleting cache file: ' + error.message, 'error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,17 +213,8 @@ function showError(message) {
|
|||||||
errorMessage.textContent = message;
|
errorMessage.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to escape HTML
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
// Quotes too: the result is interpolated into quoted attribute values, and the
|
|
||||||
// textContent/innerHTML round-trip only escapes &, < and >.
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make deleteCacheFile available globally for onclick handlers
|
|
||||||
window.deleteCacheFile = deleteCacheFile;
|
window.deleteCacheFile = deleteCacheFile;
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)"
|
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Display settings saved', 'Display settings were not saved. Check your connection and try again.')"
|
||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
novalidate
|
novalidate
|
||||||
onsubmit="fixInvalidNumberInputs(this); return true;">
|
onsubmit="fixInvalidNumberInputs(this); return true;">
|
||||||
@@ -863,62 +863,12 @@ document.getElementById('brightness').addEventListener('input', function() {
|
|||||||
document.getElementById('brightness-display').textContent = this.value;
|
document.getElementById('brightness-display').textContent = this.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fix invalid number inputs function (if not already defined globally)
|
|
||||||
if (typeof window.fixInvalidNumberInputs !== 'function') {
|
|
||||||
window.fixInvalidNumberInputs = function(form) {
|
|
||||||
if (!form) return;
|
|
||||||
const allInputs = form.querySelectorAll('input[type="number"]');
|
|
||||||
allInputs.forEach(input => {
|
|
||||||
const min = parseFloat(input.getAttribute('min'));
|
|
||||||
const max = parseFloat(input.getAttribute('max'));
|
|
||||||
const value = parseFloat(input.value);
|
|
||||||
|
|
||||||
if (!isNaN(value)) {
|
|
||||||
if (!isNaN(min) && value < min) {
|
|
||||||
input.value = min;
|
|
||||||
} else if (!isNaN(max) && value > max) {
|
|
||||||
input.value = max;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report the outcome of a display-settings save. XMLHttpRequest has no
|
|
||||||
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
|
|
||||||
// status code, otherwise a failed save reports success.
|
|
||||||
window.showDisplaySaveResult = function(xhr) {
|
|
||||||
// Only 2xx counts as saved. A network failure reports status 0, which any
|
|
||||||
// `>= 400` test would wave through as success.
|
|
||||||
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
|
|
||||||
let message = httpSuccess
|
|
||||||
? 'Display settings saved'
|
|
||||||
: 'Display settings were not saved. Check your connection and try again.';
|
|
||||||
let status = httpSuccess ? 'success' : 'error';
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(xhr.responseText);
|
|
||||||
if (data.message) message = data.message;
|
|
||||||
// A body can refine a successful verdict but never overturn a failed one.
|
|
||||||
if (httpSuccess && data.status) status = data.status;
|
|
||||||
} catch {
|
|
||||||
// Non-JSON body — fall back to the status-code verdict above.
|
|
||||||
}
|
|
||||||
showNotification(message, status);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vegas Scroll Mode Settings
|
// Vegas Scroll Mode Settings
|
||||||
(function() {
|
(function() {
|
||||||
// Escape HTML to prevent XSS
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text || '');
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape for use in HTML attributes
|
function escapeAttr(text) { return window.LEDEscape.attr(text); }
|
||||||
function escapeAttr(text) {
|
|
||||||
return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle settings visibility
|
// Toggle settings visibility
|
||||||
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
|
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Durations saved', event.detail.xhr.responseJSON?.status || 'success')"
|
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Durations saved', 'Durations were not saved. Check your connection and try again.')"
|
||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
novalidate
|
novalidate
|
||||||
onsubmit="fixInvalidNumberInputs(this); return true;">
|
onsubmit="fixInvalidNumberInputs(this); return true;">
|
||||||
|
|||||||
@@ -164,21 +164,6 @@ function initializeFontsTab() {
|
|||||||
// Reset retry counter on successful init
|
// Reset retry counter on successful init
|
||||||
initRetryCount = 0;
|
initRetryCount = 0;
|
||||||
|
|
||||||
// showNotification is implemented by the notification widget
|
|
||||||
// (widgets/notification.js). If it hasn't loaded, queue the message; the
|
|
||||||
// widget shows queued messages as soon as it registers.
|
|
||||||
if (typeof window.showNotification !== 'function') {
|
|
||||||
window.showNotification = function(message, type = 'info') {
|
|
||||||
const registry = window.LEDMatrixWidgets;
|
|
||||||
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
|
|
||||||
if (widget && typeof widget.show === 'function') {
|
|
||||||
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
|
|
||||||
}
|
|
||||||
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
|
|
||||||
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog('Initializing font management...');
|
debugLog('Initializing font management...');
|
||||||
initializeFontManagement();
|
initializeFontManagement();
|
||||||
|
|
||||||
@@ -349,14 +334,7 @@ async function loadFontData() {
|
|||||||
// Show error states
|
// Show error states
|
||||||
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
|
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
|
||||||
|
|
||||||
// Only show notification if showNotification is available
|
showNotification('Error loading font configuration', 'error');
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Error loading font configuration', 'error');
|
|
||||||
} else if (typeof window.showNotification === 'function') {
|
|
||||||
window.showNotification('Error loading font configuration', 'error');
|
|
||||||
} else {
|
|
||||||
console.error('Error loading font configuration:', error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,24 +11,7 @@
|
|||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="
|
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Settings saved', 'Failed to save settings')"
|
||||||
var xhr = event.detail.xhr;
|
|
||||||
var isSuccess = xhr.status >= 200 && xhr.status < 300;
|
|
||||||
var message = '';
|
|
||||||
var status = 'success';
|
|
||||||
try {
|
|
||||||
var data = JSON.parse(xhr.responseText);
|
|
||||||
message = data.message || '';
|
|
||||||
status = data.status || status;
|
|
||||||
} catch (e) {}
|
|
||||||
if (isSuccess) {
|
|
||||||
message = message || 'Settings saved';
|
|
||||||
} else {
|
|
||||||
message = message || 'Failed to save settings';
|
|
||||||
status = 'error';
|
|
||||||
}
|
|
||||||
showNotification(message, status);
|
|
||||||
"
|
|
||||||
class="space-y-6">
|
class="space-y-6">
|
||||||
{# Marks this post as the whole form, so an unchecked box saves as
|
{# Marks this post as the whole form, so an unchecked box saves as
|
||||||
false. Without it the save endpoint treats absent keys as unchanged
|
false. Without it the save endpoint treats absent keys as unchanged
|
||||||
|
|||||||
@@ -126,8 +126,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Global variables - use window properties to avoid redeclaration errors with HTMX reloads
|
// Scoped: this script runs at global scope after every HTMX swap, and the
|
||||||
// Initialize only if not already defined
|
// Cache partial has helpers with the same names (showError, escapeHtml). State
|
||||||
|
// that must survive a reload of this partial lives on window._logs* instead.
|
||||||
|
(function() {
|
||||||
if (typeof window._logsEventSource === 'undefined') {
|
if (typeof window._logsEventSource === 'undefined') {
|
||||||
window._logsEventSource = null;
|
window._logsEventSource = null;
|
||||||
}
|
}
|
||||||
@@ -624,9 +626,7 @@ function toggleRealtime() {
|
|||||||
|
|
||||||
function refreshLogs() {
|
function refreshLogs() {
|
||||||
loadLogs();
|
loadLogs();
|
||||||
if (typeof showNotification !== 'undefined') {
|
showNotification('Logs refreshed', 'success');
|
||||||
showNotification('Logs refreshed', 'success');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLogs() {
|
function clearLogs() {
|
||||||
@@ -637,16 +637,12 @@ function clearLogs() {
|
|||||||
}
|
}
|
||||||
showEmptyState();
|
showEmptyState();
|
||||||
updateLogStats();
|
updateLogStats();
|
||||||
if (typeof showNotification !== 'undefined') {
|
showNotification('Logs cleared', 'info');
|
||||||
showNotification('Logs cleared', 'info');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadLogs() {
|
function downloadLogs() {
|
||||||
if (window._filteredLogs.length === 0) {
|
if (window._filteredLogs.length === 0) {
|
||||||
if (typeof showNotification !== 'undefined') {
|
showNotification('No logs to download', 'warning');
|
||||||
showNotification('No logs to download', 'warning');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,9 +658,7 @@ function downloadLogs() {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
if (typeof showNotification !== 'undefined') {
|
showNotification('Logs downloaded', 'success');
|
||||||
showNotification('Logs downloaded', 'success');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track if user is near bottom for smart auto-scroll
|
// Track if user is near bottom for smart auto-scroll
|
||||||
@@ -776,16 +770,7 @@ function showError(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to escape HTML. Quotes too: the result is interpolated into
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
// quoted attribute values (option value=, title=), and the
|
|
||||||
// textContent/innerHTML round-trip only escapes &, < and >.
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshCurrentPluginStatus() {
|
function refreshCurrentPluginStatus() {
|
||||||
fetch('/api/v3/display/current-status')
|
fetch('/api/v3/display/current-status')
|
||||||
@@ -920,15 +905,13 @@ function clearPluginErrors() {
|
|||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (typeof showNotification !== 'undefined') {
|
showNotification(data.status === 'success' ? 'Plugin errors cleared'
|
||||||
showNotification(data.status === 'success' ? 'Plugin errors cleared'
|
: (data.message || 'Could not clear plugin errors'),
|
||||||
: (data.message || 'Could not clear plugin errors'),
|
data.status === 'success' ? 'success' : 'error');
|
||||||
data.status === 'success' ? 'success' : 'error');
|
|
||||||
}
|
|
||||||
refreshPluginErrors();
|
refreshPluginErrors();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error');
|
showNotification('Could not clear plugin errors', 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,4 +929,5 @@ window.addEventListener('beforeunload', function() {
|
|||||||
window._pluginErrorsPollTimer = null;
|
window._pluginErrorsPollTimer = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -260,18 +260,10 @@
|
|||||||
return 'bg-gray-100 text-gray-800';
|
return 'bg-gray-100 text-gray-800';
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
if (typeof showNotification === 'function') {
|
showNotification(message, 'error');
|
||||||
showNotification(message, 'error');
|
|
||||||
} else {
|
|
||||||
alert(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate plugin filter
|
// Populate plugin filter
|
||||||
|
|||||||
@@ -216,11 +216,7 @@
|
|||||||
if (nowDone) maybeAutoHide();
|
if (nowDone) maybeAutoHide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
|
var data = window.getApp();
|
||||||
// Same two-tier resolution as settings-search.js's getAppData():
|
|
||||||
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
|
|
||||||
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
|
|
||||||
(appEl.__x && appEl.__x.$data));
|
|
||||||
if (data) {
|
if (data) {
|
||||||
data.activeTab = btn.dataset.tab;
|
data.activeTab = btn.dataset.tab;
|
||||||
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
||||||
@@ -443,7 +439,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "start_display"}'
|
hx-vals='{"action": "start_display"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||||
<i class="fas fa-play mr-2"></i>
|
<i class="fas fa-play mr-2"></i>
|
||||||
Start Display
|
Start Display
|
||||||
@@ -452,7 +448,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "stop_display"}'
|
hx-vals='{"action": "stop_display"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
|
||||||
<i class="fas fa-stop mr-2"></i>
|
<i class="fas fa-stop mr-2"></i>
|
||||||
Stop Display
|
Stop Display
|
||||||
@@ -462,7 +458,7 @@
|
|||||||
hx-vals='{"action": "git_pull"}'
|
hx-vals='{"action": "git_pull"}'
|
||||||
hx-confirm="This will stash any local changes and update the code. Continue?"
|
hx-confirm="This will stash any local changes and update the code. Continue?"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||||
<i class="fas fa-download mr-2"></i>
|
<i class="fas fa-download mr-2"></i>
|
||||||
Update Code
|
Update Code
|
||||||
@@ -472,7 +468,7 @@
|
|||||||
hx-vals='{"action": "reboot_system"}'
|
hx-vals='{"action": "reboot_system"}'
|
||||||
hx-confirm="Are you sure you want to reboot the system?"
|
hx-confirm="Are you sure you want to reboot the system?"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
||||||
<i class="fas fa-power-off mr-2"></i>
|
<i class="fas fa-power-off mr-2"></i>
|
||||||
Reboot System
|
Reboot System
|
||||||
@@ -482,7 +478,7 @@
|
|||||||
hx-vals='{"action": "shutdown_system"}'
|
hx-vals='{"action": "shutdown_system"}'
|
||||||
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
|
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
|
||||||
<i class="fas fa-power-off mr-2"></i>
|
<i class="fas fa-power-off mr-2"></i>
|
||||||
Shutdown System
|
Shutdown System
|
||||||
@@ -491,7 +487,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "restart_display_service"}'
|
hx-vals='{"action": "restart_display_service"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||||
<i class="fas fa-redo mr-2"></i>
|
<i class="fas fa-redo mr-2"></i>
|
||||||
Restart Display Service
|
Restart Display Service
|
||||||
@@ -500,7 +496,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "restart_web_service"}'
|
hx-vals='{"action": "restart_web_service"}'
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
|
hx-on:htmx:after-request="var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||||
<i class="fas fa-redo mr-2"></i>
|
<i class="fas fa-redo mr-2"></i>
|
||||||
Restart Web Service
|
Restart Web Service
|
||||||
|
|||||||
@@ -423,11 +423,10 @@
|
|||||||
{% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %}
|
{% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %}
|
||||||
{% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %}
|
{% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% if has_schedule %}
|
{# file-upload.js rewrites .image-schedule-summary in place when the schedule is edited #}
|
||||||
<p class="text-xs text-blue-600 mt-1">
|
<p class="text-xs text-blue-600 mt-1 image-schedule-summary">
|
||||||
<i class="fas fa-clock mr-1"></i>Scheduled
|
<i class="fas fa-clock mr-1"></i>{{ 'Scheduled' if has_schedule else 'Always shown' }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2 ml-4">
|
<div class="flex items-center space-x-2 ml-4">
|
||||||
|
|||||||
@@ -540,18 +540,6 @@
|
|||||||
background-color: #e5e7eb;
|
background-color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CodeMirror editor styles */
|
|
||||||
.CodeMirror {
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 14px;
|
|
||||||
height: auto;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CodeMirror.cm-error {
|
|
||||||
border-color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Plugin config view styles */
|
/* Plugin config view styles */
|
||||||
.plugin-config-view {
|
.plugin-config-view {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
fieldId: 'schedule'
|
fieldId: 'schedule'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Schedule] Schedule picker widget initialized');
|
debugLog('[Schedule] Schedule picker widget initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle form submission response
|
// Handle form submission response
|
||||||
@@ -165,28 +165,7 @@
|
|||||||
const type = response.status || 'info';
|
const type = response.status || 'info';
|
||||||
|
|
||||||
// Use global notification function if available
|
// Use global notification function if available
|
||||||
if (typeof window.showNotification === 'function') {
|
window.showNotification(message, type);
|
||||||
window.showNotification(message, type);
|
|
||||||
} else {
|
|
||||||
// Fallback notification
|
|
||||||
const colors = {
|
|
||||||
success: 'bg-green-500',
|
|
||||||
error: 'bg-red-500',
|
|
||||||
warning: 'bg-yellow-500',
|
|
||||||
info: 'bg-blue-500'
|
|
||||||
};
|
|
||||||
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
|
|
||||||
notification.textContent = message;
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
notification.style.transition = 'opacity 0.5s';
|
|
||||||
notification.style.opacity = '0';
|
|
||||||
setTimeout(function() { notification.remove(); }, 500);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize when DOM is ready or if already loaded
|
// Initialize when DOM is ready or if already loaded
|
||||||
@@ -265,7 +244,7 @@
|
|||||||
fieldId: 'dim_schedule'
|
fieldId: 'dim_schedule'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[DimSchedule] Dim schedule picker widget initialized');
|
debugLog('[DimSchedule] Dim schedule picker widget initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle dim schedule form submission response
|
// Handle dim schedule form submission response
|
||||||
@@ -282,28 +261,7 @@
|
|||||||
const type = response.status || 'info';
|
const type = response.status || 'info';
|
||||||
|
|
||||||
// Use global notification function if available
|
// Use global notification function if available
|
||||||
if (typeof window.showNotification === 'function') {
|
window.showNotification(message, type);
|
||||||
window.showNotification(message, type);
|
|
||||||
} else {
|
|
||||||
// Fallback notification
|
|
||||||
const colors = {
|
|
||||||
success: 'bg-green-500',
|
|
||||||
error: 'bg-red-500',
|
|
||||||
warning: 'bg-yellow-500',
|
|
||||||
info: 'bg-blue-500'
|
|
||||||
};
|
|
||||||
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
|
|
||||||
notification.textContent = message;
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
notification.style.transition = 'opacity 0.5s';
|
|
||||||
notification.style.opacity = '0';
|
|
||||||
setTimeout(function() { notification.remove(); }, 500);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
|
|||||||
@@ -344,21 +344,15 @@ function forceRenderStarlarkApp(appId) {
|
|||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
if (typeof showNotification === 'function') {
|
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
|
||||||
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
|
|
||||||
} else {
|
|
||||||
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
var msg = 'Render failed: ' + (data.message || 'Unknown error');
|
var msg = 'Render failed: ' + (data.message || 'Unknown error');
|
||||||
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
showNotification(msg, 'error');
|
||||||
else alert(msg);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
var msg = 'Render failed: ' + err.message;
|
var msg = 'Render failed: ' + err.message;
|
||||||
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
showNotification(msg, 'error');
|
||||||
else alert(msg);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,22 +365,20 @@ function toggleStarlarkApp(appId, enabled) {
|
|||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
|
// A failed reload has already been reported by loadInstalledPlugins.
|
||||||
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
|
window.pluginManager.loadInstalledPlugins(true).catch(function() {});
|
||||||
var container = document.getElementById('plugin-config-starlark:' + appId);
|
var container = document.getElementById('plugin-config-starlark:' + appId);
|
||||||
if (container && window.htmx) {
|
if (container && window.htmx) {
|
||||||
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
|
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var msg = 'Toggle failed: ' + (data.message || 'Unknown error');
|
var msg = 'Toggle failed: ' + (data.message || 'Unknown error');
|
||||||
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
showNotification(msg, 'error');
|
||||||
else alert(msg);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
var msg = 'Toggle failed: ' + err.message;
|
var msg = 'Toggle failed: ' + err.message;
|
||||||
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
showNotification(msg, 'error');
|
||||||
else alert(msg);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,8 +428,7 @@ function saveStarlarkConfig(appId) {
|
|||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
if (typeof showNotification === 'function') showNotification('Configuration saved!', 'success');
|
showNotification('Configuration saved!', 'success');
|
||||||
else alert('Configuration saved!');
|
|
||||||
// Reload partial to reflect updated status
|
// Reload partial to reflect updated status
|
||||||
var container = document.getElementById('plugin-config-starlark:' + appId);
|
var container = document.getElementById('plugin-config-starlark:' + appId);
|
||||||
if (container && window.htmx) {
|
if (container && window.htmx) {
|
||||||
@@ -445,14 +436,12 @@ function saveStarlarkConfig(appId) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var msg = 'Save failed: ' + (data.message || 'Unknown error');
|
var msg = 'Save failed: ' + (data.message || 'Unknown error');
|
||||||
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
showNotification(msg, 'error');
|
||||||
else alert(msg);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
var msg = 'Save failed: ' + err.message;
|
var msg = 'Save failed: ' + err.message;
|
||||||
if (typeof showNotification === 'function') showNotification(msg, 'error');
|
showNotification(msg, 'error');
|
||||||
else alert(msg);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -435,9 +435,7 @@
|
|||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s) {
|
function escHtml(s) { return window.LEDEscape.html(s); }
|
||||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── main action dispatcher ────────────────────────────────────────────────
|
// ── main action dispatcher ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -910,11 +908,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── plugin health panel ──────────────────────────────────────────────────
|
// ── plugin health panel ──────────────────────────────────────────────────
|
||||||
function phEscape(s) {
|
function phEscape(s) { return window.LEDEscape.html(s); }
|
||||||
return String(s).replace(/[&<>"']/g, function (c) {
|
|
||||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function phFmtSecs(v) {
|
function phFmtSecs(v) {
|
||||||
if (typeof v !== 'number' || !isFinite(v)) return '—';
|
if (typeof v !== 'number' || !isFinite(v)) return '—';
|
||||||
return v.toFixed(3) + 's';
|
return v.toFixed(3) + 's';
|
||||||
|
|||||||
Reference in New Issue
Block a user