From 6c4700583bbbab666ea0a877f510b924f0f922c4 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 29 May 2026 13:38:19 -0400 Subject: [PATCH 01/17] fix(plugin-loader): detect new deps via requirements.txt hash instead of empty marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .dependencies_installed marker was an empty file, so adding a new package to requirements.txt (e.g. astral in ledmatrix-weather v2.3.0) never triggered a pip re-install on existing installs — the file existed so the check returned early. The marker now stores a SHA-256 hash of requirements.txt. On every plugin load, the loader compares the current hash to the stored one; a mismatch (or missing marker) triggers pip install and writes the new hash. store_manager._install_dependencies() also writes the hash marker after a store install/update so the loader skips a redundant pip run on next boot. Co-Authored-By: Claude Sonnet 4.6 --- src/plugin_system/plugin_loader.py | 17 ++++++++++------- src/plugin_system/store_manager.py | 7 +++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index 75628bb01..f486fac93 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -5,6 +5,7 @@ Extracted from PluginManager to improve separation of concerns. """ +import hashlib import json import importlib import importlib.util @@ -164,11 +165,15 @@ def install_dependencies( if not requirements_file.exists(): return True # No dependencies needed marker_path = plugin_dir_resolved / ".dependencies_installed" + current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest() - # Check if already installed + # Skip if requirements.txt hasn't changed since last install if marker_path.exists(): - self.logger.debug("Dependencies already installed for %s", plugin_id) - return True + stored_hash = marker_path.read_text(encoding='utf-8').strip() + 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) @@ -181,9 +186,7 @@ def install_dependencies( ) if result.returncode == 0: - # Mark as installed - marker_path.touch() - # Set proper file permissions after creating marker + marker_path.write_text(current_hash, encoding='utf-8') ensure_file_permissions(marker_path, get_plugin_file_mode()) self.logger.info("Dependencies installed successfully for %s", plugin_id) return True @@ -199,7 +202,7 @@ def install_dependencies( "Assuming they are satisfied: %s", plugin_id, stderr.strip() ) - marker_path.touch() + marker_path.write_text(current_hash, encoding='utf-8') ensure_file_permissions(marker_path, get_plugin_file_mode()) return True self.logger.warning( diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 5a7740568..2f2140cf3 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -5,6 +5,7 @@ from both the official registry and custom GitHub repositories. """ +import hashlib import os import json import stat @@ -1755,6 +1756,12 @@ def _install_dependencies(self, plugin_path: Path) -> bool: 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: From b44ff079c911eed78bd43a5a27e99dd43928e3c8 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 29 May 2026 15:42:03 -0400 Subject: [PATCH 02/17] fix(plugin-loader): address CodeQL path expression and I/O error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit relative_to() containment check after path resolution so CodeQL recognizes the plugin directory boundary (fixes 4 CodeQL alerts: Uncontrolled data used in path expression, lines 168/172/189/205) - Wrap requirements_file.read_bytes() in try/except OSError — on Raspberry Pi with flaky SD card storage this can fail; returns False with a clear log - Wrap marker_path.read_text() in try/except OSError — a corrupted marker falls through to a clean reinstall instead of crashing - Wrap both marker_path.write_text() calls in try/except OSError — pip already succeeded at this point so a marker write failure should not return False or propagate through the generic exception handler Co-Authored-By: Claude Sonnet 4.6 --- src/plugin_system/plugin_loader.py | 50 +++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index f486fac93..a6c148e3e 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -165,16 +165,36 @@ def install_dependencies( if not requirements_file.exists(): return True # No dependencies needed marker_path = plugin_dir_resolved / ".dependencies_installed" - current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest() + + # Validate both paths stay within the plugin directory (path containment check) + try: + requirements_file.relative_to(plugin_dir_resolved) + marker_path.relative_to(plugin_dir_resolved) + except ValueError: + self.logger.error("Dependency paths outside plugin directory for %s", plugin_id) + return False + + try: + current_hash = hashlib.sha256(requirements_file.read_bytes()).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 marker_path.exists(): - stored_hash = marker_path.read_text(encoding='utf-8').strip() - 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: + stored_hash = marker_path.read_text(encoding='utf-8').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( @@ -184,10 +204,13 @@ def install_dependencies( timeout=timeout, check=False ) - + if result.returncode == 0: - marker_path.write_text(current_hash, encoding='utf-8') - ensure_file_permissions(marker_path, get_plugin_file_mode()) + try: + marker_path.write_text(current_hash, encoding='utf-8') + ensure_file_permissions(marker_path, get_plugin_file_mode()) + except OSError as marker_err: + self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err) self.logger.info("Dependencies installed successfully for %s", plugin_id) return True else: @@ -202,8 +225,11 @@ def install_dependencies( "Assuming they are satisfied: %s", plugin_id, stderr.strip() ) - marker_path.write_text(current_hash, encoding='utf-8') - ensure_file_permissions(marker_path, get_plugin_file_mode()) + try: + marker_path.write_text(current_hash, encoding='utf-8') + ensure_file_permissions(marker_path, get_plugin_file_mode()) + except OSError as marker_err: + self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err) return True self.logger.warning( "Dependency installation returned non-zero exit code for %s: %s", From abade437725678bd24f102885a0935dd7ef35894 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 30 May 2026 10:32:03 -0400 Subject: [PATCH 03/17] fix(plugin-loader): use realpath+startswith containment check for CodeQL path-injection Replace relative_to() (not recognised by CodeQL as a path sanitiser) with the os.path.realpath() + startswith() pattern that CodeQL explicitly models as sanitising py/path-injection. - Add plugins_dir optional param to install_dependencies() and load_plugin() - PluginManager.load_plugin() passes self.plugins_dir as the trusted anchor; install_dependencies() validates that the resolved plugin_dir starts with the resolved plugins_dir before any file I/O - Replace all Path.read_bytes/read_text/write_text/exists with open() and os.path.isfile() so the sanitised string paths flow directly to file ops without re-introducing taint through Path object conversion Co-Authored-By: Claude Sonnet 4.6 --- src/plugin_system/plugin_loader.py | 74 +++++++++++++++++------------ src/plugin_system/plugin_manager.py | 3 +- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index a6c148e3e..654162b8f 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -139,51 +139,61 @@ def install_dependencies( 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: + + # Resolve to a canonical absolute path (normalises .. and symlinks) + plugin_dir_real = os.path.realpath(str(plugin_dir)) + + if plugins_dir is not None: + # Validate plugin_dir is within the trusted plugins base directory. + # os.path.realpath + startswith is the CodeQL-recognised sanitiser + # pattern for path-injection (py/path-injection). + plugins_dir_real = os.path.realpath(str(plugins_dir)) + if not plugin_dir_real.startswith(plugins_dir_real + os.sep): + self.logger.error( + "Plugin dir for %s is outside the plugins directory, skipping deps", + plugin_id, + ) + return False + elif not os.path.isdir(plugin_dir_real): 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" - # Validate both paths stay within the plugin directory (path containment check) - try: - requirements_file.relative_to(plugin_dir_resolved) - marker_path.relative_to(plugin_dir_resolved) - except ValueError: - self.logger.error("Dependency paths outside plugin directory for %s", plugin_id) - return False + requirements_file = os.path.join(plugin_dir_real, "requirements.txt") + marker_file = os.path.join(plugin_dir_real, ".dependencies_installed") + + if not os.path.isfile(requirements_file): + return True # No dependencies needed try: - current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest() + 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 marker_path.exists(): + if os.path.isfile(marker_file): try: - stored_hash = marker_path.read_text(encoding='utf-8').strip() + 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", @@ -198,7 +208,7 @@ def install_dependencies( 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, @@ -207,8 +217,9 @@ def install_dependencies( if result.returncode == 0: try: - marker_path.write_text(current_hash, encoding='utf-8') - ensure_file_permissions(marker_path, get_plugin_file_mode()) + 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) @@ -226,8 +237,9 @@ def install_dependencies( plugin_id, stderr.strip() ) try: - marker_path.write_text(current_hash, encoding='utf-8') - ensure_file_permissions(marker_path, get_plugin_file_mode()) + 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 @@ -572,11 +584,12 @@ def load_plugin( 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 @@ -586,16 +599,17 @@ def load_plugin( 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) + self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir) # Load module entry_point = manifest.get('entry_point', 'manager.py') diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index 18ed6b08a..e9eb4761c 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -350,7 +350,8 @@ def load_plugin(self, plugin_id: str) -> bool: 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 From 098a738891829d2052f39427236b9ffeb3588d21 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 30 May 2026 10:47:33 -0400 Subject: [PATCH 04/17] fix(plugin-loader): fail-fast when install_dependencies returns False MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the boolean result was silently discarded, so a failed pip install would log a warning but continue attempting to import the plugin module — resulting in a confusing ModuleNotFoundError instead of a clear dependency failure message. Now raises PluginError with plugin_id and plugin_dir if dependency installation fails, stopping the load before the import is attempted. Co-Authored-By: Claude Sonnet 4.6 --- src/plugin_system/plugin_loader.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index 654162b8f..7fd426aa3 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -609,7 +609,12 @@ def load_plugin( """ # Install dependencies if needed if install_deps: - self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir) + 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') From dbad01a215f5eb01a807dbf9d6debd9a199a4a4f Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 30 May 2026 14:18:57 -0400 Subject: [PATCH 05/17] fix(plugin-loader): use basename+reconstruct to satisfy CodeQL py/path-injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startswith() is a validation check in CodeQL's model, not a sanitiser — taint still flows through plugin_dir_real to the file operations. os.path.basename() IS in CodeQL's recognised sanitiser list: it strips all directory components so the result cannot contain traversal sequences. Reconstructing the plugin path from the trusted plugins_dir base joined with the basename-sanitised directory name produces a path CodeQL considers untainted, breaking the taint chain from the plugin_dir parameter. Co-Authored-By: Claude Sonnet 4.6 --- src/plugin_system/plugin_loader.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index 7fd426aa3..7bf894e57 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -162,22 +162,28 @@ def install_dependencies( plugin_dir_real = os.path.realpath(str(plugin_dir)) if plugins_dir is not None: - # Validate plugin_dir is within the trusted plugins base directory. - # os.path.realpath + startswith is the CodeQL-recognised sanitiser - # pattern for path-injection (py/path-injection). + # 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)) - if not plugin_dir_real.startswith(plugins_dir_real + os.sep): + safe_dir_name = os.path.basename(plugin_dir_real) + safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name) + if not os.path.isdir(safe_plugin_dir): self.logger.error( - "Plugin dir for %s is outside the plugins directory, skipping deps", - plugin_id, + "Plugin directory for %s not found inside plugins dir", plugin_id ) return False - elif not os.path.isdir(plugin_dir_real): - self.logger.error("Plugin directory does not exist: %s", plugin_dir) - 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(plugin_dir_real, "requirements.txt") - marker_file = os.path.join(plugin_dir_real, ".dependencies_installed") + 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 From 4dc33c256c936f496b1d9b4ccd225226a7bb138e Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 30 May 2026 14:29:03 -0400 Subject: [PATCH 06/17] fix(plugin-loader): guard against empty basename when plugin_dir resolves to fs root If plugin_dir somehow resolves to '/' or a bare drive root, os.path.basename() returns '', causing safe_plugin_dir to equal plugins_dir_real and the isdir() check to pass incorrectly. Reject early with a clear error in that case. Co-Authored-By: Claude Sonnet 4.6 --- src/plugin_system/plugin_loader.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/plugin_system/plugin_loader.py b/src/plugin_system/plugin_loader.py index 7bf894e57..49d2e4042 100644 --- a/src/plugin_system/plugin_loader.py +++ b/src/plugin_system/plugin_loader.py @@ -170,6 +170,9 @@ def install_dependencies( # 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( From b1af068f7a5d2cc2887be9045e627cac9b0b29e3 Mon Sep 17 00:00:00 2001 From: Chuck Date: Sat, 30 May 2026 17:58:02 -0400 Subject: [PATCH 07/17] feat(widgets): add plugin-file-manager, time-picker, file-upload-single widgets + array-table improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## New widgets ### plugin-file-manager (reusable) Inline file management UI driven entirely by x-widget-config in the plugin schema. Any plugin can adopt it by declaring web_ui_actions in manifest.json and adding x-widget: "plugin-file-manager" to their config schema. Features: - File card grid with enable/disable toggles, metadata (entry count, size, date) - Drag-and-drop + click upload zone with configurable hint text - Create file modal driven by create_fields schema config - Delete confirmation modal - Edit modal: auto-detects tabular data (object-of-objects) → paginated table with inline-editable cells and "Jump to today" navigation; falls back to JSON textarea for unstructured data - plugin_id auto-injected from template context; no per-plugin JS needed - Immediate saves via /api/v3/plugins/action — no Save Configuration required ### time-picker Wraps native , returns HH:MM string. Generic, zero config. ### file-upload-single Single-image upload for string fields. Shows thumbnail preview + clear button. plugin_id auto-injected from template context. ## New route (pages_v3.py) GET /v3/plugin-ui//web-ui/ Serves a plugin's web_ui/ HTML fragment as a standalone page, wrapping it with a minimal HTML page that injects window.PLUGIN_ID and loads Tailwind CSS. Enables the json-file-manager iframe fallback (Phase A) and future plugin UIs. ## plugin_config.html updates - json-file-manager: renders plugin's web_ui/file_manager.html in an iframe via the new /v3/plugin-ui/ route (Phase A compatibility) - plugin-file-manager: full inline widget registration - time-picker, file-upload-single: registered in widget elif chain - color-picker: wired for type:array (RGB triplet) fields — renders hex picker + R/G/B number inputs with bidirectional sync - Plugin Actions section: suppressed when schema has a file-manager widget or when all actions are marked ui_hidden in manifest - x-widget-config passed to all widgets in the init script block ## array-table.js improvements (v2.0.0) - enum fields → - time-picker x-widget → - file-upload-single x-widget → path input + upload button + thumbnail - Row edit modal (⚙) for non-displayed nested properties (layout, style objects) with color pickers, enum selects, number inputs - getValue() collects + * time-picker → + * file-upload-single → compact path input + upload button + * (enum values always render as + cell.style.minWidth = '90px'; + const sel = document.createElement('select'); + sel.name = inputName; + sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white'; + enumVals.forEach(opt => { + if (opt === null) return; + const o = document.createElement('option'); + o.value = opt; + o.textContent = opt; + if (String(colValue) === String(opt)) o.selected = true; + sel.appendChild(o); + }); + // If current value didn't match any option, set to first + if (!sel.value && enumVals.length > 0) sel.value = enumVals[0]; + cell.appendChild(sel); + + } else if (xWidget === 'date-picker') { + cell.style.minWidth = '140px'; + const inp = document.createElement('input'); + inp.type = 'date'; + inp.name = inputName; + inp.value = colValue || ''; + inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm'; + inp.style.minWidth = '128px'; + if (colDef.description) inp.title = colDef.description; + cell.appendChild(inp); + + } else if (xWidget === 'time-picker') { + cell.style.minWidth = '115px'; + const inp = document.createElement('input'); + inp.type = 'time'; + inp.name = inputName; + inp.value = colValue || '00:00'; + inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm'; + inp.style.minWidth = '100px'; + cell.appendChild(inp); + + } else if (xWidget === 'file-upload-single') { + // Compact: text input (stores path) + upload button + cell.style.minWidth = '200px'; + const wrap = document.createElement('div'); + wrap.className = 'flex items-center gap-1'; + + const pathInput = document.createElement('input'); + pathInput.type = 'text'; + pathInput.name = inputName; + pathInput.id = `${fullKey}_${index}_${colName}`.replace(/\./g,'_'); + pathInput.value = colValue || ''; + pathInput.className = 'block px-1 py-1 border border-gray-300 rounded text-xs flex-1'; + pathInput.style.minWidth = '100px'; + pathInput.placeholder = 'path…'; + + const preview = document.createElement('img'); + preview.className = 'w-6 h-6 object-cover rounded flex-shrink-0'; + preview.style.display = colValue ? 'inline' : 'none'; + if (colValue) { preview.src = '/' + colValue; preview.onerror = () => { preview.style.display = 'none'; }; } + + const labelEl = document.createElement('label'); + labelEl.className = 'cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100'; + labelEl.title = 'Upload image'; + labelEl.innerHTML = ''; + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif'; + fileInput.style.display = 'none'; + fileInput.dataset.pluginId = pluginId; + fileInput.dataset.targetInput = pathInput.id; + fileInput.dataset.previewImg = preview.id || ''; + fileInput.onchange = function(e) { + window.handleArrayTableImageUpload(e, pathInput, preview, pluginId); + }; + labelEl.appendChild(fileInput); + + wrap.appendChild(preview); + wrap.appendChild(pathInput); + wrap.appendChild(labelEl); + cell.appendChild(wrap); + + } else { + // Default: text input + const inp = document.createElement('input'); + inp.type = 'text'; + inp.name = inputName; + inp.value = colValue !== null && colValue !== undefined ? colValue : ''; + inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm'; + if (colDef.description) inp.placeholder = colDef.description; + if (colDef.pattern) inp.pattern = colDef.pattern; + if (colDef.minLength) inp.minLength = colDef.minLength; + if (colDef.maxLength) inp.maxLength = colDef.maxLength; + cell.appendChild(inp); + } + + return cell; + } + + /** + * Create a hidden holding flat hidden inputs for non-displayed properties + * (including nested objects like layout/style). + */ + function createAdvancedCell(fullKey, index, nonDisplayedProps, item) { + const cell = document.createElement('td'); + cell.style.display = 'none'; + cell.className = 'array-table-advanced-data'; + cell.dataset.propSchema = JSON.stringify(nonDisplayedProps); + + Object.entries(nonDisplayedProps).forEach(([propName, propSchema]) => { + const propType = Array.isArray(propSchema.type) + ? propSchema.type.find(t => t !== 'null') || 'string' + : (propSchema.type || 'string'); + + if (propType === 'object' && propSchema.properties) { + const nestedVal = (item && item[propName]) || {}; + Object.entries(propSchema.properties).forEach(([subName, subSchema]) => { + const subType = Array.isArray(subSchema.type) + ? subSchema.type.find(t => t !== 'null') || 'string' + : (subSchema.type || 'string'); + const defaultVal = subSchema.default !== undefined ? subSchema.default : null; + const currentVal = nestedVal[subName] !== undefined ? nestedVal[subName] : defaultVal; + + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = `${fullKey}.${index}.${propName}.${subName}`; + hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : ''; + hidden.dataset.nestedProp = `${propName}.${subName}`; + hidden.dataset.propType = subType; + hidden.dataset.propSchema = JSON.stringify(subSchema); + cell.appendChild(hidden); + }); + } else { + const defaultVal = propSchema.default !== undefined ? propSchema.default : null; + const currentVal = item && item[propName] !== undefined ? item[propName] : defaultVal; + + const hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = `${fullKey}.${index}.${propName}`; + hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : ''; + hidden.dataset.nestedProp = propName; + hidden.dataset.propType = propType; + hidden.dataset.propSchema = JSON.stringify(propSchema); + cell.appendChild(hidden); + } + }); + + return cell; + } + + // ─── Row creation ──────────────────────────────────────────────────────── + + function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns, fullItemProperties) { + item = item || {}; + fullItemProperties = fullItemProperties || itemProperties; + const row = document.createElement('tr'); row.className = 'array-table-row'; row.setAttribute('data-index', index); + // Visible column cells displayColumns.forEach(colName => { - const colDef = itemProperties[colName] || {}; - const colType = colDef.type || 'string'; - const colDefault = colDef.default !== undefined ? colDef.default : (colType === 'boolean' ? false : ''); + const colDef = itemProperties[colName] || {}; + const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string'); + const colDefault = colDef.default !== undefined ? colDef.default + : (colType === 'boolean' ? false : colType === 'time-picker' ? '00:00' : ''); const colValue = item[colName] !== undefined ? item[colName] : colDefault; + row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId)); + }); - const cell = document.createElement('td'); - cell.className = 'px-4 py-3 whitespace-nowrap'; - - if (colType === 'boolean') { - const hiddenInput = document.createElement('input'); - hiddenInput.type = 'hidden'; - hiddenInput.name = `${fullKey}.${index}.${colName}`; - hiddenInput.value = 'false'; - cell.appendChild(hiddenInput); - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.name = `${fullKey}.${index}.${colName}`; - checkbox.checked = Boolean(colValue); - checkbox.value = 'true'; - checkbox.className = 'h-4 w-4 text-blue-600'; - cell.appendChild(checkbox); - } else if (colType === 'integer' || colType === 'number') { - const input = document.createElement('input'); - input.type = 'number'; - input.name = `${fullKey}.${index}.${colName}`; - input.value = colValue !== null && colValue !== undefined ? colValue : ''; - if (colDef.minimum !== undefined) input.min = colDef.minimum; - if (colDef.maximum !== undefined) input.max = colDef.maximum; - input.step = colType === 'integer' ? '1' : 'any'; - input.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center'; - if (colDef.description) input.title = colDef.description; - cell.appendChild(input); - } else { - const input = document.createElement('input'); - input.type = 'text'; - input.name = `${fullKey}.${index}.${colName}`; - input.value = colValue !== null && colValue !== undefined ? colValue : ''; - input.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm'; - if (colDef.description) input.placeholder = colDef.description; - if (colDef.pattern) input.pattern = colDef.pattern; - if (colDef.minLength) input.minLength = colDef.minLength; - if (colDef.maxLength) input.maxLength = colDef.maxLength; - cell.appendChild(input); + // Determine non-displayed properties (these go into the advanced cell + edit modal) + const nonDisplayed = {}; + Object.keys(fullItemProperties).forEach(k => { + if (!displayColumns.includes(k) && k !== 'id') { + nonDisplayed[k] = fullItemProperties[k]; } - - row.appendChild(cell); }); + const hasAdvanced = Object.keys(nonDisplayed).length > 0; // Actions cell const actionsCell = document.createElement('td'); - actionsCell.className = 'px-4 py-3 whitespace-nowrap text-center'; - const removeButton = document.createElement('button'); - removeButton.type = 'button'; - removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1'; - removeButton.onclick = function() { window.removeArrayTableRow(this); }; - const removeIcon = document.createElement('i'); - removeIcon.className = 'fas fa-trash'; - removeButton.appendChild(removeIcon); - actionsCell.appendChild(removeButton); + actionsCell.className = 'px-3 py-3 whitespace-nowrap text-center'; + actionsCell.style.minWidth = '90px'; + actionsCell.style.verticalAlign = 'middle'; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1'; + removeBtn.onclick = function() { window.removeArrayTableRow(this); }; + removeBtn.innerHTML = ''; + actionsCell.appendChild(removeBtn); + + if (hasAdvanced) { + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1'; + editBtn.title = 'Edit advanced properties (layout, style…)'; + editBtn.onclick = function() { window.openArrayTableRowEditor(this); }; + editBtn.innerHTML = ''; + actionsCell.appendChild(editBtn); + } + row.appendChild(actionsCell); + // Hidden advanced data cell + if (hasAdvanced) { + row.appendChild(createAdvancedCell(fullKey, index, nonDisplayed, item)); + } + return row; } + // ─── Row editor modal ──────────────────────────────────────────────────── + + window.openArrayTableRowEditor = function(button) { + const row = button.closest('tr'); + const advancedCell = row.querySelector('.array-table-advanced-data'); + if (!advancedCell) return; + + const schema = JSON.parse(advancedCell.dataset.propSchema || '{}'); + const tbody = row.closest('tbody'); + const fieldId = tbody ? tbody.id.replace('_tbody', '') : ''; + const rowIndex = parseInt(row.dataset.index, 10); + + // Close any existing modal + const existing = document.getElementById('array-row-editor-modal'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'array-row-editor-modal'; + // Use inline styles for position/dimensions — inset-0 may be purged from the CSS bundle + // since it only appears in JS-generated markup, not in scanned templates. + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,0.5);'; + overlay.onclick = function(e) { if (e.target === overlay) window.closeArrayTableRowEditor(); }; + + const dialog = document.createElement('div'); + dialog.className = 'bg-white rounded-lg shadow-xl max-w-lg w-full max-h-screen overflow-y-auto'; + + // Header + dialog.innerHTML = ` +
+

Advanced Properties

+ +
`; + + const body = document.createElement('div'); + body.className = 'px-5 py-4 space-y-4'; + + // Render a field for each advanced property + Object.entries(schema).forEach(([propName, propSchema]) => { + const propType = Array.isArray(propSchema.type) + ? propSchema.type.find(t => t !== 'null') || 'string' + : (propSchema.type || 'string'); + const label = propSchema.title || propName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const desc = propSchema.description || ''; + + if (propType === 'object' && propSchema.properties) { + // Section for nested object + const section = document.createElement('div'); + section.className = 'border border-gray-200 rounded-lg p-3'; + section.innerHTML = `

${escapeHtml(label)}

`; + + const grid = document.createElement('div'); + grid.className = 'grid grid-cols-2 gap-3'; + + Object.entries(propSchema.properties).forEach(([subName, subSchema]) => { + const subType = Array.isArray(subSchema.type) ? subSchema.type.find(t => t !== 'null') || 'string' : (subSchema.type || 'string'); + const subLabel = subSchema.title || subName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const subDesc = subSchema.description || ''; + const nestedPath = `${propName}.${subName}`; + + // Read current value from hidden input + const hiddenInput = advancedCell.querySelector(`[data-nested-prop="${nestedPath}"]`); + const currentVal = hiddenInput ? hiddenInput.value : (subSchema.default !== undefined ? subSchema.default : ''); + + const fieldDiv = document.createElement('div'); + fieldDiv.innerHTML = ``; + fieldDiv.appendChild(buildModalInput(nestedPath, subSchema, subType, currentVal)); + grid.appendChild(fieldDiv); + }); + + section.appendChild(grid); + body.appendChild(section); + } else { + // Flat property + const hiddenInput = advancedCell.querySelector(`[data-nested-prop="${propName}"]`); + const currentVal = hiddenInput ? hiddenInput.value : (propSchema.default !== undefined ? propSchema.default : ''); + + const fieldDiv = document.createElement('div'); + fieldDiv.innerHTML = ``; + fieldDiv.appendChild(buildModalInput(propName, propSchema, propType, currentVal)); + body.appendChild(fieldDiv); + } + }); + + dialog.appendChild(body); + + // Footer + const footer = document.createElement('div'); + footer.className = 'flex justify-end gap-3 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg'; + footer.innerHTML = ` + + `; + + // Save handler + footer.querySelector('#array-row-editor-save').onclick = function() { + body.querySelectorAll('[data-modal-prop]').forEach(el => { + const propPath = el.dataset.modalProp; + const targetInput = advancedCell.querySelector(`[data-nested-prop="${propPath}"]`); + if (!targetInput) return; + if (el.type === 'checkbox') { + targetInput.value = el.checked ? 'true' : 'false'; + } else { + targetInput.value = el.value; + } + }); + window.closeArrayTableRowEditor(); + }; + + dialog.appendChild(footer); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + }; + + window.closeArrayTableRowEditor = function() { + const modal = document.getElementById('array-row-editor-modal'); + if (modal) modal.remove(); + }; + /** - * Update the Add button's disabled state based on current row count - * @param {string} fieldId - Field ID to find the tbody and button + * Build a single form control for the row editor modal. */ - function updateAddButtonState(fieldId) { - const tbody = document.getElementById(fieldId + '_tbody'); - if (!tbody) return; + function buildModalInput(propPath, schema, propType, currentVal) { + const xWidget = schema['x-widget'] || schema['x_widget']; + const enumVals = schema.enum; + const wrap = document.createElement('div'); + + if (propType === 'boolean') { + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'h-4 w-4 text-blue-600'; + cb.checked = currentVal === 'true' || currentVal === true || currentVal === 1; + cb.dataset.modalProp = propPath; + wrap.appendChild(cb); + return wrap; + } - // Find the add button by looking for the button with matching data-field-id - const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`); - if (!addButton) return; + // Array[3] with x-widget color-picker → R/G/B row + if ((propType === 'array' || xWidget === 'color-picker') && + (schema.minItems === 3 || schema.maxItems === 3 || xWidget === 'color-picker')) { + const parts = currentVal ? String(currentVal).split(',').map(s => s.trim()) : ['', '', '']; + const rVal = parts[0] || ''; + const gVal = parts[1] || ''; + const bVal = parts[2] || ''; + + // Hex color picker for visual selection + const hexVal = (rVal && gVal && bVal) + ? '#' + [rVal, gVal, bVal].map(n => parseInt(n, 10).toString(16).padStart(2, '0')).join('') + : '#ffffff'; + + const colorRow = document.createElement('div'); + colorRow.className = 'flex items-center gap-2 flex-wrap'; + + const colorPick = document.createElement('input'); + colorPick.type = 'color'; + colorPick.value = hexVal; + colorPick.className = 'h-8 w-10 cursor-pointer rounded border'; + colorRow.appendChild(colorPick); + + ['R', 'G', 'B'].forEach((ch, i) => { + const lbl = document.createElement('label'); + lbl.className = 'text-xs text-gray-500'; + lbl.textContent = ch; + const numInp = document.createElement('input'); + numInp.type = 'number'; + numInp.min = '0'; + numInp.max = '255'; + numInp.step = '1'; + numInp.value = [rVal, gVal, bVal][i]; + numInp.className = 'w-14 px-1 py-1 border border-gray-300 rounded text-sm text-center'; + numInp.dataset.colorChannel = i; + colorRow.appendChild(lbl); + colorRow.appendChild(numInp); + }); - const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10); - const currentRows = tbody.querySelectorAll('.array-table-row'); - const isAtMax = currentRows.length >= maxItems; + // Hidden aggregate input that the save handler reads + const agg = document.createElement('input'); + agg.type = 'hidden'; + agg.value = `${rVal},${gVal},${bVal}`; + agg.dataset.modalProp = propPath; + colorRow.appendChild(agg); + + // Sync: color picker → R/G/B numbers + agg + colorPick.oninput = function() { + const hex = colorPick.value; + const r = parseInt(hex.slice(1,3), 16); + const g = parseInt(hex.slice(3,5), 16); + const b = parseInt(hex.slice(5,7), 16); + const nums = colorRow.querySelectorAll('input[data-color-channel]'); + if (nums[0]) nums[0].value = r; + if (nums[1]) nums[1].value = g; + if (nums[2]) nums[2].value = b; + agg.value = `${r},${g},${b}`; + }; + + // Sync: R/G/B numbers → color picker + agg + colorRow.querySelectorAll('input[data-color-channel]').forEach(inp => { + inp.oninput = function() { + const nums = colorRow.querySelectorAll('input[data-color-channel]'); + const r = parseInt(nums[0] ? nums[0].value : 0, 10) || 0; + const g = parseInt(nums[1] ? nums[1].value : 0, 10) || 0; + const b = parseInt(nums[2] ? nums[2].value : 0, 10) || 0; + colorPick.value = '#' + [r,g,b].map(n => n.toString(16).padStart(2,'0')).join(''); + agg.value = `${r},${g},${b}`; + }; + }); - addButton.disabled = isAtMax; - addButton.style.opacity = isAtMax ? '0.5' : ''; + wrap.appendChild(colorRow); + return wrap; + } + + if (Array.isArray(enumVals) && enumVals.length > 0) { + const sel = document.createElement('select'); + sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white'; + sel.dataset.modalProp = propPath; + enumVals.forEach(opt => { + if (opt === null) return; + const o = document.createElement('option'); + o.value = opt; o.textContent = opt; + if (String(currentVal) === String(opt)) o.selected = true; + sel.appendChild(o); + }); + wrap.appendChild(sel); + return wrap; + } + + if (xWidget === 'date-picker') { + const inp = document.createElement('input'); + inp.type = 'date'; + inp.value = currentVal || ''; + inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm'; + inp.dataset.modalProp = propPath; + wrap.appendChild(inp); + return wrap; + } + + if (xWidget === 'time-picker') { + const inp = document.createElement('input'); + inp.type = 'time'; + inp.value = currentVal || '00:00'; + inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm'; + inp.dataset.modalProp = propPath; + wrap.appendChild(inp); + return wrap; + } + + if (propType === 'integer' || propType === 'number') { + const inp = document.createElement('input'); + inp.type = 'number'; + inp.value = currentVal !== '' && currentVal !== null ? currentVal : ''; + inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm'; + inp.dataset.modalProp = propPath; + if (schema.minimum !== undefined) inp.min = schema.minimum; + if (schema.maximum !== undefined) inp.max = schema.maximum; + inp.step = propType === 'integer' ? '1' : 'any'; + if (schema.description) inp.placeholder = schema.description; + wrap.appendChild(inp); + return wrap; + } + + // Default: text + const inp = document.createElement('input'); + inp.type = 'text'; + inp.value = currentVal || ''; + inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm'; + inp.dataset.modalProp = propPath; + if (schema.description) inp.placeholder = schema.description; + wrap.appendChild(inp); + return wrap; } - // Expose for external use if needed - window.updateArrayTableAddButtonState = updateAddButtonState; + function escapeHtml(str) { + const d = document.createElement('div'); + d.textContent = String(str || ''); + return d.innerHTML; + } + + // ─── In-cell image upload ──────────────────────────────────────────────── /** - * Add a new row to the array table - * @param {HTMLElement} button - The button element with data attributes + * Called from file-upload-single cells inside array-table rows. + * Uploads the selected file and updates the path text input. */ - window.addArrayTableRow = function(button) { - const fieldId = button.getAttribute('data-field-id'); - const fullKey = button.getAttribute('data-full-key'); - const maxItems = parseInt(button.getAttribute('data-max-items'), 10); - const pluginId = button.getAttribute('data-plugin-id'); + window.handleArrayTableImageUpload = async function(event, pathInput, previewImg, pluginId) { + const file = event.target.files && event.target.files[0]; + if (!file) return; + + const notifyFn = window.showNotification || console.log; + const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; + if (!allowed.includes(file.type)) { + notifyFn(`File type "${file.type}" not allowed`, 'error'); + return; + } + if (file.size > 5 * 1024 * 1024) { + notifyFn('File exceeds 5MB limit', 'error'); + return; + } - // Parse JSON with fallback on error - let itemProperties = {}; - let displayColumns = []; - const rawItemProps = button.getAttribute('data-item-properties') || '{}'; - const rawDisplayCols = button.getAttribute('data-display-columns') || '[]'; + const formData = new FormData(); + formData.append('plugin_id', pluginId); + formData.append('files', file); try { - itemProperties = JSON.parse(rawItemProps); - } catch (e) { - console.error('[ArrayTableWidget] Failed to parse data-item-properties:', rawItemProps, e); - itemProperties = {}; + const resp = await fetch('/api/v3/plugins/assets/upload', { method: 'POST', body: formData }); + if (!resp.ok) throw new Error(`Server error ${resp.status}`); + const data = await resp.json(); + if (data.status === 'success' && data.uploaded_files && data.uploaded_files[0]) { + const path = data.uploaded_files[0].path; + pathInput.value = path; + if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; } + notifyFn('Image uploaded', 'success'); + } else { + throw new Error(data.message || 'Upload failed'); + } + } catch (err) { + notifyFn('Upload error: ' + err.message, 'error'); + } finally { + event.target.value = ''; } + }; - try { - displayColumns = JSON.parse(rawDisplayCols); - } catch (e) { - console.error('[ArrayTableWidget] Failed to parse data-display-columns:', rawDisplayCols, e); - displayColumns = []; - } + // ─── Button helpers ────────────────────────────────────────────────────── + + function updateAddButtonState(fieldId) { + const tbody = document.getElementById(fieldId + '_tbody'); + const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`); + if (!tbody || !addButton) return; + const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10); + const currentRows = tbody.querySelectorAll('.array-table-row').length; + const isAtMax = currentRows >= maxItems; + addButton.disabled = isAtMax; + addButton.style.opacity = isAtMax ? '0.5' : ''; + } + + window.updateArrayTableAddButtonState = updateAddButtonState; + + window.addArrayTableRow = function(button) { + const fieldId = button.getAttribute('data-field-id'); + const fullKey = button.getAttribute('data-full-key'); + const maxItems = parseInt(button.getAttribute('data-max-items'), 10); + const pluginId = button.getAttribute('data-plugin-id'); + + let itemProperties = {}; + let displayColumns = []; + let fullItemProperties = {}; + + try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(e) {} + try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(e) {} + try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(e) { fullItemProperties = itemProperties; } const tbody = document.getElementById(fieldId + '_tbody'); if (!tbody) return; - const currentRows = tbody.querySelectorAll('.array-table-row'); - if (currentRows.length >= maxItems) { - const notifyFn = window.showNotification || alert; - notifyFn(`Maximum ${maxItems} items allowed`, 'error'); + const currentRows = tbody.querySelectorAll('.array-table-row').length; + if (currentRows >= maxItems) { + (window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error'); return; } - const newIndex = currentRows.length; - const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns); + const newIndex = currentRows; + const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns, fullItemProperties); tbody.appendChild(row); - - // Update button state after adding updateAddButtonState(fieldId); }; - /** - * Remove a row from the array table - * @param {HTMLElement} button - The remove button element - */ window.removeArrayTableRow = function(button) { const row = button.closest('tr'); if (!row) return; + if (!confirm('Remove this item?')) return; - if (confirm('Remove this item?')) { - const tbody = row.parentElement; - if (!tbody) return; - - // Get fieldId from tbody id (format: {fieldId}_tbody) - const fieldId = tbody.id.replace('_tbody', ''); - - row.remove(); - - // Re-index remaining rows - const rows = tbody.querySelectorAll('.array-table-row'); - rows.forEach(function(r, index) { - r.setAttribute('data-index', index); - r.querySelectorAll('input').forEach(function(input) { - const name = input.getAttribute('name'); - if (name) { - input.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.')); - } - }); + const tbody = row.parentElement; + if (!tbody) return; + const fieldId = tbody.id.replace('_tbody', ''); + row.remove(); + + // Re-index remaining rows + tbody.querySelectorAll('.array-table-row').forEach((r, index) => { + r.setAttribute('data-index', index); + r.querySelectorAll('input, select').forEach(el => { + const name = el.getAttribute('name'); + if (name) el.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.')); + // Also update data-nested-prop-based inputs (they don't have regular names needing re-index) }); + }); - // Update button state after removing - updateAddButtonState(fieldId); - } + updateAddButtonState(fieldId); }; - /** - * Initialize all array table add buttons on page load - */ function initArrayTableButtons() { - const addButtons = document.querySelectorAll('button[data-field-id][data-max-items]'); - addButtons.forEach(function(button) { - const fieldId = button.getAttribute('data-field-id'); - updateAddButtonState(fieldId); + document.querySelectorAll('button[data-field-id][data-max-items]').forEach(button => { + updateAddButtonState(button.getAttribute('data-field-id')); }); } - // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initArrayTableButtons); } else { initArrayTableButtons(); } - console.log('[ArrayTableWidget] Array table widget registered'); + console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)'); })(); diff --git a/web_interface/static/v3/js/widgets/file-upload-single.js b/web_interface/static/v3/js/widgets/file-upload-single.js new file mode 100644 index 000000000..35956c8f8 --- /dev/null +++ b/web_interface/static/v3/js/widgets/file-upload-single.js @@ -0,0 +1,266 @@ +/** + * LEDMatrix File Upload Single Widget + * + * Single-image upload for string fields. Uploads to the plugin's asset folder + * and sets the string field value to the returned relative path. + * Designed for per-item image fields within array-table rows. + * + * The plugin_id is injected automatically from the template context + * via options.pluginId — no need to specify it in the schema. + * + * Schema example (any plugin): + * { + * "image_path": { + * "type": "string", + * "x-widget": "file-upload-single", + * "x-upload-config": { + * "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"], + * "max_size_mb": 5 + * } + * } + * } + * + * @module FileUploadSingleWidget + */ + +(function() { + 'use strict'; + + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[FileUploadSingleWidget] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null; + + function escapeHtml(text) { + if (base) return base.escapeHtml(text); + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); + } + + function sanitizeId(id) { + if (base) return base.sanitizeId(id); + return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + } + + function triggerChange(fieldId, value) { + if (base) { + base.triggerChange(fieldId, value); + } else { + document.dispatchEvent(new CustomEvent('widget-change', { + detail: { fieldId, value }, + bubbles: true, + cancelable: true + })); + } + } + + function isImagePath(path) { + if (!path) return false; + return /\.(png|jpg|jpeg|bmp|gif)$/i.test(path); + } + + window.LEDMatrixWidgets.register('file-upload-single', { + name: 'File Upload Single Widget', + version: '1.0.0', + + render: function(container, config, value, options) { + const fieldId = sanitizeId(options.fieldId || container.id || 'file_upload_single'); + const uploadConfig = config['x-upload-config'] || config['x_upload_config'] || {}; + const allowedTypes = (uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']).join(','); + const maxSizeMb = uploadConfig.max_size_mb || 5; + const pluginId = options.pluginId || ''; + const currentValue = value || ''; + const hasImage = isImagePath(currentValue); + + let html = `
`; + + // Hidden input carries the actual string value + html += ``; + + // Preview area (shown when a value is set) + html += `
`; + html += `Preview`; + html += ``; + html += `
+

${escapeHtml(currentValue.split('/').pop() || '')}

+

${escapeHtml(currentValue)}

+
`; + html += ``; + html += '
'; + + // Upload drop zone (always shown, acts as change button when value is set) + html += `
+ + +

${hasImage ? 'Click to replace image' : 'Click or drag to upload image'}

+

Max ${maxSizeMb}MB

+
`; + + // Status area for upload feedback + html += ``; + + html += '
'; + container.innerHTML = html; + }, + + getValue: function(fieldId) { + const safeId = sanitizeId(fieldId); + const input = document.getElementById(safeId); + return input ? input.value : ''; + }, + + setValue: function(fieldId, value) { + const safeId = sanitizeId(fieldId); + const hidden = document.getElementById(safeId); + const preview = document.getElementById(`${safeId}_preview`); + const thumb = document.getElementById(`${safeId}_thumb`); + const thumbPlaceholder = document.getElementById(`${safeId}_thumb_placeholder`); + const filename = document.getElementById(`${safeId}_filename`); + const dropZone = document.getElementById(`${safeId}_drop_zone`); + + if (hidden) hidden.value = value || ''; + + const hasImage = isImagePath(value); + if (preview) preview.classList.toggle('hidden', !hasImage); + if (thumb && hasImage) { + thumb.src = `/${value}`; + thumb.style.display = ''; + if (thumbPlaceholder) thumbPlaceholder.style.display = 'none'; + } + if (filename) filename.textContent = hasImage ? value.split('/').pop() : ''; + + // Update drop zone hint text + const hint = dropZone ? dropZone.querySelector('p') : null; + if (hint) hint.textContent = hasImage ? 'Click to replace image' : 'Click or drag to upload image'; + }, + + handlers: { + onFileSelect: function(event, fieldId) { + const files = event.target.files; + if (files && files.length > 0) { + window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]); + } + }, + + onDrop: function(event, fieldId) { + event.preventDefault(); + const files = event.dataTransfer.files; + if (files && files.length > 0) { + window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]); + } + }, + + onClear: function(fieldId) { + const widget = window.LEDMatrixWidgets.get('file-upload-single'); + widget.setValue(fieldId, ''); + triggerChange(fieldId, ''); + // Reset file input so the same file can be re-selected + const fileInput = document.getElementById(`${sanitizeId(fieldId)}_file_input`); + if (fileInput) fileInput.value = ''; + }, + + uploadFile: async function(fieldId, file) { + const safeId = sanitizeId(fieldId); + const fileInput = document.getElementById(`${safeId}_file_input`); + const statusDiv = document.getElementById(`${safeId}_status`); + const notifyFn = window.showNotification || console.log; + + // Read config from the file input data attributes + const pluginId = (fileInput && fileInput.dataset.pluginId) || ''; + const maxSizeMb = parseFloat((fileInput && fileInput.dataset.maxSizeMb) || '5'); + const allowedTypes = ((fileInput && fileInput.dataset.allowedTypes) || 'image/png,image/jpeg,image/bmp,image/gif') + .split(',').map(t => t.trim()); + + if (!pluginId) { + notifyFn('Plugin ID not set — cannot upload', 'error'); + return; + } + + // Validate type + if (!allowedTypes.includes(file.type)) { + notifyFn(`File type "${file.type}" not allowed`, 'error'); + return; + } + + // Validate size + if (file.size > maxSizeMb * 1024 * 1024) { + notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error'); + return; + } + + // Show uploading status + if (statusDiv) { + statusDiv.className = 'mt-1 text-xs text-gray-500'; + statusDiv.innerHTML = 'Uploading...'; + } + + const formData = new FormData(); + formData.append('plugin_id', pluginId); + formData.append('files', file); + + try { + const response = await fetch('/api/v3/plugins/assets/upload', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Server error ${response.status}: ${body}`); + } + + const data = await response.json(); + + if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) { + const uploadedPath = data.uploaded_files[0].path; + const widget = window.LEDMatrixWidgets.get('file-upload-single'); + widget.setValue(fieldId, uploadedPath); + triggerChange(fieldId, uploadedPath); + + if (statusDiv) { + statusDiv.className = 'mt-1 text-xs text-green-600'; + statusDiv.innerHTML = 'Uploaded successfully'; + setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.innerHTML = ''; }, 3000); + } + notifyFn('Image uploaded successfully', 'success'); + } else { + throw new Error(data.message || 'Upload failed'); + } + } catch (error) { + if (statusDiv) { + statusDiv.className = 'mt-1 text-xs text-red-600'; + statusDiv.innerHTML = `${escapeHtml(error.message)}`; + } + notifyFn(`Upload error: ${error.message}`, 'error'); + } finally { + if (fileInput) fileInput.value = ''; + } + } + } + }); + + console.log('[FileUploadSingleWidget] File upload single widget registered'); +})(); diff --git a/web_interface/static/v3/js/widgets/plugin-file-manager.js b/web_interface/static/v3/js/widgets/plugin-file-manager.js new file mode 100644 index 000000000..0cc6c9725 --- /dev/null +++ b/web_interface/static/v3/js/widgets/plugin-file-manager.js @@ -0,0 +1,692 @@ +/** + * Plugin File Manager Widget + * + * Reusable inline file manager for plugins that manage files via the + * web_ui_actions system. Driven entirely by x-widget-config in the schema — + * no external HTML file or iframe needed. + * + * Any plugin can adopt this widget by: + * 1. Defining web_ui_actions in manifest.json (list, get, save, upload, + * delete, create, toggle) with ui_hidden: true + * 2. Adding x-widget: "plugin-file-manager" to a field in config_schema.json + * with x-widget-config mapping the action IDs + * + * Schema example: + * { + * "file_manager": { + * "type": "null", + * "title": "Data Files", + * "x-widget": "plugin-file-manager", + * "x-widget-config": { + * "actions": { + * "list": "list-files", + * "get": "get-file", + * "save": "save-file", + * "upload": "upload-file", + * "delete": "delete-file", + * "create": "create-file", + * "toggle": "toggle-category" + * }, + * "upload_hint": "JSON files with day numbers 1–365 as keys", + * "directory_label": "of_the_day/", + * "create_fields": [ + * { "key": "category_name", "label": "Category Name", + * "placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$", + * "hint": "Lowercase letters, numbers, underscores" }, + * { "key": "display_name", "label": "Display Name", + * "placeholder": "e.g., My Words", "hint": "Optional — auto-generated if blank" } + * ] + * } + * } + * } + * + * @module PluginFileManagerWidget + */ + +(function () { + 'use strict'; + + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[PluginFileManager] LEDMatrixWidgets registry not found.'); + return; + } + + // ─── Inject widget-scoped styles once ──────────────────────────────────── + + if (!document.getElementById('pfm-styles')) { + const style = document.createElement('style'); + style.id = 'pfm-styles'; + style.textContent = ` +.pfm-root { font-family: inherit; } +.pfm-header { display:flex; align-items:center; justify-content:space-between; + margin-bottom:.75rem; } +.pfm-title { font-size:1rem; font-weight:600; color:#111827; } +.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; } +.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem; + text-align:center; cursor:pointer; transition:border-color .15s,background .15s; } +.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; } +.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; } +.pfm-upload small { font-size:.75rem; color:#9ca3af; } +.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); + gap:.75rem; margin-top:.75rem; } +.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem; + background:#fff; transition:box-shadow .15s; } +.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); } +.pfm-card.disabled { opacity:.55; } +.pfm-card-top { display:flex; align-items:center; justify-content:space-between; + margin-bottom:.5rem; } +.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem; + display:flex; align-items:center; justify-content:center; + color:#6b7280; font-size:1rem; } +.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; } +.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; } +.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; } +.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem; + border-radius:.375rem; font-size:.8125rem; font-weight:500; + border:none; cursor:pointer; transition:background .15s; } +.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; } +.pfm-btn-primary:hover { background:#1d4ed8; } +.pfm-btn-danger { background:#dc2626; color:#fff; } +.pfm-btn-danger:hover { background:#b91c1c; } +.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; } +.pfm-btn-secondary:hover { background:#e5e7eb; } +.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; } +.pfm-btn-create { background:#059669; color:#fff; } +.pfm-btn-create:hover { background:#047857; } +.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; } +.pfm-toggle-label { font-size:.75rem; color:#6b7280; } +.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; } +.pfm-toggle-cb input { opacity:0; width:0; height:0; } +.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px; + cursor:pointer; transition:background .2s; } +.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem; + left:.1875rem; bottom:.1875rem; background:#fff; + border-radius:50%; transition:transform .2s; } +.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; } +.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); } +.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; } +.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; } + +/* Modal */ +.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5); + display:flex; align-items:flex-start; justify-content:center; + z-index:9999; padding:2rem 1rem; overflow-y:auto; } +.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem; + box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; } +.pfm-modal-header { display:flex; align-items:center; justify-content:space-between; + padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; } +.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; } +.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; } +.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem; + padding:.875rem 1.25rem; border-top:1px solid #e5e7eb; + background:#f9fafb; border-radius:0 0 .75rem .75rem; } + +/* Entry table */ +.pfm-table-wrap { overflow-x:auto; } +.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; } +.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem; + font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb; + white-space:nowrap; position:sticky; top:0; } +.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6; + vertical-align:top; } +.pfm-table tr.today-row td { background:#fef9c3; } +.pfm-table td input, .pfm-table td textarea { + width:100%; border:1px solid #d1d5db; border-radius:.25rem; + padding:.25rem .375rem; font-size:.8125rem; font-family:inherit; + resize:vertical; background:#fff; } +.pfm-table td input:focus, .pfm-table td textarea:focus { + outline:none; border-color:#3b82f6; } +.pfm-day-col { width:3rem; text-align:center; font-weight:600; + color:#6b7280; white-space:nowrap; } +.pfm-pagination { display:flex; align-items:center; justify-content:space-between; + margin-top:.75rem; font-size:.8125rem; color:#6b7280; } +.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; } +.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db; + border-radius:.25rem; text-align:center; } + +/* Form in create modal */ +.pfm-field { margin-bottom:.875rem; } +.pfm-field label { display:block; font-size:.875rem; font-weight:500; + color:#374151; margin-bottom:.25rem; } +.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db; + border-radius:.375rem; font-size:.875rem; } +.pfm-field input:focus { outline:none; border-color:#3b82f6; } +.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; } +.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; } + +/* Delete danger box */ +.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca; + border-radius:.5rem; padding:.875rem; font-size:.875rem; + color:#991b1b; } +`; + document.head.appendChild(style); + } + + // ─── Per-instance state ─────────────────────────────────────────────────── + + const _state = new Map(); // fieldId → { pluginId, actions, createFields, files, page, entriesPerPage, modal } + + function getState(fieldId) { + if (!_state.has(fieldId)) _state.set(fieldId, { + pluginId: '', actions: {}, createFields: [], uploadHint: '', + directoryLabel: '', files: [], page: 1, entriesPerPage: 20, + currentModal: null + }); + return _state.get(fieldId); + } + + // ─── API helper ─────────────────────────────────────────────────────────── + + async function callAction(pluginId, actionId, params = {}) { + const resp = await fetch('/api/v3/plugins/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plugin_id: pluginId, action_id: actionId, params }) + }); + return resp.json(); + } + + function notify(msg, type) { + if (window.showNotification) window.showNotification(msg, type); + else console.log(`[PFM][${type}] ${msg}`); + } + + function escHtml(s) { + const d = document.createElement('div'); + d.textContent = String(s ?? ''); + return d.innerHTML; + } + + function formatSize(bytes) { + if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'; + return (bytes / 1024).toFixed(2) + ' KB'; + } + + function formatDate(iso) { + try { return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); } + catch { return iso; } + } + + // ─── Core: load files ───────────────────────────────────────────────────── + + async function loadFiles(fieldId) { + const st = getState(fieldId); + const root = document.getElementById(`${fieldId}_pfm`); + if (!root) return; + const grid = root.querySelector('.pfm-grid'); + if (grid) grid.innerHTML = '
Loading…
'; + + const data = await callAction(st.pluginId, st.actions.list).catch(() => null); + if (!data || data.status !== 'success') { + if (grid) grid.innerHTML = '
Failed to load files.
'; + return; + } + st.files = data.files || []; + renderCards(fieldId); + } + + // ─── Card grid ──────────────────────────────────────────────────────────── + + function renderCards(fieldId) { + const st = getState(fieldId); + const root = document.getElementById(`${fieldId}_pfm`); + if (!root) return; + const grid = root.querySelector('.pfm-grid'); + if (!grid) return; + + if (!st.files.length) { + grid.innerHTML = '
No files yet. Create or upload one.
'; + return; + } + + grid.innerHTML = st.files.map(f => ` +
+
+ ${f.enabled !== false ? 'Enabled' : 'Disabled'} + ${st.actions.toggle ? ` + ` : ''} +
+
+
${escHtml(f.display_name || f.filename)}
+
+ ${escHtml(f.filename)}
+ ${f.entry_count != null ? escHtml(f.entry_count) + ' entries' : ''} • ${formatSize(f.size)}
+ ${formatDate(f.modified)} +
+
+ ${st.actions.get && st.actions.save ? ` + ` : ''} + ${st.actions.delete ? ` + ` : ''} +
+
`).join(''); + } + + // ─── Edit modal ─────────────────────────────────────────────────────────── + + window._pfmOpenEdit = async function (fieldId, filename) { + const st = getState(fieldId); + const overlay = createOverlay(fieldId); + overlay.innerHTML = ` +
+
+ ${escHtml(filename)} + +
+
+
Loading…
+
+ +
`; + + const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null); + const body = document.getElementById(`${fieldId}_edit_body`); + if (!data || data.status !== 'success' || !body) { + if (body) body.innerHTML = '
Failed to load file.
'; + return; + } + + const content = data.content || data.data || {}; + st._editData = content; + st._editFilename = filename; + + if (isTabular(content)) { + renderEntryTable(fieldId, body, content); + } else { + // Fallback: JSON textarea + body.innerHTML = ` + +
`; + } + }; + + function isTabular(data) { + if (typeof data !== 'object' || Array.isArray(data)) return false; + const keys = Object.keys(data); + if (!keys.length) return false; + const first = data[keys[0]]; + if (typeof first !== 'object' || Array.isArray(first)) return false; + const entryKeys = Object.keys(first); + return entryKeys.length > 0 && entryKeys.length <= 8; + } + + function renderEntryTable(fieldId, container, content) { + const st = getState(fieldId); + const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0])); + if (!entries.length) { container.innerHTML = '
No entries.
'; return; } + + const cols = Object.keys(entries[0][1]); + const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86400000); + const total = entries.length; + const perPage = st.entriesPerPage; + + function buildPage(page) { + const start = (page - 1) * perPage; + const pageEntries = entries.slice(start, start + perPage); + const totalPages = Math.ceil(total / perPage); + + container.innerHTML = ` +
+ ${total} entries total + +
+
+ + + + + ${cols.map(c => ``).join('')} + + + + ${pageEntries.map(([day, val]) => ` + + + ${cols.map(col => { + const v = val[col] ?? ''; + const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content'; + return isLong + ? `` + : ``; + }).join('')} + `).join('')} + +
Day${escHtml(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}
${escHtml(day)}
+
+
+ Page ${page} of ${totalPages} +
+ + Go to + + +
+
`; + st._tablePage = page; + st._tableEntries = entries; + st._tableCols = cols; + } + + buildPage(st._tablePage || 1); + window._pfmTablePage = function (fId, p) { + const s = getState(fId); + const totalP = Math.ceil(s._tableEntries.length / s.entriesPerPage); + buildPage(Math.max(1, Math.min(p, totalP))); + }; + } + + window._pfmCellEdit = function (fieldId, day, col, value) { + const st = getState(fieldId); + if (st._editData && st._editData[day]) st._editData[day][col] = value; + }; + + window._pfmSave = async function (fieldId, filename) { + const st = getState(fieldId); + const saveBtn = document.getElementById(`${fieldId}_save_btn`); + let content; + + // Try getting from inline table data first, then textarea fallback + if (st._editData) { + content = st._editData; + } else { + const ta = document.getElementById(`${fieldId}_json_ta`); + if (!ta) return; + try { content = JSON.parse(ta.value); } + catch (e) { + const errEl = document.getElementById(`${fieldId}_json_err`); + if (errEl) errEl.textContent = 'Invalid JSON: ' + e.message; + return; + } + } + + if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = 'Saving…'; } + + const result = await callAction(st.pluginId, st.actions.save, { + filename, content: JSON.stringify(content) + }).catch(() => ({ status: 'error', message: 'Network error' })); + + if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'Save'; } + + if (result.status === 'success') { + notify('File saved successfully', 'success'); + window._pfmCloseModal(fieldId); + await loadFiles(fieldId); + } else { + notify('Save failed: ' + (result.message || 'Unknown error'), 'error'); + } + }; + + // ─── Delete modal ───────────────────────────────────────────────────────── + + window._pfmOpenDelete = function (fieldId, filename) { + const overlay = createOverlay(fieldId); + overlay.innerHTML = ` +
+
+ Delete File + +
+
+
+ ${escHtml(filename)} will be permanently deleted and removed + from the plugin configuration. This cannot be undone. +
+
+ +
`; + }; + + window._pfmConfirmDelete = async function (fieldId, filename) { + const st = getState(fieldId); + const result = await callAction(st.pluginId, st.actions.delete, { filename }) + .catch(() => ({ status: 'error', message: 'Network error' })); + if (result.status === 'success') { + notify('File deleted', 'success'); + window._pfmCloseModal(fieldId); + await loadFiles(fieldId); + } else { + notify('Delete failed: ' + (result.message || ''), 'error'); + } + }; + + // ─── Create modal ───────────────────────────────────────────────────────── + + window._pfmOpenCreate = function (fieldId) { + const st = getState(fieldId); + const fields = st.createFields; + const overlay = createOverlay(fieldId); + overlay.innerHTML = ` +
+
+ Create New File + +
+
+
+ ${fields.map(f => ` +
+ + + ${f.hint ? `
${escHtml(f.hint)}
` : ''} +
`).join('')} +
+ +
`; + }; + + window._pfmConfirmCreate = async function (fieldId) { + const st = getState(fieldId); + const errEl = document.getElementById(`${fieldId}_create_err`); + const btn = document.getElementById(`${fieldId}_create_btn`); + const params = {}; + + for (const f of st.createFields) { + const inp = document.getElementById(`${fieldId}_cf_${f.key}`); + if (!inp) continue; + const val = inp.value.trim(); + if (f.pattern && val && !new RegExp(f.pattern).test(val)) { + if (errEl) errEl.textContent = `${f.label || f.key}: invalid format — ${f.hint || ''}`; + inp.focus(); return; + } + params[f.key] = val; + } + + if (btn) { btn.disabled = true; btn.innerHTML = 'Creating…'; } + if (errEl) errEl.textContent = ''; + + const result = await callAction(st.pluginId, st.actions.create, params) + .catch(() => ({ status: 'error', message: 'Network error' })); + + if (btn) { btn.disabled = false; btn.innerHTML = 'Create'; } + + if (result.status === 'success') { + notify('File created', 'success'); + window._pfmCloseModal(fieldId); + await loadFiles(fieldId); + } else { + if (errEl) errEl.textContent = result.message || 'Create failed'; + } + }; + + // ─── Toggle ─────────────────────────────────────────────────────────────── + + window._pfmToggle = async function (fieldId, categoryName, enabled) { + const st = getState(fieldId); + const result = await callAction(st.pluginId, st.actions.toggle, { category_name: categoryName, enabled }) + .catch(() => ({ status: 'error' })); + if (result.status === 'success') { + notify(enabled ? `${categoryName} enabled` : `${categoryName} disabled`, 'success'); + await loadFiles(fieldId); + } else { + notify('Toggle failed', 'error'); + await loadFiles(fieldId); // revert UI + } + }; + + // ─── Upload ─────────────────────────────────────────────────────────────── + + window._pfmUpload = async function (fieldId, file) { + const st = getState(fieldId); + const notifyFn = window.showNotification || console.log; + if (!file.name.toLowerCase().endsWith('.json')) { + notifyFn('Only .json files can be uploaded', 'error'); return; + } + let content; + try { content = await file.text(); JSON.parse(content); } + catch { notifyFn('File contains invalid JSON', 'error'); return; } + + const result = await callAction(st.pluginId, st.actions.upload, { + filename: file.name, content + }).catch(() => ({ status: 'error', message: 'Network error' })); + + if (result.status === 'success') { + notify('File uploaded: ' + (result.filename || file.name), 'success'); + await loadFiles(fieldId); + } else { + notify('Upload failed: ' + (result.message || ''), 'error'); + } + }; + + // ─── Modal helpers ──────────────────────────────────────────────────────── + + function createOverlay(fieldId) { + window._pfmCloseModal(fieldId); // close any open modal first + const overlay = document.createElement('div'); + overlay.className = 'pfm-overlay'; + overlay.id = `${fieldId}_pfm_overlay`; + // Close on backdrop click + overlay.addEventListener('click', e => { if (e.target === overlay) window._pfmCloseModal(fieldId); }); + document.body.appendChild(overlay); + getState(fieldId).currentModal = overlay; + return overlay; + } + + window._pfmCloseModal = function (fieldId) { + const st = getState(fieldId); + if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; } + st._editData = null; + st._editFilename = null; + }; + + // ─── Widget registration ────────────────────────────────────────────────── + + window.LEDMatrixWidgets.register('plugin-file-manager', { + name: 'Plugin File Manager Widget', + version: '1.0.0', + + render: function (container, config, value, options) { + const fieldId = (options.fieldId || container.id || 'pfm').replace(/[^a-zA-Z0-9_-]/g, '_'); + const wc = config['x-widget-config'] || {}; + const actions = wc.actions || {}; + const pluginId = options.pluginId || ''; + + const st = getState(fieldId); + Object.assign(st, { + pluginId, + actions, + createFields: wc.create_fields || [], + uploadHint: wc.upload_hint || 'Upload JSON files', + directoryLabel: wc.directory_label || '' + }); + + container.innerHTML = ` +
+
+
+
File Explorer
+ ${st.directoryLabel ? `
Manage files in ${escHtml(st.directoryLabel)}
` : ''} +
+
+ ${actions.create ? ` + ` : ''} +
+
+ + ${actions.upload ? ` +
+ + +

Drag and drop or click to upload

+ ${escHtml(st.uploadHint)} +
` : ''} + +
+
Loading…
+
+
`; + + 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'); +})(); diff --git a/web_interface/static/v3/js/widgets/time-picker.js b/web_interface/static/v3/js/widgets/time-picker.js new file mode 100644 index 000000000..613a15d63 --- /dev/null +++ b/web_interface/static/v3/js/widgets/time-picker.js @@ -0,0 +1,157 @@ +/** + * LEDMatrix Time Picker Widget + * + * Single time selection using the browser's native time input. + * Returns a string in HH:MM (24-hour) format. + * + * Schema example: + * { + * "target_time": { + * "type": "string", + * "x-widget": "time-picker", + * "default": "00:00", + * "x-options": { + * "placeholder": "Select time", + * "clearable": true + * } + * } + * } + * + * @module TimePickerWidget + */ + +(function() { + 'use strict'; + + const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null; + + function escapeHtml(text) { + if (base) return base.escapeHtml(text); + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); + } + + function sanitizeId(id) { + if (base) return base.sanitizeId(id); + return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + } + + function triggerChange(fieldId, value) { + if (base) { + base.triggerChange(fieldId, value); + } else { + document.dispatchEvent(new CustomEvent('widget-change', { + detail: { fieldId, value }, + bubbles: true, + cancelable: true + })); + } + } + + window.LEDMatrixWidgets.register('time-picker', { + name: 'Time Picker Widget', + version: '1.0.0', + + render: function(container, config, value, options) { + const fieldId = sanitizeId(options.fieldId || container.id || 'time_picker'); + const xOptions = config['x-options'] || config['x_options'] || {}; + const placeholder = xOptions.placeholder || ''; + const clearable = xOptions.clearable === true; + const disabled = xOptions.disabled === true; + const required = xOptions.required === true; + + const currentValue = value || ''; + + let html = `
`; + html += '
'; + html += ` +
+ +
+ +
+
+ `; + + if (clearable && !disabled) { + html += ` + + `; + } + + html += '
'; + html += ``; + html += '
'; + + container.innerHTML = html; + }, + + getValue: function(fieldId) { + const safeId = sanitizeId(fieldId); + const input = document.getElementById(`${safeId}_input`); + return input ? input.value : ''; + }, + + setValue: function(fieldId, value) { + const safeId = sanitizeId(fieldId); + const input = document.getElementById(`${safeId}_input`); + const clearBtn = document.getElementById(`${safeId}_clear`); + if (input) input.value = value || ''; + if (clearBtn) clearBtn.classList.toggle('hidden', !value); + }, + + validate: function(fieldId) { + const safeId = sanitizeId(fieldId); + const input = document.getElementById(`${safeId}_input`); + const errorEl = document.getElementById(`${safeId}_error`); + if (!input) return { valid: true, errors: [] }; + const isValid = input.checkValidity(); + if (errorEl) { + if (!isValid) { + errorEl.textContent = input.validationMessage; + errorEl.classList.remove('hidden'); + input.classList.add('border-red-500'); + } else { + errorEl.classList.add('hidden'); + input.classList.remove('border-red-500'); + } + } + return { valid: isValid, errors: isValid ? [] : [input.validationMessage] }; + }, + + handlers: { + onChange: function(fieldId) { + const widget = window.LEDMatrixWidgets.get('time-picker'); + const safeId = sanitizeId(fieldId); + const clearBtn = document.getElementById(`${safeId}_clear`); + const value = widget.getValue(fieldId); + if (clearBtn) clearBtn.classList.toggle('hidden', !value); + widget.validate(fieldId); + triggerChange(fieldId, value); + }, + + onClear: function(fieldId) { + const widget = window.LEDMatrixWidgets.get('time-picker'); + widget.setValue(fieldId, ''); + triggerChange(fieldId, ''); + } + } + }); + + console.log('[TimePickerWidget] Time picker widget registered'); +})(); diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 9072497de..6859e9c79 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -497,15 +497,26 @@ {% endif %}
- +
+
{% 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) %} - + {% set col_xwidget = col_def.get('x-widget', '') %} + {% set col_enum = col_def.get('enum', []) %} + {% set col_ctype = col_def.get('type', 'string') %} + {% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %} + {% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %} + {% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %} + {% elif col_enum %}{% set col_min_w = '90px' %} + {% elif col_ctype == 'boolean' %}{% set col_min_w = '60px' %} + {% elif col_ctype in ['integer', 'number'] %}{% set col_min_w = '80px' %} + {% else %}{% set col_min_w = '110px' %}{% endif %} + {% endfor %} - + @@ -515,8 +526,17 @@ {% for col_name in display_columns %} {% set col_def = item_properties.get(col_name, {}) %} {% set col_type = col_def.get('type', 'string') %} + {% set col_xwidget = col_def.get('x-widget', '') %} + {% set col_enum = col_def.get('enum', []) %} {% set col_value = item.get(col_name, col_def.get('default', '')) %} - + + {# 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 %} + + {% endif %} {% endfor %} @@ -563,11 +667,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 %}> Add Item + {# end overflow-x:auto wrapper #} + + {% 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) %} +
+ +
+ + +
+
+ + +
+
+ + +
+
{% else %} {# Generic array-of-objects would go here if needed in the future #} @@ -626,7 +777,19 @@ name="{{ full_key }}" value="{{ str_value }}"> - {% 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 #} +
+ +
+

+ + Changes in the file manager save immediately — no need to click Save Configuration. +

+ {% 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 #}
inside it could + # break the enclosing script tag. Re-encode those bytes as Unicode + # escapes so the value is inert in an HTML context. + safe_plugin_id_js = json.dumps(plugin_id).replace('<', r'<').replace('>', r'>').replace('&', r'&') + page = ( '\n' '\n' @@ -134,8 +150,10 @@ def serve_plugin_web_ui(plugin_id, filename): '\n' '\n' '\n' # Tailwind v2 CDN — same version used by the parent LEDMatrix UI ' `
@@ -246,7 +268,8 @@ ${st.actions.toggle ? ` ` : ''}
@@ -260,12 +283,14 @@
${st.actions.get && st.actions.save ? ` ` : ''} ${st.actions.delete ? ` ` : ''}
@@ -277,27 +302,30 @@ window._pfmOpenEdit = async function (fieldId, filename) { const st = getState(fieldId); const overlay = createOverlay(fieldId); - overlay.innerHTML = ` -
-
- ${escHtml(filename)} - -
-
-
Loading…
-
- + // Build modal using DOM methods so filename never enters a JS string literal. + const modal = document.createElement('div'); + modal.className = 'pfm-modal'; + modal.innerHTML = ` +
+ ${escHtml(filename)} + +
+
+
Loading…
+
+ `; + 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`); @@ -307,18 +335,20 @@ } const content = data.content || data.data || {}; - st._editData = content; st._editFilename = filename; if (isTabular(content)) { + // Table path: track cell edits live in _editData + st._editData = content; renderEntryTable(fieldId, body, content); } else { - // Fallback: JSON textarea + // Textarea path: _editData stays null; save() reads from the -
`; +
`; } }; @@ -401,14 +431,23 @@ 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); - window._pfmTablePage = function (fId, p) { - const s = getState(fId); - const totalP = Math.ceil(s._tableEntries.length / s.entriesPerPage); - buildPage(Math.max(1, Math.min(p, totalP))); - }; } + // 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; @@ -454,30 +493,32 @@ window._pfmOpenDelete = function (fieldId, filename) { const overlay = createOverlay(fieldId); - overlay.innerHTML = ` -
-
- Delete File - -
-
-
- ${escHtml(filename)} will be permanently deleted and removed - from the plugin configuration. This cannot be undone. -
-
-
{{ col_title }}{{ col_title }}ActionsActions
+ {% 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 %} + {% if col_type == 'boolean' %} + {% elif col_enum %} + + {% elif col_xwidget == 'date-picker' %} + + {% elif col_xwidget == 'time-picker' %} + + {% elif col_xwidget == 'file-upload-single' %} + {% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %} +
+ {% if col_value %}{% endif %} + + +
{% else %} {% endfor %} -
+ + {# 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 %} + + {% if has_advanced.value %} + + {% endif %}