mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-15 09:28:37 +00:00
Compare commits
1 Commits
b1af068f7a
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45bf5db2b1 |
16
README.md
16
README.md
@@ -1,10 +1,5 @@
|
|||||||
# LEDMatrix
|
# LEDMatrix
|
||||||
[](LICENSE)
|
|
||||||
[](https://discord.gg/RdrC37rEag)
|
|
||||||
[](https://github.com/ChuckBuilds/ledmatrix)
|
|
||||||
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||||
|
|
||||||
|
|
||||||
## Welcome to LEDMatrix!
|
## Welcome to LEDMatrix!
|
||||||
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
||||||
|
|
||||||
@@ -132,15 +127,10 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
||||||
|
|
||||||
### Raspberry Pi
|
### Raspberry Pi
|
||||||
- Raspberry Pi Zero's don't have enough processing power for this project.
|
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
|
||||||
- **Raspberry Pi 3B, 4, or 5**
|
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
||||||
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
|
|
||||||
```bash
|
|
||||||
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
|
||||||
```
|
|
||||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
|
||||||
|
|
||||||
|
|
||||||
### RGB Matrix Bonnet / HAT
|
### RGB Matrix Bonnet / HAT
|
||||||
@@ -592,7 +582,7 @@ These settings control runtime behavior and GPIO timing:
|
|||||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||||
- **Raspberry Pi 3**: Use 3
|
- **Raspberry Pi 3**: Use 3
|
||||||
- **Raspberry Pi 4**: Use 4
|
- **Raspberry Pi 4**: Use 4
|
||||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
- **Raspberry Pi 5**: Use 5 (or higher if needed)
|
||||||
- **Raspberry Pi Zero/1**: Use 1-2
|
- **Raspberry Pi Zero/1**: Use 1-2
|
||||||
- Incorrect values can cause display corruption, flickering, or system instability
|
- Incorrect values can cause display corruption, flickering, or system instability
|
||||||
- If you experience issues, try adjusting this value up or down by 1
|
- If you experience issues, try adjusting this value up or down by 1
|
||||||
|
|||||||
@@ -10,98 +10,6 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
|
|||||||
|
|
||||||
## Available Core Widgets
|
## Available Core Widgets
|
||||||
|
|
||||||
### Plugin File Manager Widget (`plugin-file-manager`)
|
|
||||||
|
|
||||||
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
|
|
||||||
|
|
||||||
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"file_manager": {
|
|
||||||
"type": "null",
|
|
||||||
"title": "Data Files",
|
|
||||||
"x-widget": "plugin-file-manager",
|
|
||||||
"x-widget-config": {
|
|
||||||
"actions": {
|
|
||||||
"list": "list-files",
|
|
||||||
"get": "get-file",
|
|
||||||
"save": "save-file",
|
|
||||||
"upload": "upload-file",
|
|
||||||
"delete": "delete-file",
|
|
||||||
"create": "create-file",
|
|
||||||
"toggle": "toggle-category"
|
|
||||||
},
|
|
||||||
"upload_hint": "JSON files with day numbers 1–365 as keys",
|
|
||||||
"directory_label": "my_data/",
|
|
||||||
"create_fields": [
|
|
||||||
{ "key": "category_name", "label": "Category Name",
|
|
||||||
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
|
|
||||||
"hint": "Lowercase letters, numbers, underscores" },
|
|
||||||
{ "key": "display_name", "label": "Display Name",
|
|
||||||
"placeholder": "e.g., My Words", "hint": "Optional" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Not all 7 actions are required — omit any key to hide the corresponding UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
|
||||||
|
|
||||||
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
|
||||||
|
|
||||||
**Used by:** of-the-day
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Time Picker Widget (`time-picker`)
|
|
||||||
|
|
||||||
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"target_time": {
|
|
||||||
"type": "string",
|
|
||||||
"x-widget": "time-picker",
|
|
||||||
"default": "00:00",
|
|
||||||
"x-options": {
|
|
||||||
"placeholder": "Select time",
|
|
||||||
"clearable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Used by:** countdown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### File Upload Single Widget (`file-upload-single`)
|
|
||||||
|
|
||||||
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
|
|
||||||
|
|
||||||
**Schema Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"image_path": {
|
|
||||||
"type": "string",
|
|
||||||
"x-widget": "file-upload-single",
|
|
||||||
"x-upload-config": {
|
|
||||||
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
|
|
||||||
"max_size_mb": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
|
|
||||||
|
|
||||||
**Used by:** countdown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### File Upload Widget (`file-upload`)
|
### File Upload Widget (`file-upload`)
|
||||||
|
|
||||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
||||||
|
|||||||
@@ -36,17 +36,9 @@ if [ -r /proc/device-tree/model ]; then
|
|||||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||||
echo "Detected device: $DEVICE_MODEL"
|
echo "Detected device: $DEVICE_MODEL"
|
||||||
else
|
else
|
||||||
DEVICE_MODEL=""
|
|
||||||
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
|
|
||||||
IS_PI5=0
|
|
||||||
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
|
|
||||||
IS_PI5=1
|
|
||||||
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking operating system requirements..."
|
echo "Checking operating system requirements..."
|
||||||
@@ -791,28 +783,9 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
|||||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||||
echo "-----------------------------------------------------"
|
echo "-----------------------------------------------------"
|
||||||
|
|
||||||
# On Pi 5, also check that the installed library has rp1_rio support.
|
# If already installed and not forcing rebuild, skip expensive build
|
||||||
# A library built before Pi 5 support was added imports fine but maps to the
|
|
||||||
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
|
|
||||||
_HAS_RP1=0
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
|
||||||
_HAS_RP1=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
_SKIP_BUILD=0
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||||
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
|
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||||
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
|
|
||||||
echo " Forcing rebuild to get Pi 5 RP1 support..."
|
|
||||||
else
|
|
||||||
_SKIP_BUILD=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_SKIP_BUILD" = "1" ]; then
|
|
||||||
_skip_suffix=""
|
|
||||||
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
|
|
||||||
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
|
||||||
else
|
else
|
||||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
@@ -879,17 +852,6 @@ except Exception as e:
|
|||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
echo "✓ rpi-rgb-led-matrix installed and verified"
|
echo "✓ rpi-rgb-led-matrix installed and verified"
|
||||||
# Pi 5: confirm the freshly-built library has rp1_rio support
|
|
||||||
if [ "$IS_PI5" = "1" ]; then
|
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
|
||||||
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
|
|
||||||
else
|
|
||||||
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
|
|
||||||
echo " Try updating the submodule and rebuilding:"
|
|
||||||
echo " git submodule update --remote rpi-rgb-led-matrix-master"
|
|
||||||
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "✗ rpi-rgb-led-matrix import test failed"
|
echo "✗ rpi-rgb-led-matrix import test failed"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -22,6 +22,5 @@
|
|||||||
"Pillow>=10.0.0",
|
"Pillow>=10.0.0",
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
"requests>=2.31.0"
|
"requests>=2.31.0"
|
||||||
],
|
]
|
||||||
"local_only": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,9 +67,8 @@ def main():
|
|||||||
print(" 📍 Will run on: http://0.0.0.0:5000")
|
print(" 📍 Will run on: http://0.0.0.0:5000")
|
||||||
print(" ⏹️ Press Ctrl+C to stop")
|
print(" ⏹️ Press Ctrl+C to stop")
|
||||||
|
|
||||||
# Run the app (debug mode controlled by env var to satisfy security scanners)
|
# Run the app (this should start the server)
|
||||||
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
app.run(host='0.0.0.0', port=5000, debug=_debug)
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n ⏹️ Server stopped by user")
|
print("\n ⏹️ Server stopped by user")
|
||||||
|
|||||||
@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||||
manifest = json.loads(manifest_raw)
|
manifest = json.loads(manifest_raw)
|
||||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||||
return False, "Invalid manifest.json", {}
|
return False, f"Invalid manifest.json: {e}", {}
|
||||||
|
|
||||||
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||||
return False, "Invalid manifest structure", {}
|
return False, "Invalid manifest structure", {}
|
||||||
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
|||||||
return True, "", result_manifest
|
return True, "", result_manifest
|
||||||
except zipfile.BadZipFile:
|
except zipfile.BadZipFile:
|
||||||
return False, "File is not a valid ZIP archive", {}
|
return False, "File is not a valid ZIP archive", {}
|
||||||
except OSError:
|
except OSError as e:
|
||||||
return False, "Could not read backup", {}
|
return False, f"Could not read backup: {e}", {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -110,10 +110,9 @@ class DisplayManager:
|
|||||||
options.rp1_rio = runtime_config.get('rp1_rio')
|
options.rp1_rio = runtime_config.get('rp1_rio')
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"rp1_rio is set in config but the installed rgbmatrix library does "
|
"rp1_rio is set in config but the current RGBMatrixOptions "
|
||||||
"not support it — the library was likely built without Pi 5 RP1 "
|
"implementation does not support it (RGBMatrixEmulator or older "
|
||||||
"support (mmap to 0x3f000000 instead of RP1 chip). "
|
"library version) — value will be ignored"
|
||||||
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
||||||
@@ -190,7 +189,7 @@ class DisplayManager:
|
|||||||
json.dump(_hw_status, _f)
|
json.dump(_hw_status, _f)
|
||||||
_f.flush()
|
_f.flush()
|
||||||
os.fsync(_f.fileno())
|
os.fsync(_f.fileno())
|
||||||
os.chmod(_tmp_path, 0o644)
|
os.chmod(_tmp_path, 0o600)
|
||||||
os.replace(_tmp_path, _status_path)
|
os.replace(_tmp_path, _status_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
|||||||
Extracted from PluginManager to improve separation of concerns.
|
Extracted from PluginManager to improve separation of concerns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -70,11 +68,6 @@ class PluginLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Path to plugin directory or None if not found
|
Path to plugin directory or None if not found
|
||||||
"""
|
"""
|
||||||
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
|
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
|
||||||
if not plugin_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Strategy 1: Use mapping from discovery
|
# Strategy 1: Use mapping from discovery
|
||||||
if plugin_directories and plugin_id in plugin_directories:
|
if plugin_directories and plugin_id in plugin_directories:
|
||||||
plugin_dir = plugin_directories[plugin_id]
|
plugin_dir = plugin_directories[plugin_id]
|
||||||
@@ -82,16 +75,14 @@ class PluginLoader:
|
|||||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||||
return plugin_dir
|
return plugin_dir
|
||||||
|
|
||||||
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
|
# Strategy 2: Direct paths
|
||||||
plugins_dir_resolved = plugins_dir.resolve()
|
plugin_dir = plugins_dir / plugin_id
|
||||||
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
|
if plugin_dir.exists():
|
||||||
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
|
return plugin_dir
|
||||||
try:
|
|
||||||
_candidate.relative_to(plugins_dir_resolved)
|
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
|
||||||
except ValueError:
|
if plugin_dir.exists():
|
||||||
continue
|
return plugin_dir
|
||||||
if _candidate.exists():
|
|
||||||
return _candidate
|
|
||||||
|
|
||||||
# Strategy 3: Case-insensitive search
|
# Strategy 3: Case-insensitive search
|
||||||
normalized_id = plugin_id.lower()
|
normalized_id = plugin_id.lower()
|
||||||
@@ -139,7 +130,6 @@ class PluginLoader:
|
|||||||
self,
|
self,
|
||||||
plugin_dir: Path,
|
plugin_dir: Path,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
plugins_dir: Optional[Path] = None,
|
|
||||||
timeout: int = 300
|
timeout: int = 300
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -148,76 +138,25 @@ class PluginLoader:
|
|||||||
Args:
|
Args:
|
||||||
plugin_dir: Plugin directory path
|
plugin_dir: Plugin directory path
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
plugins_dir: Trusted base plugins directory for path containment check
|
|
||||||
timeout: Installation timeout in seconds
|
timeout: Installation timeout in seconds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if dependencies installed or not needed, False on error
|
True if dependencies installed or not needed, False on error
|
||||||
"""
|
"""
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
requirements_file = plugin_dir / "requirements.txt"
|
||||||
if not plugin_id:
|
if not requirements_file.exists():
|
||||||
return False
|
|
||||||
|
|
||||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
|
||||||
|
|
||||||
if plugins_dir is not None:
|
|
||||||
# Reconstruct the plugin path from a trusted base + a sanitised
|
|
||||||
# directory name. os.path.basename() is CodeQL's recognised
|
|
||||||
# py/path-injection sanitiser: it strips all directory components
|
|
||||||
# so the result cannot contain traversal sequences. Joining it
|
|
||||||
# with the resolved, trusted plugins_dir produces a path that
|
|
||||||
# CodeQL considers untainted.
|
|
||||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
|
||||||
safe_dir_name = os.path.basename(plugin_dir_real)
|
|
||||||
if not safe_dir_name:
|
|
||||||
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
|
|
||||||
return False
|
|
||||||
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
|
|
||||||
if not os.path.isdir(safe_plugin_dir):
|
|
||||||
self.logger.error(
|
|
||||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
safe_plugin_dir = plugin_dir_real
|
|
||||||
if not os.path.isdir(safe_plugin_dir):
|
|
||||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
|
||||||
return False
|
|
||||||
|
|
||||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
|
||||||
marker_file = os.path.join(safe_plugin_dir, ".dependencies_installed")
|
|
||||||
|
|
||||||
if not os.path.isfile(requirements_file):
|
|
||||||
return True # No dependencies needed
|
return True # No dependencies needed
|
||||||
|
|
||||||
try:
|
# Check if already installed
|
||||||
with open(requirements_file, 'rb') as fh:
|
marker_path = plugin_dir / ".dependencies_installed"
|
||||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
if marker_path.exists():
|
||||||
except OSError as e:
|
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
return True
|
||||||
return False
|
|
||||||
|
|
||||||
# Skip if requirements.txt hasn't changed since last install
|
|
||||||
if os.path.isfile(marker_file):
|
|
||||||
try:
|
|
||||||
with open(marker_file, 'r', encoding='utf-8') as fh:
|
|
||||||
stored_hash = fh.read().strip()
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.warning(
|
|
||||||
"Could not read dependency marker for %s (%s), will reinstall dependencies",
|
|
||||||
plugin_id, e
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if stored_hash == current_hash:
|
|
||||||
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
|
|
||||||
return True
|
|
||||||
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -225,37 +164,17 @@ class PluginLoader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
try:
|
# Mark as installed
|
||||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
marker_path.touch()
|
||||||
fh.write(current_hash)
|
# Set proper file permissions after creating marker
|
||||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
|
||||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
stderr = result.stderr or ""
|
|
||||||
# uninstall-no-record-file means the package is already present at the
|
|
||||||
# system level (e.g. installed via dnf/apt without a pip RECORD file).
|
|
||||||
# pip can't replace it, but it IS installed — write the marker so we
|
|
||||||
# don't retry on every restart.
|
|
||||||
if "uninstall-no-record-file" in stderr:
|
|
||||||
self.logger.warning(
|
|
||||||
"Dependencies for %s include system-managed packages (no pip RECORD). "
|
|
||||||
"Assuming they are satisfied: %s",
|
|
||||||
plugin_id, stderr.strip()
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
|
||||||
fh.write(current_hash)
|
|
||||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
|
||||||
return True
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Dependency installation returned non-zero exit code for %s: %s",
|
"Dependency installation returned non-zero exit code for %s: %s",
|
||||||
plugin_id,
|
plugin_id,
|
||||||
stderr
|
result.stderr
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
@@ -430,20 +349,9 @@ class PluginLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Loaded module or None on error
|
Loaded module or None on error
|
||||||
"""
|
"""
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
entry_file = plugin_dir / entry_point
|
||||||
if not plugin_id:
|
|
||||||
raise PluginError("Invalid plugin ID")
|
|
||||||
try:
|
|
||||||
plugin_dir_resolved = plugin_dir.resolve(strict=True)
|
|
||||||
except OSError:
|
|
||||||
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
|
|
||||||
entry_file = (plugin_dir_resolved / entry_point).resolve()
|
|
||||||
try:
|
|
||||||
entry_file.relative_to(plugin_dir_resolved)
|
|
||||||
except ValueError:
|
|
||||||
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
|
|
||||||
if not entry_file.exists():
|
if not entry_file.exists():
|
||||||
error_msg = f"Entry point file not found for plugin {plugin_id}"
|
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
|
||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||||
|
|
||||||
@@ -593,8 +501,7 @@ class PluginLoader:
|
|||||||
display_manager: Any,
|
display_manager: Any,
|
||||||
cache_manager: Any,
|
cache_manager: Any,
|
||||||
plugin_manager: Any,
|
plugin_manager: Any,
|
||||||
install_deps: bool = True,
|
install_deps: bool = True
|
||||||
plugins_dir: Optional[Path] = None,
|
|
||||||
) -> Tuple[Any, Any]:
|
) -> Tuple[Any, Any]:
|
||||||
"""
|
"""
|
||||||
Complete plugin loading process.
|
Complete plugin loading process.
|
||||||
@@ -608,7 +515,6 @@ class PluginLoader:
|
|||||||
cache_manager: Cache manager instance
|
cache_manager: Cache manager instance
|
||||||
plugin_manager: Plugin manager instance
|
plugin_manager: Plugin manager instance
|
||||||
install_deps: Whether to install dependencies
|
install_deps: Whether to install dependencies
|
||||||
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (plugin_instance, module)
|
Tuple of (plugin_instance, module)
|
||||||
@@ -618,12 +524,7 @@ class PluginLoader:
|
|||||||
"""
|
"""
|
||||||
# Install dependencies if needed
|
# Install dependencies if needed
|
||||||
if install_deps:
|
if install_deps:
|
||||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
self.install_dependencies(plugin_dir, plugin_id)
|
||||||
raise PluginError(
|
|
||||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
context={'plugin_dir': str(plugin_dir)},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load module
|
# Load module
|
||||||
entry_point = manifest.get('entry_point', 'manager.py')
|
entry_point = manifest.get('entry_point', 'manager.py')
|
||||||
|
|||||||
@@ -350,8 +350,7 @@ class PluginManager:
|
|||||||
display_manager=self.display_manager,
|
display_manager=self.display_manager,
|
||||||
cache_manager=self.cache_manager,
|
cache_manager=self.cache_manager,
|
||||||
plugin_manager=self,
|
plugin_manager=self,
|
||||||
install_deps=True,
|
install_deps=True
|
||||||
plugins_dir=self.plugins_dir,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store module
|
# Store module
|
||||||
|
|||||||
@@ -185,19 +185,13 @@ class StateReconciliation:
|
|||||||
message=f"Reconciliation failed: {str(e)}"
|
message=f"Reconciliation failed: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Top-level config keys that are NOT plugins.
|
# Top-level config keys that are NOT plugins
|
||||||
# Includes both config.json structural keys and config_secrets.json top-level
|
|
||||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
|
||||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||||
'web_display_autostart', 'timezone', 'location', 'display',
|
'web_display_autostart', 'timezone', 'location', 'display',
|
||||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||||
'dim_schedule', 'network', 'system', 'schedule',
|
'dim_schedule', 'network', 'system', 'schedule',
|
||||||
# Multi-display sync config (config.json structural key)
|
|
||||||
'sync',
|
|
||||||
# Secrets file top-level keys (merged in by load_config)
|
|
||||||
'github', 'youtube',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
@@ -347,8 +341,8 @@ class StateReconciliation:
|
|||||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||||
fix_action=FixAction.AUTO_FIX,
|
fix_action=FixAction.AUTO_FIX,
|
||||||
current_state={'enabled': state_mgr_enabled},
|
current_state={'enabled': config_enabled},
|
||||||
expected_state={'enabled': config_enabled},
|
expected_state={'enabled': state_mgr_enabled},
|
||||||
can_auto_fix=True
|
can_auto_fix=True
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -371,23 +365,15 @@ class StateReconciliation:
|
|||||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||||
|
|
||||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||||
# config.json is the user-editable source of truth for enabled state.
|
# Sync enabled state from state manager to config
|
||||||
# Bring the state manager in sync with config rather than the reverse,
|
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||||
# so that manual config edits (or the state left behind after an
|
config = self.config_manager.load_config()
|
||||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
if inconsistency.plugin_id not in config:
|
||||||
config_enabled = inconsistency.expected_state.get('enabled')
|
config[inconsistency.plugin_id] = {}
|
||||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
config[inconsistency.plugin_id]['enabled'] = expected_enabled
|
||||||
if success:
|
self.config_manager.save_config(config)
|
||||||
self.logger.info(
|
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
|
||||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
return True
|
||||||
f"{inconsistency.plugin_id} to match config"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Failed to sync state manager enabled={config_enabled} for "
|
|
||||||
f"{inconsistency.plugin_id}"
|
|
||||||
)
|
|
||||||
return success
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ Handles plugin discovery, installation, updates, and uninstallation
|
|||||||
from both the official registry and custom GitHub repositories.
|
from both the official registry and custom GitHub repositories.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import requests
|
import requests
|
||||||
@@ -22,8 +20,6 @@ from pathlib import Path
|
|||||||
from typing import List, Dict, Optional, Any, Tuple
|
from typing import List, Dict, Optional, Any, Tuple
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from src.common.permission_utils import sudo_remove_directory
|
from src.common.permission_utils import sudo_remove_directory
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -104,10 +100,6 @@ class PluginStoreManager:
|
|||||||
# handlers. Bumping the cached-entry timestamp on failure serves
|
# handlers. Bumping the cached-entry timestamp on failure serves
|
||||||
# the stale payload cheaply until the backoff expires.
|
# the stale payload cheaply until the backoff expires.
|
||||||
self._failure_backoff_seconds = 60
|
self._failure_backoff_seconds = 60
|
||||||
# Prevents concurrent callers from each firing a network request when
|
|
||||||
# the registry cache expires. Only one thread fetches; others wait and
|
|
||||||
# then get the result from the warm cache (double-checked locking).
|
|
||||||
self._registry_fetch_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Ensure plugins directory exists
|
# Ensure plugins directory exists
|
||||||
self.plugins_dir.mkdir(exist_ok=True)
|
self.plugins_dir.mkdir(exist_ok=True)
|
||||||
@@ -359,8 +351,7 @@ class PluginStoreManager:
|
|||||||
# Extract owner/repo from URL
|
# Extract owner/repo from URL
|
||||||
try:
|
try:
|
||||||
# Handle different URL formats
|
# Handle different URL formats
|
||||||
_parsed_url = urlparse(repo_url)
|
if 'github.com' in repo_url:
|
||||||
if _parsed_url.hostname in ('github.com', 'www.github.com'):
|
|
||||||
parts = repo_url.strip('/').split('/')
|
parts = repo_url.strip('/').split('/')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
owner = parts[-2]
|
owner = parts[-2]
|
||||||
@@ -524,8 +515,7 @@ class PluginStoreManager:
|
|||||||
registry_urls = []
|
registry_urls = []
|
||||||
|
|
||||||
# Extract owner/repo from URL
|
# Extract owner/repo from URL
|
||||||
_parsed_repo_url = urlparse(repo_url)
|
if 'github.com' in repo_url:
|
||||||
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
|
|
||||||
parts = repo_url.split('/')
|
parts = repo_url.split('/')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
owner = parts[-2]
|
owner = parts[-2]
|
||||||
@@ -585,50 +575,41 @@ class PluginStoreManager:
|
|||||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
|
|
||||||
with self._registry_fetch_lock:
|
try:
|
||||||
# Re-check inside the lock — a concurrent caller that was waiting
|
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||||
# may have already populated the cache while we blocked.
|
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||||
current_time = time.time()
|
response.raise_for_status()
|
||||||
if (self.registry_cache and self.registry_cache_time and
|
self.registry_cache = response.json()
|
||||||
not force_refresh and
|
self.registry_cache_time = current_time
|
||||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||||
|
return self.registry_cache
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.error(f"Error fetching registry: {e}")
|
||||||
|
if raise_on_failure:
|
||||||
|
raise
|
||||||
|
# Prefer stale cache over an empty list so the plugin list UI
|
||||||
|
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||||
|
# registry_cache_time into a short backoff window so the next
|
||||||
|
# request serves the stale payload cheaply instead of
|
||||||
|
# re-hitting the network on every request (matches the
|
||||||
|
# pattern used by github_cache / commit_info_cache).
|
||||||
|
if self.registry_cache:
|
||||||
|
self.logger.warning("Falling back to stale registry cache")
|
||||||
|
self.registry_cache_time = (
|
||||||
|
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||||
|
)
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
|
return {"plugins": []}
|
||||||
try:
|
except json.JSONDecodeError as e:
|
||||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
if raise_on_failure:
|
||||||
response.raise_for_status()
|
raise
|
||||||
self.registry_cache = response.json()
|
if self.registry_cache:
|
||||||
self.registry_cache_time = current_time
|
self.registry_cache_time = (
|
||||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||||
|
)
|
||||||
return self.registry_cache
|
return self.registry_cache
|
||||||
except requests.RequestException as e:
|
return {"plugins": []}
|
||||||
self.logger.error(f"Error fetching registry: {e}")
|
|
||||||
if raise_on_failure:
|
|
||||||
raise
|
|
||||||
# Prefer stale cache over an empty list so the plugin list UI
|
|
||||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
|
||||||
# registry_cache_time into a short backoff window so the next
|
|
||||||
# request serves the stale payload cheaply instead of
|
|
||||||
# re-hitting the network on every request (matches the
|
|
||||||
# pattern used by github_cache / commit_info_cache).
|
|
||||||
if self.registry_cache:
|
|
||||||
self.logger.warning("Falling back to stale registry cache")
|
|
||||||
self.registry_cache_time = (
|
|
||||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
|
||||||
)
|
|
||||||
return self.registry_cache
|
|
||||||
return {"plugins": []}
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
|
||||||
if raise_on_failure:
|
|
||||||
raise
|
|
||||||
if self.registry_cache:
|
|
||||||
self.registry_cache_time = (
|
|
||||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
|
||||||
)
|
|
||||||
return self.registry_cache
|
|
||||||
return {"plugins": []}
|
|
||||||
|
|
||||||
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
@@ -780,8 +761,7 @@ class PluginStoreManager:
|
|||||||
try:
|
try:
|
||||||
# Convert repo URL to raw content URL
|
# Convert repo URL to raw content URL
|
||||||
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
||||||
_parsed_manifest_url = urlparse(repo_url)
|
if 'github.com' in repo_url:
|
||||||
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
|
|
||||||
# Handle different URL formats
|
# Handle different URL formats
|
||||||
repo_url = repo_url.rstrip('/')
|
repo_url = repo_url.rstrip('/')
|
||||||
if repo_url.endswith('.git'):
|
if repo_url.endswith('.git'):
|
||||||
@@ -1756,12 +1736,6 @@ class PluginStoreManager:
|
|||||||
timeout=300
|
timeout=300
|
||||||
)
|
)
|
||||||
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
||||||
# Write hash marker so plugin_loader skips redundant pip run on next startup
|
|
||||||
try:
|
|
||||||
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
|
|
||||||
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
|
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
|||||||
@@ -151,18 +151,6 @@ class WiFiManager:
|
|||||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||||
|
|
||||||
# Once per process: remove a stale force-AP flag left by a prior crash.
|
|
||||||
# Guard with a class-level flag so the nmcli AP-state check only runs
|
|
||||||
# once even though WiFiManager is instantiated per-request.
|
|
||||||
if not WiFiManager._startup_cleanup_done:
|
|
||||||
WiFiManager._startup_cleanup_done = True
|
|
||||||
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
|
|
||||||
try:
|
|
||||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
|
||||||
logger.debug("Removed stale force-AP flag on startup (AP not active)")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(f"Could not remove stale force-AP flag: {exc}")
|
|
||||||
|
|
||||||
def _show_led_message(self, message: str, duration: int = 5):
|
def _show_led_message(self, message: str, duration: int = 5):
|
||||||
"""
|
"""
|
||||||
Show a WiFi status message on the LED display.
|
Show a WiFi status message on the LED display.
|
||||||
@@ -486,10 +474,7 @@ class WiFiManager:
|
|||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
if '/' in line:
|
if '/' in line:
|
||||||
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
|
ip_address = line.split('/')[0].strip()
|
||||||
# bare "x.x.x.x/prefix" is also accepted defensively.
|
|
||||||
_, sep, rest = line.partition(':')
|
|
||||||
ip_address = (rest if sep else line).split('/')[0].strip()
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Final fallback: Get signal strength by matching SSID in WiFi list
|
# Final fallback: Get signal strength by matching SSID in WiFi list
|
||||||
@@ -515,13 +500,6 @@ class WiFiManager:
|
|||||||
|
|
||||||
# Check if AP mode is active
|
# Check if AP mode is active
|
||||||
ap_active = self._is_ap_mode_active()
|
ap_active = self._is_ap_mode_active()
|
||||||
# wlan0 shows as "connected" in AP mode; clear client-station fields so
|
|
||||||
# callers don't mistake the AP for an outbound WiFi connection.
|
|
||||||
if ap_active and wifi_connected:
|
|
||||||
wifi_connected = False
|
|
||||||
ssid = None
|
|
||||||
ip_address = None
|
|
||||||
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
|
|
||||||
|
|
||||||
return WiFiStatus(
|
return WiFiStatus(
|
||||||
connected=wifi_connected,
|
connected=wifi_connected,
|
||||||
@@ -712,10 +690,6 @@ class WiFiManager:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
||||||
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
|
|
||||||
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
|
|
||||||
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
|
|
||||||
_startup_cleanup_done: bool = False
|
|
||||||
|
|
||||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||||
@@ -1393,7 +1367,7 @@ class WiFiManager:
|
|||||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||||
# Trigger AP mode as last resort
|
# Trigger AP mode as last resort
|
||||||
self._show_led_message("Enabling AP mode...", duration=5)
|
self._show_led_message("Enabling AP mode...", duration=5)
|
||||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
ap_success, ap_msg = self.enable_ap_mode()
|
||||||
if ap_success:
|
if ap_success:
|
||||||
logger.info("AP mode enabled as failsafe")
|
logger.info("AP mode enabled as failsafe")
|
||||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
return False, "Connection failed and restoration failed. AP mode enabled."
|
||||||
@@ -1405,7 +1379,7 @@ class WiFiManager:
|
|||||||
elif not success:
|
elif not success:
|
||||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||||
self._show_led_message("Enabling AP mode...", duration=5)
|
self._show_led_message("Enabling AP mode...", duration=5)
|
||||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
ap_success, ap_msg = self.enable_ap_mode()
|
||||||
if ap_success:
|
if ap_success:
|
||||||
logger.info("AP mode enabled as failsafe")
|
logger.info("AP mode enabled as failsafe")
|
||||||
return False, "Connection failed. AP mode enabled."
|
return False, "Connection failed. AP mode enabled."
|
||||||
@@ -1426,7 +1400,7 @@ class WiFiManager:
|
|||||||
logger.error(f"Failed to restore after exception: {restore_error}")
|
logger.error(f"Failed to restore after exception: {restore_error}")
|
||||||
# Last resort: enable AP mode
|
# Last resort: enable AP mode
|
||||||
try:
|
try:
|
||||||
self.enable_ap_mode(force=True)
|
self.enable_ap_mode()
|
||||||
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
||||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
@@ -1490,29 +1464,26 @@ class WiFiManager:
|
|||||||
# Show LED message
|
# Show LED message
|
||||||
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
||||||
|
|
||||||
# Find existing NM connection for this SSID.
|
# First, check if connection already exists and try to activate it
|
||||||
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
# NetworkManager connection names might not match SSID exactly, so search by SSID
|
||||||
# so list all wifi connections then query each one's SSID individually.
|
check_result = subprocess.run(
|
||||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
||||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
capture_output=True,
|
||||||
capture_output=True, text=True, timeout=5
|
text=True,
|
||||||
|
timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
existing_conn_name = None
|
existing_conn_name = None
|
||||||
if list_result.returncode == 0:
|
if check_result.returncode == 0:
|
||||||
for line in list_result.stdout.strip().split('\n'):
|
for line in check_result.stdout.strip().split('\n'):
|
||||||
if ':' not in line:
|
if ':' in line:
|
||||||
continue
|
parts = line.split(':')
|
||||||
parts = line.split(':')
|
if len(parts) >= 2:
|
||||||
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
conn_name = parts[0].strip()
|
||||||
continue
|
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
||||||
conn_name = parts[0].strip()
|
if conn_ssid == ssid:
|
||||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
existing_conn_name = conn_name
|
||||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
break
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
|
||||||
existing_conn_name = conn_name
|
|
||||||
break
|
|
||||||
|
|
||||||
# Also try direct lookup by SSID (in case connection name matches SSID)
|
# Also try direct lookup by SSID (in case connection name matches SSID)
|
||||||
if not existing_conn_name:
|
if not existing_conn_name:
|
||||||
@@ -1884,7 +1855,7 @@ class WiFiManager:
|
|||||||
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
def enable_ap_mode(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Enable access point mode
|
Enable access point mode
|
||||||
|
|
||||||
@@ -1906,29 +1877,20 @@ class WiFiManager:
|
|||||||
if not self._ensure_wifi_radio_enabled():
|
if not self._ensure_wifi_radio_enabled():
|
||||||
return False, "WiFi radio is disabled and could not be enabled"
|
return False, "WiFi radio is disabled and could not be enabled"
|
||||||
|
|
||||||
# Check if WiFi is connected (skip when force=True)
|
# Check if WiFi is connected
|
||||||
status = self.get_wifi_status()
|
status = self.get_wifi_status()
|
||||||
if not force and status.connected:
|
if status.connected:
|
||||||
return False, "Cannot enable AP mode while WiFi is connected"
|
return False, "Cannot enable AP mode while WiFi is connected"
|
||||||
|
|
||||||
# Check if Ethernet is connected (skip when force=True)
|
# Check if Ethernet is connected
|
||||||
if not force and self._is_ethernet_connected():
|
if self._is_ethernet_connected():
|
||||||
return False, "Cannot enable AP mode while Ethernet is connected"
|
return False, "Cannot enable AP mode while Ethernet is connected"
|
||||||
|
|
||||||
if force:
|
|
||||||
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
|
|
||||||
|
|
||||||
# Try hostapd/dnsmasq first (captive portal mode)
|
# Try hostapd/dnsmasq first (captive portal mode)
|
||||||
if self.has_hostapd and self.has_dnsmasq:
|
if self.has_hostapd and self.has_dnsmasq:
|
||||||
result = self._enable_ap_mode_hostapd()
|
result = self._enable_ap_mode_hostapd()
|
||||||
if result[0]:
|
if result[0]:
|
||||||
self._ap_enabled_at = time.time()
|
self._ap_enabled_at = time.time()
|
||||||
if force:
|
|
||||||
try:
|
|
||||||
self._FORCE_AP_FLAG_PATH.touch()
|
|
||||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||||
@@ -1938,12 +1900,6 @@ class WiFiManager:
|
|||||||
result = self._enable_ap_mode_nmcli_hotspot()
|
result = self._enable_ap_mode_nmcli_hotspot()
|
||||||
if result[0]:
|
if result[0]:
|
||||||
self._ap_enabled_at = time.time()
|
self._ap_enabled_at = time.time()
|
||||||
if force:
|
|
||||||
try:
|
|
||||||
self._FORCE_AP_FLAG_PATH.touch()
|
|
||||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||||
@@ -2135,14 +2091,8 @@ class WiFiManager:
|
|||||||
self._clear_led_message()
|
self._clear_led_message()
|
||||||
return False, "AP started but captive-portal redirect setup failed"
|
return False, "AP started but captive-portal redirect setup failed"
|
||||||
|
|
||||||
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
|
# Verify the AP is actually running
|
||||||
status = {}
|
status = self._get_ap_status_nmcli()
|
||||||
for _attempt in range(5):
|
|
||||||
status = self._get_ap_status_nmcli()
|
|
||||||
if status.get('active'):
|
|
||||||
break
|
|
||||||
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
|
|
||||||
time.sleep(2)
|
|
||||||
if status.get('active'):
|
if status.get('active'):
|
||||||
ip = status.get('ip', '192.168.4.1')
|
ip = status.get('ip', '192.168.4.1')
|
||||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||||
@@ -2340,7 +2290,6 @@ class WiFiManager:
|
|||||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||||
|
|
||||||
self._ap_enabled_at = None
|
self._ap_enabled_at = None
|
||||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
|
||||||
logger.info("AP mode disabled successfully")
|
logger.info("AP mode disabled successfully")
|
||||||
return True, "AP mode disabled"
|
return True, "AP mode disabled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2529,29 +2478,22 @@ address=/detectportal.firefox.com/192.168.4.1
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to enable AP mode: {message}")
|
logger.warning(f"Failed to enable AP mode: {message}")
|
||||||
elif not should_have_ap and ap_active:
|
elif not should_have_ap and ap_active:
|
||||||
# Should not have AP but do - check if it was manually force-enabled
|
# Should not have AP but do - disable AP mode
|
||||||
force_active = self._FORCE_AP_FLAG_PATH.exists()
|
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
|
||||||
if status.connected:
|
if status.connected or ethernet_connected:
|
||||||
# WiFi connected: always disable AP (user successfully configured WiFi)
|
|
||||||
success, message = self.disable_ap_mode()
|
success, message = self.disable_ap_mode()
|
||||||
if success:
|
if success:
|
||||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
if status.connected:
|
||||||
self._disconnected_checks = 0
|
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||||
|
elif ethernet_connected:
|
||||||
|
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
||||||
|
self._disconnected_checks = 0 # Reset counter
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
||||||
elif ethernet_connected and not force_active:
|
|
||||||
# Ethernet connected, AP not manually forced: auto-disable
|
|
||||||
success, message = self.disable_ap_mode()
|
|
||||||
if success:
|
|
||||||
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
|
||||||
self._disconnected_checks = 0
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
|
||||||
elif ethernet_connected and force_active:
|
|
||||||
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
|
|
||||||
elif not auto_enable:
|
elif not auto_enable:
|
||||||
|
# AP is active but auto_enable is disabled - this means it was manually enabled
|
||||||
|
# Don't disable it automatically, let it stay active
|
||||||
logger.debug("AP mode is active (manually enabled), keeping active")
|
logger.debug("AP mode is active (manually enabled), keeping active")
|
||||||
|
|
||||||
# Idle-timeout check: disable AP if no client has connected within the window.
|
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||||
|
|||||||
@@ -1,342 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/base_classes/api_extractors.py
|
|
||||||
|
|
||||||
Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor,
|
|
||||||
SoccerAPIExtractor, and the shared _extract_common_details logic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import pytest
|
|
||||||
from src.base_classes.api_extractors import (
|
|
||||||
ESPNFootballExtractor,
|
|
||||||
ESPNBaseballExtractor,
|
|
||||||
ESPNHockeyExtractor,
|
|
||||||
SoccerAPIExtractor,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared test data factories
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF",
|
|
||||||
home_score: str = "14", away_score: str = "7",
|
|
||||||
date_str: str = "2024-01-15T20:00:00Z",
|
|
||||||
include_situation: bool = False,
|
|
||||||
situation: dict | None = None,
|
|
||||||
status_detail: str = "2nd Qtr 8:42",
|
|
||||||
period: int = 2) -> dict:
|
|
||||||
"""Build a minimal ESPN-style game event dict."""
|
|
||||||
comp_status = {
|
|
||||||
"type": {
|
|
||||||
"state": state,
|
|
||||||
"shortDetail": status_detail,
|
|
||||||
"detail": status_detail,
|
|
||||||
"name": "STATUS_IN_PROGRESS",
|
|
||||||
},
|
|
||||||
"period": period,
|
|
||||||
"displayClock": "8:42",
|
|
||||||
}
|
|
||||||
comp = {
|
|
||||||
"status": comp_status,
|
|
||||||
"competitors": [
|
|
||||||
{
|
|
||||||
"homeAway": "home",
|
|
||||||
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
|
|
||||||
"score": home_score,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"homeAway": "away",
|
|
||||||
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
|
|
||||||
"score": away_score,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if include_situation:
|
|
||||||
comp["situation"] = situation or {}
|
|
||||||
return {
|
|
||||||
"id": "test-game-1",
|
|
||||||
"date": date_str,
|
|
||||||
"competitions": [comp],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_logger() -> logging.Logger:
|
|
||||||
return logging.getLogger("test_extractor")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNFootballExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNFootballExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNFootballExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game_basic_fields(self):
|
|
||||||
event = _make_espn_event(state="in", home_score="14", away_score="7")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "KC"
|
|
||||||
assert result["away_abbr"] == "BUF"
|
|
||||||
assert result["home_score"] == "14"
|
|
||||||
assert result["away_score"] == "7"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
assert result["is_final"] is False
|
|
||||||
assert result["is_upcoming"] is False
|
|
||||||
|
|
||||||
def test_extract_final_game(self):
|
|
||||||
event = _make_espn_event(state="post")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_final"] is True
|
|
||||||
assert result["is_live"] is False
|
|
||||||
|
|
||||||
def test_extract_upcoming_game(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_upcoming"] is True
|
|
||||||
|
|
||||||
def test_sport_specific_fields_default_when_pregame(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "down" in fields
|
|
||||||
assert "distance" in fields
|
|
||||||
assert "possession" in fields
|
|
||||||
assert "is_redzone" in fields
|
|
||||||
assert fields["is_redzone"] is False
|
|
||||||
|
|
||||||
def test_sport_specific_fields_live_with_situation(self):
|
|
||||||
situation = {
|
|
||||||
"down": 3,
|
|
||||||
"distance": 7,
|
|
||||||
"possession": "KC",
|
|
||||||
"isRedZone": True,
|
|
||||||
"homeTimeouts": 2,
|
|
||||||
"awayTimeouts": 1,
|
|
||||||
}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["down"] == 3
|
|
||||||
assert fields["distance"] == 7
|
|
||||||
assert fields["is_redzone"] is True
|
|
||||||
assert fields["home_timeouts"] == 2
|
|
||||||
assert fields["away_timeouts"] == 1
|
|
||||||
|
|
||||||
def test_scoring_event_detected(self):
|
|
||||||
# situation must be non-empty (truthy) for the live block to execute
|
|
||||||
situation = {"down": 1, "distance": 10}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in",
|
|
||||||
include_situation=True,
|
|
||||||
situation=situation,
|
|
||||||
status_detail="touchdown scored",
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "touchdown" in fields.get("scoring_event", "").lower()
|
|
||||||
|
|
||||||
def test_returns_none_on_empty_event(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
def test_returns_none_when_teams_missing(self):
|
|
||||||
event = {
|
|
||||||
"id": "x",
|
|
||||||
"date": "2024-01-15T20:00:00Z",
|
|
||||||
"competitions": [
|
|
||||||
{
|
|
||||||
"status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}},
|
|
||||||
"competitors": [], # no competitors
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
assert self.extractor.extract_game_details(event) is None
|
|
||||||
|
|
||||||
def test_date_z_suffix_parsed(self):
|
|
||||||
event = _make_espn_event(date_str="2024-01-15T20:00:00Z")
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
# Should not raise and should return a result
|
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
def test_id_propagated(self):
|
|
||||||
event = _make_espn_event()
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result["id"] == "test-game-1"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNBaseballExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNBaseballExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNBaseballExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", home_abbr="NYY", away_abbr="BOS",
|
|
||||||
home_score="3", away_score="2"
|
|
||||||
)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "NYY"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_baseball_sport_fields_defaults(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "inning" in fields
|
|
||||||
assert "outs" in fields
|
|
||||||
assert "bases" in fields
|
|
||||||
assert "strikes" in fields
|
|
||||||
assert "balls" in fields
|
|
||||||
|
|
||||||
def test_baseball_sport_fields_live(self):
|
|
||||||
situation = {
|
|
||||||
"inning": 7,
|
|
||||||
"outs": 2,
|
|
||||||
"bases": "110",
|
|
||||||
"strikes": 2,
|
|
||||||
"balls": 3,
|
|
||||||
"pitcher": "Smith",
|
|
||||||
"batter": "Jones",
|
|
||||||
}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["inning"] == 7
|
|
||||||
assert fields["outs"] == 2
|
|
||||||
assert fields["strikes"] == 2
|
|
||||||
assert fields["pitcher"] == "Smith"
|
|
||||||
|
|
||||||
def test_returns_none_on_empty(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNHockeyExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNHockeyExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = ESPNHockeyExtractor(_make_logger())
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", home_abbr="BOS", away_abbr="TOR",
|
|
||||||
home_score="2", away_score="1"
|
|
||||||
)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_hockey_period_text_p1(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=1
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P1"
|
|
||||||
|
|
||||||
def test_hockey_period_text_p2(self):
|
|
||||||
situation = {"isPowerPlay": False} # non-empty so the live block executes
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=2
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P2"
|
|
||||||
|
|
||||||
def test_hockey_period_text_p3(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=3
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "P3"
|
|
||||||
|
|
||||||
def test_hockey_period_text_ot(self):
|
|
||||||
situation = {"isPowerPlay": False}
|
|
||||||
event = _make_espn_event(
|
|
||||||
state="in", include_situation=True, situation=situation, period=4
|
|
||||||
)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["period_text"] == "OT1"
|
|
||||||
|
|
||||||
def test_hockey_power_play(self):
|
|
||||||
situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8}
|
|
||||||
event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2)
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["power_play"] is True
|
|
||||||
assert fields["shots_on_goal"]["home"] == 12
|
|
||||||
assert fields["shots_on_goal"]["away"] == 8
|
|
||||||
|
|
||||||
def test_hockey_fields_defaults_pregame(self):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert "period" in fields
|
|
||||||
assert "power_play" in fields
|
|
||||||
assert fields["power_play"] is False
|
|
||||||
|
|
||||||
def test_returns_none_on_empty(self):
|
|
||||||
assert self.extractor.extract_game_details({}) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SoccerAPIExtractor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSoccerAPIExtractor:
|
|
||||||
def setup_method(self):
|
|
||||||
self.extractor = SoccerAPIExtractor(_make_logger())
|
|
||||||
|
|
||||||
def _make_soccer_event(self, is_live: bool = True) -> dict:
|
|
||||||
return {
|
|
||||||
"id": "soccer-1",
|
|
||||||
"home_team": {"abbreviation": "ARS", "name": "Arsenal"},
|
|
||||||
"away_team": {"abbreviation": "CHE", "name": "Chelsea"},
|
|
||||||
"home_score": "2",
|
|
||||||
"away_score": "1",
|
|
||||||
"status": "LIVE",
|
|
||||||
"is_live": is_live,
|
|
||||||
"is_final": not is_live,
|
|
||||||
"is_upcoming": False,
|
|
||||||
"half": "1",
|
|
||||||
"stoppage_time": "2",
|
|
||||||
"home_yellow_cards": 1,
|
|
||||||
"away_yellow_cards": 2,
|
|
||||||
"home_red_cards": 0,
|
|
||||||
"away_red_cards": 0,
|
|
||||||
"home_possession": 55,
|
|
||||||
"away_possession": 45,
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_extract_live_game(self):
|
|
||||||
event = self._make_soccer_event(is_live=True)
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["home_abbr"] == "ARS"
|
|
||||||
assert result["away_abbr"] == "CHE"
|
|
||||||
assert result["is_live"] is True
|
|
||||||
|
|
||||||
def test_sport_specific_cards(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["cards"]["home_yellow"] == 1
|
|
||||||
assert fields["cards"]["away_yellow"] == 2
|
|
||||||
assert fields["cards"]["home_red"] == 0
|
|
||||||
|
|
||||||
def test_sport_specific_possession(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["possession"]["home"] == 55
|
|
||||||
assert fields["possession"]["away"] == 45
|
|
||||||
|
|
||||||
def test_sport_specific_half(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
fields = self.extractor.get_sport_specific_fields(event)
|
|
||||||
assert fields["half"] == "1"
|
|
||||||
|
|
||||||
def test_scores_as_strings(self):
|
|
||||||
event = self._make_soccer_event()
|
|
||||||
result = self.extractor.extract_game_details(event)
|
|
||||||
assert result["home_score"] == "2"
|
|
||||||
assert result["away_score"] == "1"
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/background_data_service.py
|
|
||||||
|
|
||||||
Covers BackgroundDataService: submit_fetch_request, get_result,
|
|
||||||
is_request_complete, get_request_status, cancel_request, get_statistics,
|
|
||||||
_cleanup_completed_requests, shutdown, and get_background_service singleton.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch, Mock
|
|
||||||
from concurrent.futures import Future
|
|
||||||
|
|
||||||
from src.background_data_service import (
|
|
||||||
BackgroundDataService,
|
|
||||||
FetchStatus,
|
|
||||||
FetchResult,
|
|
||||||
FetchRequest,
|
|
||||||
get_background_service,
|
|
||||||
shutdown_background_service,
|
|
||||||
)
|
|
||||||
import src.background_data_service as bds_module
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def reset_global_service():
|
|
||||||
"""Ensure each test starts with no global singleton."""
|
|
||||||
shutdown_background_service()
|
|
||||||
yield
|
|
||||||
shutdown_background_service()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_cache_manager():
|
|
||||||
m = MagicMock()
|
|
||||||
m.get.return_value = None
|
|
||||||
m.set.return_value = None
|
|
||||||
m.generate_sport_cache_key.return_value = "test_key"
|
|
||||||
return m
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def service(mock_cache_manager):
|
|
||||||
svc = BackgroundDataService(mock_cache_manager, max_workers=2, request_timeout=5)
|
|
||||||
yield svc
|
|
||||||
svc.shutdown(wait=False)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Initialisation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestInitialisation:
|
|
||||||
def test_stats_zeroed(self, service):
|
|
||||||
stats = service.get_statistics()
|
|
||||||
assert stats["total_requests"] == 0
|
|
||||||
assert stats["completed_requests"] == 0
|
|
||||||
assert stats["failed_requests"] == 0
|
|
||||||
|
|
||||||
def test_no_active_requests(self, service):
|
|
||||||
assert len(service.active_requests) == 0
|
|
||||||
|
|
||||||
def test_not_shutdown(self, service):
|
|
||||||
assert service._shutdown is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cache hit path
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCacheHit:
|
|
||||||
def test_cache_hit_returns_request_id(self, service, mock_cache_manager):
|
|
||||||
mock_cache_manager.get.return_value = {"events": [{"id": "1"}]}
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024,
|
|
||||||
url="https://example.com/nfl",
|
|
||||||
cache_key="nfl_key",
|
|
||||||
)
|
|
||||||
assert req_id is not None
|
|
||||||
# Request should be immediately complete due to cache hit
|
|
||||||
result = service.get_result(req_id)
|
|
||||||
assert result is not None
|
|
||||||
assert result.success is True
|
|
||||||
assert result.cached is True
|
|
||||||
|
|
||||||
def test_cache_hit_increments_stat(self, service, mock_cache_manager):
|
|
||||||
mock_cache_manager.get.return_value = {"events": []}
|
|
||||||
service.submit_fetch_request(sport="nba", year=2024, url="https://x.com", cache_key="k")
|
|
||||||
stats = service.get_statistics()
|
|
||||||
assert stats["cached_hits"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Actual fetch path (mocked HTTP)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFetchPath:
|
|
||||||
def _valid_payload(self) -> dict:
|
|
||||||
return {"events": [{"id": "g1"}, {"id": "g2"}]}
|
|
||||||
|
|
||||||
def test_successful_fetch_completes(self, service, mock_cache_manager):
|
|
||||||
mock_resp = Mock()
|
|
||||||
mock_resp.json.return_value = self._valid_payload()
|
|
||||||
mock_resp.raise_for_status.return_value = None
|
|
||||||
|
|
||||||
with patch.object(service.session, "get", return_value=mock_resp):
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024,
|
|
||||||
url="https://example.com/nfl",
|
|
||||||
cache_key="nfl_test",
|
|
||||||
)
|
|
||||||
# Wait for the background thread
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
result = service.get_result(req_id)
|
|
||||||
assert result is not None
|
|
||||||
assert result.success is True
|
|
||||||
assert result.data == self._valid_payload()
|
|
||||||
|
|
||||||
def test_failed_fetch_records_error(self, service, mock_cache_manager):
|
|
||||||
with patch.object(service.session, "get", side_effect=Exception("network error")):
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nba", year=2024,
|
|
||||||
url="https://example.com/nba",
|
|
||||||
cache_key="nba_test",
|
|
||||||
max_retries=0,
|
|
||||||
)
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
result = service.get_result(req_id)
|
|
||||||
assert result is not None
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error is not None
|
|
||||||
|
|
||||||
def test_cache_miss_increments_stat(self, service, mock_cache_manager):
|
|
||||||
mock_resp = Mock()
|
|
||||||
mock_resp.json.return_value = self._valid_payload()
|
|
||||||
mock_resp.raise_for_status.return_value = None
|
|
||||||
|
|
||||||
with patch.object(service.session, "get", return_value=mock_resp):
|
|
||||||
service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024, url="https://x.com", cache_key="new_key",
|
|
||||||
)
|
|
||||||
stats = service.get_statistics()
|
|
||||||
assert stats["cache_misses"] == 1
|
|
||||||
|
|
||||||
def test_callback_called_on_success(self, service, mock_cache_manager):
|
|
||||||
callback = Mock()
|
|
||||||
mock_resp = Mock()
|
|
||||||
mock_resp.json.return_value = self._valid_payload()
|
|
||||||
mock_resp.raise_for_status.return_value = None
|
|
||||||
|
|
||||||
with patch.object(service.session, "get", return_value=mock_resp):
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024, url="https://x.com",
|
|
||||||
cache_key="cb_key", callback=callback, max_retries=0,
|
|
||||||
)
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
callback.assert_called_once()
|
|
||||||
call_arg = callback.call_args[0][0]
|
|
||||||
assert isinstance(call_arg, FetchResult)
|
|
||||||
|
|
||||||
def test_data_cached_after_successful_fetch(self, service, mock_cache_manager):
|
|
||||||
mock_resp = Mock()
|
|
||||||
mock_resp.json.return_value = self._valid_payload()
|
|
||||||
mock_resp.raise_for_status.return_value = None
|
|
||||||
|
|
||||||
with patch.object(service.session, "get", return_value=mock_resp):
|
|
||||||
req_id = service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024, url="https://x.com", cache_key="cache_after_key",
|
|
||||||
)
|
|
||||||
deadline = time.time() + 5
|
|
||||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
mock_cache_manager.set.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Request status / cancel
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestRequestStatusAndCancel:
|
|
||||||
def test_unknown_request_status_is_none(self, service):
|
|
||||||
assert service.get_request_status("nonexistent") is None
|
|
||||||
|
|
||||||
def test_cancel_active_request(self, service, mock_cache_manager):
|
|
||||||
# Manually insert an active request
|
|
||||||
req = FetchRequest(
|
|
||||||
id="r1", sport="nfl", year=2024,
|
|
||||||
cache_key="k", url="https://x.com",
|
|
||||||
)
|
|
||||||
req.status = FetchStatus.PENDING
|
|
||||||
service.active_requests["r1"] = req
|
|
||||||
result = service.cancel_request("r1")
|
|
||||||
assert result is True
|
|
||||||
assert "r1" not in service.active_requests
|
|
||||||
|
|
||||||
def test_cancel_nonexistent_request(self, service):
|
|
||||||
assert service.cancel_request("does-not-exist") is False
|
|
||||||
|
|
||||||
def test_is_request_complete_false_for_active(self, service, mock_cache_manager):
|
|
||||||
req = FetchRequest(
|
|
||||||
id="r2", sport="mlb", year=2024,
|
|
||||||
cache_key="k2", url="https://x.com",
|
|
||||||
)
|
|
||||||
service.active_requests["r2"] = req
|
|
||||||
assert service.is_request_complete("r2") is False
|
|
||||||
|
|
||||||
def test_is_request_complete_true_for_done(self, service):
|
|
||||||
result = FetchResult(request_id="r3", success=True)
|
|
||||||
service.completed_requests["r3"] = result
|
|
||||||
assert service.is_request_complete("r3") is True
|
|
||||||
|
|
||||||
def test_get_result_returns_none_for_unknown(self, service):
|
|
||||||
assert service.get_result("unknown") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shutdown
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestShutdown:
|
|
||||||
def test_shutdown_sets_flag(self, service):
|
|
||||||
service.shutdown(wait=False)
|
|
||||||
assert service._shutdown is True
|
|
||||||
|
|
||||||
def test_submit_after_shutdown_raises(self, service, mock_cache_manager):
|
|
||||||
service.shutdown(wait=False)
|
|
||||||
with pytest.raises(RuntimeError, match="shutting down"):
|
|
||||||
service.submit_fetch_request(
|
|
||||||
sport="nfl", year=2024, url="https://x.com", cache_key="k"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cleanup
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCleanup:
|
|
||||||
def test_cleanup_removes_old_requests(self, service):
|
|
||||||
old_result = FetchResult(request_id="old", success=True)
|
|
||||||
old_result.completed_at = time.time() - 7200 # 2 hours ago
|
|
||||||
service.completed_requests["old"] = old_result
|
|
||||||
service._last_completed_requests_cleanup = 0 # force cleanup
|
|
||||||
removed = service._cleanup_completed_requests(force=True)
|
|
||||||
assert removed >= 1
|
|
||||||
assert "old" not in service.completed_requests
|
|
||||||
|
|
||||||
def test_cleanup_respects_interval(self, service):
|
|
||||||
old_result = FetchResult(request_id="r", success=True)
|
|
||||||
old_result.completed_at = time.time() - 7200
|
|
||||||
service.completed_requests["r"] = old_result
|
|
||||||
# Cleanup interval not passed, should skip
|
|
||||||
service._last_completed_requests_cleanup = time.time()
|
|
||||||
removed = service._cleanup_completed_requests(force=False)
|
|
||||||
assert removed == 0
|
|
||||||
|
|
||||||
def test_size_limit_enforcement(self, service):
|
|
||||||
service._max_completed_requests = 3
|
|
||||||
for i in range(5):
|
|
||||||
result = FetchResult(request_id=str(i), success=True)
|
|
||||||
result.completed_at = time.time() - (5 - i) * 100 # oldest first
|
|
||||||
service.completed_requests[str(i)] = result
|
|
||||||
service._last_completed_requests_cleanup = 0
|
|
||||||
service._cleanup_completed_requests(force=True)
|
|
||||||
assert len(service.completed_requests) <= 3
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Singleton get_background_service
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetBackgroundService:
|
|
||||||
def test_first_call_requires_cache_manager(self):
|
|
||||||
with pytest.raises(ValueError, match="cache_manager is required"):
|
|
||||||
get_background_service()
|
|
||||||
|
|
||||||
def test_creates_singleton(self, mock_cache_manager):
|
|
||||||
svc1 = get_background_service(mock_cache_manager)
|
|
||||||
svc2 = get_background_service()
|
|
||||||
assert svc1 is svc2
|
|
||||||
|
|
||||||
def test_shutdown_clears_singleton(self, mock_cache_manager):
|
|
||||||
get_background_service(mock_cache_manager)
|
|
||||||
shutdown_background_service()
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
get_background_service()
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/base_classes/data_sources.py
|
|
||||||
|
|
||||||
Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource.
|
|
||||||
All HTTP calls are mocked to avoid network access.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, date
|
|
||||||
from unittest.mock import MagicMock, patch, Mock
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource
|
|
||||||
|
|
||||||
|
|
||||||
def _make_logger() -> logging.Logger:
|
|
||||||
return logging.getLogger("test_data_sources")
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_response(json_data: dict, status_code: int = 200):
|
|
||||||
resp = Mock(spec=requests.Response)
|
|
||||||
resp.status_code = status_code
|
|
||||||
resp.json.return_value = json_data
|
|
||||||
resp.raise_for_status = Mock()
|
|
||||||
if status_code >= 400:
|
|
||||||
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ESPNDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestESPNDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = ESPNDataSource(_make_logger())
|
|
||||||
|
|
||||||
def test_get_headers(self):
|
|
||||||
headers = self.source.get_headers()
|
|
||||||
assert headers["Accept"] == "application/json"
|
|
||||||
assert "LEDMatrix" in headers["User-Agent"]
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_live_events(self):
|
|
||||||
live_event = {
|
|
||||||
"competitions": [{"status": {"type": {"state": "in"}}}]
|
|
||||||
}
|
|
||||||
non_live_event = {
|
|
||||||
"competitions": [{"status": {"type": {"state": "pre"}}}]
|
|
||||||
}
|
|
||||||
payload = {"events": [live_event, non_live_event]}
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0] is live_event
|
|
||||||
|
|
||||||
def test_fetch_live_games_empty_when_none_live(self):
|
|
||||||
payload = {"events": [
|
|
||||||
{"competitions": [{"status": {"type": {"state": "post"}}}]}
|
|
||||||
]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("network failure")):
|
|
||||||
result = self.source.fetch_live_games("football", "nfl")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_all_events(self):
|
|
||||||
events = [{"id": "1"}, {"id": "2"}]
|
|
||||||
payload = {"events": events}
|
|
||||||
start = datetime(2024, 1, 1)
|
|
||||||
end = datetime(2024, 1, 7)
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("football", "nfl", (start, end))
|
|
||||||
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("timeout")):
|
|
||||||
result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"standings": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("football", "nfl")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("error")):
|
|
||||||
result = self.source.fetch_standings("football", "nfl")
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
def test_base_url_set_correctly(self):
|
|
||||||
assert "espn.com" in self.source.base_url
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MLBAPIDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestMLBAPIDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = MLBAPIDataSource(_make_logger())
|
|
||||||
|
|
||||||
def test_fetch_live_games_filters_live(self):
|
|
||||||
live_game = {"status": {"abstractGameState": "Live"}}
|
|
||||||
final_game = {"status": {"abstractGameState": "Final"}}
|
|
||||||
payload = {"dates": [{"games": [live_game, final_game]}]}
|
|
||||||
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0] is live_game
|
|
||||||
|
|
||||||
def test_fetch_live_games_empty_dates(self):
|
|
||||||
payload = {"dates": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_live_games("baseball", "mlb")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_aggregates_all_dates(self):
|
|
||||||
payload = {
|
|
||||||
"dates": [
|
|
||||||
{"games": [{"id": "1"}, {"id": "2"}]},
|
|
||||||
{"games": [{"id": "3"}]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
|
|
||||||
assert len(result) == 3
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"records": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("baseball", "mlb")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_standings("baseball", "mlb")
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SoccerAPIDataSource
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSoccerAPIDataSource:
|
|
||||||
def setup_method(self):
|
|
||||||
self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123")
|
|
||||||
|
|
||||||
def test_headers_include_api_key(self):
|
|
||||||
headers = self.source.get_headers()
|
|
||||||
assert headers["X-Auth-Token"] == "test-key-123"
|
|
||||||
|
|
||||||
def test_headers_without_api_key(self):
|
|
||||||
source = SoccerAPIDataSource(_make_logger())
|
|
||||||
headers = source.get_headers()
|
|
||||||
assert "X-Auth-Token" not in headers
|
|
||||||
|
|
||||||
def test_fetch_live_games_success(self):
|
|
||||||
payload = {"matches": [{"id": "m1"}, {"id": "m2"}]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_live_games("soccer", "eng.1")
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_fetch_live_games_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_live_games("soccer", "eng.1")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_schedule_success(self):
|
|
||||||
payload = {"matches": [{"id": "m1"}]}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_fetch_schedule_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_fetch_standings_success(self):
|
|
||||||
payload = {"standings": []}
|
|
||||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
|
||||||
result = self.source.fetch_standings("soccer", "PL")
|
|
||||||
assert result == payload
|
|
||||||
|
|
||||||
def test_fetch_standings_returns_empty_on_error(self):
|
|
||||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
|
||||||
result = self.source.fetch_standings("soccer", "PL")
|
|
||||||
assert result == {}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/common/game_helper.py
|
|
||||||
|
|
||||||
Covers GameHelper: extract_game_details, filter_*, sort_games_by_time,
|
|
||||||
process_games, get_game_summary, and all private helpers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import pytest
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
|
||||||
from src.common.game_helper import GameHelper
|
|
||||||
|
|
||||||
|
|
||||||
def _make_logger() -> logging.Logger:
|
|
||||||
return logging.getLogger("test_game_helper")
|
|
||||||
|
|
||||||
|
|
||||||
def _make_espn_event(
|
|
||||||
state: str = "in",
|
|
||||||
home_abbr: str = "LAL",
|
|
||||||
away_abbr: str = "BOS",
|
|
||||||
home_score: str = "105",
|
|
||||||
away_score: str = "98",
|
|
||||||
date_str: str = "2024-01-15T20:00:00Z",
|
|
||||||
period: int = 4,
|
|
||||||
status_name: str = "STATUS_IN_PROGRESS",
|
|
||||||
home_record: str = "30-10",
|
|
||||||
away_record: str = "25-15",
|
|
||||||
event_id: str = "game-1",
|
|
||||||
) -> dict:
|
|
||||||
return {
|
|
||||||
"id": event_id,
|
|
||||||
"date": date_str,
|
|
||||||
"competitions": [
|
|
||||||
{
|
|
||||||
"status": {
|
|
||||||
"type": {
|
|
||||||
"state": state,
|
|
||||||
"shortDetail": "Q4 2:30",
|
|
||||||
"name": status_name,
|
|
||||||
},
|
|
||||||
"period": period,
|
|
||||||
"displayClock": "2:30",
|
|
||||||
},
|
|
||||||
"competitors": [
|
|
||||||
{
|
|
||||||
"homeAway": "home",
|
|
||||||
"id": "h1",
|
|
||||||
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
|
|
||||||
"score": home_score,
|
|
||||||
"records": [{"summary": home_record}],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"homeAway": "away",
|
|
||||||
"id": "a1",
|
|
||||||
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
|
|
||||||
"score": away_score,
|
|
||||||
"records": [{"summary": away_record}],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def helper():
|
|
||||||
return GameHelper(timezone_str="UTC", logger=_make_logger())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# extract_game_details
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestExtractGameDetails:
|
|
||||||
def test_live_game(self, helper):
|
|
||||||
event = _make_espn_event(state="in")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result is not None
|
|
||||||
assert result["is_live"] is True
|
|
||||||
assert result["is_final"] is False
|
|
||||||
assert result["is_upcoming"] is False
|
|
||||||
|
|
||||||
def test_final_game(self, helper):
|
|
||||||
event = _make_espn_event(state="post")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["is_final"] is True
|
|
||||||
|
|
||||||
def test_upcoming_game(self, helper):
|
|
||||||
event = _make_espn_event(state="pre")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["is_upcoming"] is True
|
|
||||||
|
|
||||||
def test_halftime_detection(self, helper):
|
|
||||||
event = _make_espn_event(state="halftime", status_name="STATUS_HALFTIME")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["is_halftime"] is True
|
|
||||||
|
|
||||||
def test_basic_fields_present(self, helper):
|
|
||||||
event = _make_espn_event()
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
for key in ("id", "home_abbr", "away_abbr", "home_score", "away_score",
|
|
||||||
"home_record", "away_record", "start_time_utc"):
|
|
||||||
assert key in result
|
|
||||||
|
|
||||||
def test_team_abbreviations(self, helper):
|
|
||||||
event = _make_espn_event(home_abbr="MIA", away_abbr="PHX")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["home_abbr"] == "MIA"
|
|
||||||
assert result["away_abbr"] == "PHX"
|
|
||||||
|
|
||||||
def test_scores_as_strings(self, helper):
|
|
||||||
event = _make_espn_event(home_score="110", away_score="99")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["home_score"] == "110"
|
|
||||||
assert result["away_score"] == "99"
|
|
||||||
|
|
||||||
def test_returns_none_on_empty(self, helper):
|
|
||||||
assert helper.extract_game_details({}) is None
|
|
||||||
assert helper.extract_game_details(None) is None
|
|
||||||
|
|
||||||
def test_returns_none_when_no_competitors(self, helper):
|
|
||||||
event = _make_espn_event()
|
|
||||||
event["competitions"][0]["competitors"] = []
|
|
||||||
assert helper.extract_game_details(event) is None
|
|
||||||
|
|
||||||
def test_date_z_suffix_parsed(self, helper):
|
|
||||||
event = _make_espn_event(date_str="2024-06-01T19:30:00Z")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["start_time_utc"] is not None
|
|
||||||
assert result["start_time_utc"].tzinfo is not None
|
|
||||||
|
|
||||||
def test_zero_zero_record_suppressed(self, helper):
|
|
||||||
event = _make_espn_event(home_record="0-0", away_record="0-0-0")
|
|
||||||
result = helper.extract_game_details(event)
|
|
||||||
assert result["home_record"] == ""
|
|
||||||
assert result["away_record"] == ""
|
|
||||||
|
|
||||||
def test_basketball_sport_fields(self, helper):
|
|
||||||
event = _make_espn_event(period=3)
|
|
||||||
result = helper.extract_game_details(event, sport="basketball")
|
|
||||||
assert result["period_text"] == "Q3"
|
|
||||||
assert "clock" in result
|
|
||||||
|
|
||||||
def test_basketball_overtime_period(self, helper):
|
|
||||||
event = _make_espn_event(period=5)
|
|
||||||
result = helper.extract_game_details(event, sport="basketball")
|
|
||||||
assert result["period_text"] == "OT1"
|
|
||||||
|
|
||||||
def test_football_sport_fields(self, helper):
|
|
||||||
event = _make_espn_event(period=2)
|
|
||||||
result = helper.extract_game_details(event, sport="football")
|
|
||||||
assert result["period_text"] == "Q2"
|
|
||||||
|
|
||||||
def test_hockey_sport_fields_period_1(self, helper):
|
|
||||||
event = _make_espn_event(period=1)
|
|
||||||
result = helper.extract_game_details(event, sport="hockey")
|
|
||||||
assert result["period_text"] == "P1"
|
|
||||||
|
|
||||||
def test_hockey_sport_fields_ot(self, helper):
|
|
||||||
event = _make_espn_event(period=4)
|
|
||||||
result = helper.extract_game_details(event, sport="hockey")
|
|
||||||
assert result["period_text"] == "OT1"
|
|
||||||
|
|
||||||
def test_baseball_sport_fields(self, helper):
|
|
||||||
event = _make_espn_event(period=7)
|
|
||||||
result = helper.extract_game_details(event, sport="baseball")
|
|
||||||
assert result["period_text"] == "INN 7"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Filter methods
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFilterMethods:
|
|
||||||
def _make_games(self):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
return [
|
|
||||||
{"is_live": True, "is_final": False, "is_upcoming": False, "home_abbr": "LAL", "away_abbr": "BOS", "start_time_utc": now},
|
|
||||||
{"is_live": False, "is_final": True, "is_upcoming": False, "home_abbr": "MIA", "away_abbr": "PHX", "start_time_utc": now - timedelta(hours=3)},
|
|
||||||
{"is_live": False, "is_final": False, "is_upcoming": True, "home_abbr": "DAL", "away_abbr": "CHI", "start_time_utc": now + timedelta(hours=2)},
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_filter_live_games(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_live_games(games)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["home_abbr"] == "LAL"
|
|
||||||
|
|
||||||
def test_filter_final_games(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_final_games(games)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["home_abbr"] == "MIA"
|
|
||||||
|
|
||||||
def test_filter_upcoming_games(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_upcoming_games(games)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["home_abbr"] == "DAL"
|
|
||||||
|
|
||||||
def test_filter_favorite_teams_match(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_favorite_teams(games, ["LAL"])
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["home_abbr"] == "LAL"
|
|
||||||
|
|
||||||
def test_filter_favorite_teams_empty_list_returns_all(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_favorite_teams(games, [])
|
|
||||||
assert len(result) == 3
|
|
||||||
|
|
||||||
def test_filter_favorite_teams_away_match(self, helper):
|
|
||||||
games = self._make_games()
|
|
||||||
result = helper.filter_favorite_teams(games, ["BOS"])
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_filter_recent_games_within_window(self, helper):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
games = [
|
|
||||||
{"start_time_utc": now - timedelta(days=2), "is_final": True},
|
|
||||||
{"start_time_utc": now - timedelta(days=10), "is_final": True},
|
|
||||||
]
|
|
||||||
result = helper.filter_recent_games(games, days_back=7)
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_filter_recent_games_all_within(self, helper):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
games = [
|
|
||||||
{"start_time_utc": now - timedelta(days=1)},
|
|
||||||
{"start_time_utc": now - timedelta(days=3)},
|
|
||||||
]
|
|
||||||
result = helper.filter_recent_games(games, days_back=7)
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_sort_games_ascending(self, helper):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
games = [
|
|
||||||
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
|
|
||||||
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
|
|
||||||
]
|
|
||||||
result = helper.sort_games_by_time(games)
|
|
||||||
assert result[0]["id"] == "early"
|
|
||||||
|
|
||||||
def test_sort_games_descending(self, helper):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
games = [
|
|
||||||
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
|
|
||||||
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
|
|
||||||
]
|
|
||||||
result = helper.sort_games_by_time(games, reverse=True)
|
|
||||||
assert result[0]["id"] == "late"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# process_games
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestProcessGames:
|
|
||||||
def test_processes_valid_events(self, helper):
|
|
||||||
events = [
|
|
||||||
_make_espn_event(event_id="1"),
|
|
||||||
_make_espn_event(event_id="2"),
|
|
||||||
]
|
|
||||||
result = helper.process_games(events)
|
|
||||||
assert len(result) == 2
|
|
||||||
|
|
||||||
def test_skips_invalid_events(self, helper):
|
|
||||||
events = [
|
|
||||||
_make_espn_event(event_id="1"),
|
|
||||||
{}, # invalid
|
|
||||||
]
|
|
||||||
result = helper.process_games(events)
|
|
||||||
assert len(result) == 1
|
|
||||||
|
|
||||||
def test_empty_events(self, helper):
|
|
||||||
assert helper.process_games([]) == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_game_summary
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetGameSummary:
|
|
||||||
def test_live_summary(self, helper):
|
|
||||||
game = {
|
|
||||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
|
||||||
"home_score": "105", "away_score": "98",
|
|
||||||
"status_text": "Q4 2:30",
|
|
||||||
"is_live": True, "is_final": False,
|
|
||||||
}
|
|
||||||
summary = helper.get_game_summary(game)
|
|
||||||
assert "BOS" in summary
|
|
||||||
assert "LAL" in summary
|
|
||||||
assert "98" in summary
|
|
||||||
assert "105" in summary
|
|
||||||
|
|
||||||
def test_final_summary(self, helper):
|
|
||||||
game = {
|
|
||||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
|
||||||
"home_score": "110", "away_score": "102",
|
|
||||||
"status_text": "Final",
|
|
||||||
"is_live": False, "is_final": True,
|
|
||||||
}
|
|
||||||
summary = helper.get_game_summary(game)
|
|
||||||
assert "Final" in summary
|
|
||||||
|
|
||||||
def test_upcoming_summary(self, helper):
|
|
||||||
game = {
|
|
||||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
|
||||||
"home_score": "0", "away_score": "0",
|
|
||||||
"status_text": "7:30 PM",
|
|
||||||
"is_live": False, "is_final": False,
|
|
||||||
}
|
|
||||||
summary = helper.get_game_summary(game)
|
|
||||||
assert "7:30 PM" in summary
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/plugin_system/health_monitor.py
|
|
||||||
|
|
||||||
Covers PluginHealthMonitor: get_plugin_health_status, get_plugin_health_metrics,
|
|
||||||
get_all_plugin_health, _get_recovery_suggestions, start/stop_monitoring,
|
|
||||||
register_health_check.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from src.plugin_system.health_monitor import (
|
|
||||||
PluginHealthMonitor,
|
|
||||||
HealthStatus,
|
|
||||||
HealthMetrics,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_health_tracker(
|
|
||||||
summary: dict | None = None,
|
|
||||||
all_summaries: dict | None = None,
|
|
||||||
):
|
|
||||||
"""Return a mock PluginHealthTracker."""
|
|
||||||
tracker = MagicMock()
|
|
||||||
tracker.get_health_summary.return_value = summary
|
|
||||||
tracker.get_all_health_summaries.return_value = all_summaries or {}
|
|
||||||
return tracker
|
|
||||||
|
|
||||||
|
|
||||||
def _healthy_summary() -> dict:
|
|
||||||
return {
|
|
||||||
"success_rate": 100.0,
|
|
||||||
"circuit_state": "closed",
|
|
||||||
"consecutive_failures": 0,
|
|
||||||
"total_failures": 0,
|
|
||||||
"total_successes": 50,
|
|
||||||
"last_success_time": datetime.now().isoformat(),
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _degraded_summary() -> dict:
|
|
||||||
return {
|
|
||||||
"success_rate": 40.0, # 60% error rate
|
|
||||||
"circuit_state": "closed",
|
|
||||||
"consecutive_failures": 3,
|
|
||||||
"total_failures": 6,
|
|
||||||
"total_successes": 4,
|
|
||||||
"last_success_time": None,
|
|
||||||
"last_error": "timeout occurred",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _unhealthy_summary() -> dict:
|
|
||||||
return {
|
|
||||||
"success_rate": 10.0, # 90% error rate
|
|
||||||
"circuit_state": "open",
|
|
||||||
"consecutive_failures": 10,
|
|
||||||
"total_failures": 9,
|
|
||||||
"total_successes": 1,
|
|
||||||
"last_success_time": None,
|
|
||||||
"last_error": "ImportError: missing module",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def monitor():
|
|
||||||
tracker = _make_health_tracker(_healthy_summary())
|
|
||||||
return PluginHealthMonitor(health_tracker=tracker)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_plugin_health_status
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetPluginHealthStatus:
|
|
||||||
def test_healthy_status(self):
|
|
||||||
tracker = _make_health_tracker(_healthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_a")
|
|
||||||
assert status == HealthStatus.HEALTHY
|
|
||||||
|
|
||||||
def test_degraded_status(self):
|
|
||||||
tracker = _make_health_tracker(_degraded_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_b")
|
|
||||||
assert status == HealthStatus.DEGRADED
|
|
||||||
|
|
||||||
def test_unhealthy_status(self):
|
|
||||||
tracker = _make_health_tracker(_unhealthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_c")
|
|
||||||
assert status == HealthStatus.UNHEALTHY
|
|
||||||
|
|
||||||
def test_open_circuit_breaker_is_unhealthy(self):
|
|
||||||
summary = _healthy_summary()
|
|
||||||
summary["circuit_state"] = "open"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_d")
|
|
||||||
assert status == HealthStatus.UNHEALTHY
|
|
||||||
|
|
||||||
def test_unknown_when_no_tracker(self):
|
|
||||||
monitor = PluginHealthMonitor(health_tracker=None)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_e")
|
|
||||||
assert status == HealthStatus.UNKNOWN
|
|
||||||
|
|
||||||
def test_unknown_when_no_summary(self):
|
|
||||||
tracker = _make_health_tracker(None)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
status = monitor.get_plugin_health_status("plugin_f")
|
|
||||||
assert status == HealthStatus.UNKNOWN
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_plugin_health_metrics
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetPluginHealthMetrics:
|
|
||||||
def test_healthy_metrics(self):
|
|
||||||
tracker = _make_health_tracker(_healthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
|
||||||
assert isinstance(metrics, HealthMetrics)
|
|
||||||
assert metrics.status == HealthStatus.HEALTHY
|
|
||||||
assert metrics.success_rate == pytest.approx(1.0)
|
|
||||||
assert metrics.error_rate == pytest.approx(0.0)
|
|
||||||
|
|
||||||
def test_degraded_metrics(self):
|
|
||||||
tracker = _make_health_tracker(_degraded_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_b")
|
|
||||||
assert metrics.status == HealthStatus.DEGRADED
|
|
||||||
assert metrics.consecutive_failures == 3
|
|
||||||
|
|
||||||
def test_unhealthy_metrics(self):
|
|
||||||
tracker = _make_health_tracker(_unhealthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_c")
|
|
||||||
assert metrics.status == HealthStatus.UNHEALTHY
|
|
||||||
assert metrics.circuit_breaker_state == "open"
|
|
||||||
assert metrics.last_error is not None
|
|
||||||
|
|
||||||
def test_metrics_without_tracker(self):
|
|
||||||
monitor = PluginHealthMonitor(health_tracker=None)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_d")
|
|
||||||
assert metrics.status == HealthStatus.UNKNOWN
|
|
||||||
assert metrics.plugin_id == "plugin_d"
|
|
||||||
|
|
||||||
def test_metrics_without_summary(self):
|
|
||||||
tracker = _make_health_tracker(None)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_e")
|
|
||||||
assert metrics.status == HealthStatus.UNKNOWN
|
|
||||||
|
|
||||||
def test_last_successful_update_parsed(self):
|
|
||||||
summary = _healthy_summary()
|
|
||||||
summary["last_success_time"] = "2024-06-01T12:00:00"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
|
||||||
assert metrics.last_successful_update is not None
|
|
||||||
assert isinstance(metrics.last_successful_update, datetime)
|
|
||||||
|
|
||||||
def test_invalid_last_success_time_handled(self):
|
|
||||||
summary = _healthy_summary()
|
|
||||||
summary["last_success_time"] = "not-a-date"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
# Should not raise
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
|
||||||
assert metrics.last_successful_update is None
|
|
||||||
|
|
||||||
def test_total_successes_failures(self):
|
|
||||||
tracker = _make_health_tracker(_degraded_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
metrics = monitor.get_plugin_health_metrics("plugin_b")
|
|
||||||
assert metrics.total_failures == 6
|
|
||||||
assert metrics.total_successes == 4
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_all_plugin_health
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetAllPluginHealth:
|
|
||||||
def test_returns_empty_without_tracker(self):
|
|
||||||
monitor = PluginHealthMonitor(health_tracker=None)
|
|
||||||
result = monitor.get_all_plugin_health()
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
def test_returns_metrics_for_each_plugin(self):
|
|
||||||
all_summaries = {
|
|
||||||
"plugin_a": _healthy_summary(),
|
|
||||||
"plugin_b": _degraded_summary(),
|
|
||||||
}
|
|
||||||
tracker = MagicMock()
|
|
||||||
tracker.get_all_health_summaries.return_value = all_summaries
|
|
||||||
tracker.get_health_summary.side_effect = lambda pid: all_summaries.get(pid)
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
result = monitor.get_all_plugin_health()
|
|
||||||
assert "plugin_a" in result
|
|
||||||
assert "plugin_b" in result
|
|
||||||
assert isinstance(result["plugin_a"], HealthMetrics)
|
|
||||||
|
|
||||||
def test_returns_empty_when_no_summaries(self):
|
|
||||||
tracker = _make_health_tracker(all_summaries={})
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
result = monitor.get_all_plugin_health()
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _get_recovery_suggestions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetRecoverySuggestions:
|
|
||||||
def test_healthy_plugin_suggestion(self):
|
|
||||||
tracker = _make_health_tracker(_healthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", _healthy_summary(), HealthStatus.HEALTHY)
|
|
||||||
assert any("healthy" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_unhealthy_suggestions(self):
|
|
||||||
tracker = _make_health_tracker(_unhealthy_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", _unhealthy_summary(), HealthStatus.UNHEALTHY)
|
|
||||||
assert len(suggestions) > 0
|
|
||||||
assert any("unhealthy" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_open_circuit_breaker_suggestion(self):
|
|
||||||
summary = _unhealthy_summary()
|
|
||||||
summary["circuit_state"] = "open"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
|
||||||
assert any("circuit" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_timeout_error_suggestion(self):
|
|
||||||
summary = _degraded_summary()
|
|
||||||
summary["last_error"] = "connection timeout occurred"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.DEGRADED)
|
|
||||||
assert any("timeout" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_import_error_suggestion(self):
|
|
||||||
summary = _unhealthy_summary()
|
|
||||||
summary["last_error"] = "ImportError: missing module"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
|
||||||
assert any("dependencies" in s.lower() or "import" in s.lower() or "missing" in s.lower()
|
|
||||||
for s in suggestions)
|
|
||||||
|
|
||||||
def test_permission_error_suggestion(self):
|
|
||||||
summary = _unhealthy_summary()
|
|
||||||
summary["last_error"] = "permission denied to access resource"
|
|
||||||
tracker = _make_health_tracker(summary)
|
|
||||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
|
||||||
assert any("permission" in s.lower() for s in suggestions)
|
|
||||||
|
|
||||||
def test_degraded_suggestions_include_error_rate(self):
|
|
||||||
tracker = _make_health_tracker(_degraded_summary())
|
|
||||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
|
||||||
suggestions = monitor._get_recovery_suggestions("p", _degraded_summary(), HealthStatus.DEGRADED)
|
|
||||||
assert any("%" in s for s in suggestions)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# start / stop monitoring
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestMonitorLifecycle:
|
|
||||||
def test_start_monitoring(self, monitor):
|
|
||||||
monitor.start_monitoring()
|
|
||||||
try:
|
|
||||||
assert monitor._monitor_thread is not None
|
|
||||||
assert monitor._monitor_thread.is_alive()
|
|
||||||
finally:
|
|
||||||
monitor.stop_monitoring()
|
|
||||||
|
|
||||||
def test_stop_monitoring(self, monitor):
|
|
||||||
monitor.start_monitoring()
|
|
||||||
monitor.stop_monitoring()
|
|
||||||
# Thread should no longer be alive
|
|
||||||
assert not monitor._monitor_thread.is_alive()
|
|
||||||
|
|
||||||
def test_double_start_no_duplicate_threads(self, monitor):
|
|
||||||
monitor.start_monitoring()
|
|
||||||
try:
|
|
||||||
thread1 = monitor._monitor_thread
|
|
||||||
monitor.start_monitoring() # should be idempotent
|
|
||||||
assert monitor._monitor_thread is thread1
|
|
||||||
finally:
|
|
||||||
monitor.stop_monitoring()
|
|
||||||
|
|
||||||
def test_register_health_check(self, monitor):
|
|
||||||
callback = MagicMock()
|
|
||||||
monitor.register_health_check(callback)
|
|
||||||
assert callback in monitor._health_check_callbacks
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/logo_downloader.py
|
|
||||||
|
|
||||||
Focuses on the pure/static methods that don't require network calls:
|
|
||||||
normalize_abbreviation, get_logo_filename_variations, get_logo_directory,
|
|
||||||
ensure_logo_directory, and the download_missing_logo function path
|
|
||||||
(with HTTP mocked).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch, Mock, MagicMock
|
|
||||||
|
|
||||||
from src.logo_downloader import LogoDownloader
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# normalize_abbreviation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNormalizeAbbreviation:
|
|
||||||
def test_basic_lowercase(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("lal")
|
|
||||||
assert result == "LAL"
|
|
||||||
|
|
||||||
def test_uppercases(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("bos")
|
|
||||||
assert result == "BOS"
|
|
||||||
|
|
||||||
def test_ampersand_replaced(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("TA&M")
|
|
||||||
assert "&" not in result
|
|
||||||
assert "AND" in result
|
|
||||||
|
|
||||||
def test_forward_slash_replaced(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("A/B")
|
|
||||||
assert "/" not in result
|
|
||||||
|
|
||||||
def test_empty_returns_empty(self):
|
|
||||||
result = LogoDownloader.normalize_abbreviation("")
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_logo_filename_variations
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetLogoFilenameVariations:
|
|
||||||
def test_returns_list(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("LAL")
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
def test_includes_png(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("KC")
|
|
||||||
filenames = " ".join(result)
|
|
||||||
assert ".png" in filenames
|
|
||||||
|
|
||||||
def test_includes_original(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("LAL")
|
|
||||||
assert any("LAL" in f for f in result)
|
|
||||||
|
|
||||||
def test_ampersand_variation(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("TA&M")
|
|
||||||
# Should produce at least the normalized version
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
def test_empty_string_no_crash(self):
|
|
||||||
result = LogoDownloader.get_logo_filename_variations("")
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_logo_directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetLogoDirectory:
|
|
||||||
def test_known_sport_returns_string(self):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
result = downloader.get_logo_directory("nfl")
|
|
||||||
assert isinstance(result, str)
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
def test_known_sport_nba(self):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
result = downloader.get_logo_directory("nba")
|
|
||||||
assert "nba" in result.lower() or "sports" in result.lower()
|
|
||||||
|
|
||||||
def test_unknown_sport_returns_string(self):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
result = downloader.get_logo_directory("unknown_sport_xyz")
|
|
||||||
assert isinstance(result, str)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# ensure_logo_directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestEnsureLogoDirectory:
|
|
||||||
def test_creates_writable_directory(self, tmp_path):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
test_dir = str(tmp_path / "logos" / "nfl")
|
|
||||||
result = downloader.ensure_logo_directory(test_dir)
|
|
||||||
assert result is True
|
|
||||||
assert Path(test_dir).is_dir()
|
|
||||||
|
|
||||||
def test_existing_writable_directory(self, tmp_path):
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
test_dir = str(tmp_path)
|
|
||||||
result = downloader.ensure_logo_directory(test_dir)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_returns_false_when_write_test_fails(self, tmp_path):
|
|
||||||
"""Simulate a directory that exists but raises PermissionError on write."""
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
test_dir = str(tmp_path / "logos")
|
|
||||||
|
|
||||||
import builtins
|
|
||||||
original_open = builtins.open
|
|
||||||
|
|
||||||
def mock_open(path, *args, **kwargs):
|
|
||||||
if ".write_test" in str(path):
|
|
||||||
raise PermissionError("no write access")
|
|
||||||
return original_open(path, *args, **kwargs)
|
|
||||||
|
|
||||||
with patch("builtins.open", side_effect=mock_open):
|
|
||||||
result = downloader.ensure_logo_directory(test_dir)
|
|
||||||
assert result is False
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/common/scroll_helper.py
|
|
||||||
|
|
||||||
Covers ScrollHelper: create_scrolling_image, update_scroll_position,
|
|
||||||
get_visible_portion, calculate_dynamic_duration, set_* methods,
|
|
||||||
reset_scroll, clear_cache, get_scroll_info.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import time
|
|
||||||
from unittest.mock import patch
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
|
||||||
|
|
||||||
|
|
||||||
DISPLAY_W = 64
|
|
||||||
DISPLAY_H = 32
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def helper():
|
|
||||||
return ScrollHelper(display_width=DISPLAY_W, display_height=DISPLAY_H)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_image(width: int = 64, height: int = 32, color=(255, 0, 0)) -> Image.Image:
|
|
||||||
img = Image.new("RGB", (width, height), color)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# __init__ / initial state
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestScrollHelperInit:
|
|
||||||
def test_initial_scroll_position(self, helper):
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_initial_scroll_complete_false(self, helper):
|
|
||||||
assert helper.scroll_complete is False
|
|
||||||
|
|
||||||
def test_display_dimensions(self, helper):
|
|
||||||
assert helper.display_width == DISPLAY_W
|
|
||||||
assert helper.display_height == DISPLAY_H
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# create_scrolling_image
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCreateScrollingImage:
|
|
||||||
def test_empty_content_returns_blank_image(self, helper):
|
|
||||||
result = helper.create_scrolling_image([])
|
|
||||||
assert isinstance(result, Image.Image)
|
|
||||||
assert helper.total_scroll_width == 0
|
|
||||||
|
|
||||||
def test_single_item_creates_image(self, helper):
|
|
||||||
img = _make_image(width=100)
|
|
||||||
result = helper.create_scrolling_image([img])
|
|
||||||
assert isinstance(result, Image.Image)
|
|
||||||
assert result.width > DISPLAY_W # includes leading gap
|
|
||||||
|
|
||||||
def test_multiple_items_wider_image(self, helper):
|
|
||||||
items = [_make_image(width=50), _make_image(width=50)]
|
|
||||||
result = helper.create_scrolling_image(items)
|
|
||||||
# Should be wider than two items alone
|
|
||||||
assert result.width > 100
|
|
||||||
|
|
||||||
def test_scroll_position_reset(self, helper):
|
|
||||||
helper.scroll_position = 500.0
|
|
||||||
helper.create_scrolling_image([_make_image()])
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_cached_array_set(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image()])
|
|
||||||
assert helper.cached_array is not None
|
|
||||||
|
|
||||||
def test_scroll_complete_reset(self, helper):
|
|
||||||
helper.scroll_complete = True
|
|
||||||
helper.create_scrolling_image([_make_image()])
|
|
||||||
assert helper.scroll_complete is False
|
|
||||||
|
|
||||||
def test_total_scroll_width_matches_image(self, helper):
|
|
||||||
img = _make_image(width=200)
|
|
||||||
result = helper.create_scrolling_image([img])
|
|
||||||
assert helper.total_scroll_width == result.width
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# set_scrolling_image
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSetScrollingImage:
|
|
||||||
def test_sets_cached_image(self, helper):
|
|
||||||
img = _make_image(width=200)
|
|
||||||
helper.set_scrolling_image(img)
|
|
||||||
assert helper.cached_image is img
|
|
||||||
|
|
||||||
def test_sets_cached_array(self, helper):
|
|
||||||
img = _make_image(width=200)
|
|
||||||
helper.set_scrolling_image(img)
|
|
||||||
assert helper.cached_array is not None
|
|
||||||
|
|
||||||
def test_scroll_width_matches_image(self, helper):
|
|
||||||
img = _make_image(width=300)
|
|
||||||
helper.set_scrolling_image(img)
|
|
||||||
assert helper.total_scroll_width == 300
|
|
||||||
|
|
||||||
def test_none_clears_cache(self, helper):
|
|
||||||
helper.set_scrolling_image(_make_image())
|
|
||||||
helper.set_scrolling_image(None)
|
|
||||||
assert helper.cached_image is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# update_scroll_position (time-based mode)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestUpdateScrollPosition:
|
|
||||||
def test_position_advances_over_time(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=200)])
|
|
||||||
helper.scroll_speed = 100.0 # 100 px/s
|
|
||||||
helper.last_update_time = time.time() - 0.1 # pretend 100ms elapsed
|
|
||||||
initial = helper.scroll_position
|
|
||||||
helper.update_scroll_position()
|
|
||||||
assert helper.scroll_position > initial
|
|
||||||
|
|
||||||
def test_no_advance_without_image(self, helper):
|
|
||||||
helper.update_scroll_position() # no image, should not crash
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_zero_width_content_stays_zero(self, helper):
|
|
||||||
helper.create_scrolling_image([]) # empty → width 0
|
|
||||||
helper.update_scroll_position()
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_scroll_complete_clamped(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=100)])
|
|
||||||
# Force position past the end
|
|
||||||
helper.scroll_position = helper.total_scroll_width + 50
|
|
||||||
helper.total_distance_scrolled = helper.total_scroll_width + 50
|
|
||||||
helper.update_scroll_position()
|
|
||||||
assert helper.scroll_complete is True
|
|
||||||
assert helper.scroll_position <= helper.total_scroll_width
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_visible_portion
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetVisiblePortion:
|
|
||||||
def test_returns_none_without_image(self, helper):
|
|
||||||
assert helper.get_visible_portion() is None
|
|
||||||
|
|
||||||
def test_returns_image_sized_to_display(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=200)])
|
|
||||||
visible = helper.get_visible_portion()
|
|
||||||
assert visible is not None
|
|
||||||
assert visible.width == DISPLAY_W
|
|
||||||
assert visible.height == DISPLAY_H
|
|
||||||
|
|
||||||
def test_different_positions_give_different_images(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=300)])
|
|
||||||
img1 = helper.get_visible_portion()
|
|
||||||
helper.scroll_position = 50
|
|
||||||
img2 = helper.get_visible_portion()
|
|
||||||
# Images should differ (colour from scrolled content)
|
|
||||||
# Just verify both are valid PIL images with correct size
|
|
||||||
assert img1.width == img2.width == DISPLAY_W
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# reset_scroll / clear_cache
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestResetAndClear:
|
|
||||||
def test_reset_restores_position(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=200)])
|
|
||||||
helper.scroll_position = 100.0
|
|
||||||
helper.reset_scroll()
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_reset_clears_complete_flag(self, helper):
|
|
||||||
helper.scroll_complete = True
|
|
||||||
helper.reset_scroll()
|
|
||||||
assert helper.scroll_complete is False
|
|
||||||
|
|
||||||
def test_reset_alias(self, helper):
|
|
||||||
helper.scroll_position = 50.0
|
|
||||||
helper.reset()
|
|
||||||
assert helper.scroll_position == 0.0
|
|
||||||
|
|
||||||
def test_clear_cache(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image()])
|
|
||||||
helper.clear_cache()
|
|
||||||
assert helper.cached_image is None
|
|
||||||
assert helper.cached_array is None
|
|
||||||
assert helper.total_scroll_width == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# calculate_dynamic_duration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestCalculateDynamicDuration:
|
|
||||||
def test_returns_min_when_disabled(self, helper):
|
|
||||||
helper.dynamic_duration_enabled = False
|
|
||||||
helper.min_duration = 30
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert result == 30
|
|
||||||
|
|
||||||
def test_returns_min_when_no_content(self, helper):
|
|
||||||
helper.total_scroll_width = 0
|
|
||||||
helper.min_duration = 30
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert result == 30
|
|
||||||
|
|
||||||
def test_respects_min_duration(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=50)])
|
|
||||||
helper.min_duration = 60
|
|
||||||
helper.max_duration = 300
|
|
||||||
helper.scroll_speed = 500.0 # very fast → very short time
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert result >= 60
|
|
||||||
|
|
||||||
def test_respects_max_duration(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=5000)])
|
|
||||||
helper.min_duration = 10
|
|
||||||
helper.max_duration = 60
|
|
||||||
helper.scroll_speed = 1.0 # very slow → very long time
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert result <= 60
|
|
||||||
|
|
||||||
def test_time_based_calculation(self, helper):
|
|
||||||
helper.create_scrolling_image([_make_image(width=200)])
|
|
||||||
helper.scroll_speed = 100.0
|
|
||||||
helper.min_duration = 1
|
|
||||||
helper.max_duration = 600
|
|
||||||
helper.frame_based_scrolling = False
|
|
||||||
result = helper.calculate_dynamic_duration()
|
|
||||||
assert isinstance(result, int)
|
|
||||||
assert result > 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# set_* configuration methods
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSetMethods:
|
|
||||||
def test_set_scroll_speed_time_based(self, helper):
|
|
||||||
helper.frame_based_scrolling = False
|
|
||||||
helper.set_scroll_speed(50.0)
|
|
||||||
assert helper.scroll_speed == 50.0
|
|
||||||
|
|
||||||
def test_set_scroll_speed_clamped_low(self, helper):
|
|
||||||
helper.frame_based_scrolling = False
|
|
||||||
helper.set_scroll_speed(0.0)
|
|
||||||
assert helper.scroll_speed >= 1.0
|
|
||||||
|
|
||||||
def test_set_scroll_speed_clamped_high(self, helper):
|
|
||||||
helper.frame_based_scrolling = False
|
|
||||||
helper.set_scroll_speed(10000.0)
|
|
||||||
assert helper.scroll_speed <= 500.0
|
|
||||||
|
|
||||||
def test_set_scroll_delay(self, helper):
|
|
||||||
helper.set_scroll_delay(0.05)
|
|
||||||
assert helper.scroll_delay == 0.05
|
|
||||||
|
|
||||||
def test_set_scroll_delay_clamped(self, helper):
|
|
||||||
helper.set_scroll_delay(0.0001)
|
|
||||||
assert helper.scroll_delay >= 0.001
|
|
||||||
|
|
||||||
def test_set_target_fps(self, helper):
|
|
||||||
helper.set_target_fps(60.0)
|
|
||||||
assert helper.target_fps == 60.0
|
|
||||||
|
|
||||||
def test_set_target_fps_clamped(self, helper):
|
|
||||||
helper.set_target_fps(1000.0)
|
|
||||||
assert helper.target_fps <= 200.0
|
|
||||||
|
|
||||||
def test_set_sub_pixel_scrolling(self, helper):
|
|
||||||
helper.set_sub_pixel_scrolling(True)
|
|
||||||
assert helper.sub_pixel_scrolling is True
|
|
||||||
helper.set_sub_pixel_scrolling(False)
|
|
||||||
assert helper.sub_pixel_scrolling is False
|
|
||||||
|
|
||||||
def test_set_frame_based_scrolling(self, helper):
|
|
||||||
helper.set_frame_based_scrolling(True)
|
|
||||||
assert helper.frame_based_scrolling is True
|
|
||||||
|
|
||||||
def test_set_dynamic_duration_settings(self, helper):
|
|
||||||
helper.set_dynamic_duration_settings(enabled=True, min_duration=20, max_duration=120, buffer=0.2)
|
|
||||||
assert helper.dynamic_duration_enabled is True
|
|
||||||
assert helper.min_duration == 20
|
|
||||||
assert helper.max_duration == 120
|
|
||||||
assert helper.duration_buffer == pytest.approx(0.2)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_scroll_info
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetScrollInfo:
|
|
||||||
def test_returns_dict(self, helper):
|
|
||||||
info = helper.get_scroll_info()
|
|
||||||
assert isinstance(info, dict)
|
|
||||||
|
|
||||||
def test_required_keys(self, helper):
|
|
||||||
info = helper.get_scroll_info()
|
|
||||||
for key in ("scroll_position", "total_distance_scrolled", "scroll_speed",
|
|
||||||
"scroll_complete", "dynamic_duration"):
|
|
||||||
assert key in info
|
|
||||||
|
|
||||||
def test_scroll_position_reflected(self, helper):
|
|
||||||
helper.scroll_position = 42.0
|
|
||||||
info = helper.get_scroll_info()
|
|
||||||
assert info["scroll_position"] == 42.0
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/common/utils.py
|
|
||||||
|
|
||||||
Covers all pure utility functions: normalize_team_abbreviation, format_time,
|
|
||||||
format_date, get_timezone, validate_dimensions, parse_team_abbreviation,
|
|
||||||
format_score, format_period, is_live_game, is_final_game, is_upcoming_game,
|
|
||||||
sanitize_filename, truncate_text, parse_boolean.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
from src.common.utils import (
|
|
||||||
normalize_team_abbreviation,
|
|
||||||
format_time,
|
|
||||||
format_date,
|
|
||||||
get_timezone,
|
|
||||||
validate_dimensions,
|
|
||||||
parse_team_abbreviation,
|
|
||||||
format_score,
|
|
||||||
format_period,
|
|
||||||
is_live_game,
|
|
||||||
is_final_game,
|
|
||||||
is_upcoming_game,
|
|
||||||
sanitize_filename,
|
|
||||||
truncate_text,
|
|
||||||
parse_boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# normalize_team_abbreviation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNormalizeTeamAbbreviation:
|
|
||||||
def test_basic_uppercase(self):
|
|
||||||
assert normalize_team_abbreviation("lal") == "LAL"
|
|
||||||
|
|
||||||
def test_strips_spaces(self):
|
|
||||||
assert normalize_team_abbreviation(" KC ") == "KC"
|
|
||||||
|
|
||||||
def test_replaces_ampersand(self):
|
|
||||||
assert normalize_team_abbreviation("TA&M") == "TAANDM"
|
|
||||||
|
|
||||||
def test_removes_internal_spaces(self):
|
|
||||||
assert normalize_team_abbreviation("A B") == "AB"
|
|
||||||
|
|
||||||
def test_removes_hyphens(self):
|
|
||||||
assert normalize_team_abbreviation("A-B") == "AB"
|
|
||||||
|
|
||||||
def test_empty_string_returns_empty(self):
|
|
||||||
assert normalize_team_abbreviation("") == ""
|
|
||||||
|
|
||||||
def test_none_returns_empty(self):
|
|
||||||
assert normalize_team_abbreviation(None) == ""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# format_time / format_date
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFormatTime:
|
|
||||||
def _utc_dt(self, hour=20, minute=30):
|
|
||||||
return datetime(2024, 1, 15, hour, minute, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
def test_formats_utc_to_utc(self):
|
|
||||||
dt = self._utc_dt(20, 30)
|
|
||||||
result = format_time(dt, timezone_str="UTC")
|
|
||||||
# 20:30 UTC → "8:30PM" (leading zero stripped)
|
|
||||||
assert "8:30PM" in result or "8:30 PM" in result or result != ""
|
|
||||||
|
|
||||||
def test_naive_datetime_treated_as_utc(self):
|
|
||||||
dt = datetime(2024, 1, 15, 12, 0, 0) # naive
|
|
||||||
result = format_time(dt, timezone_str="UTC")
|
|
||||||
assert result != ""
|
|
||||||
|
|
||||||
def test_invalid_timezone_returns_empty(self):
|
|
||||||
dt = self._utc_dt()
|
|
||||||
result = format_time(dt, timezone_str="Invalid/TZ")
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
def test_eastern_timezone(self):
|
|
||||||
dt = self._utc_dt(20, 0) # 8 PM UTC = 3 PM ET
|
|
||||||
result = format_time(dt, timezone_str="America/New_York")
|
|
||||||
assert result != ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestFormatDate:
|
|
||||||
def test_formats_date(self):
|
|
||||||
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
|
|
||||||
result = format_date(dt, timezone_str="UTC")
|
|
||||||
assert "June" in result or "15" in result
|
|
||||||
|
|
||||||
def test_naive_datetime(self):
|
|
||||||
dt = datetime(2024, 3, 10, 12, 0, 0)
|
|
||||||
result = format_date(dt, timezone_str="UTC")
|
|
||||||
assert result != ""
|
|
||||||
|
|
||||||
def test_invalid_timezone_returns_empty(self):
|
|
||||||
dt = datetime(2024, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
|
|
||||||
result = format_date(dt, timezone_str="BadZone/Here")
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_timezone
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetTimezone:
|
|
||||||
def test_valid_timezone(self):
|
|
||||||
tz = get_timezone("America/New_York")
|
|
||||||
assert tz is not None
|
|
||||||
|
|
||||||
def test_utc(self):
|
|
||||||
tz = get_timezone("UTC")
|
|
||||||
assert tz is pytz.utc or str(tz) == "UTC"
|
|
||||||
|
|
||||||
def test_invalid_returns_utc(self):
|
|
||||||
tz = get_timezone("Not/ATimezone")
|
|
||||||
assert tz is pytz.utc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# validate_dimensions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestValidateDimensions:
|
|
||||||
def test_valid(self):
|
|
||||||
assert validate_dimensions(64, 32) is True
|
|
||||||
|
|
||||||
def test_zero_width(self):
|
|
||||||
assert validate_dimensions(0, 32) is False
|
|
||||||
|
|
||||||
def test_zero_height(self):
|
|
||||||
assert validate_dimensions(64, 0) is False
|
|
||||||
|
|
||||||
def test_negative(self):
|
|
||||||
assert validate_dimensions(-1, 32) is False
|
|
||||||
|
|
||||||
def test_too_large(self):
|
|
||||||
assert validate_dimensions(1001, 32) is False
|
|
||||||
|
|
||||||
def test_max_valid(self):
|
|
||||||
assert validate_dimensions(1000, 1000) is True
|
|
||||||
|
|
||||||
def test_non_integer(self):
|
|
||||||
assert validate_dimensions("64", 32) is False # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# parse_team_abbreviation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestParseTeamAbbreviation:
|
|
||||||
def test_empty_string(self):
|
|
||||||
assert parse_team_abbreviation("") == ""
|
|
||||||
|
|
||||||
def test_none_returns_empty(self):
|
|
||||||
assert parse_team_abbreviation(None) == ""
|
|
||||||
|
|
||||||
def test_extracts_uppercase(self):
|
|
||||||
result = parse_team_abbreviation("LAL")
|
|
||||||
assert result == "LAL"
|
|
||||||
|
|
||||||
def test_fallback_first_three(self):
|
|
||||||
# text without recognisable 2-4 char uppercase block
|
|
||||||
result = parse_team_abbreviation("ab")
|
|
||||||
assert len(result) <= 3
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# format_score
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFormatScore:
|
|
||||||
def test_format_score(self):
|
|
||||||
assert format_score(14, 7) == "7-14"
|
|
||||||
|
|
||||||
def test_format_score_strings(self):
|
|
||||||
assert format_score("21", "14") == "14-21"
|
|
||||||
|
|
||||||
def test_zero_zero(self):
|
|
||||||
assert format_score(0, 0) == "0-0"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# format_period
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFormatPeriod:
|
|
||||||
def test_basketball_q1(self):
|
|
||||||
assert format_period(1, "basketball") == "Q1"
|
|
||||||
|
|
||||||
def test_basketball_q4(self):
|
|
||||||
assert format_period(4, "basketball") == "Q4"
|
|
||||||
|
|
||||||
def test_basketball_ot1(self):
|
|
||||||
assert format_period(5, "basketball") == "OT1"
|
|
||||||
|
|
||||||
def test_basketball_ot2(self):
|
|
||||||
assert format_period(6, "basketball") == "OT2"
|
|
||||||
|
|
||||||
def test_football_q1(self):
|
|
||||||
assert format_period(1, "football") == "Q1"
|
|
||||||
|
|
||||||
def test_football_ot(self):
|
|
||||||
assert format_period(5, "football") == "OT1"
|
|
||||||
|
|
||||||
def test_hockey_p1(self):
|
|
||||||
assert format_period(1, "hockey") == "P1"
|
|
||||||
|
|
||||||
def test_hockey_p3(self):
|
|
||||||
assert format_period(3, "hockey") == "P3"
|
|
||||||
|
|
||||||
def test_hockey_ot(self):
|
|
||||||
assert format_period(4, "hockey") == "OT1"
|
|
||||||
|
|
||||||
def test_baseball_inning(self):
|
|
||||||
assert format_period(7, "baseball") == "INN 7"
|
|
||||||
|
|
||||||
def test_unknown_sport(self):
|
|
||||||
result = format_period(2, "unknown")
|
|
||||||
assert "2" in result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# is_live_game / is_final_game / is_upcoming_game
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGameStatusHelpers:
|
|
||||||
def test_is_live_game_true(self):
|
|
||||||
assert is_live_game("In Progress") is True
|
|
||||||
assert is_live_game("halftime") is True
|
|
||||||
assert is_live_game("overtime") is True
|
|
||||||
|
|
||||||
def test_is_live_game_false(self):
|
|
||||||
assert is_live_game("Final") is False
|
|
||||||
assert is_live_game("Scheduled") is False
|
|
||||||
|
|
||||||
def test_is_final_game_true(self):
|
|
||||||
assert is_final_game("Final") is True
|
|
||||||
assert is_final_game("COMPLETED") is True
|
|
||||||
|
|
||||||
def test_is_final_game_false(self):
|
|
||||||
assert is_final_game("In Progress") is False
|
|
||||||
|
|
||||||
def test_is_upcoming_game_true(self):
|
|
||||||
assert is_upcoming_game("Scheduled") is True
|
|
||||||
assert is_upcoming_game("upcoming") is True
|
|
||||||
|
|
||||||
def test_is_upcoming_game_false(self):
|
|
||||||
assert is_upcoming_game("Final") is False
|
|
||||||
assert is_upcoming_game("In Progress") is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# sanitize_filename
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestSanitizeFilename:
|
|
||||||
def test_removes_invalid_chars(self):
|
|
||||||
result = sanitize_filename('file<>:"/\\|?*.txt')
|
|
||||||
assert "<" not in result
|
|
||||||
assert ">" not in result
|
|
||||||
assert ":" not in result
|
|
||||||
|
|
||||||
def test_collapses_underscores(self):
|
|
||||||
result = sanitize_filename("file___name")
|
|
||||||
assert "__" not in result
|
|
||||||
|
|
||||||
def test_strips_leading_trailing(self):
|
|
||||||
result = sanitize_filename("_file_")
|
|
||||||
assert not result.startswith("_")
|
|
||||||
assert not result.endswith("_")
|
|
||||||
|
|
||||||
def test_normal_filename_unchanged(self):
|
|
||||||
result = sanitize_filename("my_logo")
|
|
||||||
assert result == "my_logo"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# truncate_text
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestTruncateText:
|
|
||||||
def test_no_truncation_needed(self):
|
|
||||||
assert truncate_text("hello", 10) == "hello"
|
|
||||||
|
|
||||||
def test_truncation_adds_suffix(self):
|
|
||||||
result = truncate_text("hello world", 8)
|
|
||||||
assert result.endswith("...")
|
|
||||||
assert len(result) == 8
|
|
||||||
|
|
||||||
def test_exact_length(self):
|
|
||||||
assert truncate_text("hello", 5) == "hello"
|
|
||||||
|
|
||||||
def test_custom_suffix(self):
|
|
||||||
result = truncate_text("hello world", 8, suffix="~")
|
|
||||||
assert result.endswith("~")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# parse_boolean
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestParseBoolean:
|
|
||||||
def test_true_bool(self):
|
|
||||||
assert parse_boolean(True) is True
|
|
||||||
|
|
||||||
def test_false_bool(self):
|
|
||||||
assert parse_boolean(False) is False
|
|
||||||
|
|
||||||
def test_int_1(self):
|
|
||||||
assert parse_boolean(1) is True
|
|
||||||
|
|
||||||
def test_int_0(self):
|
|
||||||
assert parse_boolean(0) is False
|
|
||||||
|
|
||||||
def test_string_true(self):
|
|
||||||
for val in ("true", "True", "TRUE", "1", "yes", "on", "enabled"):
|
|
||||||
assert parse_boolean(val) is True, f"Expected True for {val!r}"
|
|
||||||
|
|
||||||
def test_string_false(self):
|
|
||||||
for val in ("false", "False", "0", "no", "off", "disabled"):
|
|
||||||
assert parse_boolean(val) is False, f"Expected False for {val!r}"
|
|
||||||
|
|
||||||
def test_none_returns_false(self):
|
|
||||||
assert parse_boolean(None) is False # type: ignore[arg-type]
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for src/vegas_mode/config.py
|
|
||||||
|
|
||||||
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
|
|
||||||
is_plugin_included, get_ordered_plugins, validate, update.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default construction
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestVegasModeConfigDefaults:
|
|
||||||
def test_default_disabled(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.enabled is False
|
|
||||||
|
|
||||||
def test_default_scroll_speed(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.scroll_speed == 50.0
|
|
||||||
|
|
||||||
def test_default_separator_width(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.separator_width == 32
|
|
||||||
|
|
||||||
def test_default_target_fps(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.target_fps == 125
|
|
||||||
|
|
||||||
def test_default_plugin_order_empty(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert cfg.plugin_order == []
|
|
||||||
|
|
||||||
def test_default_excluded_plugins_empty(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
assert len(cfg.excluded_plugins) == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# from_config
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFromConfig:
|
|
||||||
def _cfg(self, **kwargs) -> dict:
|
|
||||||
return {"display": {"vegas_scroll": kwargs}}
|
|
||||||
|
|
||||||
def test_enabled_flag(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(enabled=True))
|
|
||||||
assert cfg.enabled is True
|
|
||||||
|
|
||||||
def test_scroll_speed(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(scroll_speed=80.0))
|
|
||||||
assert cfg.scroll_speed == 80.0
|
|
||||||
|
|
||||||
def test_separator_width(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(separator_width=16))
|
|
||||||
assert cfg.separator_width == 16
|
|
||||||
|
|
||||||
def test_plugin_order(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(plugin_order=["a", "b", "c"]))
|
|
||||||
assert cfg.plugin_order == ["a", "b", "c"]
|
|
||||||
|
|
||||||
def test_excluded_plugins(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(excluded_plugins=["x", "y"]))
|
|
||||||
assert "x" in cfg.excluded_plugins
|
|
||||||
assert "y" in cfg.excluded_plugins
|
|
||||||
|
|
||||||
def test_target_fps(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(target_fps=60))
|
|
||||||
assert cfg.target_fps == 60
|
|
||||||
|
|
||||||
def test_buffer_ahead(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(buffer_ahead=3))
|
|
||||||
assert cfg.buffer_ahead == 3
|
|
||||||
|
|
||||||
def test_min_max_cycle_duration(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(min_cycle_duration=30, max_cycle_duration=120))
|
|
||||||
assert cfg.min_cycle_duration == 30
|
|
||||||
assert cfg.max_cycle_duration == 120
|
|
||||||
|
|
||||||
def test_defaults_when_missing(self):
|
|
||||||
cfg = VegasModeConfig.from_config({})
|
|
||||||
assert cfg.enabled is False
|
|
||||||
assert cfg.scroll_speed == 50.0
|
|
||||||
|
|
||||||
def test_frame_based_scrolling(self):
|
|
||||||
cfg = VegasModeConfig.from_config(self._cfg(frame_based_scrolling=False))
|
|
||||||
assert cfg.frame_based_scrolling is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# to_dict
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestToDict:
|
|
||||||
def test_roundtrip(self):
|
|
||||||
original = VegasModeConfig(
|
|
||||||
enabled=True,
|
|
||||||
scroll_speed=75.0,
|
|
||||||
separator_width=24,
|
|
||||||
plugin_order=["a", "b"],
|
|
||||||
excluded_plugins={"z"},
|
|
||||||
target_fps=100,
|
|
||||||
)
|
|
||||||
d = original.to_dict()
|
|
||||||
assert d["enabled"] is True
|
|
||||||
assert d["scroll_speed"] == 75.0
|
|
||||||
assert d["separator_width"] == 24
|
|
||||||
assert d["plugin_order"] == ["a", "b"]
|
|
||||||
assert "z" in d["excluded_plugins"]
|
|
||||||
assert d["target_fps"] == 100
|
|
||||||
|
|
||||||
def test_excluded_plugins_is_list(self):
|
|
||||||
cfg = VegasModeConfig(excluded_plugins={"x"})
|
|
||||||
d = cfg.to_dict()
|
|
||||||
assert isinstance(d["excluded_plugins"], list)
|
|
||||||
|
|
||||||
def test_all_keys_present(self):
|
|
||||||
d = VegasModeConfig().to_dict()
|
|
||||||
for key in ("enabled", "scroll_speed", "separator_width", "plugin_order",
|
|
||||||
"excluded_plugins", "target_fps", "buffer_ahead",
|
|
||||||
"frame_based_scrolling", "scroll_delay",
|
|
||||||
"dynamic_duration_enabled", "min_cycle_duration", "max_cycle_duration"):
|
|
||||||
assert key in d
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_frame_interval
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetFrameInterval:
|
|
||||||
def test_125fps(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=125)
|
|
||||||
assert abs(cfg.get_frame_interval() - 1.0 / 125) < 1e-9
|
|
||||||
|
|
||||||
def test_60fps(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=60)
|
|
||||||
assert abs(cfg.get_frame_interval() - 1.0 / 60) < 1e-6
|
|
||||||
|
|
||||||
def test_zero_fps_guarded(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=0)
|
|
||||||
# Should not raise ZeroDivisionError (max(1, fps) guard)
|
|
||||||
result = cfg.get_frame_interval()
|
|
||||||
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
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestGetOrderedPlugins:
|
|
||||||
def test_natural_order_when_no_order_configured(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert result == ["a", "b", "c"]
|
|
||||||
|
|
||||||
def test_explicit_order_followed(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=["c", "a", "b"])
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert result == ["c", "a", "b"]
|
|
||||||
|
|
||||||
def test_unavailable_plugins_skipped(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=["c", "x", "a"])
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert "x" not in result
|
|
||||||
assert result[:2] == ["c", "a"]
|
|
||||||
|
|
||||||
def test_excluded_plugins_removed(self):
|
|
||||||
cfg = VegasModeConfig(excluded_plugins={"b"})
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert "b" not in result
|
|
||||||
|
|
||||||
def test_unordered_available_appended(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=["a"])
|
|
||||||
available = ["a", "b", "c"]
|
|
||||||
result = cfg.get_ordered_plugins(available)
|
|
||||||
assert result[0] == "a"
|
|
||||||
assert "b" in result
|
|
||||||
assert "c" in result
|
|
||||||
|
|
||||||
def test_empty_available(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=["a"])
|
|
||||||
result = cfg.get_ordered_plugins([])
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# validate
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestValidate:
|
|
||||||
def test_valid_config_no_errors(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
def test_scroll_speed_too_low(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=0.5)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("scroll_speed" in e for e in errors)
|
|
||||||
|
|
||||||
def test_scroll_speed_too_high(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=300.0)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("scroll_speed" in e for e in errors)
|
|
||||||
|
|
||||||
def test_separator_width_negative(self):
|
|
||||||
cfg = VegasModeConfig(separator_width=-1)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("separator_width" in e for e in errors)
|
|
||||||
|
|
||||||
def test_separator_width_too_large(self):
|
|
||||||
cfg = VegasModeConfig(separator_width=200)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("separator_width" in e for e in errors)
|
|
||||||
|
|
||||||
def test_target_fps_too_low(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=10)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("target_fps" in e for e in errors)
|
|
||||||
|
|
||||||
def test_target_fps_too_high(self):
|
|
||||||
cfg = VegasModeConfig(target_fps=300)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("target_fps" in e for e in errors)
|
|
||||||
|
|
||||||
def test_buffer_ahead_too_low(self):
|
|
||||||
cfg = VegasModeConfig(buffer_ahead=0)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("buffer_ahead" in e for e in errors)
|
|
||||||
|
|
||||||
def test_buffer_ahead_too_high(self):
|
|
||||||
cfg = VegasModeConfig(buffer_ahead=10)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert any("buffer_ahead" in e for e in errors)
|
|
||||||
|
|
||||||
def test_multiple_errors_returned(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=0.1, target_fps=5)
|
|
||||||
errors = cfg.validate()
|
|
||||||
assert len(errors) >= 2
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# update
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestUpdate:
|
|
||||||
def _wrap(self, **kwargs) -> dict:
|
|
||||||
return {"display": {"vegas_scroll": kwargs}}
|
|
||||||
|
|
||||||
def test_update_enabled(self):
|
|
||||||
cfg = VegasModeConfig(enabled=False)
|
|
||||||
cfg.update(self._wrap(enabled=True))
|
|
||||||
assert cfg.enabled is True
|
|
||||||
|
|
||||||
def test_update_scroll_speed(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
|
||||||
cfg.update(self._wrap(scroll_speed=90.0))
|
|
||||||
assert cfg.scroll_speed == 90.0
|
|
||||||
|
|
||||||
def test_update_separator_width(self):
|
|
||||||
cfg = VegasModeConfig(separator_width=32)
|
|
||||||
cfg.update(self._wrap(separator_width=8))
|
|
||||||
assert cfg.separator_width == 8
|
|
||||||
|
|
||||||
def test_update_plugin_order(self):
|
|
||||||
cfg = VegasModeConfig(plugin_order=[])
|
|
||||||
cfg.update(self._wrap(plugin_order=["x", "y"]))
|
|
||||||
assert cfg.plugin_order == ["x", "y"]
|
|
||||||
|
|
||||||
def test_update_excluded_plugins(self):
|
|
||||||
cfg = VegasModeConfig()
|
|
||||||
cfg.update(self._wrap(excluded_plugins=["skip_me"]))
|
|
||||||
assert "skip_me" in cfg.excluded_plugins
|
|
||||||
|
|
||||||
def test_update_ignores_missing_keys(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
|
||||||
cfg.update(self._wrap(target_fps=80)) # only fps, not speed
|
|
||||||
assert cfg.scroll_speed == 50.0
|
|
||||||
assert cfg.target_fps == 80
|
|
||||||
|
|
||||||
def test_empty_update_no_change(self):
|
|
||||||
cfg = VegasModeConfig(scroll_speed=50.0)
|
|
||||||
cfg.update({})
|
|
||||||
assert cfg.scroll_speed == 50.0
|
|
||||||
@@ -2,11 +2,8 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -25,9 +22,6 @@ from src.plugin_system.state_manager import PluginStateManager
|
|||||||
from src.plugin_system.operation_history import OperationHistory
|
from src.plugin_system.operation_history import OperationHistory
|
||||||
from src.plugin_system.health_monitor import PluginHealthMonitor
|
from src.plugin_system.health_monitor import PluginHealthMonitor
|
||||||
|
|
||||||
_JOURNALCTL = shutil.which('journalctl')
|
|
||||||
_SYSTEMCTL = shutil.which('systemctl')
|
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.urandom(24)
|
app.secret_key = os.urandom(24)
|
||||||
@@ -210,12 +204,24 @@ def serve_plugin_asset(plugin_id, filename):
|
|||||||
# Use send_from_directory to serve the file
|
# Use send_from_directory to serve the file
|
||||||
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
return send_from_directory(str(assets_dir), filename, mimetype=content_type)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
# Log the exception with full traceback server-side
|
||||||
|
import traceback
|
||||||
app.logger.exception('Error serving plugin asset file')
|
app.logger.exception('Error serving plugin asset file')
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
# Return generic error message to client (avoid leaking internal details)
|
||||||
'message': 'Internal server error'
|
# Only include detailed error information when in debug mode
|
||||||
}), 500
|
if app.debug:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e),
|
||||||
|
'traceback': traceback.format_exc()
|
||||||
|
}), 500
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Internal server error'
|
||||||
|
}), 500
|
||||||
|
|
||||||
# Prime psutil CPU measurement once at startup so interval=None returns a real value
|
# Prime psutil CPU measurement once at startup so interval=None returns a real value
|
||||||
try:
|
try:
|
||||||
@@ -336,25 +342,35 @@ def not_found_error(error):
|
|||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
"""Handle 500 errors."""
|
"""Handle 500 errors."""
|
||||||
|
import traceback
|
||||||
|
error_details = traceback.format_exc()
|
||||||
|
|
||||||
|
# Log the error
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('web_interface')
|
logger = logging.getLogger('web_interface')
|
||||||
logger.error("Internal server error", exc_info=True)
|
logger.error(f"Internal server error: {error}", exc_info=True)
|
||||||
|
|
||||||
|
# Return user-friendly error (hide internal details in production)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error_code': 'INTERNAL_ERROR',
|
'error_code': 'INTERNAL_ERROR',
|
||||||
'message': 'An internal error occurred; see logs for details',
|
'message': 'An internal error occurred',
|
||||||
|
'details': error_details if app.debug else None
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
def handle_exception(error):
|
def handle_exception(error):
|
||||||
"""Handle all unhandled exceptions."""
|
"""Handle all unhandled exceptions."""
|
||||||
|
import traceback
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('web_interface')
|
logger = logging.getLogger('web_interface')
|
||||||
logger.error("Unhandled exception", exc_info=True)
|
logger.error(f"Unhandled exception: {error}", exc_info=True)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'error_code': 'UNKNOWN_ERROR',
|
'error_code': 'UNKNOWN_ERROR',
|
||||||
'message': 'An error occurred; see logs for details',
|
'message': str(error) if app.debug else 'An error occurred',
|
||||||
|
'details': traceback.format_exc() if app.debug else None
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
# Captive portal redirect middleware
|
# Captive portal redirect middleware
|
||||||
@@ -419,53 +435,13 @@ def add_security_headers(response):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
class _StreamBroadcaster:
|
# SSE helper function
|
||||||
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients.
|
def sse_response(generator_func):
|
||||||
|
"""Helper to create SSE responses"""
|
||||||
This means N browser tabs share one generator instead of each running their own,
|
def generate():
|
||||||
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
|
for data in generator_func():
|
||||||
"""
|
yield f"data: {json.dumps(data)}\n\n"
|
||||||
|
return Response(generate(), mimetype='text/event-stream')
|
||||||
def __init__(self, generator_factory):
|
|
||||||
self._generator_factory = generator_factory
|
|
||||||
self._clients: set = set()
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._thread: threading.Thread | None = None
|
|
||||||
|
|
||||||
def subscribe(self) -> queue.Queue:
|
|
||||||
q: queue.Queue = queue.Queue(maxsize=5)
|
|
||||||
with self._lock:
|
|
||||||
self._clients.add(q)
|
|
||||||
if not (self._thread and self._thread.is_alive()):
|
|
||||||
self._thread = threading.Thread(target=self._broadcast, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
return q
|
|
||||||
|
|
||||||
def unsubscribe(self, q: queue.Queue) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._clients.discard(q)
|
|
||||||
|
|
||||||
def _broadcast(self):
|
|
||||||
for data in self._generator_factory():
|
|
||||||
with self._lock:
|
|
||||||
if not self._clients:
|
|
||||||
# No subscribers — exit so the thread doesn't spin indefinitely.
|
|
||||||
# subscribe() will restart it when a new client arrives.
|
|
||||||
break
|
|
||||||
for q in self._clients:
|
|
||||||
try:
|
|
||||||
q.put_nowait(data)
|
|
||||||
except queue.Full:
|
|
||||||
# Client is reading too slowly; drop the oldest item and
|
|
||||||
# deliver the latest so the queue never stalls the client.
|
|
||||||
try:
|
|
||||||
q.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
q.put_nowait(data)
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# System status generator for SSE
|
# System status generator for SSE
|
||||||
def system_status_generator():
|
def system_status_generator():
|
||||||
@@ -496,13 +472,12 @@ def system_status_generator():
|
|||||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
||||||
if _SYSTEMCTL:
|
try:
|
||||||
try:
|
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
||||||
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
|
capture_output=True, text=True, timeout=2)
|
||||||
capture_output=True, text=True, timeout=2)
|
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
except (subprocess.SubprocessError, OSError):
|
||||||
except (subprocess.SubprocessError, OSError) as e:
|
pass
|
||||||
app.logger.warning("systemctl status check failed: %s", e)
|
|
||||||
_ledmatrix_service_cache['timestamp'] = now
|
_ledmatrix_service_cache['timestamp'] = now
|
||||||
service_active = _ledmatrix_service_cache['active']
|
service_active = _ledmatrix_service_cache['active']
|
||||||
|
|
||||||
@@ -517,8 +492,7 @@ def system_status_generator():
|
|||||||
}
|
}
|
||||||
yield status
|
yield status
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error("SSE generator error", exc_info=True)
|
yield {'error': str(e)}
|
||||||
yield {'error': 'An error occurred; see server logs'}
|
|
||||||
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
|
time.sleep(10) # Update every 10 seconds (reduced frequency for better performance)
|
||||||
|
|
||||||
# Display preview generator for SSE
|
# Display preview generator for SSE
|
||||||
@@ -581,8 +555,7 @@ def display_preview_generator():
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error("SSE generator error", exc_info=True)
|
yield {'error': str(e)}
|
||||||
yield {'error': 'An error occurred; see server logs'}
|
|
||||||
|
|
||||||
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
|
time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s
|
||||||
|
|
||||||
@@ -594,13 +567,8 @@ def logs_generator():
|
|||||||
# Get recent logs from journalctl (simplified version)
|
# Get recent logs from journalctl (simplified version)
|
||||||
# Note: User should be in systemd-journal group to read logs without sudo
|
# Note: User should be in systemd-journal group to read logs without sudo
|
||||||
try:
|
try:
|
||||||
if not _JOURNALCTL:
|
|
||||||
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
|
|
||||||
time.sleep(60)
|
|
||||||
continue
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'],
|
||||||
'-n', '50', '--no-pager', '--output=short-iso'],
|
|
||||||
capture_output=True, text=True, timeout=5
|
capture_output=True, text=True, timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -616,7 +584,7 @@ def logs_generator():
|
|||||||
# No logs available
|
# No logs available
|
||||||
logs_data = {
|
logs_data = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'logs': 'No logs available from ledmatrix or ledmatrix-web service'
|
'logs': 'No logs available from ledmatrix service'
|
||||||
}
|
}
|
||||||
yield logs_data
|
yield logs_data
|
||||||
else:
|
else:
|
||||||
@@ -630,68 +598,36 @@ def logs_generator():
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
# Timeout - just skip this update
|
# Timeout - just skip this update
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception as e:
|
||||||
app.logger.error("Error running journalctl", exc_info=True)
|
|
||||||
error_data = {
|
error_data = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'logs': 'Error running journalctl; see server logs'
|
'logs': f'Error running journalctl: {str(e)}'
|
||||||
}
|
}
|
||||||
yield error_data
|
yield error_data
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
app.logger.error("Unexpected error in logs generator", exc_info=True)
|
|
||||||
error_data = {
|
error_data = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
'logs': 'Unexpected error in logs generator; see server logs'
|
'logs': f'Unexpected error in logs generator: {str(e)}'
|
||||||
}
|
}
|
||||||
yield error_data
|
yield error_data
|
||||||
|
|
||||||
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
|
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
|
||||||
|
|
||||||
# One broadcaster per stream — shared across all SSE clients
|
|
||||||
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
|
|
||||||
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
|
|
||||||
_logs_broadcaster = _StreamBroadcaster(logs_generator)
|
|
||||||
|
|
||||||
|
|
||||||
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
|
|
||||||
"""Return a streaming SSE response backed by a shared broadcaster."""
|
|
||||||
q = broadcaster.subscribe()
|
|
||||||
|
|
||||||
def generate():
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
data = q.get(timeout=30)
|
|
||||||
yield f"data: {json.dumps(data)}\n\n"
|
|
||||||
except queue.Empty:
|
|
||||||
# Send an SSE comment heartbeat to keep the connection alive
|
|
||||||
# through proxies that close idle connections.
|
|
||||||
yield ": heartbeat\n\n"
|
|
||||||
except GeneratorExit:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
broadcaster.unsubscribe(q)
|
|
||||||
|
|
||||||
return Response(generate(), mimetype='text/event-stream')
|
|
||||||
|
|
||||||
|
|
||||||
# SSE endpoints
|
# SSE endpoints
|
||||||
@app.route('/api/v3/stream/stats')
|
@app.route('/api/v3/stream/stats')
|
||||||
def stream_stats():
|
def stream_stats():
|
||||||
return _sse_stream(_stats_broadcaster)
|
return sse_response(system_status_generator)
|
||||||
|
|
||||||
@app.route('/api/v3/stream/display')
|
@app.route('/api/v3/stream/display')
|
||||||
def stream_display():
|
def stream_display():
|
||||||
return _sse_stream(_display_broadcaster)
|
return sse_response(display_preview_generator)
|
||||||
|
|
||||||
@app.route('/api/v3/stream/logs')
|
@app.route('/api/v3/stream/logs')
|
||||||
def stream_logs():
|
def stream_logs():
|
||||||
return _sse_stream(_logs_broadcaster)
|
return sse_response(logs_generator)
|
||||||
|
|
||||||
# Exempt SSE streams from CSRF and apply a generous rate limit.
|
# Exempt SSE streams from CSRF and add rate limiting
|
||||||
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
|
|
||||||
# tight "20 per minute" default would be exhausted quickly on reconnects.
|
|
||||||
if csrf:
|
if csrf:
|
||||||
csrf.exempt(stream_stats)
|
csrf.exempt(stream_stats)
|
||||||
csrf.exempt(stream_display)
|
csrf.exempt(stream_display)
|
||||||
@@ -699,9 +635,9 @@ if csrf:
|
|||||||
# Note: api_v3 blueprint is exempted above after registration
|
# Note: api_v3 blueprint is exempted above after registration
|
||||||
|
|
||||||
if limiter:
|
if limiter:
|
||||||
limiter.limit("200 per minute")(stream_stats)
|
limiter.limit("20 per minute")(stream_stats)
|
||||||
limiter.limit("200 per minute")(stream_display)
|
limiter.limit("20 per minute")(stream_display)
|
||||||
limiter.limit("200 per minute")(stream_logs)
|
limiter.limit("20 per minute")(stream_logs)
|
||||||
|
|
||||||
# Main route - redirect to v3 interface as default
|
# Main route - redirect to v3 interface as default
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@@ -780,41 +716,6 @@ def _run_startup_reconciliation() -> None:
|
|||||||
"manual 'Reconcile' action to resolve.",
|
"manual 'Reconcile' action to resolve.",
|
||||||
len(result.inconsistencies_manual),
|
len(result.inconsistencies_manual),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write status file so the web UI can surface unresolved issues as a
|
|
||||||
# banner without the user having to read journalctl. Mirrors the
|
|
||||||
# hw_status pattern (/tmp/led_matrix_hw_status.json).
|
|
||||||
import json as _json, tempfile as _tempfile, os as _os
|
|
||||||
_recon_status = {
|
|
||||||
"done": True,
|
|
||||||
"successful": result.reconciliation_successful,
|
|
||||||
"fixed_count": len(result.inconsistencies_fixed),
|
|
||||||
"unresolved": [
|
|
||||||
{
|
|
||||||
"plugin_id": inc.plugin_id,
|
|
||||||
"type": inc.inconsistency_type.value,
|
|
||||||
"description": inc.description,
|
|
||||||
}
|
|
||||||
for inc in result.inconsistencies_manual
|
|
||||||
],
|
|
||||||
}
|
|
||||||
_recon_path = _os.path.join(_tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
|
||||||
_tmp = None
|
|
||||||
try:
|
|
||||||
if not _os.path.islink(_recon_path):
|
|
||||||
_fd, _tmp = _tempfile.mkstemp(dir=_tempfile.gettempdir(), prefix=".led_recon_")
|
|
||||||
with _os.fdopen(_fd, "w") as _f:
|
|
||||||
_json.dump(_recon_status, _f)
|
|
||||||
_os.replace(_tmp, _recon_path)
|
|
||||||
_tmp = None # Rename succeeded; nothing to clean up
|
|
||||||
except (OSError, ValueError, TypeError) as _e:
|
|
||||||
_logger.warning("[Reconciliation] Could not write status file: %s", _e)
|
|
||||||
finally:
|
|
||||||
if _tmp is not None and _os.path.exists(_tmp):
|
|
||||||
try:
|
|
||||||
_os.unlink(_tmp)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@ from flask import Blueprint, render_template, flash
|
|||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
|
||||||
@@ -86,11 +84,10 @@ def load_partial(partial_name):
|
|||||||
elif partial_name == 'operation-history':
|
elif partial_name == 'operation-history':
|
||||||
return _load_operation_history_partial()
|
return _load_operation_history_partial()
|
||||||
else:
|
else:
|
||||||
return "Partial not found", 404
|
return f"Partial '{partial_name}' not found", 404
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial %s", partial_name, exc_info=True)
|
return f"Error loading partial '{partial_name}': {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/partials/plugin-config/<plugin_id>')
|
@pages_v3.route('/partials/plugin-config/<plugin_id>')
|
||||||
@@ -98,62 +95,8 @@ def load_plugin_config_partial(plugin_id):
|
|||||||
"""Load plugin configuration partial via HTMX - server-side rendered form"""
|
"""Load plugin configuration partial via HTMX - server-side rendered form"""
|
||||||
try:
|
try:
|
||||||
return _load_plugin_config_partial(plugin_id)
|
return _load_plugin_config_partial(plugin_id)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
|
||||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
|
|
||||||
def serve_plugin_web_ui(plugin_id, filename):
|
|
||||||
"""Serve a plugin's web_ui/ HTML fragment as a standalone page.
|
|
||||||
|
|
||||||
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
|
||||||
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
|
||||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
|
||||||
# Path traversal guard — plugin_dir must be inside plugins base
|
|
||||||
_plugin_dir.relative_to(_plugins_base)
|
|
||||||
|
|
||||||
web_ui_path = (_plugin_dir / 'web_ui' / filename).resolve()
|
|
||||||
# Second guard — web_ui_path must stay inside web_ui/
|
|
||||||
web_ui_path.relative_to(_plugin_dir / 'web_ui')
|
|
||||||
|
|
||||||
if not web_ui_path.exists():
|
|
||||||
return f'web_ui file not found: {filename}', 404
|
|
||||||
if web_ui_path.suffix.lower() != '.html':
|
|
||||||
return 'Only .html files may be served here', 403
|
|
||||||
|
|
||||||
fragment = web_ui_path.read_text(encoding='utf-8')
|
|
||||||
|
|
||||||
page = (
|
|
||||||
'<!DOCTYPE html>\n'
|
|
||||||
'<html lang="en">\n'
|
|
||||||
'<head>\n'
|
|
||||||
'<meta charset="UTF-8">\n'
|
|
||||||
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
|
||||||
'<script>\n'
|
|
||||||
# Inject plugin context before the fragment runs
|
|
||||||
f' window.PLUGIN_ID = {json.dumps(plugin_id)};\n'
|
|
||||||
'</script>\n'
|
|
||||||
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
|
||||||
'<link rel="stylesheet" '
|
|
||||||
'href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" '
|
|
||||||
'crossorigin="anonymous">\n'
|
|
||||||
'<style>body{margin:0;padding:0;background:#fff;}</style>\n'
|
|
||||||
'</head>\n'
|
|
||||||
'<body>\n'
|
|
||||||
+ fragment +
|
|
||||||
'\n</body>\n</html>'
|
|
||||||
)
|
|
||||||
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
return 'Forbidden', 403
|
|
||||||
except Exception:
|
|
||||||
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
|
||||||
return 'Error serving file', 500
|
|
||||||
|
|
||||||
def _load_overview_partial():
|
def _load_overview_partial():
|
||||||
"""Load overview partial with system stats"""
|
"""Load overview partial with system stats"""
|
||||||
@@ -164,8 +107,7 @@ def _load_overview_partial():
|
|||||||
return render_template('v3/partials/overview.html',
|
return render_template('v3/partials/overview.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_general_partial():
|
def _load_general_partial():
|
||||||
"""Load general settings partial"""
|
"""Load general settings partial"""
|
||||||
@@ -175,8 +117,7 @@ def _load_general_partial():
|
|||||||
return render_template('v3/partials/general.html',
|
return render_template('v3/partials/general.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_display_partial():
|
def _load_display_partial():
|
||||||
"""Load display settings partial"""
|
"""Load display settings partial"""
|
||||||
@@ -186,8 +127,7 @@ def _load_display_partial():
|
|||||||
return render_template('v3/partials/display.html',
|
return render_template('v3/partials/display.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_durations_partial():
|
def _load_durations_partial():
|
||||||
"""Load display durations partial"""
|
"""Load display durations partial"""
|
||||||
@@ -197,8 +137,7 @@ def _load_durations_partial():
|
|||||||
return render_template('v3/partials/durations.html',
|
return render_template('v3/partials/durations.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_schedule_partial():
|
def _load_schedule_partial():
|
||||||
"""Load schedule settings partial"""
|
"""Load schedule settings partial"""
|
||||||
@@ -214,8 +153,7 @@ def _load_schedule_partial():
|
|||||||
dim_schedule_config=dim_schedule_config,
|
dim_schedule_config=dim_schedule_config,
|
||||||
normal_brightness=normal_brightness)
|
normal_brightness=normal_brightness)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
|
|
||||||
def _load_weather_partial():
|
def _load_weather_partial():
|
||||||
@@ -226,8 +164,7 @@ def _load_weather_partial():
|
|||||||
return render_template('v3/partials/weather.html',
|
return render_template('v3/partials/weather.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_stocks_partial():
|
def _load_stocks_partial():
|
||||||
"""Load stocks configuration partial"""
|
"""Load stocks configuration partial"""
|
||||||
@@ -237,8 +174,7 @@ def _load_stocks_partial():
|
|||||||
return render_template('v3/partials/stocks.html',
|
return render_template('v3/partials/stocks.html',
|
||||||
main_config=main_config)
|
main_config=main_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_plugins_partial():
|
def _load_plugins_partial():
|
||||||
"""Load plugins management partial"""
|
"""Load plugins management partial"""
|
||||||
@@ -272,7 +208,7 @@ def _load_plugins_partial():
|
|||||||
plugin_info.update(fresh_manifest)
|
plugin_info.update(fresh_manifest)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If we can't read the fresh manifest, use the cached one
|
# If we can't read the fresh manifest, use the cached one
|
||||||
logger.warning("Could not read fresh manifest for plugin: %s", plugin_id)
|
print(f"Warning: Could not read fresh manifest for {plugin_id}: {e}")
|
||||||
|
|
||||||
# Get enabled status from config (source of truth)
|
# Get enabled status from config (source of truth)
|
||||||
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
# Read from config file first, fall back to plugin instance if config doesn't have the key
|
||||||
@@ -320,13 +256,12 @@ def _load_plugins_partial():
|
|||||||
'branch': branch
|
'branch': branch
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading plugin data", exc_info=True)
|
print(f"Error loading plugin data: {e}")
|
||||||
|
|
||||||
return render_template('v3/partials/plugins.html',
|
return render_template('v3/partials/plugins.html',
|
||||||
plugins=plugins_data)
|
plugins=plugins_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_fonts_partial():
|
def _load_fonts_partial():
|
||||||
"""Load fonts management partial"""
|
"""Load fonts management partial"""
|
||||||
@@ -336,16 +271,14 @@ def _load_fonts_partial():
|
|||||||
return render_template('v3/partials/fonts.html',
|
return render_template('v3/partials/fonts.html',
|
||||||
fonts=fonts_data)
|
fonts=fonts_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_logs_partial():
|
def _load_logs_partial():
|
||||||
"""Load logs viewer partial"""
|
"""Load logs viewer partial"""
|
||||||
try:
|
try:
|
||||||
return render_template('v3/partials/logs.html')
|
return render_template('v3/partials/logs.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_raw_json_partial():
|
def _load_raw_json_partial():
|
||||||
"""Load raw JSON editor partial"""
|
"""Load raw JSON editor partial"""
|
||||||
@@ -362,16 +295,14 @@ def _load_raw_json_partial():
|
|||||||
main_config_path=pages_v3.config_manager.get_config_path(),
|
main_config_path=pages_v3.config_manager.get_config_path(),
|
||||||
secrets_config_path=pages_v3.config_manager.get_secrets_path())
|
secrets_config_path=pages_v3.config_manager.get_secrets_path())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_backup_restore_partial():
|
def _load_backup_restore_partial():
|
||||||
"""Load backup & restore partial."""
|
"""Load backup & restore partial."""
|
||||||
try:
|
try:
|
||||||
return render_template('v3/partials/backup_restore.html')
|
return render_template('v3/partials/backup_restore.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
@pages_v3.route('/setup')
|
@pages_v3.route('/setup')
|
||||||
def captive_setup():
|
def captive_setup():
|
||||||
@@ -383,24 +314,21 @@ def _load_wifi_partial():
|
|||||||
try:
|
try:
|
||||||
return render_template('v3/partials/wifi.html')
|
return render_template('v3/partials/wifi.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_cache_partial():
|
def _load_cache_partial():
|
||||||
"""Load cache management partial"""
|
"""Load cache management partial"""
|
||||||
try:
|
try:
|
||||||
return render_template('v3/partials/cache.html')
|
return render_template('v3/partials/cache.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
def _load_operation_history_partial():
|
def _load_operation_history_partial():
|
||||||
"""Load operation history partial"""
|
"""Load operation history partial"""
|
||||||
try:
|
try:
|
||||||
return render_template('v3/partials/operation_history.html')
|
return render_template('v3/partials/operation_history.html')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading partial", exc_info=True)
|
return f"Error: {str(e)}", 500
|
||||||
return "Error loading partial", 500
|
|
||||||
|
|
||||||
|
|
||||||
def _load_plugin_config_partial(plugin_id):
|
def _load_plugin_config_partial(plugin_id):
|
||||||
@@ -408,11 +336,6 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
Load plugin configuration partial - server-side rendered form.
|
Load plugin configuration partial - server-side rendered form.
|
||||||
This replaces the client-side generateConfigForm() JavaScript.
|
This replaces the client-side generateConfigForm() JavaScript.
|
||||||
"""
|
"""
|
||||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
|
||||||
plugin_id = os.path.basename(plugin_id or '')
|
|
||||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9._\-:]*$', plugin_id):
|
|
||||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not pages_v3.plugin_manager:
|
if not pages_v3.plugin_manager:
|
||||||
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
return '<div class="text-red-500 p-4">Plugin manager not available</div>', 500
|
||||||
@@ -421,14 +344,6 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
if plugin_id.startswith('starlark:'):
|
if plugin_id.startswith('starlark:'):
|
||||||
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
|
return _load_starlark_config_partial(plugin_id[len('starlark:'):])
|
||||||
|
|
||||||
# Resolve and validate all plugin paths against the plugins base directory
|
|
||||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
|
||||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
|
||||||
try:
|
|
||||||
_plugin_dir.relative_to(_plugins_base)
|
|
||||||
except ValueError:
|
|
||||||
return '<div class="text-red-500 p-4">Invalid plugin ID</div>', 400
|
|
||||||
|
|
||||||
# Try to get plugin info first
|
# Try to get plugin info first
|
||||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||||
|
|
||||||
@@ -438,7 +353,7 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
plugin_info = pages_v3.plugin_manager.get_plugin_info(plugin_id)
|
||||||
|
|
||||||
if not plugin_info:
|
if not plugin_info:
|
||||||
return '<div class="text-red-500 p-4">Plugin not found</div>', 404
|
return f'<div class="text-red-500 p-4">Plugin "{escape(plugin_id)}" not found</div>', 404
|
||||||
|
|
||||||
# Get plugin instance (may be None if not loaded)
|
# Get plugin instance (may be None if not loaded)
|
||||||
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
plugin_instance = pages_v3.plugin_manager.get_plugin(plugin_id)
|
||||||
@@ -450,56 +365,59 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
config = full_config.get(plugin_id, {})
|
config = full_config.get(plugin_id, {})
|
||||||
|
|
||||||
# Load uploaded images from metadata file if images field exists in schema
|
# Load uploaded images from metadata file if images field exists in schema
|
||||||
schema_path_temp = _plugin_dir / "config_schema.json"
|
# This ensures uploaded images appear even if config hasn't been saved yet
|
||||||
|
schema_path_temp = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
|
||||||
if schema_path_temp.exists():
|
if schema_path_temp.exists():
|
||||||
try:
|
try:
|
||||||
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
with open(schema_path_temp, 'r', encoding='utf-8') as f:
|
||||||
temp_schema = json.load(f)
|
temp_schema = json.load(f)
|
||||||
|
# Check if schema has an images field with x-widget: file-upload
|
||||||
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
if (temp_schema.get('properties', {}).get('images', {}).get('x-widget') == 'file-upload' or
|
||||||
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
temp_schema.get('properties', {}).get('images', {}).get('x_widget') == 'file-upload'):
|
||||||
_assets_base = (Path(__file__).parent.parent.parent / 'assets' / 'plugins').resolve()
|
# Load metadata file
|
||||||
metadata_file = (_assets_base / plugin_id / 'uploads' / '.metadata.json').resolve()
|
# Get PROJECT_ROOT relative to this file
|
||||||
try:
|
project_root = Path(__file__).parent.parent.parent
|
||||||
metadata_file.relative_to(_assets_base)
|
metadata_file = project_root / 'assets' / 'plugins' / plugin_id / 'uploads' / '.metadata.json'
|
||||||
except ValueError:
|
if metadata_file.exists():
|
||||||
metadata_file = None
|
|
||||||
if metadata_file and metadata_file.exists():
|
|
||||||
try:
|
try:
|
||||||
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
with open(metadata_file, 'r', encoding='utf-8') as mf:
|
||||||
metadata = json.load(mf)
|
metadata = json.load(mf)
|
||||||
|
# Convert metadata dict to list of image objects
|
||||||
images_from_metadata = list(metadata.values())
|
images_from_metadata = list(metadata.values())
|
||||||
|
# Only use metadata images if config doesn't have images or config images is empty
|
||||||
if not config.get('images') or len(config.get('images', [])) == 0:
|
if not config.get('images') or len(config.get('images', [])) == 0:
|
||||||
config['images'] = images_from_metadata
|
config['images'] = images_from_metadata
|
||||||
else:
|
else:
|
||||||
|
# Merge: add metadata images that aren't already in config
|
||||||
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
|
config_image_ids = {img.get('id') for img in config.get('images', []) if img.get('id')}
|
||||||
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
|
new_images = [img for img in images_from_metadata if img.get('id') not in config_image_ids]
|
||||||
if new_images:
|
if new_images:
|
||||||
config['images'] = config.get('images', []) + new_images
|
config['images'] = config.get('images', []) + new_images
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not load plugin upload metadata: %s", e)
|
print(f"Warning: Could not load metadata for {plugin_id}: {e}")
|
||||||
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
||||||
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
||||||
|
|
||||||
# Get plugin schema
|
# Get plugin schema
|
||||||
schema = {}
|
schema = {}
|
||||||
schema_path = _plugin_dir / "config_schema.json"
|
schema_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "config_schema.json"
|
||||||
if schema_path.exists():
|
if schema_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not load schema for plugin: %s", e)
|
print(f"Warning: Could not load schema for {plugin_id}: {e}")
|
||||||
|
|
||||||
# Get web UI actions from plugin manifest
|
# Get web UI actions from plugin manifest
|
||||||
web_ui_actions = []
|
web_ui_actions = []
|
||||||
manifest_path = _plugin_dir / "manifest.json"
|
manifest_path = Path(pages_v3.plugin_manager.plugins_dir) / plugin_id / "manifest.json"
|
||||||
if manifest_path.exists():
|
if manifest_path.exists():
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
web_ui_actions = manifest.get('web_ui_actions', [])
|
web_ui_actions = manifest.get('web_ui_actions', [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not load manifest for plugin: %s", e)
|
print(f"Warning: Could not load manifest for {plugin_id}: {e}")
|
||||||
|
|
||||||
# Mask secret fields before rendering template (fail closed — never leak secrets)
|
# Mask secret fields before rendering template (fail closed — never leak secrets)
|
||||||
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
|
schema_properties = schema.get('properties') if isinstance(schema, dict) else None
|
||||||
@@ -535,24 +453,20 @@ def _load_plugin_config_partial(plugin_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
import traceback
|
||||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
traceback.print_exc()
|
||||||
|
return f'<div class="text-red-500 p-4">Error loading plugin config: {escape(str(e))}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
def _load_starlark_config_partial(app_id):
|
def _load_starlark_config_partial(app_id):
|
||||||
"""Load configuration partial for a Starlark app."""
|
"""Load configuration partial for a Starlark app."""
|
||||||
# Sanitize with basename (CodeQL-recognized sanitizer) then regex-validate format
|
|
||||||
app_id = os.path.basename(app_id or '')
|
|
||||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_\-]*$', app_id):
|
|
||||||
return '<div class="text-red-500 p-4">Invalid app ID</div>', 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
|
starlark_plugin = pages_v3.plugin_manager.get_plugin('starlark-apps') if pages_v3.plugin_manager else None
|
||||||
|
|
||||||
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
|
if starlark_plugin and hasattr(starlark_plugin, 'apps'):
|
||||||
app = starlark_plugin.apps.get(app_id)
|
app = starlark_plugin.apps.get(app_id)
|
||||||
if not app:
|
if not app:
|
||||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||||
return render_template(
|
return render_template(
|
||||||
'v3/partials/starlark_config.html',
|
'v3/partials/starlark_config.html',
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
@@ -568,45 +482,36 @@ def _load_starlark_config_partial(app_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Standalone: read from manifest file
|
# Standalone: read from manifest file
|
||||||
starlark_base = (Path(__file__).resolve().parent.parent.parent / 'starlark-apps').resolve()
|
manifest_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / 'manifest.json'
|
||||||
manifest_file = starlark_base / 'manifest.json'
|
|
||||||
if not manifest_file.exists():
|
if not manifest_file.exists():
|
||||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||||
|
|
||||||
with open(manifest_file, 'r') as f:
|
with open(manifest_file, 'r') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
|
|
||||||
app_data = manifest.get('apps', {}).get(app_id)
|
app_data = manifest.get('apps', {}).get(app_id)
|
||||||
if not app_data:
|
if not app_data:
|
||||||
return '<div class="text-red-500 p-4">Starlark app not found</div>', 404
|
return f'<div class="text-red-500 p-4">Starlark app not found: {app_id}</div>', 404
|
||||||
|
|
||||||
# Load schema from schema.json if it exists — validate path stays within starlark_base
|
# Load schema from schema.json if it exists
|
||||||
schema = None
|
schema = None
|
||||||
schema_file = (starlark_base / app_id / 'schema.json').resolve()
|
schema_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'schema.json'
|
||||||
try:
|
if schema_file.exists():
|
||||||
schema_file.relative_to(starlark_base)
|
|
||||||
except ValueError:
|
|
||||||
schema_file = None
|
|
||||||
if schema_file and schema_file.exists():
|
|
||||||
try:
|
try:
|
||||||
with open(schema_file, 'r') as f:
|
with open(schema_file, 'r') as f:
|
||||||
schema = json.load(f)
|
schema = json.load(f)
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
logger.warning("Could not load starlark schema for app: %s", e)
|
logger.warning(f"[Pages V3] Could not load schema for {app_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
# Load config from config.json if it exists — validate path stays within starlark_base
|
# Load config from config.json if it exists
|
||||||
config = {}
|
config = {}
|
||||||
config_file = (starlark_base / app_id / 'config.json').resolve()
|
config_file = Path(__file__).resolve().parent.parent.parent / 'starlark-apps' / app_id / 'config.json'
|
||||||
try:
|
if config_file.exists():
|
||||||
config_file.relative_to(starlark_base)
|
|
||||||
except ValueError:
|
|
||||||
config_file = None
|
|
||||||
if config_file and config_file.exists():
|
|
||||||
try:
|
try:
|
||||||
with open(config_file, 'r') as f:
|
with open(config_file, 'r') as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
logger.warning("Could not load starlark config for app: %s", e)
|
logger.warning(f"[Pages V3] Could not load config for {app_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'v3/partials/starlark_config.html',
|
'v3/partials/starlark_config.html',
|
||||||
@@ -623,5 +528,5 @@ def _load_starlark_config_partial(app_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("[Pages V3] Error loading starlark config for app", exc_info=True)
|
logger.exception(f"[Pages V3] Error loading starlark config for {app_id}")
|
||||||
return '<div class="text-red-500 p-4">Error loading starlark config; see logs for details</div>', 500
|
return f'<div class="text-red-500 p-4">Error loading starlark config: {str(e)}</div>', 500
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
|
/* global showNotification, updateSystemStats, htmx */
|
||||||
// LED Matrix v3 JavaScript
|
// LED Matrix v3 JavaScript
|
||||||
// Additional helpers for HTMX and Alpine.js integration
|
// Additional helpers for HTMX and Alpine.js integration
|
||||||
|
|
||||||
@@ -51,8 +51,7 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
// SSE reconnection helper
|
||||||
// reattaching the open/error handlers defined in base.html.
|
|
||||||
window.reconnectSSE = function() {
|
window.reconnectSSE = function() {
|
||||||
if (window.statsSource) {
|
if (window.statsSource) {
|
||||||
window.statsSource.close();
|
window.statsSource.close();
|
||||||
@@ -61,18 +60,14 @@ window.reconnectSSE = function() {
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (typeof updateSystemStats === 'function') updateSystemStats(data);
|
if (typeof updateSystemStats === 'function') updateSystemStats(data);
|
||||||
};
|
};
|
||||||
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
|
|
||||||
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.displaySource) {
|
if (window.displaySource) {
|
||||||
window.displaySource.close();
|
window.displaySource.close();
|
||||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||||
window.displaySource.onmessage = function(event) {
|
window.displaySource.onmessage = function() {
|
||||||
const data = JSON.parse(event.data);
|
// Handle display updates
|
||||||
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
|
|
||||||
};
|
};
|
||||||
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,21 @@
|
|||||||
* Handles adding, removing, and editing array items with object properties.
|
* Handles adding, removing, and editing array items with object properties.
|
||||||
* Reads column definitions from the schema's items.properties.
|
* Reads column definitions from the schema's items.properties.
|
||||||
*
|
*
|
||||||
* Supported x-widget hints on item properties:
|
* Usage in config_schema.json:
|
||||||
* date-picker → <input type="date">
|
* "my_array": {
|
||||||
* time-picker → <input type="time">
|
* "type": "array",
|
||||||
* file-upload-single → compact path input + upload button
|
* "x-widget": "array-table",
|
||||||
* (enum values always render as <select>)
|
* "x-columns": ["name", "code", "priority", "enabled"], // optional
|
||||||
*
|
* "items": {
|
||||||
* Non-displayed properties (objects like layout/style) are stored in a hidden
|
* "type": "object",
|
||||||
* cell and editable via the ⚙ row editor modal.
|
* "properties": {
|
||||||
|
* "name": { "type": "string" },
|
||||||
|
* "code": { "type": "string" },
|
||||||
|
* "priority": { "type": "integer", "default": 50 },
|
||||||
|
* "enabled": { "type": "boolean", "default": true }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
*
|
*
|
||||||
* @module ArrayTableWidget
|
* @module ArrayTableWidget
|
||||||
*/
|
*/
|
||||||
@@ -20,16 +27,18 @@
|
|||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// Ensure LEDMatrixWidgets registry exists
|
||||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||||
console.error('[ArrayTableWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
console.error('[ArrayTableWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Widget registration ────────────────────────────────────────────────
|
/**
|
||||||
|
* Register the array-table widget
|
||||||
|
*/
|
||||||
window.LEDMatrixWidgets.register('array-table', {
|
window.LEDMatrixWidgets.register('array-table', {
|
||||||
name: 'Array Table Widget',
|
name: 'Array Table Widget',
|
||||||
version: '2.0.0',
|
version: '1.0.0',
|
||||||
|
|
||||||
render: function(container, config, value, options) {
|
render: function(container, config, value, options) {
|
||||||
console.log('[ArrayTableWidget] Render called (server-side rendered)');
|
console.log('[ArrayTableWidget] Render called (server-side rendered)');
|
||||||
@@ -44,39 +53,24 @@
|
|||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const item = {};
|
const item = {};
|
||||||
|
row.querySelectorAll('input').forEach(input => {
|
||||||
// Collect all named form controls (input + select), skip type=hidden except
|
const name = input.getAttribute('name');
|
||||||
// for boolean hidden sentinels (those end in the field name only, not .enabled).
|
if (!name || name.endsWith('.enabled') || input.type === 'hidden') return;
|
||||||
row.querySelectorAll('input, select').forEach(el => {
|
const match = name.match(/\.\d+\.([^.]+)$/);
|
||||||
const name = el.getAttribute('name');
|
if (match) {
|
||||||
if (!name) return;
|
const propName = match[1];
|
||||||
// Skip hidden inputs that are boolean sentinels (they duplicate checkboxes)
|
if (input.type === 'checkbox') {
|
||||||
if (el.type === 'hidden' && !el.dataset.nestedProp) return;
|
item[propName] = input.checked;
|
||||||
|
} else if (input.type === 'number') {
|
||||||
// Nested advanced props stored in hidden cell
|
item[propName] = input.value ? parseFloat(input.value) : null;
|
||||||
if (el.dataset.nestedProp) {
|
} else {
|
||||||
const propPath = el.dataset.nestedProp;
|
item[propName] = input.value;
|
||||||
setNestedValue(item, propPath, coerceValue(el.value, el.dataset.propType || 'string'));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard display-column props: name matches fullKey.index.propName[.subKey...]
|
|
||||||
const match = name.match(/\.\d+\.(.+)$/);
|
|
||||||
if (!match) return;
|
|
||||||
const propPath = match[1];
|
|
||||||
|
|
||||||
if (el.tagName === 'SELECT') {
|
|
||||||
setNestedValue(item, propPath, el.value);
|
|
||||||
} else if (el.type === 'checkbox') {
|
|
||||||
setNestedValue(item, propPath, el.checked);
|
|
||||||
} else if (el.type === 'number') {
|
|
||||||
setNestedValue(item, propPath, el.value !== '' ? parseFloat(el.value) : null);
|
|
||||||
} else {
|
|
||||||
setNestedValue(item, propPath, el.value);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (Object.keys(item).length > 0) {
|
||||||
if (Object.keys(item).length > 0) items.push(item);
|
items.push(item);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
@@ -87,711 +81,236 @@
|
|||||||
console.error('[ArrayTableWidget] setValue expects an array');
|
console.error('[ArrayTableWidget] setValue expects an array');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options || !options.fullKey || !options.pluginId) {
|
if (!options || !options.fullKey || !options.pluginId) {
|
||||||
throw new Error('ArrayTableWidget.setValue requires options.fullKey and options.pluginId');
|
throw new Error('ArrayTableWidget.setValue requires options.fullKey and options.pluginId');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById(`${fieldId}_tbody`);
|
const tbody = document.getElementById(`${fieldId}_tbody`);
|
||||||
if (!tbody) return;
|
if (!tbody) {
|
||||||
|
console.warn(`[ArrayTableWidget] tbody not found for fieldId: ${fieldId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const row = createArrayTableRow(
|
const row = createArrayTableRow(
|
||||||
fieldId, options.fullKey, index, options.pluginId,
|
fieldId,
|
||||||
item, options.itemProperties || {}, options.displayColumns || [],
|
options.fullKey,
|
||||||
options.fullItemProperties || options.itemProperties || {}
|
index,
|
||||||
|
options.pluginId,
|
||||||
|
item,
|
||||||
|
options.itemProperties || {},
|
||||||
|
options.displayColumns || []
|
||||||
);
|
);
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refresh Add button state after repopulating rows
|
||||||
updateAddButtonState(fieldId);
|
updateAddButtonState(fieldId);
|
||||||
},
|
},
|
||||||
|
|
||||||
handlers: {}
|
handlers: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function setNestedValue(obj, path, value) {
|
|
||||||
const parts = path.split('.');
|
|
||||||
let cur = obj;
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
if (cur[parts[i]] === undefined || typeof cur[parts[i]] !== 'object') {
|
|
||||||
cur[parts[i]] = {};
|
|
||||||
}
|
|
||||||
cur = cur[parts[i]];
|
|
||||||
}
|
|
||||||
cur[parts[parts.length - 1]] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNestedValue(obj, path) {
|
|
||||||
return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
function coerceValue(strVal, typeHint) {
|
|
||||||
if (strVal === '' || strVal === null || strVal === undefined) return null;
|
|
||||||
if (typeHint === 'integer') return parseInt(strVal, 10);
|
|
||||||
if (typeHint === 'number') return parseFloat(strVal);
|
|
||||||
if (typeHint === 'boolean') return strVal === 'true' || strVal === '1';
|
|
||||||
// nullable integer/number: "integer|null"
|
|
||||||
if (typeHint && typeHint.includes('integer')) return strVal !== '' ? parseInt(strVal, 10) : null;
|
|
||||||
if (typeHint && typeHint.includes('number')) return strVal !== '' ? parseFloat(strVal) : null;
|
|
||||||
return strVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Cell rendering ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create one <td> for a display column.
|
* Create a table row element for array item
|
||||||
*/
|
*/
|
||||||
function createCell(fullKey, index, colName, colDef, colValue, pluginId) {
|
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns) {
|
||||||
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
|
item = item || {};
|
||||||
const xWidget = colDef['x-widget'] || colDef['x_widget'];
|
|
||||||
const enumVals = colDef.enum;
|
|
||||||
const inputName = `${fullKey}.${index}.${colName}`;
|
|
||||||
|
|
||||||
const cell = document.createElement('td');
|
|
||||||
cell.className = 'px-3 py-3 whitespace-nowrap';
|
|
||||||
cell.style.verticalAlign = 'middle';
|
|
||||||
|
|
||||||
if (colType === 'boolean') {
|
|
||||||
// Boolean: hidden sentinel + visible checkbox
|
|
||||||
const hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.name = inputName;
|
|
||||||
hidden.value = 'false';
|
|
||||||
cell.appendChild(hidden);
|
|
||||||
|
|
||||||
const cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.name = inputName;
|
|
||||||
cb.checked = Boolean(colValue);
|
|
||||||
cb.value = 'true';
|
|
||||||
cb.className = 'h-4 w-4 text-blue-600';
|
|
||||||
cell.appendChild(cb);
|
|
||||||
|
|
||||||
} else if (colType === 'integer' || colType === 'number') {
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'number';
|
|
||||||
inp.name = inputName;
|
|
||||||
inp.value = colValue !== null && colValue !== undefined ? colValue : '';
|
|
||||||
if (colDef.minimum !== undefined) inp.min = colDef.minimum;
|
|
||||||
if (colDef.maximum !== undefined) inp.max = colDef.maximum;
|
|
||||||
inp.step = colType === 'integer' ? '1' : 'any';
|
|
||||||
inp.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center';
|
|
||||||
if (colDef.description) inp.title = colDef.description;
|
|
||||||
cell.appendChild(inp);
|
|
||||||
|
|
||||||
} else if (Array.isArray(enumVals) && enumVals.length > 0) {
|
|
||||||
// Enum: render <select>
|
|
||||||
cell.style.minWidth = '90px';
|
|
||||||
const sel = document.createElement('select');
|
|
||||||
sel.name = inputName;
|
|
||||||
sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white';
|
|
||||||
enumVals.forEach(opt => {
|
|
||||||
if (opt === null) return;
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = opt;
|
|
||||||
o.textContent = opt;
|
|
||||||
if (String(colValue) === String(opt)) o.selected = true;
|
|
||||||
sel.appendChild(o);
|
|
||||||
});
|
|
||||||
// If current value didn't match any option, set to first
|
|
||||||
if (!sel.value && enumVals.length > 0) sel.value = enumVals[0];
|
|
||||||
cell.appendChild(sel);
|
|
||||||
|
|
||||||
} else if (xWidget === 'date-picker') {
|
|
||||||
cell.style.minWidth = '140px';
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'date';
|
|
||||||
inp.name = inputName;
|
|
||||||
inp.value = colValue || '';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
inp.style.minWidth = '128px';
|
|
||||||
if (colDef.description) inp.title = colDef.description;
|
|
||||||
cell.appendChild(inp);
|
|
||||||
|
|
||||||
} else if (xWidget === 'time-picker') {
|
|
||||||
cell.style.minWidth = '115px';
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'time';
|
|
||||||
inp.name = inputName;
|
|
||||||
inp.value = colValue || '00:00';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
inp.style.minWidth = '100px';
|
|
||||||
cell.appendChild(inp);
|
|
||||||
|
|
||||||
} else if (xWidget === 'file-upload-single') {
|
|
||||||
// Compact: text input (stores path) + upload button
|
|
||||||
cell.style.minWidth = '200px';
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.className = 'flex items-center gap-1';
|
|
||||||
|
|
||||||
const pathInput = document.createElement('input');
|
|
||||||
pathInput.type = 'text';
|
|
||||||
pathInput.name = inputName;
|
|
||||||
pathInput.id = `${fullKey}_${index}_${colName}`.replace(/\./g,'_');
|
|
||||||
pathInput.value = colValue || '';
|
|
||||||
pathInput.className = 'block px-1 py-1 border border-gray-300 rounded text-xs flex-1';
|
|
||||||
pathInput.style.minWidth = '100px';
|
|
||||||
pathInput.placeholder = 'path…';
|
|
||||||
|
|
||||||
const preview = document.createElement('img');
|
|
||||||
preview.className = 'w-6 h-6 object-cover rounded flex-shrink-0';
|
|
||||||
preview.style.display = colValue ? 'inline' : 'none';
|
|
||||||
if (colValue) { preview.src = '/' + colValue; preview.onerror = () => { preview.style.display = 'none'; }; }
|
|
||||||
|
|
||||||
const labelEl = document.createElement('label');
|
|
||||||
labelEl.className = 'cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100';
|
|
||||||
labelEl.title = 'Upload image';
|
|
||||||
labelEl.innerHTML = '<i class="fas fa-upload"></i>';
|
|
||||||
|
|
||||||
const fileInput = document.createElement('input');
|
|
||||||
fileInput.type = 'file';
|
|
||||||
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
|
|
||||||
fileInput.style.display = 'none';
|
|
||||||
fileInput.dataset.pluginId = pluginId;
|
|
||||||
fileInput.dataset.targetInput = pathInput.id;
|
|
||||||
fileInput.dataset.previewImg = preview.id || '';
|
|
||||||
fileInput.onchange = function(e) {
|
|
||||||
window.handleArrayTableImageUpload(e, pathInput, preview, pluginId);
|
|
||||||
};
|
|
||||||
labelEl.appendChild(fileInput);
|
|
||||||
|
|
||||||
wrap.appendChild(preview);
|
|
||||||
wrap.appendChild(pathInput);
|
|
||||||
wrap.appendChild(labelEl);
|
|
||||||
cell.appendChild(wrap);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Default: text input
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'text';
|
|
||||||
inp.name = inputName;
|
|
||||||
inp.value = colValue !== null && colValue !== undefined ? colValue : '';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
if (colDef.description) inp.placeholder = colDef.description;
|
|
||||||
if (colDef.pattern) inp.pattern = colDef.pattern;
|
|
||||||
if (colDef.minLength) inp.minLength = colDef.minLength;
|
|
||||||
if (colDef.maxLength) inp.maxLength = colDef.maxLength;
|
|
||||||
cell.appendChild(inp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a hidden <td> holding flat hidden inputs for non-displayed properties
|
|
||||||
* (including nested objects like layout/style).
|
|
||||||
*/
|
|
||||||
function createAdvancedCell(fullKey, index, nonDisplayedProps, item) {
|
|
||||||
const cell = document.createElement('td');
|
|
||||||
cell.style.display = 'none';
|
|
||||||
cell.className = 'array-table-advanced-data';
|
|
||||||
cell.dataset.propSchema = JSON.stringify(nonDisplayedProps);
|
|
||||||
|
|
||||||
Object.entries(nonDisplayedProps).forEach(([propName, propSchema]) => {
|
|
||||||
const propType = Array.isArray(propSchema.type)
|
|
||||||
? propSchema.type.find(t => t !== 'null') || 'string'
|
|
||||||
: (propSchema.type || 'string');
|
|
||||||
|
|
||||||
if (propType === 'object' && propSchema.properties) {
|
|
||||||
const nestedVal = (item && item[propName]) || {};
|
|
||||||
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
|
|
||||||
const subType = Array.isArray(subSchema.type)
|
|
||||||
? subSchema.type.find(t => t !== 'null') || 'string'
|
|
||||||
: (subSchema.type || 'string');
|
|
||||||
const defaultVal = subSchema.default !== undefined ? subSchema.default : null;
|
|
||||||
const currentVal = nestedVal[subName] !== undefined ? nestedVal[subName] : defaultVal;
|
|
||||||
|
|
||||||
const hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.name = `${fullKey}.${index}.${propName}.${subName}`;
|
|
||||||
hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : '';
|
|
||||||
hidden.dataset.nestedProp = `${propName}.${subName}`;
|
|
||||||
hidden.dataset.propType = subType;
|
|
||||||
hidden.dataset.propSchema = JSON.stringify(subSchema);
|
|
||||||
cell.appendChild(hidden);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const defaultVal = propSchema.default !== undefined ? propSchema.default : null;
|
|
||||||
const currentVal = item && item[propName] !== undefined ? item[propName] : defaultVal;
|
|
||||||
|
|
||||||
const hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.name = `${fullKey}.${index}.${propName}`;
|
|
||||||
hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : '';
|
|
||||||
hidden.dataset.nestedProp = propName;
|
|
||||||
hidden.dataset.propType = propType;
|
|
||||||
hidden.dataset.propSchema = JSON.stringify(propSchema);
|
|
||||||
cell.appendChild(hidden);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Row creation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns, fullItemProperties) {
|
|
||||||
item = item || {};
|
|
||||||
fullItemProperties = fullItemProperties || itemProperties;
|
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.className = 'array-table-row';
|
row.className = 'array-table-row';
|
||||||
row.setAttribute('data-index', index);
|
row.setAttribute('data-index', index);
|
||||||
|
|
||||||
// Visible column cells
|
|
||||||
displayColumns.forEach(colName => {
|
displayColumns.forEach(colName => {
|
||||||
const colDef = itemProperties[colName] || {};
|
const colDef = itemProperties[colName] || {};
|
||||||
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
|
const colType = colDef.type || 'string';
|
||||||
const colDefault = colDef.default !== undefined ? colDef.default
|
const colDefault = colDef.default !== undefined ? colDef.default : (colType === 'boolean' ? false : '');
|
||||||
: (colType === 'boolean' ? false : colType === 'time-picker' ? '00:00' : '');
|
|
||||||
const colValue = item[colName] !== undefined ? item[colName] : colDefault;
|
const colValue = item[colName] !== undefined ? item[colName] : colDefault;
|
||||||
row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine non-displayed properties (these go into the advanced cell + edit modal)
|
const cell = document.createElement('td');
|
||||||
const nonDisplayed = {};
|
cell.className = 'px-4 py-3 whitespace-nowrap';
|
||||||
Object.keys(fullItemProperties).forEach(k => {
|
|
||||||
if (!displayColumns.includes(k) && k !== 'id') {
|
if (colType === 'boolean') {
|
||||||
nonDisplayed[k] = fullItemProperties[k];
|
const hiddenInput = document.createElement('input');
|
||||||
|
hiddenInput.type = 'hidden';
|
||||||
|
hiddenInput.name = `${fullKey}.${index}.${colName}`;
|
||||||
|
hiddenInput.value = 'false';
|
||||||
|
cell.appendChild(hiddenInput);
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.name = `${fullKey}.${index}.${colName}`;
|
||||||
|
checkbox.checked = Boolean(colValue);
|
||||||
|
checkbox.value = 'true';
|
||||||
|
checkbox.className = 'h-4 w-4 text-blue-600';
|
||||||
|
cell.appendChild(checkbox);
|
||||||
|
} else if (colType === 'integer' || colType === 'number') {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.name = `${fullKey}.${index}.${colName}`;
|
||||||
|
input.value = colValue !== null && colValue !== undefined ? colValue : '';
|
||||||
|
if (colDef.minimum !== undefined) input.min = colDef.minimum;
|
||||||
|
if (colDef.maximum !== undefined) input.max = colDef.maximum;
|
||||||
|
input.step = colType === 'integer' ? '1' : 'any';
|
||||||
|
input.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center';
|
||||||
|
if (colDef.description) input.title = colDef.description;
|
||||||
|
cell.appendChild(input);
|
||||||
|
} else {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.name = `${fullKey}.${index}.${colName}`;
|
||||||
|
input.value = colValue !== null && colValue !== undefined ? colValue : '';
|
||||||
|
input.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
||||||
|
if (colDef.description) input.placeholder = colDef.description;
|
||||||
|
if (colDef.pattern) input.pattern = colDef.pattern;
|
||||||
|
if (colDef.minLength) input.minLength = colDef.minLength;
|
||||||
|
if (colDef.maxLength) input.maxLength = colDef.maxLength;
|
||||||
|
cell.appendChild(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
row.appendChild(cell);
|
||||||
});
|
});
|
||||||
const hasAdvanced = Object.keys(nonDisplayed).length > 0;
|
|
||||||
|
|
||||||
// Actions cell
|
// Actions cell
|
||||||
const actionsCell = document.createElement('td');
|
const actionsCell = document.createElement('td');
|
||||||
actionsCell.className = 'px-3 py-3 whitespace-nowrap text-center';
|
actionsCell.className = 'px-4 py-3 whitespace-nowrap text-center';
|
||||||
actionsCell.style.minWidth = '90px';
|
const removeButton = document.createElement('button');
|
||||||
actionsCell.style.verticalAlign = 'middle';
|
removeButton.type = 'button';
|
||||||
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||||
const removeBtn = document.createElement('button');
|
removeButton.onclick = function() { window.removeArrayTableRow(this); };
|
||||||
removeBtn.type = 'button';
|
const removeIcon = document.createElement('i');
|
||||||
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
removeIcon.className = 'fas fa-trash';
|
||||||
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
|
removeButton.appendChild(removeIcon);
|
||||||
removeBtn.innerHTML = '<i class="fas fa-trash"></i>';
|
actionsCell.appendChild(removeButton);
|
||||||
actionsCell.appendChild(removeBtn);
|
|
||||||
|
|
||||||
if (hasAdvanced) {
|
|
||||||
const editBtn = document.createElement('button');
|
|
||||||
editBtn.type = 'button';
|
|
||||||
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
|
|
||||||
editBtn.title = 'Edit advanced properties (layout, style…)';
|
|
||||||
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
|
|
||||||
editBtn.innerHTML = '<i class="fas fa-sliders-h"></i>';
|
|
||||||
actionsCell.appendChild(editBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.appendChild(actionsCell);
|
row.appendChild(actionsCell);
|
||||||
|
|
||||||
// Hidden advanced data cell
|
|
||||||
if (hasAdvanced) {
|
|
||||||
row.appendChild(createAdvancedCell(fullKey, index, nonDisplayed, item));
|
|
||||||
}
|
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Row editor modal ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window.openArrayTableRowEditor = function(button) {
|
|
||||||
const row = button.closest('tr');
|
|
||||||
const advancedCell = row.querySelector('.array-table-advanced-data');
|
|
||||||
if (!advancedCell) return;
|
|
||||||
|
|
||||||
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
|
||||||
const tbody = row.closest('tbody');
|
|
||||||
const fieldId = tbody ? tbody.id.replace('_tbody', '') : '';
|
|
||||||
const rowIndex = parseInt(row.dataset.index, 10);
|
|
||||||
|
|
||||||
// Close any existing modal
|
|
||||||
const existing = document.getElementById('array-row-editor-modal');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.id = 'array-row-editor-modal';
|
|
||||||
// Use inline styles for position/dimensions — inset-0 may be purged from the CSS bundle
|
|
||||||
// since it only appears in JS-generated markup, not in scanned templates.
|
|
||||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,0.5);';
|
|
||||||
overlay.onclick = function(e) { if (e.target === overlay) window.closeArrayTableRowEditor(); };
|
|
||||||
|
|
||||||
const dialog = document.createElement('div');
|
|
||||||
dialog.className = 'bg-white rounded-lg shadow-xl max-w-lg w-full max-h-screen overflow-y-auto';
|
|
||||||
|
|
||||||
// Header
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
|
||||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
|
||||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.className = 'px-5 py-4 space-y-4';
|
|
||||||
|
|
||||||
// Render a field for each advanced property
|
|
||||||
Object.entries(schema).forEach(([propName, propSchema]) => {
|
|
||||||
const propType = Array.isArray(propSchema.type)
|
|
||||||
? propSchema.type.find(t => t !== 'null') || 'string'
|
|
||||||
: (propSchema.type || 'string');
|
|
||||||
const label = propSchema.title || propName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
const desc = propSchema.description || '';
|
|
||||||
|
|
||||||
if (propType === 'object' && propSchema.properties) {
|
|
||||||
// Section for nested object
|
|
||||||
const section = document.createElement('div');
|
|
||||||
section.className = 'border border-gray-200 rounded-lg p-3';
|
|
||||||
section.innerHTML = `<h4 class="text-sm font-medium text-gray-700 mb-3">${escapeHtml(label)}</h4>`;
|
|
||||||
|
|
||||||
const grid = document.createElement('div');
|
|
||||||
grid.className = 'grid grid-cols-2 gap-3';
|
|
||||||
|
|
||||||
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
|
|
||||||
const subType = Array.isArray(subSchema.type) ? subSchema.type.find(t => t !== 'null') || 'string' : (subSchema.type || 'string');
|
|
||||||
const subLabel = subSchema.title || subName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
const subDesc = subSchema.description || '';
|
|
||||||
const nestedPath = `${propName}.${subName}`;
|
|
||||||
|
|
||||||
// Read current value from hidden input
|
|
||||||
const hiddenInput = advancedCell.querySelector(`[data-nested-prop="${nestedPath}"]`);
|
|
||||||
const currentVal = hiddenInput ? hiddenInput.value : (subSchema.default !== undefined ? subSchema.default : '');
|
|
||||||
|
|
||||||
const fieldDiv = document.createElement('div');
|
|
||||||
fieldDiv.innerHTML = `<label class="block text-xs font-medium text-gray-600 mb-1" title="${escapeHtml(subDesc)}">${escapeHtml(subLabel)}</label>`;
|
|
||||||
fieldDiv.appendChild(buildModalInput(nestedPath, subSchema, subType, currentVal));
|
|
||||||
grid.appendChild(fieldDiv);
|
|
||||||
});
|
|
||||||
|
|
||||||
section.appendChild(grid);
|
|
||||||
body.appendChild(section);
|
|
||||||
} else {
|
|
||||||
// Flat property
|
|
||||||
const hiddenInput = advancedCell.querySelector(`[data-nested-prop="${propName}"]`);
|
|
||||||
const currentVal = hiddenInput ? hiddenInput.value : (propSchema.default !== undefined ? propSchema.default : '');
|
|
||||||
|
|
||||||
const fieldDiv = document.createElement('div');
|
|
||||||
fieldDiv.innerHTML = `<label class="block text-sm font-medium text-gray-700 mb-1" title="${escapeHtml(desc)}">${escapeHtml(label)}</label>`;
|
|
||||||
fieldDiv.appendChild(buildModalInput(propName, propSchema, propType, currentVal));
|
|
||||||
body.appendChild(fieldDiv);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dialog.appendChild(body);
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
const footer = document.createElement('div');
|
|
||||||
footer.className = 'flex justify-end gap-3 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg';
|
|
||||||
footer.innerHTML = `
|
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
|
||||||
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
|
||||||
<button type="button" id="array-row-editor-save"
|
|
||||||
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`;
|
|
||||||
|
|
||||||
// Save handler
|
|
||||||
footer.querySelector('#array-row-editor-save').onclick = function() {
|
|
||||||
body.querySelectorAll('[data-modal-prop]').forEach(el => {
|
|
||||||
const propPath = el.dataset.modalProp;
|
|
||||||
const targetInput = advancedCell.querySelector(`[data-nested-prop="${propPath}"]`);
|
|
||||||
if (!targetInput) return;
|
|
||||||
if (el.type === 'checkbox') {
|
|
||||||
targetInput.value = el.checked ? 'true' : 'false';
|
|
||||||
} else {
|
|
||||||
targetInput.value = el.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.closeArrayTableRowEditor();
|
|
||||||
};
|
|
||||||
|
|
||||||
dialog.appendChild(footer);
|
|
||||||
overlay.appendChild(dialog);
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.closeArrayTableRowEditor = function() {
|
|
||||||
const modal = document.getElementById('array-row-editor-modal');
|
|
||||||
if (modal) modal.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a single form control for the row editor modal.
|
* Update the Add button's disabled state based on current row count
|
||||||
|
* @param {string} fieldId - Field ID to find the tbody and button
|
||||||
*/
|
*/
|
||||||
function buildModalInput(propPath, schema, propType, currentVal) {
|
|
||||||
const xWidget = schema['x-widget'] || schema['x_widget'];
|
|
||||||
const enumVals = schema.enum;
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
|
|
||||||
if (propType === 'boolean') {
|
|
||||||
const cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.className = 'h-4 w-4 text-blue-600';
|
|
||||||
cb.checked = currentVal === 'true' || currentVal === true || currentVal === 1;
|
|
||||||
cb.dataset.modalProp = propPath;
|
|
||||||
wrap.appendChild(cb);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Array[3] with x-widget color-picker → R/G/B row
|
|
||||||
if ((propType === 'array' || xWidget === 'color-picker') &&
|
|
||||||
(schema.minItems === 3 || schema.maxItems === 3 || xWidget === 'color-picker')) {
|
|
||||||
const parts = currentVal ? String(currentVal).split(',').map(s => s.trim()) : ['', '', ''];
|
|
||||||
const rVal = parts[0] || '';
|
|
||||||
const gVal = parts[1] || '';
|
|
||||||
const bVal = parts[2] || '';
|
|
||||||
|
|
||||||
// Hex color picker for visual selection
|
|
||||||
const hexVal = (rVal && gVal && bVal)
|
|
||||||
? '#' + [rVal, gVal, bVal].map(n => parseInt(n, 10).toString(16).padStart(2, '0')).join('')
|
|
||||||
: '#ffffff';
|
|
||||||
|
|
||||||
const colorRow = document.createElement('div');
|
|
||||||
colorRow.className = 'flex items-center gap-2 flex-wrap';
|
|
||||||
|
|
||||||
const colorPick = document.createElement('input');
|
|
||||||
colorPick.type = 'color';
|
|
||||||
colorPick.value = hexVal;
|
|
||||||
colorPick.className = 'h-8 w-10 cursor-pointer rounded border';
|
|
||||||
colorRow.appendChild(colorPick);
|
|
||||||
|
|
||||||
['R', 'G', 'B'].forEach((ch, i) => {
|
|
||||||
const lbl = document.createElement('label');
|
|
||||||
lbl.className = 'text-xs text-gray-500';
|
|
||||||
lbl.textContent = ch;
|
|
||||||
const numInp = document.createElement('input');
|
|
||||||
numInp.type = 'number';
|
|
||||||
numInp.min = '0';
|
|
||||||
numInp.max = '255';
|
|
||||||
numInp.step = '1';
|
|
||||||
numInp.value = [rVal, gVal, bVal][i];
|
|
||||||
numInp.className = 'w-14 px-1 py-1 border border-gray-300 rounded text-sm text-center';
|
|
||||||
numInp.dataset.colorChannel = i;
|
|
||||||
colorRow.appendChild(lbl);
|
|
||||||
colorRow.appendChild(numInp);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hidden aggregate input that the save handler reads
|
|
||||||
const agg = document.createElement('input');
|
|
||||||
agg.type = 'hidden';
|
|
||||||
agg.value = `${rVal},${gVal},${bVal}`;
|
|
||||||
agg.dataset.modalProp = propPath;
|
|
||||||
colorRow.appendChild(agg);
|
|
||||||
|
|
||||||
// Sync: color picker → R/G/B numbers + agg
|
|
||||||
colorPick.oninput = function() {
|
|
||||||
const hex = colorPick.value;
|
|
||||||
const r = parseInt(hex.slice(1,3), 16);
|
|
||||||
const g = parseInt(hex.slice(3,5), 16);
|
|
||||||
const b = parseInt(hex.slice(5,7), 16);
|
|
||||||
const nums = colorRow.querySelectorAll('input[data-color-channel]');
|
|
||||||
if (nums[0]) nums[0].value = r;
|
|
||||||
if (nums[1]) nums[1].value = g;
|
|
||||||
if (nums[2]) nums[2].value = b;
|
|
||||||
agg.value = `${r},${g},${b}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sync: R/G/B numbers → color picker + agg
|
|
||||||
colorRow.querySelectorAll('input[data-color-channel]').forEach(inp => {
|
|
||||||
inp.oninput = function() {
|
|
||||||
const nums = colorRow.querySelectorAll('input[data-color-channel]');
|
|
||||||
const r = parseInt(nums[0] ? nums[0].value : 0, 10) || 0;
|
|
||||||
const g = parseInt(nums[1] ? nums[1].value : 0, 10) || 0;
|
|
||||||
const b = parseInt(nums[2] ? nums[2].value : 0, 10) || 0;
|
|
||||||
colorPick.value = '#' + [r,g,b].map(n => n.toString(16).padStart(2,'0')).join('');
|
|
||||||
agg.value = `${r},${g},${b}`;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
wrap.appendChild(colorRow);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(enumVals) && enumVals.length > 0) {
|
|
||||||
const sel = document.createElement('select');
|
|
||||||
sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white';
|
|
||||||
sel.dataset.modalProp = propPath;
|
|
||||||
enumVals.forEach(opt => {
|
|
||||||
if (opt === null) return;
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = opt; o.textContent = opt;
|
|
||||||
if (String(currentVal) === String(opt)) o.selected = true;
|
|
||||||
sel.appendChild(o);
|
|
||||||
});
|
|
||||||
wrap.appendChild(sel);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xWidget === 'date-picker') {
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'date';
|
|
||||||
inp.value = currentVal || '';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
inp.dataset.modalProp = propPath;
|
|
||||||
wrap.appendChild(inp);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xWidget === 'time-picker') {
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'time';
|
|
||||||
inp.value = currentVal || '00:00';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
inp.dataset.modalProp = propPath;
|
|
||||||
wrap.appendChild(inp);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (propType === 'integer' || propType === 'number') {
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'number';
|
|
||||||
inp.value = currentVal !== '' && currentVal !== null ? currentVal : '';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
inp.dataset.modalProp = propPath;
|
|
||||||
if (schema.minimum !== undefined) inp.min = schema.minimum;
|
|
||||||
if (schema.maximum !== undefined) inp.max = schema.maximum;
|
|
||||||
inp.step = propType === 'integer' ? '1' : 'any';
|
|
||||||
if (schema.description) inp.placeholder = schema.description;
|
|
||||||
wrap.appendChild(inp);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: text
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'text';
|
|
||||||
inp.value = currentVal || '';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
inp.dataset.modalProp = propPath;
|
|
||||||
if (schema.description) inp.placeholder = schema.description;
|
|
||||||
wrap.appendChild(inp);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = String(str || '');
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── In-cell image upload ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from file-upload-single cells inside array-table rows.
|
|
||||||
* Uploads the selected file and updates the path text input.
|
|
||||||
*/
|
|
||||||
window.handleArrayTableImageUpload = async function(event, pathInput, previewImg, pluginId) {
|
|
||||||
const file = event.target.files && event.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const notifyFn = window.showNotification || console.log;
|
|
||||||
const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
|
||||||
if (!allowed.includes(file.type)) {
|
|
||||||
notifyFn(`File type "${file.type}" not allowed`, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
notifyFn('File exceeds 5MB limit', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('plugin_id', pluginId);
|
|
||||||
formData.append('files', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/v3/plugins/assets/upload', { method: 'POST', body: formData });
|
|
||||||
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.status === 'success' && data.uploaded_files && data.uploaded_files[0]) {
|
|
||||||
const path = data.uploaded_files[0].path;
|
|
||||||
pathInput.value = path;
|
|
||||||
if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; }
|
|
||||||
notifyFn('Image uploaded', 'success');
|
|
||||||
} else {
|
|
||||||
throw new Error(data.message || 'Upload failed');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
notifyFn('Upload error: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
event.target.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Button helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function updateAddButtonState(fieldId) {
|
function updateAddButtonState(fieldId) {
|
||||||
const tbody = document.getElementById(fieldId + '_tbody');
|
const tbody = document.getElementById(fieldId + '_tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
// Find the add button by looking for the button with matching data-field-id
|
||||||
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
|
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
|
||||||
if (!tbody || !addButton) return;
|
if (!addButton) return;
|
||||||
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
|
|
||||||
const currentRows = tbody.querySelectorAll('.array-table-row').length;
|
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
|
||||||
const isAtMax = currentRows >= maxItems;
|
const currentRows = tbody.querySelectorAll('.array-table-row');
|
||||||
addButton.disabled = isAtMax;
|
const isAtMax = currentRows.length >= maxItems;
|
||||||
|
|
||||||
|
addButton.disabled = isAtMax;
|
||||||
addButton.style.opacity = isAtMax ? '0.5' : '';
|
addButton.style.opacity = isAtMax ? '0.5' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose for external use if needed
|
||||||
window.updateArrayTableAddButtonState = updateAddButtonState;
|
window.updateArrayTableAddButtonState = updateAddButtonState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new row to the array table
|
||||||
|
* @param {HTMLElement} button - The button element with data attributes
|
||||||
|
*/
|
||||||
window.addArrayTableRow = function(button) {
|
window.addArrayTableRow = function(button) {
|
||||||
const fieldId = button.getAttribute('data-field-id');
|
const fieldId = button.getAttribute('data-field-id');
|
||||||
const fullKey = button.getAttribute('data-full-key');
|
const fullKey = button.getAttribute('data-full-key');
|
||||||
const maxItems = parseInt(button.getAttribute('data-max-items'), 10);
|
const maxItems = parseInt(button.getAttribute('data-max-items'), 10);
|
||||||
const pluginId = button.getAttribute('data-plugin-id');
|
const pluginId = button.getAttribute('data-plugin-id');
|
||||||
|
|
||||||
let itemProperties = {};
|
// Parse JSON with fallback on error
|
||||||
let displayColumns = [];
|
let itemProperties = {};
|
||||||
let fullItemProperties = {};
|
let displayColumns = [];
|
||||||
|
const rawItemProps = button.getAttribute('data-item-properties') || '{}';
|
||||||
|
const rawDisplayCols = button.getAttribute('data-display-columns') || '[]';
|
||||||
|
|
||||||
try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(e) {}
|
try {
|
||||||
try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(e) {}
|
itemProperties = JSON.parse(rawItemProps);
|
||||||
try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(e) { fullItemProperties = itemProperties; }
|
} catch (e) {
|
||||||
|
console.error('[ArrayTableWidget] Failed to parse data-item-properties:', rawItemProps, e);
|
||||||
|
itemProperties = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
displayColumns = JSON.parse(rawDisplayCols);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ArrayTableWidget] Failed to parse data-display-columns:', rawDisplayCols, e);
|
||||||
|
displayColumns = [];
|
||||||
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById(fieldId + '_tbody');
|
const tbody = document.getElementById(fieldId + '_tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
const currentRows = tbody.querySelectorAll('.array-table-row').length;
|
const currentRows = tbody.querySelectorAll('.array-table-row');
|
||||||
if (currentRows >= maxItems) {
|
if (currentRows.length >= maxItems) {
|
||||||
(window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error');
|
const notifyFn = window.showNotification || alert;
|
||||||
|
notifyFn(`Maximum ${maxItems} items allowed`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndex = currentRows;
|
const newIndex = currentRows.length;
|
||||||
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns, fullItemProperties);
|
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns);
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
|
|
||||||
|
// Update button state after adding
|
||||||
updateAddButtonState(fieldId);
|
updateAddButtonState(fieldId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a row from the array table
|
||||||
|
* @param {HTMLElement} button - The remove button element
|
||||||
|
*/
|
||||||
window.removeArrayTableRow = function(button) {
|
window.removeArrayTableRow = function(button) {
|
||||||
const row = button.closest('tr');
|
const row = button.closest('tr');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
if (!confirm('Remove this item?')) return;
|
|
||||||
|
|
||||||
const tbody = row.parentElement;
|
if (confirm('Remove this item?')) {
|
||||||
if (!tbody) return;
|
const tbody = row.parentElement;
|
||||||
const fieldId = tbody.id.replace('_tbody', '');
|
if (!tbody) return;
|
||||||
row.remove();
|
|
||||||
|
|
||||||
// Re-index remaining rows
|
// Get fieldId from tbody id (format: {fieldId}_tbody)
|
||||||
tbody.querySelectorAll('.array-table-row').forEach((r, index) => {
|
const fieldId = tbody.id.replace('_tbody', '');
|
||||||
r.setAttribute('data-index', index);
|
|
||||||
r.querySelectorAll('input, select').forEach(el => {
|
row.remove();
|
||||||
const name = el.getAttribute('name');
|
|
||||||
if (name) el.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.'));
|
// Re-index remaining rows
|
||||||
// Also update data-nested-prop-based inputs (they don't have regular names needing re-index)
|
const rows = tbody.querySelectorAll('.array-table-row');
|
||||||
|
rows.forEach(function(r, index) {
|
||||||
|
r.setAttribute('data-index', index);
|
||||||
|
r.querySelectorAll('input').forEach(function(input) {
|
||||||
|
const name = input.getAttribute('name');
|
||||||
|
if (name) {
|
||||||
|
input.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
updateAddButtonState(fieldId);
|
// Update button state after removing
|
||||||
|
updateAddButtonState(fieldId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all array table add buttons on page load
|
||||||
|
*/
|
||||||
function initArrayTableButtons() {
|
function initArrayTableButtons() {
|
||||||
document.querySelectorAll('button[data-field-id][data-max-items]').forEach(button => {
|
const addButtons = document.querySelectorAll('button[data-field-id][data-max-items]');
|
||||||
updateAddButtonState(button.getAttribute('data-field-id'));
|
addButtons.forEach(function(button) {
|
||||||
|
const fieldId = button.getAttribute('data-field-id');
|
||||||
|
updateAddButtonState(fieldId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initArrayTableButtons);
|
document.addEventListener('DOMContentLoaded', initArrayTableButtons);
|
||||||
} else {
|
} else {
|
||||||
initArrayTableButtons();
|
initArrayTableButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)');
|
console.log('[ArrayTableWidget] Array table widget registered');
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -51,10 +51,8 @@
|
|||||||
sanitizeValue(value) {
|
sanitizeValue(value) {
|
||||||
// Base implementation - widgets should override for specific needs
|
// Base implementation - widgets should override for specific needs
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
// Strip all HTML tags via the DOM parser to prevent XSS
|
// Basic XSS prevention
|
||||||
const div = document.createElement('div');
|
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||||
div.textContent = value;
|
|
||||||
return div.textContent;
|
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* LEDMatrix File Upload Single Widget
|
|
||||||
*
|
|
||||||
* Single-image upload for string fields. Uploads to the plugin's asset folder
|
|
||||||
* and sets the string field value to the returned relative path.
|
|
||||||
* Designed for per-item image fields within array-table rows.
|
|
||||||
*
|
|
||||||
* The plugin_id is injected automatically from the template context
|
|
||||||
* via options.pluginId — no need to specify it in the schema.
|
|
||||||
*
|
|
||||||
* Schema example (any plugin):
|
|
||||||
* {
|
|
||||||
* "image_path": {
|
|
||||||
* "type": "string",
|
|
||||||
* "x-widget": "file-upload-single",
|
|
||||||
* "x-upload-config": {
|
|
||||||
* "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
|
|
||||||
* "max_size_mb": 5
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @module FileUploadSingleWidget
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
|
||||||
console.error('[FileUploadSingleWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
|
||||||
if (base) return base.sanitizeId(id);
|
|
||||||
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerChange(fieldId, value) {
|
|
||||||
if (base) {
|
|
||||||
base.triggerChange(fieldId, value);
|
|
||||||
} else {
|
|
||||||
document.dispatchEvent(new CustomEvent('widget-change', {
|
|
||||||
detail: { fieldId, value },
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isImagePath(path) {
|
|
||||||
if (!path) return false;
|
|
||||||
return /\.(png|jpg|jpeg|bmp|gif)$/i.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.LEDMatrixWidgets.register('file-upload-single', {
|
|
||||||
name: 'File Upload Single Widget',
|
|
||||||
version: '1.0.0',
|
|
||||||
|
|
||||||
render: function(container, config, value, options) {
|
|
||||||
const fieldId = sanitizeId(options.fieldId || container.id || 'file_upload_single');
|
|
||||||
const uploadConfig = config['x-upload-config'] || config['x_upload_config'] || {};
|
|
||||||
const allowedTypes = (uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']).join(',');
|
|
||||||
const maxSizeMb = uploadConfig.max_size_mb || 5;
|
|
||||||
const pluginId = options.pluginId || '';
|
|
||||||
const currentValue = value || '';
|
|
||||||
const hasImage = isImagePath(currentValue);
|
|
||||||
|
|
||||||
let html = `<div id="${fieldId}_widget" class="file-upload-single-widget" data-field-id="${fieldId}" data-plugin-id="${escapeHtml(pluginId)}">`;
|
|
||||||
|
|
||||||
// Hidden input carries the actual string value
|
|
||||||
html += `<input type="hidden" id="${fieldId}" name="${escapeHtml(options.name || fieldId)}" value="${escapeHtml(currentValue)}">`;
|
|
||||||
|
|
||||||
// Preview area (shown when a value is set)
|
|
||||||
html += `<div id="${fieldId}_preview" class="${hasImage ? '' : 'hidden'} flex items-center space-x-3 mb-2 p-2 bg-gray-50 rounded border border-gray-200">`;
|
|
||||||
html += `<img id="${fieldId}_thumb" src="/${escapeHtml(currentValue)}" alt="Preview"
|
|
||||||
class="w-12 h-12 object-cover rounded"
|
|
||||||
onerror="this.style.display='none';document.getElementById('${fieldId}_thumb_placeholder').style.display='flex'">`;
|
|
||||||
html += `<div id="${fieldId}_thumb_placeholder" style="display:none" class="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
|
|
||||||
<i class="fas fa-image text-gray-400 text-lg"></i>
|
|
||||||
</div>`;
|
|
||||||
html += `<div class="flex-1 min-w-0">
|
|
||||||
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${escapeHtml(currentValue.split('/').pop() || '')}</p>
|
|
||||||
<p class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
|
|
||||||
</div>`;
|
|
||||||
html += `<button type="button"
|
|
||||||
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
|
||||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>`;
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
// Upload drop zone (always shown, acts as change button when value is set)
|
|
||||||
html += `<div id="${fieldId}_drop_zone"
|
|
||||||
class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
||||||
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
|
|
||||||
ondragover="event.preventDefault()"
|
|
||||||
onclick="document.getElementById('${fieldId}_file_input').click()">
|
|
||||||
<input type="file"
|
|
||||||
id="${fieldId}_file_input"
|
|
||||||
accept="${escapeHtml(allowedTypes)}"
|
|
||||||
style="display:none"
|
|
||||||
data-field-id="${fieldId}"
|
|
||||||
data-plugin-id="${escapeHtml(pluginId)}"
|
|
||||||
data-max-size-mb="${maxSizeMb}"
|
|
||||||
data-allowed-types="${escapeHtml(allowedTypes)}"
|
|
||||||
onchange="window.LEDMatrixWidgets.getHandlers('file-upload-single').onFileSelect(event, '${fieldId}')">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-xl text-gray-400 mb-1"></i>
|
|
||||||
<p class="text-xs text-gray-500">${hasImage ? 'Click to replace image' : 'Click or drag to upload image'}</p>
|
|
||||||
<p class="text-xs text-gray-400">Max ${maxSizeMb}MB</p>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// Status area for upload feedback
|
|
||||||
html += `<div id="${fieldId}_status" class="mt-1 text-xs hidden"></div>`;
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
},
|
|
||||||
|
|
||||||
getValue: function(fieldId) {
|
|
||||||
const safeId = sanitizeId(fieldId);
|
|
||||||
const input = document.getElementById(safeId);
|
|
||||||
return input ? input.value : '';
|
|
||||||
},
|
|
||||||
|
|
||||||
setValue: function(fieldId, value) {
|
|
||||||
const safeId = sanitizeId(fieldId);
|
|
||||||
const hidden = document.getElementById(safeId);
|
|
||||||
const preview = document.getElementById(`${safeId}_preview`);
|
|
||||||
const thumb = document.getElementById(`${safeId}_thumb`);
|
|
||||||
const thumbPlaceholder = document.getElementById(`${safeId}_thumb_placeholder`);
|
|
||||||
const filename = document.getElementById(`${safeId}_filename`);
|
|
||||||
const dropZone = document.getElementById(`${safeId}_drop_zone`);
|
|
||||||
|
|
||||||
if (hidden) hidden.value = value || '';
|
|
||||||
|
|
||||||
const hasImage = isImagePath(value);
|
|
||||||
if (preview) preview.classList.toggle('hidden', !hasImage);
|
|
||||||
if (thumb && hasImage) {
|
|
||||||
thumb.src = `/${value}`;
|
|
||||||
thumb.style.display = '';
|
|
||||||
if (thumbPlaceholder) thumbPlaceholder.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (filename) filename.textContent = hasImage ? value.split('/').pop() : '';
|
|
||||||
|
|
||||||
// Update drop zone hint text
|
|
||||||
const hint = dropZone ? dropZone.querySelector('p') : null;
|
|
||||||
if (hint) hint.textContent = hasImage ? 'Click to replace image' : 'Click or drag to upload image';
|
|
||||||
},
|
|
||||||
|
|
||||||
handlers: {
|
|
||||||
onFileSelect: function(event, fieldId) {
|
|
||||||
const files = event.target.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onDrop: function(event, fieldId) {
|
|
||||||
event.preventDefault();
|
|
||||||
const files = event.dataTransfer.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onClear: function(fieldId) {
|
|
||||||
const widget = window.LEDMatrixWidgets.get('file-upload-single');
|
|
||||||
widget.setValue(fieldId, '');
|
|
||||||
triggerChange(fieldId, '');
|
|
||||||
// Reset file input so the same file can be re-selected
|
|
||||||
const fileInput = document.getElementById(`${sanitizeId(fieldId)}_file_input`);
|
|
||||||
if (fileInput) fileInput.value = '';
|
|
||||||
},
|
|
||||||
|
|
||||||
uploadFile: async function(fieldId, file) {
|
|
||||||
const safeId = sanitizeId(fieldId);
|
|
||||||
const fileInput = document.getElementById(`${safeId}_file_input`);
|
|
||||||
const statusDiv = document.getElementById(`${safeId}_status`);
|
|
||||||
const notifyFn = window.showNotification || console.log;
|
|
||||||
|
|
||||||
// Read config from the file input data attributes
|
|
||||||
const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
|
|
||||||
const maxSizeMb = parseFloat((fileInput && fileInput.dataset.maxSizeMb) || '5');
|
|
||||||
const allowedTypes = ((fileInput && fileInput.dataset.allowedTypes) || 'image/png,image/jpeg,image/bmp,image/gif')
|
|
||||||
.split(',').map(t => t.trim());
|
|
||||||
|
|
||||||
if (!pluginId) {
|
|
||||||
notifyFn('Plugin ID not set — cannot upload', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate type
|
|
||||||
if (!allowedTypes.includes(file.type)) {
|
|
||||||
notifyFn(`File type "${file.type}" not allowed`, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate size
|
|
||||||
if (file.size > maxSizeMb * 1024 * 1024) {
|
|
||||||
notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show uploading status
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.className = 'mt-1 text-xs text-gray-500';
|
|
||||||
statusDiv.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Uploading...';
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('plugin_id', pluginId);
|
|
||||||
formData.append('files', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v3/plugins/assets/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text();
|
|
||||||
throw new Error(`Server error ${response.status}: ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
|
|
||||||
const uploadedPath = data.uploaded_files[0].path;
|
|
||||||
const widget = window.LEDMatrixWidgets.get('file-upload-single');
|
|
||||||
widget.setValue(fieldId, uploadedPath);
|
|
||||||
triggerChange(fieldId, uploadedPath);
|
|
||||||
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.className = 'mt-1 text-xs text-green-600';
|
|
||||||
statusDiv.innerHTML = '<i class="fas fa-check-circle mr-1"></i>Uploaded successfully';
|
|
||||||
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.innerHTML = ''; }, 3000);
|
|
||||||
}
|
|
||||||
notifyFn('Image uploaded successfully', 'success');
|
|
||||||
} else {
|
|
||||||
throw new Error(data.message || 'Upload failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (statusDiv) {
|
|
||||||
statusDiv.className = 'mt-1 text-xs text-red-600';
|
|
||||||
statusDiv.innerHTML = `<i class="fas fa-exclamation-circle mr-1"></i>${escapeHtml(error.message)}`;
|
|
||||||
}
|
|
||||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
if (fileInput) fileInput.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[FileUploadSingleWidget] File upload single widget registered');
|
|
||||||
})();
|
|
||||||
@@ -1,783 +0,0 @@
|
|||||||
/**
|
|
||||||
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
|
|
||||||
*
|
|
||||||
* Usage via config_schema.json:
|
|
||||||
* "file_manager": {
|
|
||||||
* "type": "null",
|
|
||||||
* "title": "Data Files",
|
|
||||||
* "x-widget": "json-file-manager",
|
|
||||||
* "x-widget-config": {
|
|
||||||
* "actions": {
|
|
||||||
* "list": "list-files", // required
|
|
||||||
* "get": "get-file", // required for editing
|
|
||||||
* "save": "save-file", // required for editing
|
|
||||||
* "upload": "upload-file", // optional
|
|
||||||
* "delete": "delete-file", // optional
|
|
||||||
* "create": "create-file", // optional
|
|
||||||
* "toggle": "toggle-category" // optional
|
|
||||||
* },
|
|
||||||
* "upload_hint": "Hint text under the drop zone",
|
|
||||||
* "directory_label": "of_the_day/",
|
|
||||||
* "create_fields": [
|
|
||||||
* { "key": "category_name", "label": "Category Name",
|
|
||||||
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
|
|
||||||
* "hint": "Used as filename" },
|
|
||||||
* { "key": "display_name", "label": "Display Name",
|
|
||||||
* "placeholder": "My Words" }
|
|
||||||
* ],
|
|
||||||
* "toggle_key": "category_name"
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* No CDN dependencies. Works on all modern browsers.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
class JsonFileManager {
|
|
||||||
constructor(container, config, pluginId) {
|
|
||||||
// Prevent duplicate instances on the same container
|
|
||||||
if (container._jfmInstance) {
|
|
||||||
container._jfmInstance._destroy();
|
|
||||||
}
|
|
||||||
container._jfmInstance = this;
|
|
||||||
|
|
||||||
this.el = container;
|
|
||||||
this.pluginId = pluginId;
|
|
||||||
this.actions = config.actions || {};
|
|
||||||
this.uploadHint = config.upload_hint || '';
|
|
||||||
this.dirLabel = config.directory_label || '';
|
|
||||||
this.createFields = config.create_fields || [];
|
|
||||||
this.toggleKey = config.toggle_key || null;
|
|
||||||
|
|
||||||
// Unique prefix for all DOM IDs in this instance
|
|
||||||
this._uid = 'jfm_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
// Mutable state
|
|
||||||
this._editFile = null;
|
|
||||||
this._deleteFile = null;
|
|
||||||
this._keyHandler = this._onKey.bind(this);
|
|
||||||
|
|
||||||
this._inject();
|
|
||||||
this._bind();
|
|
||||||
this._loadList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_destroy() {
|
|
||||||
document.removeEventListener('keydown', this._keyHandler);
|
|
||||||
this.el._jfmInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DOM Injection ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_inject() {
|
|
||||||
const u = this._uid;
|
|
||||||
const hasUpload = !!this.actions.upload;
|
|
||||||
const hasCreate = !!this.actions.create;
|
|
||||||
const hasDelete = !!this.actions.delete;
|
|
||||||
|
|
||||||
this.el.innerHTML = this._css(u) + `
|
|
||||||
<div id="${u}" class="jfm">
|
|
||||||
|
|
||||||
<div class="jfm-header">
|
|
||||||
<div class="jfm-header-left">
|
|
||||||
<span class="jfm-title">Data Files</span>
|
|
||||||
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-header-right">
|
|
||||||
${hasCreate ? `<button type="button" class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">↻</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="${u}-list" class="jfm-list">
|
|
||||||
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${hasUpload ? `
|
|
||||||
<div class="jfm-upload-wrap">
|
|
||||||
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
|
|
||||||
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
|
|
||||||
aria-label="Upload JSON file">
|
|
||||||
<span class="jfm-drop-icon">📁</span>
|
|
||||||
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
|
|
||||||
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<!-- ── Edit modal ─────────────────────────────────────── -->
|
|
||||||
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box jfm-modal-wide">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
|
|
||||||
<div class="jfm-modal-tools">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
|
|
||||||
<textarea id="${u}-editor" class="jfm-editor"
|
|
||||||
spellcheck="false" autocomplete="off"
|
|
||||||
autocorrect="off" autocapitalize="off"
|
|
||||||
aria-label="JSON editor"></textarea>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<span id="${u}-charcount" class="jfm-stat"></span>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Delete modal ───────────────────────────────────── -->
|
|
||||||
${hasDelete ? `
|
|
||||||
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span class="jfm-modal-title">Delete file</span>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-body">
|
|
||||||
<p>Delete <strong id="${u}-del-name"></strong>?</p>
|
|
||||||
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<!-- ── Create modal ───────────────────────────────────── -->
|
|
||||||
${hasCreate ? `
|
|
||||||
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span class="jfm-modal-title">Create new file</span>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-body">
|
|
||||||
${this.createFields.map(f => `
|
|
||||||
<div class="jfm-field">
|
|
||||||
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
|
|
||||||
<input type="text" id="${u}-cf-${this._esc(f.key)}"
|
|
||||||
placeholder="${this._esc(f.placeholder || '')}"
|
|
||||||
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
|
|
||||||
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
</div>`; // end #${u}
|
|
||||||
|
|
||||||
// Cache frequently-used elements
|
|
||||||
this._root = document.getElementById(u);
|
|
||||||
this._listEl = document.getElementById(`${u}-list`);
|
|
||||||
this._editorEl = document.getElementById(`${u}-editor`);
|
|
||||||
this._editModal = document.getElementById(`${u}-edit-modal`);
|
|
||||||
this._delModal = document.getElementById(`${u}-del-modal`);
|
|
||||||
this._createModal = document.getElementById(`${u}-create-modal`);
|
|
||||||
this._dropzone = document.getElementById(`${u}-dropzone`);
|
|
||||||
this._fileInput = document.getElementById(`${u}-fileinput`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_css(u) {
|
|
||||||
return `<style>
|
|
||||||
#${u}{font-family:inherit;color:#111827;}
|
|
||||||
#${u} *{box-sizing:border-box;}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
|
|
||||||
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
|
|
||||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
|
|
||||||
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
|
||||||
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
|
||||||
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
|
|
||||||
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
|
|
||||||
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
|
|
||||||
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
|
|
||||||
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
|
||||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
|
|
||||||
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
|
|
||||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
|
|
||||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
|
|
||||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
|
|
||||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
|
||||||
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
|
|
||||||
|
|
||||||
/* File list */
|
|
||||||
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
|
|
||||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
|
|
||||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
|
|
||||||
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
|
|
||||||
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
|
|
||||||
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
|
|
||||||
|
|
||||||
/* File cards */
|
|
||||||
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
|
||||||
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
|
|
||||||
#${u} .jfm-card.jfm-off{opacity:.6;}
|
|
||||||
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
|
|
||||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
|
|
||||||
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
|
||||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
|
|
||||||
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
|
|
||||||
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
|
|
||||||
|
|
||||||
/* Toggle */
|
|
||||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
|
|
||||||
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
|
|
||||||
|
|
||||||
/* Upload zone */
|
|
||||||
#${u} .jfm-upload-wrap{margin-top:.25rem;}
|
|
||||||
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
|
|
||||||
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
|
|
||||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
|
|
||||||
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
|
|
||||||
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
|
|
||||||
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
|
|
||||||
|
|
||||||
/* Modals */
|
|
||||||
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
|
|
||||||
#${u} .jfm-modal[hidden]{display:none;}
|
|
||||||
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
|
||||||
#${u} .jfm-modal-wide{max-width:880px;}
|
|
||||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
|
|
||||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
||||||
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
|
|
||||||
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
|
|
||||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
|
|
||||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
|
|
||||||
|
|
||||||
/* JSON editor */
|
|
||||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
|
|
||||||
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
|
||||||
#${u} .jfm-err-bar[hidden]{display:none;}
|
|
||||||
|
|
||||||
/* Create form */
|
|
||||||
#${u} .jfm-field{margin-bottom:.875rem;}
|
|
||||||
#${u} .jfm-field:last-child{margin-bottom:0;}
|
|
||||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
|
|
||||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
|
|
||||||
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
|
|
||||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
|
|
||||||
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
|
|
||||||
|
|
||||||
/* Spinner */
|
|
||||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
|
||||||
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
|
|
||||||
</style>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event Binding ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_bind() {
|
|
||||||
// Delegated clicks on the widget root
|
|
||||||
this._root.addEventListener('click', this._onClick.bind(this));
|
|
||||||
this._root.addEventListener('change', this._onChange.bind(this));
|
|
||||||
|
|
||||||
// Drag-and-drop on the dropzone
|
|
||||||
if (this._dropzone) {
|
|
||||||
this._dropzone.addEventListener('dragover', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
this._dropzone.classList.add('jfm-over');
|
|
||||||
});
|
|
||||||
this._dropzone.addEventListener('dragleave', () => {
|
|
||||||
this._dropzone.classList.remove('jfm-over');
|
|
||||||
});
|
|
||||||
this._dropzone.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
this._dropzone.classList.remove('jfm-over');
|
|
||||||
const file = e.dataTransfer?.files[0];
|
|
||||||
if (file) this._uploadFile(file);
|
|
||||||
});
|
|
||||||
// Keyboard activation of drop zone
|
|
||||||
this._dropzone.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
this._fileInput?.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal backdrop clicks
|
|
||||||
[this._editModal, this._delModal, this._createModal].forEach(m => {
|
|
||||||
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Editor: char count + Tab indent
|
|
||||||
if (this._editorEl) {
|
|
||||||
this._editorEl.addEventListener('input', () => this._updateStat());
|
|
||||||
this._editorEl.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
const s = this._editorEl.selectionStart;
|
|
||||||
const end = this._editorEl.selectionEnd;
|
|
||||||
const v = this._editorEl.value;
|
|
||||||
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
|
|
||||||
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
|
|
||||||
this._updateStat();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', this._keyHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onKey(e) {
|
|
||||||
const editOpen = this._editModal && !this._editModal.hidden;
|
|
||||||
const delOpen = this._delModal && !this._delModal.hidden;
|
|
||||||
const createOpen = this._createModal && !this._createModal.hidden;
|
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
if (editOpen) { this._closeEdit(); return; }
|
|
||||||
if (delOpen) { this._closeDel(); return; }
|
|
||||||
if (createOpen) { this._closeCreate(); return; }
|
|
||||||
}
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
this._doSave();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onClick(e) {
|
|
||||||
const btn = e.target.closest('[data-jfm]');
|
|
||||||
if (!btn) return;
|
|
||||||
const action = btn.dataset.jfm;
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'refresh': this._loadList(); break;
|
|
||||||
case 'open-picker': this._fileInput?.click(); break;
|
|
||||||
case 'open-create': this._openCreate(); break;
|
|
||||||
case 'close-edit': this._closeEdit(); break;
|
|
||||||
case 'close-del': this._closeDel(); break;
|
|
||||||
case 'close-create': this._closeCreate(); break;
|
|
||||||
case 'fmt': this._formatJson(); break;
|
|
||||||
case 'validate': this._validateJson(); break;
|
|
||||||
case 'save': this._doSave(); break;
|
|
||||||
case 'confirm-del': this._doDelete(); break;
|
|
||||||
case 'do-create': this._doCreate(); break;
|
|
||||||
case 'edit-file': {
|
|
||||||
const card = btn.closest('[data-jfm-file]');
|
|
||||||
if (card) this._openEdit(card.dataset.jfmFile);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'del-file': {
|
|
||||||
const card = btn.closest('[data-jfm-file]');
|
|
||||||
if (card) this._openDel(card.dataset.jfmFile);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onChange(e) {
|
|
||||||
// Toggle checkbox
|
|
||||||
if (e.target.classList.contains('jfm-toggle-cb')) {
|
|
||||||
const catName = e.target.dataset.cat;
|
|
||||||
const enabled = e.target.checked;
|
|
||||||
this._doToggle(catName, enabled, e.target);
|
|
||||||
}
|
|
||||||
// File input
|
|
||||||
if (e.target === this._fileInput) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) this._uploadFile(file);
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API helper ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _api(actionKey, params) {
|
|
||||||
const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined;
|
|
||||||
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
|
|
||||||
const body = { plugin_id: this.pluginId, action_id: actionId };
|
|
||||||
if (params !== undefined) body.params = params;
|
|
||||||
const r = await fetch('/api/v3/plugins/action', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error('Server error ' + r.status);
|
|
||||||
const ct = r.headers.get('content-type') || '';
|
|
||||||
if (!ct.includes('application/json')) {
|
|
||||||
const txt = await r.text();
|
|
||||||
throw new Error('Unexpected response: ' + txt.slice(0, 120));
|
|
||||||
}
|
|
||||||
return r.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File List ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _loadList() {
|
|
||||||
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
|
|
||||||
try {
|
|
||||||
const data = await this._api('list');
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
|
||||||
this._renderList(data.files || []);
|
|
||||||
} catch (err) {
|
|
||||||
this._listEl.innerHTML = `
|
|
||||||
<div class="jfm-empty">
|
|
||||||
<div class="jfm-empty-icon">⚠</div>
|
|
||||||
<p class="jfm-empty-title">Failed to load files</p>
|
|
||||||
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderList(files) {
|
|
||||||
if (!files.length) {
|
|
||||||
this._listEl.innerHTML = `
|
|
||||||
<div class="jfm-empty">
|
|
||||||
<div class="jfm-empty-icon">📁</div>
|
|
||||||
<p class="jfm-empty-title">No files yet</p>
|
|
||||||
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
_card(f) {
|
|
||||||
const enabled = f.enabled !== false;
|
|
||||||
const displayName = this._esc(f.display_name || f.filename);
|
|
||||||
const filename = this._esc(f.filename);
|
|
||||||
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
|
|
||||||
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
|
|
||||||
const hasEdit = !!this.actions.get && !!this.actions.save;
|
|
||||||
const hasDelete = !!this.actions.delete;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
|
|
||||||
<div class="jfm-card-top">
|
|
||||||
<span class="jfm-card-name" title="${filename}">${displayName}</span>
|
|
||||||
${showToggle ? `
|
|
||||||
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
|
|
||||||
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
|
|
||||||
<span>${enabled ? 'On' : 'Off'}</span>
|
|
||||||
</label>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-card-meta">
|
|
||||||
<span>📄 ${filename}</span>
|
|
||||||
<span>📊 ${f.entry_count ?? 0} entries · ${this._fmtSize(f.size || 0)}</span>
|
|
||||||
<span>🕑 ${this._fmtDate(f.modified)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-card-actions">
|
|
||||||
${hasEdit ? `<button type="button" class="jfm-btn jfm-btn-sm" data-jfm="edit-file">✎ Edit</button>` : ''}
|
|
||||||
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">🗑</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Edit flow ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _openEdit(filename) {
|
|
||||||
this._editFile = filename;
|
|
||||||
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
|
|
||||||
this._clearErr();
|
|
||||||
this._editorEl.value = 'Loading…';
|
|
||||||
this._updateStat();
|
|
||||||
this._editModal.hidden = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await this._api('get', { filename });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
|
||||||
this._editorEl.value = JSON.stringify(data.content, null, 2);
|
|
||||||
this._updateStat();
|
|
||||||
this._editorEl.focus();
|
|
||||||
this._editorEl.setSelectionRange(0, 0);
|
|
||||||
this._editorEl.scrollTop = 0;
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Failed to load file: ' + err.message);
|
|
||||||
this._editorEl.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeEdit() {
|
|
||||||
if (this._editModal) this._editModal.hidden = true;
|
|
||||||
this._editFile = null;
|
|
||||||
this._clearErr();
|
|
||||||
}
|
|
||||||
|
|
||||||
_formatJson() {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
this._editorEl.value = JSON.stringify(parsed, null, 2);
|
|
||||||
this._updateStat();
|
|
||||||
this._clearErr();
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Invalid JSON — ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_validateJson() {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
|
|
||||||
this._clearErr();
|
|
||||||
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Invalid JSON — ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doSave() {
|
|
||||||
if (!this._editFile) return;
|
|
||||||
let contentStr;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
contentStr = JSON.stringify(parsed, null, 2);
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Cannot save — fix JSON first: ' + err.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = document.getElementById(`${this._uid}-save-btn`);
|
|
||||||
this._busy(btn, 'Saving…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('save', { filename: this._editFile, content: contentStr });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
|
|
||||||
this._notify('File saved', 'success');
|
|
||||||
this._closeEdit();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Save failed: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Save');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete flow ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_openDel(filename) {
|
|
||||||
this._deleteFile = filename;
|
|
||||||
const el = document.getElementById(`${this._uid}-del-name`);
|
|
||||||
if (el) el.textContent = filename;
|
|
||||||
if (this._delModal) this._delModal.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeDel() {
|
|
||||||
if (this._delModal) this._delModal.hidden = true;
|
|
||||||
this._deleteFile = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doDelete() {
|
|
||||||
if (!this._deleteFile) return;
|
|
||||||
const btn = document.getElementById(`${this._uid}-del-btn`);
|
|
||||||
this._busy(btn, 'Deleting…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('delete', { filename: this._deleteFile });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
|
|
||||||
this._notify('File deleted', 'success');
|
|
||||||
this._closeDel();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Delete failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Delete');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create flow ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_openCreate() {
|
|
||||||
if (!this._createModal) return;
|
|
||||||
this.createFields.forEach(f => {
|
|
||||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
|
||||||
if (el) el.value = '';
|
|
||||||
});
|
|
||||||
this._createModal.hidden = false;
|
|
||||||
const first = this.createFields[0];
|
|
||||||
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeCreate() {
|
|
||||||
if (this._createModal) this._createModal.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doCreate() {
|
|
||||||
const params = {};
|
|
||||||
for (const f of this.createFields) {
|
|
||||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
|
||||||
const val = (el?.value || '').trim();
|
|
||||||
// display_name may be blank — auto-derived from category_name below
|
|
||||||
if (!val && f.key !== 'display_name') {
|
|
||||||
this._notify(`"${f.label}" is required`, 'error');
|
|
||||||
el?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (f.pattern && val && el && el.validity.patternMismatch) {
|
|
||||||
this._notify(`"${f.label}" format is invalid`, 'error');
|
|
||||||
el?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (val) params[f.key] = val;
|
|
||||||
}
|
|
||||||
// Auto-derive display_name from category_name when left blank
|
|
||||||
if (!params.display_name && params.category_name) {
|
|
||||||
params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
}
|
|
||||||
const btn = document.getElementById(`${this._uid}-create-btn`);
|
|
||||||
this._busy(btn, 'Creating…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('create', params);
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
|
|
||||||
this._notify('File created', 'success');
|
|
||||||
this._closeCreate();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Create failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Create');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Upload ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _uploadFile(file) {
|
|
||||||
if (!file.name.endsWith('.json')) {
|
|
||||||
this._notify('Please select a .json file', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let content;
|
|
||||||
try {
|
|
||||||
content = await file.text();
|
|
||||||
JSON.parse(content); // client-side validation
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Invalid JSON: ' + err.message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._dropzone) this._dropzone.style.opacity = '.5';
|
|
||||||
try {
|
|
||||||
const data = await this._api('upload', { filename: file.name, content });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
|
|
||||||
this._notify(`"${file.name}" uploaded`, 'success');
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Upload failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
if (this._dropzone) this._dropzone.style.opacity = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toggle ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _doToggle(catName, enabled, checkbox) {
|
|
||||||
checkbox.disabled = true;
|
|
||||||
try {
|
|
||||||
const params = { enabled };
|
|
||||||
if (this.toggleKey) params[this.toggleKey] = catName;
|
|
||||||
const data = await this._api('toggle', params);
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
|
|
||||||
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Toggle failed: ' + err.message, 'error');
|
|
||||||
checkbox.checked = !enabled; // revert
|
|
||||||
checkbox.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_closeAll() {
|
|
||||||
this._closeEdit();
|
|
||||||
this._closeDel();
|
|
||||||
this._closeCreate();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateStat() {
|
|
||||||
const v = this._editorEl?.value || '';
|
|
||||||
const lines = v ? v.split('\n').length : 0;
|
|
||||||
const el = document.getElementById(`${this._uid}-charcount`);
|
|
||||||
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_showErr(msg) {
|
|
||||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
|
||||||
if (el) { el.textContent = msg; el.hidden = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearErr() {
|
|
||||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
|
||||||
if (el) { el.textContent = ''; el.hidden = true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
_notify(msg, type) {
|
|
||||||
if (typeof window.showNotification === 'function') {
|
|
||||||
window.showNotification(msg, type || 'info');
|
|
||||||
} else {
|
|
||||||
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_busy(btn, label) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn._jfmOrigText = btn.textContent;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '';
|
|
||||||
const spin = document.createElement('span');
|
|
||||||
spin.className = 'jfm-spin';
|
|
||||||
btn.appendChild(spin);
|
|
||||||
btn.appendChild(document.createTextNode(' ' + label));
|
|
||||||
}
|
|
||||||
|
|
||||||
_idle(btn, label) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label;
|
|
||||||
delete btn._jfmOrigText;
|
|
||||||
}
|
|
||||||
|
|
||||||
_esc(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = String(str ?? '');
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
_fmtSize(bytes) {
|
|
||||||
if (!bytes) return '0 B';
|
|
||||||
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
|
|
||||||
const unit = ['B', 'KB', 'MB'][i];
|
|
||||||
const val = bytes / Math.pow(1024, i);
|
|
||||||
return (i ? val.toFixed(1) : val) + ' ' + unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
_fmtDate(str) {
|
|
||||||
if (!str) return '—';
|
|
||||||
try {
|
|
||||||
return new Date(str).toLocaleDateString(undefined, {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric'
|
|
||||||
});
|
|
||||||
} catch { return str; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Widget registry integration ──────────────────────────────────────────
|
|
||||||
|
|
||||||
window.JsonFileManager = JsonFileManager;
|
|
||||||
|
|
||||||
if (typeof window.LEDMatrixWidgets !== 'undefined') {
|
|
||||||
window.LEDMatrixWidgets.register('json-file-manager', {
|
|
||||||
name: 'JSON File Manager',
|
|
||||||
version: '1.0.0',
|
|
||||||
render(container, config, _value, options) {
|
|
||||||
new JsonFileManager(container, config || {}, options?.pluginId || '');
|
|
||||||
},
|
|
||||||
getValue() { return null; },
|
|
||||||
setValue() {}
|
|
||||||
});
|
|
||||||
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
|
|
||||||
} else {
|
|
||||||
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,692 +0,0 @@
|
|||||||
/**
|
|
||||||
* Plugin File Manager Widget
|
|
||||||
*
|
|
||||||
* Reusable inline file manager for plugins that manage files via the
|
|
||||||
* web_ui_actions system. Driven entirely by x-widget-config in the schema —
|
|
||||||
* no external HTML file or iframe needed.
|
|
||||||
*
|
|
||||||
* Any plugin can adopt this widget by:
|
|
||||||
* 1. Defining web_ui_actions in manifest.json (list, get, save, upload,
|
|
||||||
* delete, create, toggle) with ui_hidden: true
|
|
||||||
* 2. Adding x-widget: "plugin-file-manager" to a field in config_schema.json
|
|
||||||
* with x-widget-config mapping the action IDs
|
|
||||||
*
|
|
||||||
* Schema example:
|
|
||||||
* {
|
|
||||||
* "file_manager": {
|
|
||||||
* "type": "null",
|
|
||||||
* "title": "Data Files",
|
|
||||||
* "x-widget": "plugin-file-manager",
|
|
||||||
* "x-widget-config": {
|
|
||||||
* "actions": {
|
|
||||||
* "list": "list-files",
|
|
||||||
* "get": "get-file",
|
|
||||||
* "save": "save-file",
|
|
||||||
* "upload": "upload-file",
|
|
||||||
* "delete": "delete-file",
|
|
||||||
* "create": "create-file",
|
|
||||||
* "toggle": "toggle-category"
|
|
||||||
* },
|
|
||||||
* "upload_hint": "JSON files with day numbers 1–365 as keys",
|
|
||||||
* "directory_label": "of_the_day/",
|
|
||||||
* "create_fields": [
|
|
||||||
* { "key": "category_name", "label": "Category Name",
|
|
||||||
* "placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
|
|
||||||
* "hint": "Lowercase letters, numbers, underscores" },
|
|
||||||
* { "key": "display_name", "label": "Display Name",
|
|
||||||
* "placeholder": "e.g., My Words", "hint": "Optional — auto-generated if blank" }
|
|
||||||
* ]
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @module PluginFileManagerWidget
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
|
||||||
console.error('[PluginFileManager] LEDMatrixWidgets registry not found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Inject widget-scoped styles once ────────────────────────────────────
|
|
||||||
|
|
||||||
if (!document.getElementById('pfm-styles')) {
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = 'pfm-styles';
|
|
||||||
style.textContent = `
|
|
||||||
.pfm-root { font-family: inherit; }
|
|
||||||
.pfm-header { display:flex; align-items:center; justify-content:space-between;
|
|
||||||
margin-bottom:.75rem; }
|
|
||||||
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
|
|
||||||
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
|
|
||||||
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
|
|
||||||
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
|
|
||||||
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
|
|
||||||
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
|
|
||||||
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
|
|
||||||
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
|
|
||||||
gap:.75rem; margin-top:.75rem; }
|
|
||||||
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
|
|
||||||
background:#fff; transition:box-shadow .15s; }
|
|
||||||
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
|
|
||||||
.pfm-card.disabled { opacity:.55; }
|
|
||||||
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
|
|
||||||
margin-bottom:.5rem; }
|
|
||||||
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
|
|
||||||
display:flex; align-items:center; justify-content:center;
|
|
||||||
color:#6b7280; font-size:1rem; }
|
|
||||||
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
|
|
||||||
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
|
|
||||||
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
|
|
||||||
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
|
|
||||||
border-radius:.375rem; font-size:.8125rem; font-weight:500;
|
|
||||||
border:none; cursor:pointer; transition:background .15s; }
|
|
||||||
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
|
|
||||||
.pfm-btn-primary:hover { background:#1d4ed8; }
|
|
||||||
.pfm-btn-danger { background:#dc2626; color:#fff; }
|
|
||||||
.pfm-btn-danger:hover { background:#b91c1c; }
|
|
||||||
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
|
|
||||||
.pfm-btn-secondary:hover { background:#e5e7eb; }
|
|
||||||
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
|
|
||||||
.pfm-btn-create { background:#059669; color:#fff; }
|
|
||||||
.pfm-btn-create:hover { background:#047857; }
|
|
||||||
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
|
|
||||||
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
|
|
||||||
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
|
|
||||||
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
|
|
||||||
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
|
|
||||||
cursor:pointer; transition:background .2s; }
|
|
||||||
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
|
|
||||||
left:.1875rem; bottom:.1875rem; background:#fff;
|
|
||||||
border-radius:50%; transition:transform .2s; }
|
|
||||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
|
|
||||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
|
|
||||||
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
|
|
||||||
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
|
|
||||||
display:flex; align-items:flex-start; justify-content:center;
|
|
||||||
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
|
|
||||||
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
|
|
||||||
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
|
|
||||||
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
|
|
||||||
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
|
|
||||||
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
|
|
||||||
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
|
|
||||||
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
|
|
||||||
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
|
|
||||||
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
|
|
||||||
|
|
||||||
/* Entry table */
|
|
||||||
.pfm-table-wrap { overflow-x:auto; }
|
|
||||||
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
|
|
||||||
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
|
|
||||||
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
|
|
||||||
white-space:nowrap; position:sticky; top:0; }
|
|
||||||
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
|
|
||||||
vertical-align:top; }
|
|
||||||
.pfm-table tr.today-row td { background:#fef9c3; }
|
|
||||||
.pfm-table td input, .pfm-table td textarea {
|
|
||||||
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
|
|
||||||
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
|
|
||||||
resize:vertical; background:#fff; }
|
|
||||||
.pfm-table td input:focus, .pfm-table td textarea:focus {
|
|
||||||
outline:none; border-color:#3b82f6; }
|
|
||||||
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
|
|
||||||
color:#6b7280; white-space:nowrap; }
|
|
||||||
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
|
|
||||||
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
|
|
||||||
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
|
|
||||||
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
|
|
||||||
border-radius:.25rem; text-align:center; }
|
|
||||||
|
|
||||||
/* Form in create modal */
|
|
||||||
.pfm-field { margin-bottom:.875rem; }
|
|
||||||
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
|
|
||||||
color:#374151; margin-bottom:.25rem; }
|
|
||||||
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
|
|
||||||
border-radius:.375rem; font-size:.875rem; }
|
|
||||||
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
|
|
||||||
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
|
|
||||||
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
|
|
||||||
|
|
||||||
/* Delete danger box */
|
|
||||||
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
|
|
||||||
border-radius:.5rem; padding:.875rem; font-size:.875rem;
|
|
||||||
color:#991b1b; }
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Per-instance state ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const _state = new Map(); // fieldId → { pluginId, actions, createFields, files, page, entriesPerPage, modal }
|
|
||||||
|
|
||||||
function getState(fieldId) {
|
|
||||||
if (!_state.has(fieldId)) _state.set(fieldId, {
|
|
||||||
pluginId: '', actions: {}, createFields: [], uploadHint: '',
|
|
||||||
directoryLabel: '', files: [], page: 1, entriesPerPage: 20,
|
|
||||||
currentModal: null
|
|
||||||
});
|
|
||||||
return _state.get(fieldId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── API helper ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function callAction(pluginId, actionId, params = {}) {
|
|
||||||
const resp = await fetch('/api/v3/plugins/action', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ plugin_id: pluginId, action_id: actionId, params })
|
|
||||||
});
|
|
||||||
return resp.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function notify(msg, type) {
|
|
||||||
if (window.showNotification) window.showNotification(msg, type);
|
|
||||||
else console.log(`[PFM][${type}] ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(s) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = String(s ?? '');
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
return (bytes / 1024).toFixed(2) + ' KB';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso) {
|
|
||||||
try { return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); }
|
|
||||||
catch { return iso; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Core: load files ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadFiles(fieldId) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const root = document.getElementById(`${fieldId}_pfm`);
|
|
||||||
if (!root) return;
|
|
||||||
const grid = root.querySelector('.pfm-grid');
|
|
||||||
if (grid) grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>';
|
|
||||||
|
|
||||||
const data = await callAction(st.pluginId, st.actions.list).catch(() => null);
|
|
||||||
if (!data || data.status !== 'success') {
|
|
||||||
if (grid) grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
st.files = data.files || [];
|
|
||||||
renderCards(fieldId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Card grid ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function renderCards(fieldId) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const root = document.getElementById(`${fieldId}_pfm`);
|
|
||||||
if (!root) return;
|
|
||||||
const grid = root.querySelector('.pfm-grid');
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
if (!st.files.length) {
|
|
||||||
grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.innerHTML = st.files.map(f => `
|
|
||||||
<div class="pfm-card${f.enabled === false ? ' disabled' : ''}" data-filename="${escHtml(f.filename)}" data-category="${escHtml(f.category_name)}">
|
|
||||||
<div class="pfm-card-top">
|
|
||||||
<span class="pfm-toggle-label">${f.enabled !== false ? 'Enabled' : 'Disabled'}</span>
|
|
||||||
${st.actions.toggle ? `
|
|
||||||
<label class="pfm-toggle-cb" title="${f.enabled !== false ? 'Click to disable' : 'Click to enable'}">
|
|
||||||
<input type="checkbox" ${f.enabled !== false ? 'checked' : ''}
|
|
||||||
onchange="window._pfmToggle('${fieldId}','${escHtml(f.category_name)}',this.checked)">
|
|
||||||
<span class="pfm-toggle-slider"></span>
|
|
||||||
</label>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="pfm-card-icon"><i class="fas fa-file-code"></i></div>
|
|
||||||
<div class="pfm-card-name">${escHtml(f.display_name || f.filename)}</div>
|
|
||||||
<div class="pfm-card-meta">
|
|
||||||
${escHtml(f.filename)}<br>
|
|
||||||
${f.entry_count != null ? escHtml(f.entry_count) + ' entries' : ''} • ${formatSize(f.size)}<br>
|
|
||||||
${formatDate(f.modified)}
|
|
||||||
</div>
|
|
||||||
<div class="pfm-card-actions">
|
|
||||||
${st.actions.get && st.actions.save ? `
|
|
||||||
<button class="pfm-btn pfm-btn-primary"
|
|
||||||
onclick="window._pfmOpenEdit('${fieldId}','${escHtml(f.filename)}')">
|
|
||||||
<i class="fas fa-edit"></i> Edit
|
|
||||||
</button>` : ''}
|
|
||||||
${st.actions.delete ? `
|
|
||||||
<button class="pfm-btn pfm-btn-danger pfm-btn-sm"
|
|
||||||
onclick="window._pfmOpenDelete('${fieldId}','${escHtml(f.filename)}')">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Edit modal ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window._pfmOpenEdit = async function (fieldId, filename) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const overlay = createOverlay(fieldId);
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="pfm-modal">
|
|
||||||
<div class="pfm-modal-header">
|
|
||||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-body" id="${fieldId}_edit_body">
|
|
||||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-footer">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
|
||||||
<button class="pfm-btn pfm-btn-primary" id="${fieldId}_save_btn"
|
|
||||||
onclick="window._pfmSave('${fieldId}','${escHtml(filename)}')">
|
|
||||||
<i class="fas fa-save mr-1"></i>Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
|
||||||
const body = document.getElementById(`${fieldId}_edit_body`);
|
|
||||||
if (!data || data.status !== 'success' || !body) {
|
|
||||||
if (body) body.innerHTML = '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = data.content || data.data || {};
|
|
||||||
st._editData = content;
|
|
||||||
st._editFilename = filename;
|
|
||||||
|
|
||||||
if (isTabular(content)) {
|
|
||||||
renderEntryTable(fieldId, body, content);
|
|
||||||
} else {
|
|
||||||
// Fallback: JSON textarea
|
|
||||||
body.innerHTML = `
|
|
||||||
<textarea id="${fieldId}_json_ta" rows="20"
|
|
||||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
|
||||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
|
||||||
<div id="${fieldId}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function isTabular(data) {
|
|
||||||
if (typeof data !== 'object' || Array.isArray(data)) return false;
|
|
||||||
const keys = Object.keys(data);
|
|
||||||
if (!keys.length) return false;
|
|
||||||
const first = data[keys[0]];
|
|
||||||
if (typeof first !== 'object' || Array.isArray(first)) return false;
|
|
||||||
const entryKeys = Object.keys(first);
|
|
||||||
return entryKeys.length > 0 && entryKeys.length <= 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEntryTable(fieldId, container, content) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
|
||||||
if (!entries.length) { container.innerHTML = '<div class="pfm-empty">No entries.</div>'; return; }
|
|
||||||
|
|
||||||
const cols = Object.keys(entries[0][1]);
|
|
||||||
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);
|
|
||||||
const total = entries.length;
|
|
||||||
const perPage = st.entriesPerPage;
|
|
||||||
|
|
||||||
function buildPage(page) {
|
|
||||||
const start = (page - 1) * perPage;
|
|
||||||
const pageEntries = entries.slice(start, start + perPage);
|
|
||||||
const totalPages = Math.ceil(total / perPage);
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
|
||||||
${total} entries total
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
|
||||||
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
|
|
||||||
<i class="fas fa-calendar-day"></i> Jump to today (day ${todayDoy})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="${fieldId}_tbl_wrap" class="pfm-table-wrap" style="max-height:52vh;overflow-y:auto;">
|
|
||||||
<table class="pfm-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="pfm-day-col">Day</th>
|
|
||||||
${cols.map(c => `<th>${escHtml(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}</th>`).join('')}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${pageEntries.map(([day, val]) => `
|
|
||||||
<tr data-day="${day}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
|
|
||||||
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
|
|
||||||
${cols.map(col => {
|
|
||||||
const v = val[col] ?? '';
|
|
||||||
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
|
|
||||||
return isLong
|
|
||||||
? `<td><textarea data-day="${day}" data-col="${escHtml(col)}" rows="2"
|
|
||||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"
|
|
||||||
>${escHtml(String(v))}</textarea></td>`
|
|
||||||
: `<td><input type="text" data-day="${day}" data-col="${escHtml(col)}"
|
|
||||||
value="${escHtml(String(v))}"
|
|
||||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"></td>`;
|
|
||||||
}).join('')}
|
|
||||||
</tr>`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-pagination">
|
|
||||||
<span>Page ${page} of ${totalPages}</span>
|
|
||||||
<div class="pfm-page-jump">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
|
||||||
${page <= 1 ? 'disabled' : ''}
|
|
||||||
onclick="window._pfmTablePage('${fieldId}',${page - 1})">‹ Prev</button>
|
|
||||||
<span>Go to</span>
|
|
||||||
<input type="number" min="1" max="${totalPages}" value="${page}"
|
|
||||||
onchange="window._pfmTablePage('${fieldId}',+this.value)">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
|
||||||
${page >= totalPages ? 'disabled' : ''}
|
|
||||||
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next ›</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
st._tablePage = page;
|
|
||||||
st._tableEntries = entries;
|
|
||||||
st._tableCols = cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildPage(st._tablePage || 1);
|
|
||||||
window._pfmTablePage = function (fId, p) {
|
|
||||||
const s = getState(fId);
|
|
||||||
const totalP = Math.ceil(s._tableEntries.length / s.entriesPerPage);
|
|
||||||
buildPage(Math.max(1, Math.min(p, totalP)));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window._pfmCellEdit = function (fieldId, day, col, value) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
if (st._editData && st._editData[day]) st._editData[day][col] = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
window._pfmSave = async function (fieldId, filename) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const saveBtn = document.getElementById(`${fieldId}_save_btn`);
|
|
||||||
let content;
|
|
||||||
|
|
||||||
// Try getting from inline table data first, then textarea fallback
|
|
||||||
if (st._editData) {
|
|
||||||
content = st._editData;
|
|
||||||
} else {
|
|
||||||
const ta = document.getElementById(`${fieldId}_json_ta`);
|
|
||||||
if (!ta) return;
|
|
||||||
try { content = JSON.parse(ta.value); }
|
|
||||||
catch (e) {
|
|
||||||
const errEl = document.getElementById(`${fieldId}_json_err`);
|
|
||||||
if (errEl) errEl.textContent = 'Invalid JSON: ' + e.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving…'; }
|
|
||||||
|
|
||||||
const result = await callAction(st.pluginId, st.actions.save, {
|
|
||||||
filename, content: JSON.stringify(content)
|
|
||||||
}).catch(() => ({ status: 'error', message: 'Network error' }));
|
|
||||||
|
|
||||||
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="fas fa-save mr-1"></i>Save'; }
|
|
||||||
|
|
||||||
if (result.status === 'success') {
|
|
||||||
notify('File saved successfully', 'success');
|
|
||||||
window._pfmCloseModal(fieldId);
|
|
||||||
await loadFiles(fieldId);
|
|
||||||
} else {
|
|
||||||
notify('Save failed: ' + (result.message || 'Unknown error'), 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Delete modal ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window._pfmOpenDelete = function (fieldId, filename) {
|
|
||||||
const overlay = createOverlay(fieldId);
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="pfm-modal" style="max-width:28rem;">
|
|
||||||
<div class="pfm-modal-header">
|
|
||||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-body">
|
|
||||||
<div class="pfm-danger-box">
|
|
||||||
<strong>${escHtml(filename)}</strong> will be permanently deleted and removed
|
|
||||||
from the plugin configuration. This cannot be undone.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-footer">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
|
||||||
<button class="pfm-btn pfm-btn-danger"
|
|
||||||
onclick="window._pfmConfirmDelete('${fieldId}','${escHtml(filename)}')">
|
|
||||||
<i class="fas fa-trash mr-1"></i>Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
window._pfmConfirmDelete = async function (fieldId, filename) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const result = await callAction(st.pluginId, st.actions.delete, { filename })
|
|
||||||
.catch(() => ({ status: 'error', message: 'Network error' }));
|
|
||||||
if (result.status === 'success') {
|
|
||||||
notify('File deleted', 'success');
|
|
||||||
window._pfmCloseModal(fieldId);
|
|
||||||
await loadFiles(fieldId);
|
|
||||||
} else {
|
|
||||||
notify('Delete failed: ' + (result.message || ''), 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Create modal ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window._pfmOpenCreate = function (fieldId) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const fields = st.createFields;
|
|
||||||
const overlay = createOverlay(fieldId);
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="pfm-modal" style="max-width:32rem;">
|
|
||||||
<div class="pfm-modal-header">
|
|
||||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-body">
|
|
||||||
<div id="${fieldId}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
|
||||||
${fields.map(f => `
|
|
||||||
<div class="pfm-field">
|
|
||||||
<label for="${fieldId}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
|
||||||
<input type="text" id="${fieldId}_cf_${escHtml(f.key)}"
|
|
||||||
placeholder="${escHtml(f.placeholder || '')}"
|
|
||||||
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
|
||||||
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-footer">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
|
||||||
<button class="pfm-btn pfm-btn-create" id="${fieldId}_create_btn"
|
|
||||||
onclick="window._pfmConfirmCreate('${fieldId}')">
|
|
||||||
<i class="fas fa-plus mr-1"></i>Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
window._pfmConfirmCreate = async function (fieldId) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const errEl = document.getElementById(`${fieldId}_create_err`);
|
|
||||||
const btn = document.getElementById(`${fieldId}_create_btn`);
|
|
||||||
const params = {};
|
|
||||||
|
|
||||||
for (const f of st.createFields) {
|
|
||||||
const inp = document.getElementById(`${fieldId}_cf_${f.key}`);
|
|
||||||
if (!inp) continue;
|
|
||||||
const val = inp.value.trim();
|
|
||||||
if (f.pattern && val && !new RegExp(f.pattern).test(val)) {
|
|
||||||
if (errEl) errEl.textContent = `${f.label || f.key}: invalid format — ${f.hint || ''}`;
|
|
||||||
inp.focus(); return;
|
|
||||||
}
|
|
||||||
params[f.key] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Creating…'; }
|
|
||||||
if (errEl) errEl.textContent = '';
|
|
||||||
|
|
||||||
const result = await callAction(st.pluginId, st.actions.create, params)
|
|
||||||
.catch(() => ({ status: 'error', message: 'Network error' }));
|
|
||||||
|
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-plus mr-1"></i>Create'; }
|
|
||||||
|
|
||||||
if (result.status === 'success') {
|
|
||||||
notify('File created', 'success');
|
|
||||||
window._pfmCloseModal(fieldId);
|
|
||||||
await loadFiles(fieldId);
|
|
||||||
} else {
|
|
||||||
if (errEl) errEl.textContent = result.message || 'Create failed';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Toggle ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window._pfmToggle = async function (fieldId, categoryName, enabled) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const result = await callAction(st.pluginId, st.actions.toggle, { category_name: categoryName, enabled })
|
|
||||||
.catch(() => ({ status: 'error' }));
|
|
||||||
if (result.status === 'success') {
|
|
||||||
notify(enabled ? `${categoryName} enabled` : `${categoryName} disabled`, 'success');
|
|
||||||
await loadFiles(fieldId);
|
|
||||||
} else {
|
|
||||||
notify('Toggle failed', 'error');
|
|
||||||
await loadFiles(fieldId); // revert UI
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Upload ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window._pfmUpload = async function (fieldId, file) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const notifyFn = window.showNotification || console.log;
|
|
||||||
if (!file.name.toLowerCase().endsWith('.json')) {
|
|
||||||
notifyFn('Only .json files can be uploaded', 'error'); return;
|
|
||||||
}
|
|
||||||
let content;
|
|
||||||
try { content = await file.text(); JSON.parse(content); }
|
|
||||||
catch { notifyFn('File contains invalid JSON', 'error'); return; }
|
|
||||||
|
|
||||||
const result = await callAction(st.pluginId, st.actions.upload, {
|
|
||||||
filename: file.name, content
|
|
||||||
}).catch(() => ({ status: 'error', message: 'Network error' }));
|
|
||||||
|
|
||||||
if (result.status === 'success') {
|
|
||||||
notify('File uploaded: ' + (result.filename || file.name), 'success');
|
|
||||||
await loadFiles(fieldId);
|
|
||||||
} else {
|
|
||||||
notify('Upload failed: ' + (result.message || ''), 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Modal helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function createOverlay(fieldId) {
|
|
||||||
window._pfmCloseModal(fieldId); // close any open modal first
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'pfm-overlay';
|
|
||||||
overlay.id = `${fieldId}_pfm_overlay`;
|
|
||||||
// Close on backdrop click
|
|
||||||
overlay.addEventListener('click', e => { if (e.target === overlay) window._pfmCloseModal(fieldId); });
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
getState(fieldId).currentModal = overlay;
|
|
||||||
return overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
window._pfmCloseModal = function (fieldId) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
|
|
||||||
st._editData = null;
|
|
||||||
st._editFilename = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Widget registration ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window.LEDMatrixWidgets.register('plugin-file-manager', {
|
|
||||||
name: 'Plugin File Manager Widget',
|
|
||||||
version: '1.0.0',
|
|
||||||
|
|
||||||
render: function (container, config, value, options) {
|
|
||||||
const fieldId = (options.fieldId || container.id || 'pfm').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
const wc = config['x-widget-config'] || {};
|
|
||||||
const actions = wc.actions || {};
|
|
||||||
const pluginId = options.pluginId || '';
|
|
||||||
|
|
||||||
const st = getState(fieldId);
|
|
||||||
Object.assign(st, {
|
|
||||||
pluginId,
|
|
||||||
actions,
|
|
||||||
createFields: wc.create_fields || [],
|
|
||||||
uploadHint: wc.upload_hint || 'Upload JSON files',
|
|
||||||
directoryLabel: wc.directory_label || ''
|
|
||||||
});
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="pfm-root" id="${fieldId}_pfm">
|
|
||||||
<div class="pfm-header">
|
|
||||||
<div>
|
|
||||||
<div class="pfm-title">File Explorer</div>
|
|
||||||
${st.directoryLabel ? `<div class="pfm-dir">Manage files in <code>${escHtml(st.directoryLabel)}</code></div>` : ''}
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:.375rem;">
|
|
||||||
${actions.create ? `
|
|
||||||
<button class="pfm-btn pfm-btn-create"
|
|
||||||
onclick="window._pfmOpenCreate('${fieldId}')">
|
|
||||||
<i class="fas fa-plus mr-1"></i>New File
|
|
||||||
</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${actions.upload ? `
|
|
||||||
<div class="pfm-upload" id="${fieldId}_upload_zone"
|
|
||||||
onclick="document.getElementById('${fieldId}_file_input').click()"
|
|
||||||
ondragover="event.preventDefault();this.classList.add('dragover')"
|
|
||||||
ondragleave="this.classList.remove('dragover')"
|
|
||||||
ondrop="this.classList.remove('dragover');event.preventDefault();
|
|
||||||
if(event.dataTransfer.files[0])window._pfmUpload('${fieldId}',event.dataTransfer.files[0])">
|
|
||||||
<input type="file" id="${fieldId}_file_input" accept=".json"
|
|
||||||
style="display:none"
|
|
||||||
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
|
|
||||||
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
|
|
||||||
<p>Drag and drop or click to upload</p>
|
|
||||||
<small>${escHtml(st.uploadHint)}</small>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<div class="pfm-grid">
|
|
||||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
loadFiles(fieldId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getValue: function () { return null; }, // file ops are immediate; nothing to submit
|
|
||||||
setValue: function (fieldId) { loadFiles(fieldId); }
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[PluginFileManager] plugin-file-manager widget registered');
|
|
||||||
})();
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* LEDMatrix Time Picker Widget
|
|
||||||
*
|
|
||||||
* Single time selection using the browser's native time input.
|
|
||||||
* Returns a string in HH:MM (24-hour) format.
|
|
||||||
*
|
|
||||||
* Schema example:
|
|
||||||
* {
|
|
||||||
* "target_time": {
|
|
||||||
* "type": "string",
|
|
||||||
* "x-widget": "time-picker",
|
|
||||||
* "default": "00:00",
|
|
||||||
* "x-options": {
|
|
||||||
* "placeholder": "Select time",
|
|
||||||
* "clearable": true
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @module TimePickerWidget
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (base) return base.escapeHtml(text);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = String(text);
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeId(id) {
|
|
||||||
if (base) return base.sanitizeId(id);
|
|
||||||
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerChange(fieldId, value) {
|
|
||||||
if (base) {
|
|
||||||
base.triggerChange(fieldId, value);
|
|
||||||
} else {
|
|
||||||
document.dispatchEvent(new CustomEvent('widget-change', {
|
|
||||||
detail: { fieldId, value },
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.LEDMatrixWidgets.register('time-picker', {
|
|
||||||
name: 'Time Picker Widget',
|
|
||||||
version: '1.0.0',
|
|
||||||
|
|
||||||
render: function(container, config, value, options) {
|
|
||||||
const fieldId = sanitizeId(options.fieldId || container.id || 'time_picker');
|
|
||||||
const xOptions = config['x-options'] || config['x_options'] || {};
|
|
||||||
const placeholder = xOptions.placeholder || '';
|
|
||||||
const clearable = xOptions.clearable === true;
|
|
||||||
const disabled = xOptions.disabled === true;
|
|
||||||
const required = xOptions.required === true;
|
|
||||||
|
|
||||||
const currentValue = value || '';
|
|
||||||
|
|
||||||
let html = `<div id="${fieldId}_widget" class="time-picker-widget" data-field-id="${fieldId}">`;
|
|
||||||
html += '<div class="flex items-center">';
|
|
||||||
html += `
|
|
||||||
<div class="relative flex-1">
|
|
||||||
<input type="time"
|
|
||||||
id="${fieldId}_input"
|
|
||||||
name="${escapeHtml(options.name || fieldId)}"
|
|
||||||
value="${escapeHtml(currentValue)}"
|
|
||||||
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
|
|
||||||
${disabled ? 'disabled' : ''}
|
|
||||||
${required ? 'required' : ''}
|
|
||||||
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
|
|
||||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
|
||||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
|
||||||
<i class="fas fa-clock text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (clearable && !disabled) {
|
|
||||||
html += `
|
|
||||||
<button type="button"
|
|
||||||
id="${fieldId}_clear"
|
|
||||||
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
|
|
||||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
|
||||||
title="Clear">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
},
|
|
||||||
|
|
||||||
getValue: function(fieldId) {
|
|
||||||
const safeId = sanitizeId(fieldId);
|
|
||||||
const input = document.getElementById(`${safeId}_input`);
|
|
||||||
return input ? input.value : '';
|
|
||||||
},
|
|
||||||
|
|
||||||
setValue: function(fieldId, value) {
|
|
||||||
const safeId = sanitizeId(fieldId);
|
|
||||||
const input = document.getElementById(`${safeId}_input`);
|
|
||||||
const clearBtn = document.getElementById(`${safeId}_clear`);
|
|
||||||
if (input) input.value = value || '';
|
|
||||||
if (clearBtn) clearBtn.classList.toggle('hidden', !value);
|
|
||||||
},
|
|
||||||
|
|
||||||
validate: function(fieldId) {
|
|
||||||
const safeId = sanitizeId(fieldId);
|
|
||||||
const input = document.getElementById(`${safeId}_input`);
|
|
||||||
const errorEl = document.getElementById(`${safeId}_error`);
|
|
||||||
if (!input) return { valid: true, errors: [] };
|
|
||||||
const isValid = input.checkValidity();
|
|
||||||
if (errorEl) {
|
|
||||||
if (!isValid) {
|
|
||||||
errorEl.textContent = input.validationMessage;
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
input.classList.add('border-red-500');
|
|
||||||
} else {
|
|
||||||
errorEl.classList.add('hidden');
|
|
||||||
input.classList.remove('border-red-500');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { valid: isValid, errors: isValid ? [] : [input.validationMessage] };
|
|
||||||
},
|
|
||||||
|
|
||||||
handlers: {
|
|
||||||
onChange: function(fieldId) {
|
|
||||||
const widget = window.LEDMatrixWidgets.get('time-picker');
|
|
||||||
const safeId = sanitizeId(fieldId);
|
|
||||||
const clearBtn = document.getElementById(`${safeId}_clear`);
|
|
||||||
const value = widget.getValue(fieldId);
|
|
||||||
if (clearBtn) clearBtn.classList.toggle('hidden', !value);
|
|
||||||
widget.validate(fieldId);
|
|
||||||
triggerChange(fieldId, value);
|
|
||||||
},
|
|
||||||
|
|
||||||
onClear: function(fieldId) {
|
|
||||||
const widget = window.LEDMatrixWidgets.get('time-picker');
|
|
||||||
widget.setValue(fieldId, '');
|
|
||||||
triggerChange(fieldId, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[TimePickerWidget] Time picker widget registered');
|
|
||||||
})();
|
|
||||||
@@ -1442,14 +1442,9 @@ function renderInstalledPlugins(plugins) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to escape values for use in HTML attributes
|
// Helper function to escape attributes for use in HTML
|
||||||
const escapeAttr = (text) => {
|
const escapeAttr = (text) => {
|
||||||
return (text || '')
|
return (text || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
|
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
|
||||||
@@ -3446,28 +3441,6 @@ function generateFieldHtml(key, prop, value, prefix = '') {
|
|||||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
html += `<option value="${option}" ${selected}>${option}</option>`;
|
||||||
});
|
});
|
||||||
html += `</select>`;
|
html += `</select>`;
|
||||||
} else if (prop['x-widget'] === 'json-file-manager') {
|
|
||||||
// Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
|
|
||||||
const widgetConfig = prop['x-widget-config'] || {};
|
|
||||||
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
|
|
||||||
const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
|
|
||||||
html += `<div id="${safeFieldId}_jfm_mount"></div>`;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
|
|
||||||
if (!mount) return;
|
|
||||||
// Destroy the previous instance for this mount only — leave other instances intact
|
|
||||||
window.__jfmInstances = window.__jfmInstances || {};
|
|
||||||
const prev = window.__jfmInstances[safeFieldId];
|
|
||||||
if (prev?._destroy) prev._destroy();
|
|
||||||
if (typeof JsonFileManager !== 'undefined') {
|
|
||||||
window.__jfmInstances[safeFieldId] = new JsonFileManager(mount, widgetConfig, pluginId);
|
|
||||||
} else {
|
|
||||||
window.__jfmInstances[safeFieldId] = null;
|
|
||||||
mount.innerHTML = '<p style="color:#dc2626;font-size:.875rem;">json-file-manager widget not loaded. Check base.html includes json-file-manager.js.</p>';
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
} else if (prop['x-widget'] === 'custom-html') {
|
} else if (prop['x-widget'] === 'custom-html') {
|
||||||
// Custom HTML widget - load HTML from plugin directory
|
// Custom HTML widget - load HTML from plugin directory
|
||||||
const htmlFile = prop['x-html-file'];
|
const htmlFile = prop['x-html-file'];
|
||||||
@@ -4534,8 +4507,6 @@ function syncFormToJson() {
|
|||||||
// Deep merge with existing config to preserve nested structures
|
// Deep merge with existing config to preserve nested structures
|
||||||
function deepMerge(target, source) {
|
function deepMerge(target, source) {
|
||||||
for (const key in source) {
|
for (const key in source) {
|
||||||
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
|
||||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
|
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
|
||||||
target[key] = {};
|
target[key] = {};
|
||||||
@@ -7502,28 +7473,17 @@ setTimeout(function() {
|
|||||||
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try to attach install button handler after a delay (fallback).
|
// Also try to attach install button handler after a delay (fallback)
|
||||||
// Only run if the install button element is already in the DOM (i.e. the
|
|
||||||
// plugins partial has been loaded); otherwise the htmx:afterSettle listener
|
|
||||||
// below handles it when the tab is first visited.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof window.attachInstallButtonHandler === 'function' &&
|
if (typeof window.attachInstallButtonHandler === 'function') {
|
||||||
document.getElementById('install-plugin-from-url')) {
|
console.log('[FALLBACK] Attempting to attach install button handler...');
|
||||||
window.attachInstallButtonHandler();
|
window.attachInstallButtonHandler();
|
||||||
|
} else {
|
||||||
|
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
// Re-run install button wiring after HTMX settles the plugins tab content.
|
|
||||||
// Guard with element check so it only fires when the plugins partial is in the DOM,
|
|
||||||
// preventing spurious warnings on other tab loads.
|
|
||||||
document.addEventListener('htmx:afterSettle', function() {
|
|
||||||
if (document.getElementById('install-plugin-from-url') &&
|
|
||||||
typeof window.attachInstallButtonHandler === 'function') {
|
|
||||||
window.attachInstallButtonHandler();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Starlark Apps Integration ──────────────────────────────────────────────
|
// ─── Starlark Apps Integration ──────────────────────────────────────────────
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
@@ -136,7 +136,6 @@
|
|||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (typeof htmx !== 'undefined') {
|
if (typeof htmx !== 'undefined') {
|
||||||
console.log('HTMX loaded from fallback');
|
console.log('HTMX loaded from fallback');
|
||||||
window.dispatchEvent(new Event('htmx:ready'));
|
|
||||||
// Load extensions after core loads
|
// Load extensions after core loads
|
||||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
||||||
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
||||||
@@ -153,7 +152,6 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('HTMX loaded successfully');
|
console.log('HTMX loaded successfully');
|
||||||
window.dispatchEvent(new Event('htmx:ready'));
|
|
||||||
// Load extensions after core loads
|
// Load extensions after core loads
|
||||||
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
||||||
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
||||||
@@ -351,20 +349,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set data-loaded on tab containers after HTMX settles their content,
|
|
||||||
// preventing repeated re-fetches on every tab switch.
|
|
||||||
// Scoped to elements with hx-trigger="revealed" (tab containers only) so
|
|
||||||
// modals and plugin config panels that legitimately reload are unaffected.
|
|
||||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
|
||||||
if (event.detail && event.detail.target) {
|
|
||||||
var target = event.detail.target;
|
|
||||||
var trigger = target.getAttribute('hx-trigger') || '';
|
|
||||||
if (trigger.includes('revealed')) {
|
|
||||||
target.setAttribute('data-loaded', 'true');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
||||||
@@ -427,9 +411,6 @@
|
|||||||
.then(html => {
|
.then(html => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
if (typeof htmx !== 'undefined') {
|
|
||||||
htmx.process(content);
|
|
||||||
}
|
|
||||||
// Trigger full initialization chain
|
// Trigger full initialization chain
|
||||||
if (window.pluginManager) {
|
if (window.pluginManager) {
|
||||||
window.pluginManager.initialized = false;
|
window.pluginManager.initialized = false;
|
||||||
@@ -449,7 +430,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if HTMX doesn't load within 5 seconds
|
// Fallback if HTMX doesn't load within 5 seconds
|
||||||
var _pluginsFallbackTimer = setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof htmx === 'undefined') {
|
if (typeof htmx === 'undefined') {
|
||||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
||||||
// Load plugins tab content directly regardless of active tab,
|
// Load plugins tab content directly regardless of active tab,
|
||||||
@@ -457,7 +438,6 @@
|
|||||||
loadPluginsDirect();
|
loadPluginsDirect();
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { 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>
|
<script>
|
||||||
@@ -1050,9 +1030,6 @@
|
|||||||
.then(html => {
|
.then(html => {
|
||||||
overviewContent.innerHTML = html;
|
overviewContent.innerHTML = html;
|
||||||
overviewContent.setAttribute('data-loaded', 'true');
|
overviewContent.setAttribute('data-loaded', 'true');
|
||||||
if (typeof htmx !== 'undefined') {
|
|
||||||
htmx.process(overviewContent);
|
|
||||||
}
|
|
||||||
// Re-initialize Alpine.js for the new content
|
// Re-initialize Alpine.js for the new content
|
||||||
if (window.Alpine) {
|
if (window.Alpine) {
|
||||||
window.Alpine.initTree(overviewContent);
|
window.Alpine.initTree(overviewContent);
|
||||||
@@ -1081,7 +1058,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Also try direct load if HTMX doesn't load within 5 seconds
|
// Also try direct load if HTMX doesn't load within 5 seconds
|
||||||
var _overviewFallbackTimer = setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof htmx === 'undefined') {
|
if (typeof htmx === 'undefined') {
|
||||||
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
||||||
const appElement = document.querySelector('[x-data="app()"]');
|
const appElement = document.querySelector('[x-data="app()"]');
|
||||||
@@ -1093,7 +1070,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- General tab -->
|
<!-- General tab -->
|
||||||
@@ -1370,64 +1346,34 @@
|
|||||||
|
|
||||||
<!-- SSE connection for real-time updates -->
|
<!-- SSE connection for real-time updates -->
|
||||||
<script>
|
<script>
|
||||||
// Assign to window so reconnectSSE() in app.js can reach them.
|
// Connect to SSE streams
|
||||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
const statsSource = new EventSource('/api/v3/stream/stats');
|
||||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
const displaySource = new EventSource('/api/v3/stream/display');
|
||||||
|
|
||||||
window.statsSource.onmessage = function(event) {
|
statsSource.onmessage = function(event) {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
updateSystemStats(data);
|
updateSystemStats(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.displaySource.onmessage = function(event) {
|
displaySource.onmessage = function(event) {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
updateDisplayPreview(data);
|
updateDisplayPreview(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
function _setConnectionStatus(connected, reconnecting) {
|
// Connection status
|
||||||
const el = document.getElementById('connection-status');
|
statsSource.addEventListener('open', function() {
|
||||||
if (!el) return;
|
document.getElementById('connection-status').innerHTML = `
|
||||||
if (connected) {
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
el.innerHTML = `
|
<span class="text-gray-600">Connected</span>
|
||||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
`;
|
||||||
<span class="text-gray-600">Connected</span>
|
});
|
||||||
`;
|
|
||||||
} else if (reconnecting) {
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
|
||||||
<span class="text-gray-600">Reconnecting…</span>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
||||||
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _statsErrorCount = 0;
|
statsSource.addEventListener('error', function() {
|
||||||
|
document.getElementById('connection-status').innerHTML = `
|
||||||
// Named on window so reconnectSSE() in app.js can reattach them after
|
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||||
// replacing the EventSource instances.
|
<span class="text-gray-600">Disconnected</span>
|
||||||
window._statsOpenHandler = function() {
|
`;
|
||||||
_statsErrorCount = 0;
|
});
|
||||||
_setConnectionStatus(true, false);
|
|
||||||
};
|
|
||||||
window._statsErrorHandler = function() {
|
|
||||||
_statsErrorCount++;
|
|
||||||
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
|
|
||||||
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
|
|
||||||
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
|
|
||||||
};
|
|
||||||
window._displayErrorHandler = function() {
|
|
||||||
// Display stream errors don't change the status badge but log to console
|
|
||||||
// so failures aren't completely silent.
|
|
||||||
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.statsSource.addEventListener('open', window._statsOpenHandler);
|
|
||||||
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
|
||||||
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
|
||||||
|
|
||||||
function updateSystemStats(data) {
|
function updateSystemStats(data) {
|
||||||
// Update CPU in header
|
// Update CPU in header
|
||||||
@@ -1870,18 +1816,13 @@
|
|||||||
htmx.trigger(contentEl, 'revealed');
|
htmx.trigger(contentEl, 'revealed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// HTMX is still loading asynchronously — retry when it signals ready,
|
// HTMX not available, use direct fetch
|
||||||
// or fall back to direct fetch if it fails to load entirely.
|
console.warn('HTMX not available, using direct fetch for tab:', tab);
|
||||||
const self = this;
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
|
||||||
function onReady() { window.removeEventListener('htmx-load-failed', onFailed); self.loadTabContent(tab); }
|
loadOverviewDirect();
|
||||||
function onFailed() {
|
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
|
||||||
window.removeEventListener('htmx:ready', onReady);
|
loadWifiDirect();
|
||||||
if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
|
|
||||||
else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
|
|
||||||
else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
|
|
||||||
}
|
}
|
||||||
window.addEventListener('htmx:ready', onReady, { once: true });
|
|
||||||
window.addEventListener('htmx-load-failed', onFailed, { once: true });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -4625,9 +4566,6 @@
|
|||||||
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
||||||
|
|
||||||
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
|
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
|
|
||||||
|
|
||||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium 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
|
||||||
@@ -82,7 +82,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium 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
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<button hx-post="/api/v3/system/action"
|
<button hx-post="/api/v3/system/action"
|
||||||
hx-vals='{"action": "git_pull"}'
|
hx-vals='{"action": "git_pull"}'
|
||||||
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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 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
|
||||||
@@ -101,7 +101,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium 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
|
||||||
|
|||||||
@@ -843,14 +843,6 @@ async function updateFontPreview() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// BDF bitmap fonts cannot be rendered server-side — skip the API call
|
|
||||||
if (family.toLowerCase().endsWith('.bdf')) {
|
|
||||||
previewImage.style.display = 'none';
|
|
||||||
loadingText.style.display = 'block';
|
|
||||||
loadingText.textContent = 'Preview not available for BDF bitmap fonts';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
loadingText.textContent = 'Loading preview...';
|
loadingText.textContent = 'Loading preview...';
|
||||||
loadingText.style.display = 'block';
|
loadingText.style.display = 'block';
|
||||||
|
|||||||
@@ -1,66 +1,3 @@
|
|||||||
<!-- Reconciliation warning banner: shown when startup reconciliation found stale plugin config entries -->
|
|
||||||
<div id="reconciliation-banner" class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 mb-4 flex items-start" style="display:none !important" role="alert">
|
|
||||||
<div class="flex-shrink-0 mr-3 mt-0.5">
|
|
||||||
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium text-yellow-800">Plugin Config Warning</p>
|
|
||||||
<p class="text-sm text-yellow-700 mt-1" id="reconciliation-banner-text"></p>
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="window.dismissReconciliationBanner()" class="ml-4 flex-shrink-0 text-yellow-500 hover:text-yellow-700" aria-label="Dismiss">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var DISMISS_KEY = 'ledmatrix-recon-dismissed';
|
|
||||||
var _recon_timer = null;
|
|
||||||
|
|
||||||
function checkReconciliation() {
|
|
||||||
fetch('/api/v3/plugins/reconciliation-status')
|
|
||||||
.then(function (r) { return r.json(); })
|
|
||||||
.then(function (resp) {
|
|
||||||
var d = resp.data || {};
|
|
||||||
if (!d.done) {
|
|
||||||
// Reconciliation still running — poll again shortly
|
|
||||||
_recon_timer = setTimeout(checkReconciliation, 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_recon_timer = null;
|
|
||||||
if (!d.unresolved || d.unresolved.length === 0) return;
|
|
||||||
var key = d.unresolved.map(function (i) { return i.plugin_id; }).sort().join(',');
|
|
||||||
if (sessionStorage.getItem(DISMISS_KEY) === key) return;
|
|
||||||
var ids = d.unresolved.map(function (i) { return i.plugin_id; }).join(', ');
|
|
||||||
document.getElementById('reconciliation-banner-text').textContent =
|
|
||||||
'Stale plugin config entries found: ' + ids +
|
|
||||||
'. Remove them from config.json or reinstall via the Plugin Store.';
|
|
||||||
var banner = document.getElementById('reconciliation-banner');
|
|
||||||
banner.dataset.dismissKey = key;
|
|
||||||
banner.style.setProperty('display', 'flex', 'important');
|
|
||||||
})
|
|
||||||
.catch(function () {});
|
|
||||||
}
|
|
||||||
checkReconciliation();
|
|
||||||
|
|
||||||
window.dismissReconciliationBanner = function () {
|
|
||||||
var banner = document.getElementById('reconciliation-banner');
|
|
||||||
banner.style.setProperty('display', 'none', 'important');
|
|
||||||
if (_recon_timer !== null) {
|
|
||||||
clearTimeout(_recon_timer);
|
|
||||||
_recon_timer = null;
|
|
||||||
}
|
|
||||||
// Persist dismissal immediately so the banner won't reappear on reload
|
|
||||||
// even if the background sync fetch below fails.
|
|
||||||
var key = banner.dataset.dismissKey;
|
|
||||||
if (key) {
|
|
||||||
try { sessionStorage.setItem(DISMISS_KEY, key); } catch (e) {}
|
|
||||||
}
|
|
||||||
// Background sync only — do not rely on this for DISMISS_KEY or hiding.
|
|
||||||
fetch('/api/v3/plugins/reconciliation-status').catch(function () {});
|
|
||||||
};
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
<h2 class="text-lg font-semibold text-gray-900">System Overview</h2>
|
||||||
@@ -151,7 +88,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display started', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
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
|
||||||
@@ -160,7 +97,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display stopped', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
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
|
||||||
@@ -170,7 +107,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Code update completed', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
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
|
||||||
@@ -180,7 +117,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System rebooting...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
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
|
||||||
@@ -190,7 +127,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'System shutting down...', event.detail.xhr.responseJSON.status || 'info'); }"
|
||||||
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
|
||||||
@@ -199,7 +136,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Display service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
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
|
||||||
@@ -208,7 +145,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="if (typeof showNotification !== 'undefined' && event.detail.xhr && event.detail.xhr.responseJSON) { showNotification(event.detail.xhr.responseJSON.message || 'Web service restarted', event.detail.xhr.responseJSON.status || 'success'); }"
|
||||||
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
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
||||||
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
||||||
{% set description = prop.description if prop.description else '' %}
|
{% set description = prop.description if prop.description else '' %}
|
||||||
{% set _pt = prop.get('type') %}
|
{% set field_type = prop.type if prop.type is string else (prop.type[0] if prop.type is iterable else 'string') %}
|
||||||
{% set field_type = _pt if (_pt is string) else ((_pt | first) if (_pt and _pt is iterable and _pt is not string) else 'string') %}
|
|
||||||
|
|
||||||
{# Handle nested objects - check for widget first #}
|
{# Handle nested objects - check for widget first #}
|
||||||
{% if field_type == 'object' %}
|
{% if field_type == 'object' %}
|
||||||
@@ -497,26 +496,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="array-table-container mt-1" data-field-id="{{ field_id }}" data-full-key="{{ full_key }}" data-max-items="{{ max_items }}" data-plugin-id="{{ plugin_id }}">
|
<div class="array-table-container mt-1" data-field-id="{{ field_id }}" data-full-key="{{ full_key }}" data-max-items="{{ max_items }}" data-plugin-id="{{ plugin_id }}">
|
||||||
<div style="overflow-x:auto">
|
<table class="min-w-full divide-y divide-gray-200 border border-gray-300 rounded-lg">
|
||||||
<table class="divide-y divide-gray-200 border border-gray-300 rounded-lg" style="min-width:max-content;width:100%">
|
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
{% for col_name in display_columns %}
|
{% for col_name in display_columns %}
|
||||||
{% set col_def = item_properties.get(col_name, {}) %}
|
{% set col_def = item_properties.get(col_name, {}) %}
|
||||||
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
||||||
{% set col_xwidget = col_def.get('x-widget', '') %}
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ col_title }}</th>
|
||||||
{% set col_enum = col_def.get('enum', []) %}
|
|
||||||
{% set col_ctype = col_def.get('type', 'string') %}
|
|
||||||
{% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %}
|
|
||||||
{% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %}
|
|
||||||
{% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %}
|
|
||||||
{% elif col_enum %}{% set col_min_w = '90px' %}
|
|
||||||
{% elif col_ctype == 'boolean' %}{% set col_min_w = '60px' %}
|
|
||||||
{% elif col_ctype in ['integer', 'number'] %}{% set col_min_w = '80px' %}
|
|
||||||
{% else %}{% set col_min_w = '110px' %}{% endif %}
|
|
||||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:{{ col_min_w }}">{{ col_title }}</th>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:90px">Actions</th>
|
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
|
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
|
||||||
@@ -526,17 +514,8 @@
|
|||||||
{% for col_name in display_columns %}
|
{% for col_name in display_columns %}
|
||||||
{% set col_def = item_properties.get(col_name, {}) %}
|
{% set col_def = item_properties.get(col_name, {}) %}
|
||||||
{% set col_type = col_def.get('type', 'string') %}
|
{% set col_type = col_def.get('type', 'string') %}
|
||||||
{% set col_xwidget = col_def.get('x-widget', '') %}
|
|
||||||
{% set col_enum = col_def.get('enum', []) %}
|
|
||||||
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
||||||
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
{% elif col_xwidget == 'time-picker' %}{% set td_min_w = '115px' %}
|
|
||||||
{% elif col_xwidget == 'file-upload-single' %}{% set td_min_w = '200px' %}
|
|
||||||
{% elif col_enum %}{% set td_min_w = '90px' %}
|
|
||||||
{% elif col_type == 'boolean' %}{% set td_min_w = '60px' %}
|
|
||||||
{% elif col_type in ['integer', 'number'] %}{% set td_min_w = '80px' %}
|
|
||||||
{% else %}{% set td_min_w = '110px' %}{% endif %}
|
|
||||||
<td class="px-3 py-3 whitespace-nowrap" style="min-width:{{ td_min_w }};vertical-align:middle">
|
|
||||||
{% if col_type == 'boolean' %}
|
{% if col_type == 'boolean' %}
|
||||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="false">
|
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="false">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
@@ -553,43 +532,6 @@
|
|||||||
{% if col_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
|
{% if col_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
|
||||||
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
||||||
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
|
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
|
||||||
{% elif col_enum %}
|
|
||||||
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
||||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
|
|
||||||
{% for opt in col_enum %}{% if opt is not none %}
|
|
||||||
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option>
|
|
||||||
{% endif %}{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% elif col_xwidget == 'date-picker' %}
|
|
||||||
<input type="date"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
||||||
value="{{ col_value if col_value is not none else '' }}"
|
|
||||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
|
||||||
{% elif col_xwidget == 'time-picker' %}
|
|
||||||
<input type="time"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
||||||
value="{{ col_value if col_value is not none else '00:00' }}"
|
|
||||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
|
||||||
{% elif col_xwidget == 'file-upload-single' %}
|
|
||||||
{% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{% if col_value %}<img src="/{{ col_value }}" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
|
|
||||||
<input type="text"
|
|
||||||
id="{{ cell_input_id }}"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
||||||
value="{{ col_value if col_value is not none else '' }}"
|
|
||||||
class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs"
|
|
||||||
placeholder="path…">
|
|
||||||
<label class="cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
|
|
||||||
<i class="fas fa-upload"></i>
|
|
||||||
<input type="file"
|
|
||||||
accept="image/png,image/jpeg,image/bmp,image/gif"
|
|
||||||
style="display:none"
|
|
||||||
data-plugin-id="{{ plugin_id }}"
|
|
||||||
data-target-input="{{ cell_input_id }}"
|
|
||||||
onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||||
@@ -602,60 +544,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||||
{# Actions cell: delete + optional edit button for advanced props #}
|
|
||||||
{% set has_advanced = namespace(value=false) %}
|
|
||||||
{% for k in item_properties.keys() %}{% if k not in display_columns and k != 'id' %}{% set has_advanced.value = true %}{% endif %}{% endfor %}
|
|
||||||
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="removeArrayTableRow(this)"
|
onclick="removeArrayTableRow(this)"
|
||||||
class="text-red-600 hover:text-red-800 px-2 py-1">
|
class="text-red-600 hover:text-red-800 px-2 py-1">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
{% if has_advanced.value %}
|
|
||||||
<button type="button"
|
|
||||||
onclick="openArrayTableRowEditor(this)"
|
|
||||||
class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1"
|
|
||||||
title="Edit layout, style and other advanced properties">
|
|
||||||
<i class="fas fa-sliders-h"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# Hidden cell: flat hidden inputs for non-displayed props (layout, style, etc.) #}
|
|
||||||
{% if has_advanced.value %}
|
|
||||||
{% set adv_schema = namespace(d={}) %}
|
|
||||||
{% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %}
|
|
||||||
<td style="display:none" class="array-table-advanced-data"
|
|
||||||
data-prop-schema='{{ adv_schema.d|tojson }}'>
|
|
||||||
{% for prop_name, prop_schema in adv_schema.d.items() %}
|
|
||||||
{% set prop_type = prop_schema.get('type', 'string') %}
|
|
||||||
{% if prop_type == 'object' and prop_schema.get('properties') %}
|
|
||||||
{% for sub_name, sub_schema in prop_schema.get('properties', {}).items() %}
|
|
||||||
{% set sub_val = item.get(prop_name, {}).get(sub_name) %}
|
|
||||||
{% set sub_default = sub_schema.get('default') %}
|
|
||||||
{% set final_val = sub_val if sub_val is not none else sub_default %}
|
|
||||||
<input type="hidden"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}.{{ sub_name }}"
|
|
||||||
data-nested-prop="{{ prop_name }}.{{ sub_name }}"
|
|
||||||
data-prop-type="{{ sub_schema.get('type', 'string') }}"
|
|
||||||
data-prop-schema='{{ sub_schema|tojson }}'
|
|
||||||
value="{{ final_val if final_val is not none else '' }}">
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
{% set prop_val = item.get(prop_name) %}
|
|
||||||
{% set prop_default = prop_schema.get('default') %}
|
|
||||||
{% set final_val = prop_val if prop_val is not none else prop_default %}
|
|
||||||
<input type="hidden"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}"
|
|
||||||
data-nested-prop="{{ prop_name }}"
|
|
||||||
data-prop-type="{{ prop_schema.get('type', 'string') }}"
|
|
||||||
data-prop-schema='{{ prop_schema|tojson }}'
|
|
||||||
value="{{ final_val if final_val is not none else '' }}">
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -667,58 +562,11 @@
|
|||||||
data-max-items="{{ max_items }}"
|
data-max-items="{{ max_items }}"
|
||||||
data-plugin-id="{{ plugin_id }}"
|
data-plugin-id="{{ plugin_id }}"
|
||||||
data-item-properties='{% set ns = namespace(d={}) %}{% for k in display_columns %}{% if k in item_properties %}{% set _ = ns.d.update({k: item_properties[k]}) %}{% endif %}{% endfor %}{{ ns.d|tojson }}'
|
data-item-properties='{% set ns = namespace(d={}) %}{% for k in display_columns %}{% if k in item_properties %}{% set _ = ns.d.update({k: item_properties[k]}) %}{% endif %}{% endfor %}{{ ns.d|tojson }}'
|
||||||
data-full-item-properties='{{ item_properties|tojson }}'
|
|
||||||
data-display-columns='{{ display_columns|tojson }}'
|
data-display-columns='{{ display_columns|tojson }}'
|
||||||
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md"
|
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md"
|
||||||
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
|
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
|
||||||
<i class="fas fa-plus mr-1"></i> Add Item
|
<i class="fas fa-plus mr-1"></i> Add Item
|
||||||
</button>
|
</button>
|
||||||
</div>{# end overflow-x:auto wrapper #}
|
|
||||||
</div>
|
|
||||||
{% elif x_widget == 'color-picker' %}
|
|
||||||
{# RGB color array: R / G / B number inputs + visual swatch + sync'd hex picker #}
|
|
||||||
{% set color_arr = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else [255, 255, 255]) %}
|
|
||||||
{% set r_val = color_arr[0] if color_arr|length > 0 else 255 %}
|
|
||||||
{% set g_val = color_arr[1] if color_arr|length > 1 else 255 %}
|
|
||||||
{% set b_val = color_arr[2] if color_arr|length > 2 else 255 %}
|
|
||||||
{% set hex_val = '#%02x%02x%02x' % (r_val|int, g_val|int, b_val|int) %}
|
|
||||||
<div class="flex items-center gap-3 flex-wrap mt-1" id="{{ field_id }}_color_row">
|
|
||||||
<input type="color"
|
|
||||||
id="{{ field_id }}_hex"
|
|
||||||
value="{{ hex_val }}"
|
|
||||||
class="h-9 w-12 cursor-pointer rounded border border-gray-300"
|
|
||||||
title="Color picker"
|
|
||||||
oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">R</label>
|
|
||||||
<input type="number" min="0" max="255" step="1"
|
|
||||||
id="{{ field_id }}_r"
|
|
||||||
name="{{ full_key }}.0"
|
|
||||||
value="{{ r_val }}"
|
|
||||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
||||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">G</label>
|
|
||||||
<input type="number" min="0" max="255" step="1"
|
|
||||||
id="{{ field_id }}_g"
|
|
||||||
name="{{ full_key }}.1"
|
|
||||||
value="{{ g_val }}"
|
|
||||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
||||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">B</label>
|
|
||||||
<input type="number" min="0" max="255" step="1"
|
|
||||||
id="{{ field_id }}_b"
|
|
||||||
name="{{ full_key }}.2"
|
|
||||||
value="{{ b_val }}"
|
|
||||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
||||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
|
||||||
</div>
|
|
||||||
<div class="w-8 h-8 rounded border border-gray-300 flex-shrink-0"
|
|
||||||
style="background-color: rgb({{ r_val }}, {{ g_val }}, {{ b_val }})"
|
|
||||||
title="Color preview"></div>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Generic array-of-objects would go here if needed in the future #}
|
{# Generic array-of-objects would go here if needed in the future #}
|
||||||
@@ -777,19 +625,7 @@
|
|||||||
name="{{ full_key }}"
|
name="{{ full_key }}"
|
||||||
value="{{ str_value }}">
|
value="{{ str_value }}">
|
||||||
</div>
|
</div>
|
||||||
{% elif str_widget == 'json-file-manager' %}
|
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
|
||||||
{# Embedded file manager — plugin's web_ui/file_manager.html served via /v3/plugin-ui/ route #}
|
|
||||||
<div class="mt-1 rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<iframe id="{{ field_id }}_frame"
|
|
||||||
src="/v3/plugin-ui/{{ plugin_id }}/web-ui/file_manager.html"
|
|
||||||
style="width:100%;height:640px;border:none;"
|
|
||||||
title="File Manager for {{ plugin_id }}"></iframe>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-amber-600 mt-2 flex items-center">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Changes in the file manager save immediately — no need to click Save Configuration.
|
|
||||||
</p>
|
|
||||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %}
|
|
||||||
{# Render widget container #}
|
{# Render widget container #}
|
||||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||||
<script>
|
<script>
|
||||||
@@ -806,9 +642,7 @@
|
|||||||
'enum': {{ (prop.enum or [])|tojson|safe }},
|
'enum': {{ (prop.enum or [])|tojson|safe }},
|
||||||
'minimum': {{ prop.minimum|tojson if prop.minimum is defined else 'null' }},
|
'minimum': {{ prop.minimum|tojson if prop.minimum is defined else 'null' }},
|
||||||
'maximum': {{ prop.maximum|tojson if prop.maximum is defined else 'null' }},
|
'maximum': {{ prop.maximum|tojson if prop.maximum is defined else 'null' }},
|
||||||
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
|
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }}
|
||||||
'x-upload-config': {{ (prop.get('x-upload-config') or prop.get('x_upload_config') or {})|tojson|safe }},
|
|
||||||
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
|
|
||||||
};
|
};
|
||||||
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
|
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
|
||||||
}
|
}
|
||||||
@@ -1029,28 +863,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Web UI Actions — hide if schema has a dedicated file-manager widget,
|
{# Web UI Actions (if any) #}
|
||||||
or if every action is marked ui_hidden in the manifest. #}
|
{% if web_ui_actions %}
|
||||||
{% set has_file_manager_widget = namespace(value=false) %}
|
|
||||||
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
|
||||||
{% if _fp.get('x-widget') in ('json-file-manager', 'plugin-file-manager') %}
|
|
||||||
{% set has_file_manager_widget.value = true %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% set visible_actions = [] %}
|
|
||||||
{% for _a in web_ui_actions %}
|
|
||||||
{% if not _a.get('ui_hidden', false) %}
|
|
||||||
{% set _ = visible_actions.append(_a) %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if visible_actions and not has_file_manager_widget.value %}
|
|
||||||
<div class="mt-6 pt-4 border-t border-gray-200">
|
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
||||||
{% if visible_actions[0].section_description %}
|
{% if web_ui_actions[0].section_description %}
|
||||||
<p class="text-sm text-gray-600 mb-4">{{ visible_actions[0].section_description }}</p>
|
<p class="text-sm text-gray-600 mb-4">{{ web_ui_actions[0].section_description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{% for action in visible_actions %}
|
{% for action in web_ui_actions %}
|
||||||
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||||
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||||
{% set bg_color = action.color or 'blue' %}
|
{% set bg_color = action.color or 'blue' %}
|
||||||
|
|||||||
Reference in New Issue
Block a user