mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-15 09:28:37 +00:00
Compare commits
24 Commits
fix/wifi-a
...
a63a2c6044
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a63a2c6044 | ||
|
|
98ea9748fc | ||
|
|
fc6d8060de | ||
|
|
08c70ea31f | ||
|
|
6cdafe7b29 | ||
|
|
e00b124b8f | ||
|
|
19c5fbb62f | ||
|
|
4be334c678 | ||
|
|
e873632f95 | ||
|
|
98d4b3b55b | ||
|
|
b1af068f7a | ||
|
|
4dc33c256c | ||
|
|
dbad01a215 | ||
|
|
098a738891 | ||
|
|
abade43772 | ||
|
|
b44ff079c9 | ||
|
|
6c4700583b | ||
|
|
f96fdd9f24 | ||
|
|
35c540d0e0 | ||
|
|
7603909c59 | ||
|
|
34b186125a | ||
|
|
ea95f37d73 | ||
|
|
0c7d03a476 | ||
|
|
321a87f734 |
7
.codacy.yml
Normal file
7
.codacy.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "plugin-repos/**"
|
||||
- "plugins/**"
|
||||
- "assets/**"
|
||||
- "test/**"
|
||||
- "scripts/debug/**"
|
||||
@@ -10,6 +10,98 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
|
||||
|
||||
## 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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its 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`)
|
||||
|
||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
"Pillow>=10.0.0",
|
||||
"PyYAML>=6.0",
|
||||
"requests>=2.31.0"
|
||||
]
|
||||
],
|
||||
"local_only": true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
||||
Extracted from PluginManager to improve separation of concerns.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import importlib
|
||||
import importlib.util
|
||||
@@ -138,53 +139,98 @@ class PluginLoader:
|
||||
self,
|
||||
plugin_dir: Path,
|
||||
plugin_id: str,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
timeout: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
Install plugin dependencies from requirements.txt.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_dir: Plugin directory path
|
||||
plugin_id: Plugin identifier
|
||||
plugins_dir: Trusted base plugins directory for path containment check
|
||||
timeout: Installation timeout in seconds
|
||||
|
||||
|
||||
Returns:
|
||||
True if dependencies installed or not needed, False on error
|
||||
"""
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
return False
|
||||
# Resolve and validate plugin_dir before constructing any derived paths
|
||||
try:
|
||||
plugin_dir_resolved = plugin_dir.resolve(strict=True)
|
||||
except OSError:
|
||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
||||
return False
|
||||
requirements_file = plugin_dir_resolved / "requirements.txt"
|
||||
if not requirements_file.exists():
|
||||
return True # No dependencies needed
|
||||
marker_path = plugin_dir_resolved / ".dependencies_installed"
|
||||
|
||||
# Check if already installed
|
||||
if marker_path.exists():
|
||||
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||
return True
|
||||
|
||||
# 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
|
||||
|
||||
try:
|
||||
with open(requirements_file, 'rb') as fh:
|
||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
||||
except OSError as e:
|
||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
||||
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:
|
||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
|
||||
|
||||
if result.returncode == 0:
|
||||
# Mark as installed
|
||||
marker_path.touch()
|
||||
# Set proper file permissions after creating marker
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
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)
|
||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||
return True
|
||||
else:
|
||||
@@ -199,8 +245,12 @@ class PluginLoader:
|
||||
"Assuming they are satisfied: %s",
|
||||
plugin_id, stderr.strip()
|
||||
)
|
||||
marker_path.touch()
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
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(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
@@ -543,11 +593,12 @@ class PluginLoader:
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
install_deps: bool = True
|
||||
install_deps: bool = True,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
) -> Tuple[Any, Any]:
|
||||
"""
|
||||
Complete plugin loading process.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
manifest: Plugin manifest
|
||||
@@ -557,16 +608,22 @@ class PluginLoader:
|
||||
cache_manager: Cache manager instance
|
||||
plugin_manager: Plugin manager instance
|
||||
install_deps: Whether to install dependencies
|
||||
|
||||
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
|
||||
|
||||
Returns:
|
||||
Tuple of (plugin_instance, module)
|
||||
|
||||
|
||||
Raises:
|
||||
PluginError: If loading fails
|
||||
"""
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
self.install_dependencies(plugin_dir, plugin_id)
|
||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
||||
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
|
||||
entry_point = manifest.get('entry_point', 'manager.py')
|
||||
|
||||
@@ -350,7 +350,8 @@ class PluginManager:
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
|
||||
# Store module
|
||||
|
||||
@@ -185,13 +185,19 @@ class StateReconciliation:
|
||||
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({
|
||||
'web_display_autostart', 'timezone', 'location', 'display',
|
||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||
'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]]:
|
||||
@@ -334,15 +340,15 @@ class StateReconciliation:
|
||||
# Check: Enabled state mismatch
|
||||
config_enabled = config.get('enabled', False)
|
||||
state_mgr_enabled = state_mgr.get('enabled')
|
||||
|
||||
|
||||
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'enabled': config_enabled},
|
||||
expected_state={'enabled': state_mgr_enabled},
|
||||
current_state={'enabled': state_mgr_enabled},
|
||||
expected_state={'enabled': config_enabled},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
@@ -365,15 +371,23 @@ class StateReconciliation:
|
||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||
# Sync enabled state from state manager to config
|
||||
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||
config = self.config_manager.load_config()
|
||||
if inconsistency.plugin_id not in config:
|
||||
config[inconsistency.plugin_id] = {}
|
||||
config[inconsistency.plugin_id]['enabled'] = expected_enabled
|
||||
self.config_manager.save_config(config)
|
||||
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
|
||||
return True
|
||||
# config.json is the user-editable source of truth for enabled state.
|
||||
# Bring the state manager in sync with config rather than the reverse,
|
||||
# so that manual config edits (or the state left behind after an
|
||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
||||
config_enabled = inconsistency.expected_state.get('enabled')
|
||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
||||
if success:
|
||||
self.logger.info(
|
||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
||||
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:
|
||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||
|
||||
@@ -5,6 +5,7 @@ Handles plugin discovery, installation, updates, and uninstallation
|
||||
from both the official registry and custom GitHub repositories.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import json
|
||||
import stat
|
||||
@@ -1755,6 +1756,12 @@ class PluginStoreManager:
|
||||
timeout=300
|
||||
)
|
||||
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
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -150,6 +150,18 @@ class WiFiManager:
|
||||
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||
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):
|
||||
"""
|
||||
@@ -474,7 +486,10 @@ class WiFiManager:
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '/' in line:
|
||||
ip_address = line.split('/')[0].strip()
|
||||
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
|
||||
# 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
|
||||
|
||||
# Final fallback: Get signal strength by matching SSID in WiFi list
|
||||
@@ -500,6 +515,13 @@ class WiFiManager:
|
||||
|
||||
# Check if AP mode is 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(
|
||||
connected=wifi_connected,
|
||||
@@ -690,6 +712,10 @@ class WiFiManager:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_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]:
|
||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||
@@ -1367,7 +1393,7 @@ class WiFiManager:
|
||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||
# Trigger AP mode as last resort
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
||||
@@ -1379,7 +1405,7 @@ class WiFiManager:
|
||||
elif not success:
|
||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed. AP mode enabled."
|
||||
@@ -1400,7 +1426,7 @@ class WiFiManager:
|
||||
logger.error(f"Failed to restore after exception: {restore_error}")
|
||||
# Last resort: enable AP mode
|
||||
try:
|
||||
self.enable_ap_mode()
|
||||
self.enable_ap_mode(force=True)
|
||||
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)
|
||||
return False, str(e)
|
||||
@@ -1464,26 +1490,29 @@ class WiFiManager:
|
||||
# Show LED message
|
||||
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
||||
|
||||
# First, check if connection already exists and try to activate it
|
||||
# NetworkManager connection names might not match SSID exactly, so search by SSID
|
||||
check_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
# Find existing NM connection for this SSID.
|
||||
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
||||
# so list all wifi connections then query each one's SSID individually.
|
||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
existing_conn_name = None
|
||||
if check_result.returncode == 0:
|
||||
for line in check_result.stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
conn_name = parts[0].strip()
|
||||
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
||||
if conn_ssid == ssid:
|
||||
existing_conn_name = conn_name
|
||||
break
|
||||
if list_result.returncode == 0:
|
||||
for line in list_result.stdout.strip().split('\n'):
|
||||
if ':' not in line:
|
||||
continue
|
||||
parts = line.split(':')
|
||||
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
||||
continue
|
||||
conn_name = parts[0].strip()
|
||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
||||
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)
|
||||
if not existing_conn_name:
|
||||
@@ -1855,7 +1884,7 @@ class WiFiManager:
|
||||
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
||||
return False
|
||||
|
||||
def enable_ap_mode(self) -> Tuple[bool, str]:
|
||||
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Enable access point mode
|
||||
|
||||
@@ -1877,20 +1906,29 @@ class WiFiManager:
|
||||
if not self._ensure_wifi_radio_enabled():
|
||||
return False, "WiFi radio is disabled and could not be enabled"
|
||||
|
||||
# Check if WiFi is connected
|
||||
# Check if WiFi is connected (skip when force=True)
|
||||
status = self.get_wifi_status()
|
||||
if status.connected:
|
||||
if not force and status.connected:
|
||||
return False, "Cannot enable AP mode while WiFi is connected"
|
||||
|
||||
# Check if Ethernet is connected
|
||||
if self._is_ethernet_connected():
|
||||
# Check if Ethernet is connected (skip when force=True)
|
||||
if not force and self._is_ethernet_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)
|
||||
if self.has_hostapd and self.has_dnsmasq:
|
||||
result = self._enable_ap_mode_hostapd()
|
||||
if result[0]:
|
||||
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
|
||||
|
||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||
@@ -1900,6 +1938,12 @@ class WiFiManager:
|
||||
result = self._enable_ap_mode_nmcli_hotspot()
|
||||
if result[0]:
|
||||
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 False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||
@@ -2091,8 +2135,14 @@ class WiFiManager:
|
||||
self._clear_led_message()
|
||||
return False, "AP started but captive-portal redirect setup failed"
|
||||
|
||||
# Verify the AP is actually running
|
||||
status = self._get_ap_status_nmcli()
|
||||
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
|
||||
status = {}
|
||||
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'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||
@@ -2290,6 +2340,7 @@ class WiFiManager:
|
||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||
|
||||
self._ap_enabled_at = None
|
||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
||||
logger.info("AP mode disabled successfully")
|
||||
return True, "AP mode disabled"
|
||||
except Exception as e:
|
||||
@@ -2478,22 +2529,29 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
else:
|
||||
logger.warning(f"Failed to enable AP mode: {message}")
|
||||
elif not should_have_ap and ap_active:
|
||||
# Should not have AP but do - disable AP mode
|
||||
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
|
||||
if status.connected or ethernet_connected:
|
||||
# Should not have AP but do - check if it was manually force-enabled
|
||||
force_active = self._FORCE_AP_FLAG_PATH.exists()
|
||||
if status.connected:
|
||||
# WiFi connected: always disable AP (user successfully configured WiFi)
|
||||
success, message = self.disable_ap_mode()
|
||||
if success:
|
||||
if status.connected:
|
||||
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
|
||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||
self._disconnected_checks = 0
|
||||
return True
|
||||
else:
|
||||
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:
|
||||
# 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")
|
||||
|
||||
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||
|
||||
@@ -2,8 +2,11 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
@@ -22,6 +25,9 @@ from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.plugin_system.operation_history import OperationHistory
|
||||
from src.plugin_system.health_monitor import PluginHealthMonitor
|
||||
|
||||
_JOURNALCTL = shutil.which('journalctl')
|
||||
_SYSTEMCTL = shutil.which('systemctl')
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.urandom(24)
|
||||
@@ -413,13 +419,53 @@ def add_security_headers(response):
|
||||
|
||||
return response
|
||||
|
||||
# SSE helper function
|
||||
def sse_response(generator_func):
|
||||
"""Helper to create SSE responses"""
|
||||
def generate():
|
||||
for data in generator_func():
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
class _StreamBroadcaster:
|
||||
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients.
|
||||
|
||||
This means N browser tabs share one generator instead of each running their own,
|
||||
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
|
||||
"""
|
||||
|
||||
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
|
||||
def system_status_generator():
|
||||
@@ -450,12 +496,13 @@ def system_status_generator():
|
||||
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||
now = time.time()
|
||||
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
||||
try:
|
||||
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
if _SYSTEMCTL:
|
||||
try:
|
||||
result = subprocess.run([_SYSTEMCTL, 'is-active', 'ledmatrix'],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
app.logger.warning("systemctl status check failed: %s", e)
|
||||
_ledmatrix_service_cache['timestamp'] = now
|
||||
service_active = _ledmatrix_service_cache['active']
|
||||
|
||||
@@ -547,8 +594,13 @@ def logs_generator():
|
||||
# Get recent logs from journalctl (simplified version)
|
||||
# Note: User should be in systemd-journal group to read logs without sudo
|
||||
try:
|
||||
if not _JOURNALCTL:
|
||||
yield {'timestamp': time.time(), 'logs': 'journalctl not found; cannot read logs'}
|
||||
time.sleep(60)
|
||||
continue
|
||||
result = subprocess.run(
|
||||
['journalctl', '-u', 'ledmatrix.service', '-n', '50', '--no-pager'],
|
||||
[_JOURNALCTL, '-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
||||
'-n', '50', '--no-pager', '--output=short-iso'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
@@ -564,7 +616,7 @@ def logs_generator():
|
||||
# No logs available
|
||||
logs_data = {
|
||||
'timestamp': time.time(),
|
||||
'logs': 'No logs available from ledmatrix service'
|
||||
'logs': 'No logs available from ledmatrix or ledmatrix-web service'
|
||||
}
|
||||
yield logs_data
|
||||
else:
|
||||
@@ -596,20 +648,50 @@ def logs_generator():
|
||||
|
||||
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
|
||||
@app.route('/api/v3/stream/stats')
|
||||
def stream_stats():
|
||||
return sse_response(system_status_generator)
|
||||
return _sse_stream(_stats_broadcaster)
|
||||
|
||||
@app.route('/api/v3/stream/display')
|
||||
def stream_display():
|
||||
return sse_response(display_preview_generator)
|
||||
return _sse_stream(_display_broadcaster)
|
||||
|
||||
@app.route('/api/v3/stream/logs')
|
||||
def stream_logs():
|
||||
return sse_response(logs_generator)
|
||||
return _sse_stream(_logs_broadcaster)
|
||||
|
||||
# Exempt SSE streams from CSRF and add rate limiting
|
||||
# Exempt SSE streams from CSRF and apply a generous rate limit.
|
||||
# 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:
|
||||
csrf.exempt(stream_stats)
|
||||
csrf.exempt(stream_display)
|
||||
@@ -617,9 +699,9 @@ if csrf:
|
||||
# Note: api_v3 blueprint is exempted above after registration
|
||||
|
||||
if limiter:
|
||||
limiter.limit("20 per minute")(stream_stats)
|
||||
limiter.limit("20 per minute")(stream_display)
|
||||
limiter.limit("20 per minute")(stream_logs)
|
||||
limiter.limit("200 per minute")(stream_stats)
|
||||
limiter.limit("200 per minute")(stream_display)
|
||||
limiter.limit("200 per minute")(stream_logs)
|
||||
|
||||
# Main route - redirect to v3 interface as default
|
||||
@app.route('/')
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
@@ -25,6 +26,9 @@ from src.web_interface.validators import (
|
||||
)
|
||||
from src.error_aggregator import get_error_aggregator
|
||||
|
||||
_SUDO = shutil.which('sudo')
|
||||
_JOURNALCTL = shutil.which('journalctl')
|
||||
|
||||
# Will be initialized when blueprint is registered
|
||||
config_manager = None
|
||||
plugin_manager = None
|
||||
@@ -1456,31 +1460,41 @@ def execute_system_action():
|
||||
if mode:
|
||||
# For on-demand modes, we would need to integrate with the display controller
|
||||
# For now, just start the display service
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
try:
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
logger.error("start_display (%s) timed out: %s", mode, e)
|
||||
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
||||
logger.info("start_display (%s) returned code %d", mode, result.returncode)
|
||||
return jsonify({
|
||||
if result.returncode != 0 and result.stderr:
|
||||
logger.error("start_display (%s) stderr: %s", mode, result.stderr.strip())
|
||||
resp = {
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': 'Display started' if result.returncode == 0 else 'Failed to start display',
|
||||
})
|
||||
}
|
||||
if result.returncode != 0:
|
||||
resp['returncode'] = result.returncode
|
||||
resp['stderr'] = result.stderr.strip()
|
||||
return jsonify(resp)
|
||||
else:
|
||||
result = subprocess.run(['sudo', 'systemctl', 'start', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'stop_display':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'stop', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'enable_autostart':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'enable', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'disable_autostart':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'disable', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'reboot_system':
|
||||
result = subprocess.run(['sudo', 'reboot'],
|
||||
capture_output=True, text=True)
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'shutdown_system':
|
||||
result = subprocess.run(['sudo', 'poweroff'],
|
||||
capture_output=True, text=True)
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'git_pull':
|
||||
# Use PROJECT_ROOT instead of hardcoded path
|
||||
project_dir = str(PROJECT_ROOT)
|
||||
@@ -1555,20 +1569,29 @@ def execute_system_action():
|
||||
})
|
||||
elif action == 'restart_display_service':
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix'],
|
||||
capture_output=True, text=True)
|
||||
capture_output=True, text=True, timeout=10)
|
||||
elif action == 'restart_web_service':
|
||||
# Try to restart the web service (assuming it's ledmatrix-web.service)
|
||||
result = subprocess.run(['sudo', 'systemctl', 'restart', 'ledmatrix-web'],
|
||||
capture_output=True, text=True)
|
||||
capture_output=True, text=True, timeout=10)
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Unknown action'}), 400
|
||||
|
||||
logger.info("system action '%s' returncode=%d", action, result.returncode)
|
||||
return jsonify({
|
||||
if result.returncode != 0 and result.stderr:
|
||||
logger.error("system action '%s' stderr: %s", action, result.stderr.strip())
|
||||
resp = {
|
||||
'status': 'success' if result.returncode == 0 else 'error',
|
||||
'message': 'Action completed' if result.returncode == 0 else 'Action failed; check logs for details',
|
||||
})
|
||||
}
|
||||
if result.returncode != 0:
|
||||
resp['returncode'] = result.returncode
|
||||
resp['stderr'] = result.stderr.strip()
|
||||
return jsonify(resp)
|
||||
|
||||
except subprocess.TimeoutExpired as e:
|
||||
logger.error("system action '%s' timed out: %s", action, e)
|
||||
return jsonify({'status': 'error', 'message': 'Command timed out', 'returncode': -1, 'stderr': 'timeout'})
|
||||
except Exception as e:
|
||||
logger.error("execute_system_action failed: %s", e, exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'Action failed; see logs for details'}), 500
|
||||
@@ -2664,6 +2687,16 @@ def update_plugin():
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
current_last_updated = manifest.get('last_updated')
|
||||
if manifest.get('local_only'):
|
||||
logger.debug("Skipping update for local-only plugin: %s", plugin_id)
|
||||
if api_v3.operation_history:
|
||||
api_v3.operation_history.record_operation(
|
||||
"update",
|
||||
plugin_id=plugin_id,
|
||||
status="skipped",
|
||||
details={"reason": "local_only"}
|
||||
)
|
||||
return success_response(message=f'Plugin {plugin_id} is managed locally and does not receive registry updates')
|
||||
except Exception as e:
|
||||
logger.debug("Could not read local manifest for plugin: %s", e)
|
||||
|
||||
@@ -6425,9 +6458,14 @@ def list_plugin_assets():
|
||||
def get_logs():
|
||||
"""Get system logs from journalctl"""
|
||||
try:
|
||||
if not _JOURNALCTL:
|
||||
return jsonify({'status': 'error', 'message': 'journalctl not found on this system'}), 503
|
||||
# Get recent logs from journalctl
|
||||
_cmd = ([_SUDO, _JOURNALCTL] if _SUDO else [_JOURNALCTL]) + [
|
||||
'-u', 'ledmatrix.service', '-u', 'ledmatrix-web.service',
|
||||
'-n', '100', '--no-pager', '--output=short-iso']
|
||||
result = subprocess.run(
|
||||
['sudo', 'journalctl', '-u', 'ledmatrix.service', '-n', '100', '--no-pager'],
|
||||
_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
@@ -6438,7 +6476,7 @@ def get_logs():
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'logs': logs_text if logs_text else 'No logs available from ledmatrix service'
|
||||
'logs': logs_text if logs_text else 'No logs available from ledmatrix or ledmatrix-web service'
|
||||
}
|
||||
})
|
||||
else:
|
||||
@@ -6542,7 +6580,7 @@ def scan_wifi_networks():
|
||||
ap_was_active = wifi_manager._is_ap_mode_active()
|
||||
|
||||
# Perform the scan (this will handle AP mode disabling/enabling internally)
|
||||
networks = wifi_manager.scan_networks()
|
||||
networks, _was_cached = wifi_manager.scan_networks()
|
||||
|
||||
# Convert to dict format
|
||||
networks_data = [
|
||||
@@ -6680,7 +6718,9 @@ def enable_ap_mode():
|
||||
from src.wifi_manager import WiFiManager
|
||||
|
||||
wifi_manager = WiFiManager()
|
||||
success, message = wifi_manager.enable_ap_mode()
|
||||
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
|
||||
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
|
||||
success, message = wifi_manager.enable_ap_mode(force=force)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
|
||||
@@ -3,8 +3,13 @@ from markupsafe import escape
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Strict allowlists for URL-derived values used in path and script operations.
|
||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -102,6 +107,99 @@ def load_plugin_config_partial(plugin_id):
|
||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
||||
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.
|
||||
"""
|
||||
# Validate URL-derived values against strict allowlists before any path or
|
||||
# script operations.
|
||||
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
|
||||
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
||||
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
# os.path.basename() is the CodeQL-recognised path sanitizer used throughout
|
||||
# this codebase (see plugin_loader.py). Applying it here breaks the taint
|
||||
# chain even though the allowlist above already prevents path separators.
|
||||
safe_id = os.path.basename(plugin_id)
|
||||
safe_fn = os.path.basename(filename)
|
||||
if not safe_id or not safe_fn:
|
||||
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
if not pages_v3.plugin_manager:
|
||||
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
|
||||
|
||||
try:
|
||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
|
||||
# Reconstruct from sanitised basename — CodeQL-approved pattern.
|
||||
_plugin_dir = (_plugins_base / safe_id).resolve()
|
||||
_plugin_dir.relative_to(_plugins_base) # containment guard
|
||||
|
||||
# Mirror PluginManager's ledmatrix- prefix fallback.
|
||||
if not _plugin_dir.exists():
|
||||
_alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
||||
_alt = (_plugins_base / _alt_id).resolve()
|
||||
try:
|
||||
_alt.relative_to(_plugins_base)
|
||||
_plugin_dir = _alt
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
web_ui_path = (_plugin_dir / 'web_ui' / safe_fn).resolve()
|
||||
web_ui_path.relative_to(_plugin_dir / 'web_ui') # second guard
|
||||
|
||||
if not web_ui_path.exists():
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
fragment = web_ui_path.read_text(encoding='utf-8')
|
||||
|
||||
# json.dumps wraps the value in quotes. Replace HTML meta-chars with
|
||||
# their JS Unicode escape sequences so the value cannot close or escape
|
||||
# the enclosing <script> tag.
|
||||
# r'<' is the 6-char literal string <, which JavaScript
|
||||
# interprets as <. This is the standard JSON-in-HTML hardening pattern.
|
||||
safe_plugin_id_js = (
|
||||
json.dumps(safe_id)
|
||||
.replace('<', '\\u003c')
|
||||
.replace('>', '\\u003e')
|
||||
.replace('&', '\\u0026')
|
||||
)
|
||||
|
||||
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.
|
||||
# plugin_id is validated to [a-zA-Z0-9_-] above, so this is safe,
|
||||
# but we also Unicode-escape HTML meta-chars as defence in depth.
|
||||
f' window.PLUGIN_ID = {safe_plugin_id_js};\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, {'Content-Type': 'text/plain'}
|
||||
except Exception:
|
||||
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
||||
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||
|
||||
def _load_overview_partial():
|
||||
"""Load overview partial with system stats"""
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* global showNotification, updateSystemStats, htmx */
|
||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
|
||||
// LED Matrix v3 JavaScript
|
||||
// Additional helpers for HTMX and Alpine.js integration
|
||||
|
||||
@@ -51,7 +51,8 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
}
|
||||
});
|
||||
|
||||
// SSE reconnection helper
|
||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
||||
// reattaching the open/error handlers defined in base.html.
|
||||
window.reconnectSSE = function() {
|
||||
if (window.statsSource) {
|
||||
window.statsSource.close();
|
||||
@@ -60,14 +61,18 @@ window.reconnectSSE = function() {
|
||||
const data = JSON.parse(event.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) {
|
||||
window.displaySource.close();
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
window.displaySource.onmessage = function() {
|
||||
// Handle display updates
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
|
||||
};
|
||||
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
291
web_interface/static/v3/js/widgets/file-upload-single.js
Normal file
291
web_interface/static/v3/js/widgets/file-upload-single.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
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 id="${fieldId}_fullpath" 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 — keyboard accessible via tabindex + Enter/Space
|
||||
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"
|
||||
role="button" tabindex="0"
|
||||
aria-label="${hasImage ? 'Replace image' : 'Upload image'}"
|
||||
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
|
||||
ondragover="event.preventDefault()"
|
||||
onclick="document.getElementById('${fieldId}_file_input').click()"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();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>';
|
||||
safeSetHTML(container, 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() : '';
|
||||
const fullpath = document.getElementById(`${safeId}_fullpath`);
|
||||
if (fullpath) fullpath.textContent = value || '';
|
||||
|
||||
// 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 — use DOM methods to avoid innerHTML with dynamic data
|
||||
if (statusDiv) {
|
||||
statusDiv.className = 'mt-1 text-xs text-gray-500';
|
||||
statusDiv.textContent = '';
|
||||
const spinner = document.createElement('i');
|
||||
spinner.className = 'fas fa-spinner fa-spin mr-1';
|
||||
statusDiv.appendChild(spinner);
|
||||
statusDiv.appendChild(document.createTextNode('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.textContent = '';
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-check-circle mr-1';
|
||||
statusDiv.appendChild(icon);
|
||||
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
|
||||
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 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.textContent = '';
|
||||
const errIcon = document.createElement('i');
|
||||
errIcon.className = 'fas fa-exclamation-circle mr-1';
|
||||
statusDiv.appendChild(errIcon);
|
||||
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
|
||||
}
|
||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[FileUploadSingleWidget] File upload single widget registered');
|
||||
})();
|
||||
783
web_interface/static/v3/js/widgets/json-file-manager.js
Normal file
783
web_interface/static/v3/js/widgets/json-file-manager.js
Normal file
@@ -0,0 +1,783 @@
|
||||
/**
|
||||
* 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)');
|
||||
}
|
||||
})();
|
||||
797
web_interface/static/v3/js/widgets/plugin-file-manager.js
Normal file
797
web_interface/static/v3/js/widgets/plugin-file-manager.js
Normal file
@@ -0,0 +1,797 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// ─── Safe HTML helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse html in a sandboxed DOMParser document (scripts never execute) and
|
||||
* replace target's children with the result. All dynamic values in html
|
||||
* must be escaped by the caller before passing here.
|
||||
*/
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
// ─── 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) safeSetHTML(grid, '<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) safeSetHTML(grid, '<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) {
|
||||
safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing delegated listener before re-render
|
||||
if (st._gridClickHandler) grid.removeEventListener('click', st._gridClickHandler);
|
||||
if (st._gridChangeHandler) grid.removeEventListener('change', st._gridChangeHandler);
|
||||
|
||||
// Event delegation: handles edit/delete/toggle via data attributes so
|
||||
// filenames and category names are never interpolated into JS string literals.
|
||||
st._gridClickHandler = function(e) {
|
||||
const btn = e.target.closest('[data-pfm-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.pfmAction;
|
||||
const fId = btn.dataset.pfmField;
|
||||
if (action === 'edit') window._pfmOpenEdit(fId, btn.dataset.pfmFile);
|
||||
if (action === 'delete') window._pfmOpenDelete(fId, btn.dataset.pfmFile);
|
||||
};
|
||||
st._gridChangeHandler = function(e) {
|
||||
const inp = e.target.closest('[data-pfm-action="toggle"]');
|
||||
if (!inp) return;
|
||||
window._pfmToggle(inp.dataset.pfmField, inp.dataset.pfmCategory, inp.checked);
|
||||
};
|
||||
grid.addEventListener('click', st._gridClickHandler);
|
||||
grid.addEventListener('change', st._gridChangeHandler);
|
||||
|
||||
// Build cards with DOM methods so no user-derived data flows through innerHTML.
|
||||
grid.textContent = '';
|
||||
const frag = document.createDocumentFragment();
|
||||
st.files.forEach(function(f) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'pfm-card' + (f.enabled === false ? ' disabled' : '');
|
||||
card.dataset.filename = f.filename;
|
||||
card.dataset.category = f.category_name;
|
||||
|
||||
// Top row: label + optional toggle
|
||||
const top = document.createElement('div');
|
||||
top.className = 'pfm-card-top';
|
||||
const lbl = document.createElement('span');
|
||||
lbl.className = 'pfm-toggle-label';
|
||||
lbl.textContent = f.enabled !== false ? 'Enabled' : 'Disabled';
|
||||
top.appendChild(lbl);
|
||||
if (st.actions.toggle) {
|
||||
const tglLabel = document.createElement('label');
|
||||
tglLabel.className = 'pfm-toggle-cb';
|
||||
tglLabel.title = f.enabled !== false ? 'Click to disable' : 'Click to enable';
|
||||
const tglInput = document.createElement('input');
|
||||
tglInput.type = 'checkbox';
|
||||
tglInput.checked = f.enabled !== false;
|
||||
tglInput.dataset.pfmAction = 'toggle';
|
||||
tglInput.dataset.pfmField = fieldId;
|
||||
tglInput.dataset.pfmCategory = f.category_name;
|
||||
const tglSlider = document.createElement('span');
|
||||
tglSlider.className = 'pfm-toggle-slider';
|
||||
tglLabel.appendChild(tglInput);
|
||||
tglLabel.appendChild(tglSlider);
|
||||
top.appendChild(tglLabel);
|
||||
}
|
||||
card.appendChild(top);
|
||||
|
||||
// Icon (static markup)
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'pfm-card-icon';
|
||||
icon.innerHTML = '<i class="fas fa-file-code"></i>';
|
||||
card.appendChild(icon);
|
||||
|
||||
// Name & meta — textContent avoids any HTML injection
|
||||
const name = document.createElement('div');
|
||||
name.className = 'pfm-card-name';
|
||||
name.textContent = f.display_name || f.filename;
|
||||
card.appendChild(name);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'pfm-card-meta';
|
||||
meta.appendChild(document.createTextNode(f.filename));
|
||||
meta.appendChild(document.createElement('br'));
|
||||
if (f.entry_count != null) {
|
||||
meta.appendChild(document.createTextNode(f.entry_count + ' entries · ' + formatSize(f.size)));
|
||||
}
|
||||
meta.appendChild(document.createElement('br'));
|
||||
meta.appendChild(document.createTextNode(formatDate(f.modified)));
|
||||
card.appendChild(meta);
|
||||
|
||||
// Action buttons
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'pfm-card-actions';
|
||||
if (st.actions.get && st.actions.save) {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'pfm-btn pfm-btn-primary';
|
||||
editBtn.dataset.pfmAction = 'edit';
|
||||
editBtn.dataset.pfmField = fieldId;
|
||||
editBtn.dataset.pfmFile = f.filename;
|
||||
editBtn.innerHTML = '<i class="fas fa-edit"></i> Edit'; // static
|
||||
actions.appendChild(editBtn);
|
||||
}
|
||||
if (st.actions.delete) {
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'pfm-btn pfm-btn-danger pfm-btn-sm';
|
||||
delBtn.dataset.pfmAction = 'delete';
|
||||
delBtn.dataset.pfmField = fieldId;
|
||||
delBtn.dataset.pfmFile = f.filename;
|
||||
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
|
||||
actions.appendChild(delBtn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
frag.appendChild(card);
|
||||
});
|
||||
grid.appendChild(frag);
|
||||
}
|
||||
|
||||
// ─── Edit modal ───────────────────────────────────────────────────────────
|
||||
|
||||
window._pfmOpenEdit = async function (fieldId, filename) {
|
||||
const st = getState(fieldId);
|
||||
const overlay = createOverlay(fieldId);
|
||||
// Build modal using DOM methods so filename never enters a JS string literal.
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
safeSetHTML(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" id="${escHtml(fieldId)}_modal_close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body" id="${escHtml(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" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1"></i>Save
|
||||
</button>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
// Bind events after DOM insertion — filename captured in closure, not in HTML.
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
|
||||
|
||||
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) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = data.content || data.data || {};
|
||||
st._editFilename = filename;
|
||||
|
||||
if (isTabular(content)) {
|
||||
// Table path: track cell edits live in _editData
|
||||
st._editData = content;
|
||||
renderEntryTable(fieldId, body, content);
|
||||
} else {
|
||||
// Textarea path: _editData stays null; save() reads from the <textarea>
|
||||
st._editData = null;
|
||||
safeSetHTML(body, `
|
||||
<textarea id="${escHtml(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="${escHtml(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.textContent = 'No entries.'; return; }
|
||||
|
||||
const cols = Object.keys(entries[0][1]);
|
||||
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
|
||||
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
|
||||
const total = entries.length;
|
||||
const perPage = st.entriesPerPage;
|
||||
|
||||
function buildPage(page) {
|
||||
const start = (page - 1) * perPage; // eslint-disable-line no-magic-numbers
|
||||
const pageEntries = entries.slice(start, start + perPage);
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
safeSetHTML(container, `
|
||||
<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;
|
||||
}
|
||||
|
||||
// Store buildPage in per-instance state so multiple instances don't
|
||||
// clobber each other's pagination via a shared global.
|
||||
st._buildPage = buildPage;
|
||||
buildPage(st._tablePage || 1);
|
||||
}
|
||||
|
||||
// Global dispatcher — resolves the per-instance buildPage from state so
|
||||
// multiple plugin-file-manager instances don't clobber each other.
|
||||
window._pfmTablePage = function (fId, p) {
|
||||
const s = getState(fId);
|
||||
if (s._buildPage) {
|
||||
const total = s._tableEntries ? s._tableEntries.length : 0;
|
||||
const totalP = Math.ceil(total / s.entriesPerPage) || 1;
|
||||
s._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; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Saving…'));})(saveBtn); }
|
||||
|
||||
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; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-save mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Save'));})(saveBtn); }
|
||||
|
||||
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);
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
modal.style.maxWidth = '28rem';
|
||||
safeSetHTML(modal, `
|
||||
<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" id="${escHtml(fieldId)}_del_close">
|
||||
<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" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
</button>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
|
||||
};
|
||||
|
||||
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);
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'pfm-modal';
|
||||
modal.style.maxWidth = '32rem';
|
||||
safeSetHTML(modal, `
|
||||
<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" id="${escHtml(fieldId)}_cre_close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||
${fields.map(f => `
|
||||
<div class="pfm-field">
|
||||
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||
<input type="text" id="${escHtml(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" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1"></i>Create
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
|
||||
};
|
||||
|
||||
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();
|
||||
// Client-side pattern validation omitted — server-side create-file script validates.
|
||||
params[f.key] = val;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Creating…'));})(btn); }
|
||||
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; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-plus mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Create'));})(btn); }
|
||||
|
||||
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 || ''
|
||||
});
|
||||
|
||||
safeSetHTML(container, `
|
||||
<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');
|
||||
})();
|
||||
166
web_interface/static/v3/js/widgets/time-picker.js
Normal file
166
web_interface/static/v3/js/widgets/time-picker.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetHTML(target, html) {
|
||||
target.textContent = '';
|
||||
// createContextualFragment parses html relative to the document context
|
||||
// without executing scripts — a widely recognised safe insertion method.
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
target.appendChild(frag);
|
||||
}
|
||||
|
||||
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>';
|
||||
|
||||
safeSetHTML(container, 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, '');
|
||||
widget.validate(fieldId); // refresh required/error state
|
||||
triggerChange(fieldId, '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[TimePickerWidget] Time picker widget registered');
|
||||
})();
|
||||
@@ -3446,6 +3446,28 @@ function generateFieldHtml(key, prop, value, prefix = '') {
|
||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
||||
});
|
||||
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') {
|
||||
// Custom HTML widget - load HTML from plugin directory
|
||||
const htmlFile = prop['x-html-file'];
|
||||
|
||||
@@ -1370,34 +1370,64 @@
|
||||
|
||||
<!-- SSE connection for real-time updates -->
|
||||
<script>
|
||||
// Connect to SSE streams
|
||||
const statsSource = new EventSource('/api/v3/stream/stats');
|
||||
const displaySource = new EventSource('/api/v3/stream/display');
|
||||
// Assign to window so reconnectSSE() in app.js can reach them.
|
||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
|
||||
statsSource.onmessage = function(event) {
|
||||
window.statsSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateSystemStats(data);
|
||||
};
|
||||
|
||||
displaySource.onmessage = function(event) {
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateDisplayPreview(data);
|
||||
};
|
||||
|
||||
// Connection status
|
||||
statsSource.addEventListener('open', function() {
|
||||
document.getElementById('connection-status').innerHTML = `
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Connected</span>
|
||||
`;
|
||||
});
|
||||
function _setConnectionStatus(connected, reconnecting) {
|
||||
const el = document.getElementById('connection-status');
|
||||
if (!el) return;
|
||||
if (connected) {
|
||||
el.innerHTML = `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
statsSource.addEventListener('error', function() {
|
||||
document.getElementById('connection-status').innerHTML = `
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Disconnected</span>
|
||||
`;
|
||||
});
|
||||
var _statsErrorCount = 0;
|
||||
|
||||
// Named on window so reconnectSSE() in app.js can reattach them after
|
||||
// replacing the EventSource instances.
|
||||
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) {
|
||||
// Update CPU in header
|
||||
@@ -4595,6 +4625,9 @@
|
||||
<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>
|
||||
|
||||
<!-- 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) -->
|
||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
||||
|
||||
|
||||
@@ -497,15 +497,31 @@
|
||||
{% 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 }}">
|
||||
<table class="min-w-full divide-y divide-gray-200 border border-gray-300 rounded-lg">
|
||||
<div style="overflow-x:auto">
|
||||
<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">
|
||||
<tr>
|
||||
{% for col_name in display_columns %}
|
||||
{% set col_def = item_properties.get(col_name, {}) %}
|
||||
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ col_title }}</th>
|
||||
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||
{% set col_enum = col_def.get('enum', []) %}
|
||||
{% set _raw_ctype = col_def.get('type', 'string') %}
|
||||
{% if _raw_ctype is iterable and _raw_ctype is not string %}
|
||||
{% set col_ctype = (_raw_ctype | reject('equalto','null') | list | first) or 'string' %}
|
||||
{% else %}
|
||||
{% set col_ctype = _raw_ctype or 'string' %}
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Actions</th>
|
||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:90px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
|
||||
@@ -514,9 +530,24 @@
|
||||
<tr class="array-table-row" data-index="{{ item_index }}">
|
||||
{% for col_name in display_columns %}
|
||||
{% set col_def = item_properties.get(col_name, {}) %}
|
||||
{% set col_type = col_def.get('type', 'string') %}
|
||||
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
|
||||
{% set _raw_type = col_def.get('type', 'string') %}
|
||||
{% if _raw_type is iterable and _raw_type is not string %}
|
||||
{% set col_type = (_raw_type | reject('equalto','null') | list | first) or 'string' %}
|
||||
{% else %}
|
||||
{% set col_type = _raw_type or 'string' %}
|
||||
{% endif %}
|
||||
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||
{% set col_enum = col_def.get('enum', []) %}
|
||||
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
||||
{% 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' %}
|
||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="false">
|
||||
<input type="checkbox"
|
||||
@@ -533,6 +564,43 @@
|
||||
{% 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"
|
||||
{% 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 %}
|
||||
<input type="text"
|
||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||
@@ -545,13 +613,60 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
{% 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"
|
||||
onclick="removeArrayTableRow(this)"
|
||||
class="text-red-600 hover:text-red-800 px-2 py-1">
|
||||
<i class="fas fa-trash"></i>
|
||||
</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>
|
||||
|
||||
{# 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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -563,11 +678,58 @@
|
||||
data-max-items="{{ max_items }}"
|
||||
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-full-item-properties='{{ item_properties|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"
|
||||
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
|
||||
<i class="fas fa-plus mr-1"></i> Add Item
|
||||
</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>
|
||||
{% else %}
|
||||
{# Generic array-of-objects would go here if needed in the future #}
|
||||
@@ -626,7 +788,19 @@
|
||||
name="{{ full_key }}"
|
||||
value="{{ str_value }}">
|
||||
</div>
|
||||
{% 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'] %}
|
||||
{% elif str_widget == 'json-file-manager' %}
|
||||
{# 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 #}
|
||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||
<script>
|
||||
@@ -643,7 +817,9 @@
|
||||
'enum': {{ (prop.enum or [])|tojson|safe }},
|
||||
'minimum': {{ prop.minimum|tojson if prop.minimum 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 }}' });
|
||||
}
|
||||
@@ -864,15 +1040,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Web UI Actions (if any) #}
|
||||
{% if web_ui_actions %}
|
||||
{# Web UI Actions — hide if schema has a dedicated file-manager widget,
|
||||
or if every action is marked ui_hidden in the manifest. #}
|
||||
{% set has_file_manager_widget = namespace(value=false) %}
|
||||
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
||||
{% if (_fp.get('x-widget') or _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">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
||||
{% if web_ui_actions[0].section_description %}
|
||||
<p class="text-sm text-gray-600 mb-4">{{ web_ui_actions[0].section_description }}</p>
|
||||
{% if visible_actions[0].section_description %}
|
||||
<p class="text-sm text-gray-600 mb-4">{{ visible_actions[0].section_description }}</p>
|
||||
{% endif %}
|
||||
<div class="space-y-3">
|
||||
{% for action in web_ui_actions %}
|
||||
{% for action in visible_actions %}
|
||||
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||
{% set bg_color = action.color or 'blue' %}
|
||||
|
||||
Reference in New Issue
Block a user