mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-16 09:38:38 +00:00
Compare commits
1 Commits
098a738891
...
fix/log-vi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c15af5bb2 |
@@ -22,6 +22,5 @@
|
|||||||
"Pillow>=10.0.0",
|
"Pillow>=10.0.0",
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
"requests>=2.31.0"
|
"requests>=2.31.0"
|
||||||
],
|
]
|
||||||
"local_only": true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
|||||||
Extracted from PluginManager to improve separation of concerns.
|
Extracted from PluginManager to improve separation of concerns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
@@ -139,7 +138,6 @@ class PluginLoader:
|
|||||||
self,
|
self,
|
||||||
plugin_dir: Path,
|
plugin_dir: Path,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
plugins_dir: Optional[Path] = None,
|
|
||||||
timeout: int = 300
|
timeout: int = 300
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -148,7 +146,6 @@ class PluginLoader:
|
|||||||
Args:
|
Args:
|
||||||
plugin_dir: Plugin directory path
|
plugin_dir: Plugin directory path
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
plugins_dir: Trusted base plugins directory for path containment check
|
|
||||||
timeout: Installation timeout in seconds
|
timeout: Installation timeout in seconds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -157,58 +154,26 @@ class PluginLoader:
|
|||||||
plugin_id = os.path.basename(plugin_id or '')
|
plugin_id = os.path.basename(plugin_id or '')
|
||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
return False
|
return False
|
||||||
|
# Resolve and validate plugin_dir before constructing any derived paths
|
||||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
try:
|
||||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
plugin_dir_resolved = plugin_dir.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
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)
|
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
||||||
return False
|
return False
|
||||||
|
requirements_file = plugin_dir_resolved / "requirements.txt"
|
||||||
requirements_file = os.path.join(plugin_dir_real, "requirements.txt")
|
if not requirements_file.exists():
|
||||||
marker_file = os.path.join(plugin_dir_real, ".dependencies_installed")
|
|
||||||
|
|
||||||
if not os.path.isfile(requirements_file):
|
|
||||||
return True # No dependencies needed
|
return True # No dependencies needed
|
||||||
|
marker_path = plugin_dir_resolved / ".dependencies_installed"
|
||||||
|
|
||||||
try:
|
# Check if already installed
|
||||||
with open(requirements_file, 'rb') as fh:
|
if marker_path.exists():
|
||||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||||
except OSError as e:
|
|
||||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Skip if requirements.txt hasn't changed since last install
|
|
||||||
if os.path.isfile(marker_file):
|
|
||||||
try:
|
|
||||||
with open(marker_file, 'r', encoding='utf-8') as fh:
|
|
||||||
stored_hash = fh.read().strip()
|
|
||||||
except OSError as e:
|
|
||||||
self.logger.warning(
|
|
||||||
"Could not read dependency marker for %s (%s), will reinstall dependencies",
|
|
||||||
plugin_id, e
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if stored_hash == current_hash:
|
|
||||||
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
|
|
||||||
return True
|
return True
|
||||||
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -216,12 +181,10 @@ class PluginLoader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
try:
|
# Mark as installed
|
||||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
marker_path.touch()
|
||||||
fh.write(current_hash)
|
# Set proper file permissions after creating marker
|
||||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
|
||||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
@@ -236,12 +199,8 @@ class PluginLoader:
|
|||||||
"Assuming they are satisfied: %s",
|
"Assuming they are satisfied: %s",
|
||||||
plugin_id, stderr.strip()
|
plugin_id, stderr.strip()
|
||||||
)
|
)
|
||||||
try:
|
marker_path.touch()
|
||||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||||
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
|
return True
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"Dependency installation returned non-zero exit code for %s: %s",
|
"Dependency installation returned non-zero exit code for %s: %s",
|
||||||
@@ -584,8 +543,7 @@ class PluginLoader:
|
|||||||
display_manager: Any,
|
display_manager: Any,
|
||||||
cache_manager: Any,
|
cache_manager: Any,
|
||||||
plugin_manager: Any,
|
plugin_manager: Any,
|
||||||
install_deps: bool = True,
|
install_deps: bool = True
|
||||||
plugins_dir: Optional[Path] = None,
|
|
||||||
) -> Tuple[Any, Any]:
|
) -> Tuple[Any, Any]:
|
||||||
"""
|
"""
|
||||||
Complete plugin loading process.
|
Complete plugin loading process.
|
||||||
@@ -599,7 +557,6 @@ class PluginLoader:
|
|||||||
cache_manager: Cache manager instance
|
cache_manager: Cache manager instance
|
||||||
plugin_manager: Plugin manager instance
|
plugin_manager: Plugin manager instance
|
||||||
install_deps: Whether to install dependencies
|
install_deps: Whether to install dependencies
|
||||||
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (plugin_instance, module)
|
Tuple of (plugin_instance, module)
|
||||||
@@ -609,12 +566,7 @@ class PluginLoader:
|
|||||||
"""
|
"""
|
||||||
# Install dependencies if needed
|
# Install dependencies if needed
|
||||||
if install_deps:
|
if install_deps:
|
||||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
self.install_dependencies(plugin_dir, plugin_id)
|
||||||
raise PluginError(
|
|
||||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
context={'plugin_dir': str(plugin_dir)},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load module
|
# Load module
|
||||||
entry_point = manifest.get('entry_point', 'manager.py')
|
entry_point = manifest.get('entry_point', 'manager.py')
|
||||||
|
|||||||
@@ -350,8 +350,7 @@ class PluginManager:
|
|||||||
display_manager=self.display_manager,
|
display_manager=self.display_manager,
|
||||||
cache_manager=self.cache_manager,
|
cache_manager=self.cache_manager,
|
||||||
plugin_manager=self,
|
plugin_manager=self,
|
||||||
install_deps=True,
|
install_deps=True
|
||||||
plugins_dir=self.plugins_dir,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store module
|
# Store module
|
||||||
|
|||||||
@@ -185,19 +185,13 @@ class StateReconciliation:
|
|||||||
message=f"Reconciliation failed: {str(e)}"
|
message=f"Reconciliation failed: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Top-level config keys that are NOT plugins.
|
# Top-level config keys that are NOT plugins
|
||||||
# Includes both config.json structural keys and config_secrets.json top-level
|
|
||||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
|
||||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||||
'web_display_autostart', 'timezone', 'location', 'display',
|
'web_display_autostart', 'timezone', 'location', 'display',
|
||||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||||
'dim_schedule', 'network', 'system', 'schedule',
|
'dim_schedule', 'network', 'system', 'schedule',
|
||||||
# Multi-display sync config (config.json structural key)
|
|
||||||
'sync',
|
|
||||||
# Secrets file top-level keys (merged in by load_config)
|
|
||||||
'github', 'youtube',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
@@ -347,8 +341,8 @@ class StateReconciliation:
|
|||||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||||
fix_action=FixAction.AUTO_FIX,
|
fix_action=FixAction.AUTO_FIX,
|
||||||
current_state={'enabled': state_mgr_enabled},
|
current_state={'enabled': config_enabled},
|
||||||
expected_state={'enabled': config_enabled},
|
expected_state={'enabled': state_mgr_enabled},
|
||||||
can_auto_fix=True
|
can_auto_fix=True
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -371,23 +365,15 @@ class StateReconciliation:
|
|||||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||||
|
|
||||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||||
# config.json is the user-editable source of truth for enabled state.
|
# Sync enabled state from state manager to config
|
||||||
# Bring the state manager in sync with config rather than the reverse,
|
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||||
# so that manual config edits (or the state left behind after an
|
config = self.config_manager.load_config()
|
||||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
if inconsistency.plugin_id not in config:
|
||||||
config_enabled = inconsistency.expected_state.get('enabled')
|
config[inconsistency.plugin_id] = {}
|
||||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
config[inconsistency.plugin_id]['enabled'] = expected_enabled
|
||||||
if success:
|
self.config_manager.save_config(config)
|
||||||
self.logger.info(
|
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
|
||||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
return True
|
||||||
f"{inconsistency.plugin_id} to match config"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
|
||||||
f"Failed to sync state manager enabled={config_enabled} for "
|
|
||||||
f"{inconsistency.plugin_id}"
|
|
||||||
)
|
|
||||||
return success
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Handles plugin discovery, installation, updates, and uninstallation
|
|||||||
from both the official registry and custom GitHub repositories.
|
from both the official registry and custom GitHub repositories.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import stat
|
import stat
|
||||||
@@ -1756,12 +1755,6 @@ class PluginStoreManager:
|
|||||||
timeout=300
|
timeout=300
|
||||||
)
|
)
|
||||||
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
||||||
# Write hash marker so plugin_loader skips redundant pip run on next startup
|
|
||||||
try:
|
|
||||||
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
|
|
||||||
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
|
|
||||||
except OSError as marker_err:
|
|
||||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
|||||||
@@ -2687,16 +2687,6 @@ def update_plugin():
|
|||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
current_last_updated = manifest.get('last_updated')
|
current_last_updated = manifest.get('last_updated')
|
||||||
if manifest.get('local_only'):
|
|
||||||
logger.debug("Skipping update for local-only plugin: %s", plugin_id)
|
|
||||||
if api_v3.operation_history:
|
|
||||||
api_v3.operation_history.record_operation(
|
|
||||||
"update",
|
|
||||||
plugin_id=plugin_id,
|
|
||||||
status="skipped",
|
|
||||||
details={"reason": "local_only"}
|
|
||||||
)
|
|
||||||
return success_response(message=f'Plugin {plugin_id} is managed locally and does not receive registry updates')
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read local manifest for plugin: %s", e)
|
logger.debug("Could not read local manifest for plugin: %s", e)
|
||||||
|
|
||||||
|
|||||||
@@ -1,783 +0,0 @@
|
|||||||
/**
|
|
||||||
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
|
|
||||||
*
|
|
||||||
* Usage via config_schema.json:
|
|
||||||
* "file_manager": {
|
|
||||||
* "type": "null",
|
|
||||||
* "title": "Data Files",
|
|
||||||
* "x-widget": "json-file-manager",
|
|
||||||
* "x-widget-config": {
|
|
||||||
* "actions": {
|
|
||||||
* "list": "list-files", // required
|
|
||||||
* "get": "get-file", // required for editing
|
|
||||||
* "save": "save-file", // required for editing
|
|
||||||
* "upload": "upload-file", // optional
|
|
||||||
* "delete": "delete-file", // optional
|
|
||||||
* "create": "create-file", // optional
|
|
||||||
* "toggle": "toggle-category" // optional
|
|
||||||
* },
|
|
||||||
* "upload_hint": "Hint text under the drop zone",
|
|
||||||
* "directory_label": "of_the_day/",
|
|
||||||
* "create_fields": [
|
|
||||||
* { "key": "category_name", "label": "Category Name",
|
|
||||||
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
|
|
||||||
* "hint": "Used as filename" },
|
|
||||||
* { "key": "display_name", "label": "Display Name",
|
|
||||||
* "placeholder": "My Words" }
|
|
||||||
* ],
|
|
||||||
* "toggle_key": "category_name"
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* No CDN dependencies. Works on all modern browsers.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
class JsonFileManager {
|
|
||||||
constructor(container, config, pluginId) {
|
|
||||||
// Prevent duplicate instances on the same container
|
|
||||||
if (container._jfmInstance) {
|
|
||||||
container._jfmInstance._destroy();
|
|
||||||
}
|
|
||||||
container._jfmInstance = this;
|
|
||||||
|
|
||||||
this.el = container;
|
|
||||||
this.pluginId = pluginId;
|
|
||||||
this.actions = config.actions || {};
|
|
||||||
this.uploadHint = config.upload_hint || '';
|
|
||||||
this.dirLabel = config.directory_label || '';
|
|
||||||
this.createFields = config.create_fields || [];
|
|
||||||
this.toggleKey = config.toggle_key || null;
|
|
||||||
|
|
||||||
// Unique prefix for all DOM IDs in this instance
|
|
||||||
this._uid = 'jfm_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
// Mutable state
|
|
||||||
this._editFile = null;
|
|
||||||
this._deleteFile = null;
|
|
||||||
this._keyHandler = this._onKey.bind(this);
|
|
||||||
|
|
||||||
this._inject();
|
|
||||||
this._bind();
|
|
||||||
this._loadList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_destroy() {
|
|
||||||
document.removeEventListener('keydown', this._keyHandler);
|
|
||||||
this.el._jfmInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DOM Injection ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_inject() {
|
|
||||||
const u = this._uid;
|
|
||||||
const hasUpload = !!this.actions.upload;
|
|
||||||
const hasCreate = !!this.actions.create;
|
|
||||||
const hasDelete = !!this.actions.delete;
|
|
||||||
|
|
||||||
this.el.innerHTML = this._css(u) + `
|
|
||||||
<div id="${u}" class="jfm">
|
|
||||||
|
|
||||||
<div class="jfm-header">
|
|
||||||
<div class="jfm-header-left">
|
|
||||||
<span class="jfm-title">Data Files</span>
|
|
||||||
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-header-right">
|
|
||||||
${hasCreate ? `<button type="button" class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">↻</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="${u}-list" class="jfm-list">
|
|
||||||
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${hasUpload ? `
|
|
||||||
<div class="jfm-upload-wrap">
|
|
||||||
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
|
|
||||||
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
|
|
||||||
aria-label="Upload JSON file">
|
|
||||||
<span class="jfm-drop-icon">📁</span>
|
|
||||||
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
|
|
||||||
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<!-- ── Edit modal ─────────────────────────────────────── -->
|
|
||||||
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box jfm-modal-wide">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
|
|
||||||
<div class="jfm-modal-tools">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
|
|
||||||
<textarea id="${u}-editor" class="jfm-editor"
|
|
||||||
spellcheck="false" autocomplete="off"
|
|
||||||
autocorrect="off" autocapitalize="off"
|
|
||||||
aria-label="JSON editor"></textarea>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<span id="${u}-charcount" class="jfm-stat"></span>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Delete modal ───────────────────────────────────── -->
|
|
||||||
${hasDelete ? `
|
|
||||||
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span class="jfm-modal-title">Delete file</span>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-body">
|
|
||||||
<p>Delete <strong id="${u}-del-name"></strong>?</p>
|
|
||||||
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<!-- ── Create modal ───────────────────────────────────── -->
|
|
||||||
${hasCreate ? `
|
|
||||||
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
|
|
||||||
<div class="jfm-modal-box">
|
|
||||||
<div class="jfm-modal-head">
|
|
||||||
<span class="jfm-modal-title">Create new file</span>
|
|
||||||
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-body">
|
|
||||||
${this.createFields.map(f => `
|
|
||||||
<div class="jfm-field">
|
|
||||||
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
|
|
||||||
<input type="text" id="${u}-cf-${this._esc(f.key)}"
|
|
||||||
placeholder="${this._esc(f.placeholder || '')}"
|
|
||||||
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
|
|
||||||
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-modal-foot">
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
|
|
||||||
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
</div>`; // end #${u}
|
|
||||||
|
|
||||||
// Cache frequently-used elements
|
|
||||||
this._root = document.getElementById(u);
|
|
||||||
this._listEl = document.getElementById(`${u}-list`);
|
|
||||||
this._editorEl = document.getElementById(`${u}-editor`);
|
|
||||||
this._editModal = document.getElementById(`${u}-edit-modal`);
|
|
||||||
this._delModal = document.getElementById(`${u}-del-modal`);
|
|
||||||
this._createModal = document.getElementById(`${u}-create-modal`);
|
|
||||||
this._dropzone = document.getElementById(`${u}-dropzone`);
|
|
||||||
this._fileInput = document.getElementById(`${u}-fileinput`);
|
|
||||||
}
|
|
||||||
|
|
||||||
_css(u) {
|
|
||||||
return `<style>
|
|
||||||
#${u}{font-family:inherit;color:#111827;}
|
|
||||||
#${u} *{box-sizing:border-box;}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
|
|
||||||
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
|
|
||||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
|
|
||||||
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
|
||||||
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
|
||||||
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
|
|
||||||
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
|
|
||||||
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
|
|
||||||
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
|
|
||||||
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
|
||||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
|
|
||||||
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
|
|
||||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
|
|
||||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
|
|
||||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
|
|
||||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
|
||||||
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
|
|
||||||
|
|
||||||
/* File list */
|
|
||||||
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
|
|
||||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
|
|
||||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
|
|
||||||
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
|
|
||||||
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
|
|
||||||
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
|
|
||||||
|
|
||||||
/* File cards */
|
|
||||||
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
|
||||||
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
|
|
||||||
#${u} .jfm-card.jfm-off{opacity:.6;}
|
|
||||||
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
|
|
||||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
|
|
||||||
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
|
||||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
|
|
||||||
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
|
|
||||||
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
|
|
||||||
|
|
||||||
/* Toggle */
|
|
||||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
|
|
||||||
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
|
|
||||||
|
|
||||||
/* Upload zone */
|
|
||||||
#${u} .jfm-upload-wrap{margin-top:.25rem;}
|
|
||||||
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
|
|
||||||
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
|
|
||||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
|
|
||||||
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
|
|
||||||
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
|
|
||||||
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
|
|
||||||
|
|
||||||
/* Modals */
|
|
||||||
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
|
|
||||||
#${u} .jfm-modal[hidden]{display:none;}
|
|
||||||
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
|
||||||
#${u} .jfm-modal-wide{max-width:880px;}
|
|
||||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
|
|
||||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
||||||
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
|
|
||||||
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
|
|
||||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
|
|
||||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
|
|
||||||
|
|
||||||
/* JSON editor */
|
|
||||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
|
|
||||||
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
|
||||||
#${u} .jfm-err-bar[hidden]{display:none;}
|
|
||||||
|
|
||||||
/* Create form */
|
|
||||||
#${u} .jfm-field{margin-bottom:.875rem;}
|
|
||||||
#${u} .jfm-field:last-child{margin-bottom:0;}
|
|
||||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
|
|
||||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
|
|
||||||
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
|
|
||||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
|
|
||||||
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
|
|
||||||
|
|
||||||
/* Spinner */
|
|
||||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
|
||||||
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
|
|
||||||
</style>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event Binding ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_bind() {
|
|
||||||
// Delegated clicks on the widget root
|
|
||||||
this._root.addEventListener('click', this._onClick.bind(this));
|
|
||||||
this._root.addEventListener('change', this._onChange.bind(this));
|
|
||||||
|
|
||||||
// Drag-and-drop on the dropzone
|
|
||||||
if (this._dropzone) {
|
|
||||||
this._dropzone.addEventListener('dragover', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
this._dropzone.classList.add('jfm-over');
|
|
||||||
});
|
|
||||||
this._dropzone.addEventListener('dragleave', () => {
|
|
||||||
this._dropzone.classList.remove('jfm-over');
|
|
||||||
});
|
|
||||||
this._dropzone.addEventListener('drop', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
this._dropzone.classList.remove('jfm-over');
|
|
||||||
const file = e.dataTransfer?.files[0];
|
|
||||||
if (file) this._uploadFile(file);
|
|
||||||
});
|
|
||||||
// Keyboard activation of drop zone
|
|
||||||
this._dropzone.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
this._fileInput?.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal backdrop clicks
|
|
||||||
[this._editModal, this._delModal, this._createModal].forEach(m => {
|
|
||||||
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Editor: char count + Tab indent
|
|
||||||
if (this._editorEl) {
|
|
||||||
this._editorEl.addEventListener('input', () => this._updateStat());
|
|
||||||
this._editorEl.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
const s = this._editorEl.selectionStart;
|
|
||||||
const end = this._editorEl.selectionEnd;
|
|
||||||
const v = this._editorEl.value;
|
|
||||||
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
|
|
||||||
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
|
|
||||||
this._updateStat();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', this._keyHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onKey(e) {
|
|
||||||
const editOpen = this._editModal && !this._editModal.hidden;
|
|
||||||
const delOpen = this._delModal && !this._delModal.hidden;
|
|
||||||
const createOpen = this._createModal && !this._createModal.hidden;
|
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
if (editOpen) { this._closeEdit(); return; }
|
|
||||||
if (delOpen) { this._closeDel(); return; }
|
|
||||||
if (createOpen) { this._closeCreate(); return; }
|
|
||||||
}
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
this._doSave();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onClick(e) {
|
|
||||||
const btn = e.target.closest('[data-jfm]');
|
|
||||||
if (!btn) return;
|
|
||||||
const action = btn.dataset.jfm;
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'refresh': this._loadList(); break;
|
|
||||||
case 'open-picker': this._fileInput?.click(); break;
|
|
||||||
case 'open-create': this._openCreate(); break;
|
|
||||||
case 'close-edit': this._closeEdit(); break;
|
|
||||||
case 'close-del': this._closeDel(); break;
|
|
||||||
case 'close-create': this._closeCreate(); break;
|
|
||||||
case 'fmt': this._formatJson(); break;
|
|
||||||
case 'validate': this._validateJson(); break;
|
|
||||||
case 'save': this._doSave(); break;
|
|
||||||
case 'confirm-del': this._doDelete(); break;
|
|
||||||
case 'do-create': this._doCreate(); break;
|
|
||||||
case 'edit-file': {
|
|
||||||
const card = btn.closest('[data-jfm-file]');
|
|
||||||
if (card) this._openEdit(card.dataset.jfmFile);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'del-file': {
|
|
||||||
const card = btn.closest('[data-jfm-file]');
|
|
||||||
if (card) this._openDel(card.dataset.jfmFile);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_onChange(e) {
|
|
||||||
// Toggle checkbox
|
|
||||||
if (e.target.classList.contains('jfm-toggle-cb')) {
|
|
||||||
const catName = e.target.dataset.cat;
|
|
||||||
const enabled = e.target.checked;
|
|
||||||
this._doToggle(catName, enabled, e.target);
|
|
||||||
}
|
|
||||||
// File input
|
|
||||||
if (e.target === this._fileInput) {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) this._uploadFile(file);
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API helper ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _api(actionKey, params) {
|
|
||||||
const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined;
|
|
||||||
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
|
|
||||||
const body = { plugin_id: this.pluginId, action_id: actionId };
|
|
||||||
if (params !== undefined) body.params = params;
|
|
||||||
const r = await fetch('/api/v3/plugins/action', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error('Server error ' + r.status);
|
|
||||||
const ct = r.headers.get('content-type') || '';
|
|
||||||
if (!ct.includes('application/json')) {
|
|
||||||
const txt = await r.text();
|
|
||||||
throw new Error('Unexpected response: ' + txt.slice(0, 120));
|
|
||||||
}
|
|
||||||
return r.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File List ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _loadList() {
|
|
||||||
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
|
|
||||||
try {
|
|
||||||
const data = await this._api('list');
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
|
||||||
this._renderList(data.files || []);
|
|
||||||
} catch (err) {
|
|
||||||
this._listEl.innerHTML = `
|
|
||||||
<div class="jfm-empty">
|
|
||||||
<div class="jfm-empty-icon">⚠</div>
|
|
||||||
<p class="jfm-empty-title">Failed to load files</p>
|
|
||||||
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_renderList(files) {
|
|
||||||
if (!files.length) {
|
|
||||||
this._listEl.innerHTML = `
|
|
||||||
<div class="jfm-empty">
|
|
||||||
<div class="jfm-empty-icon">📁</div>
|
|
||||||
<p class="jfm-empty-title">No files yet</p>
|
|
||||||
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
_card(f) {
|
|
||||||
const enabled = f.enabled !== false;
|
|
||||||
const displayName = this._esc(f.display_name || f.filename);
|
|
||||||
const filename = this._esc(f.filename);
|
|
||||||
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
|
|
||||||
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
|
|
||||||
const hasEdit = !!this.actions.get && !!this.actions.save;
|
|
||||||
const hasDelete = !!this.actions.delete;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
|
|
||||||
<div class="jfm-card-top">
|
|
||||||
<span class="jfm-card-name" title="${filename}">${displayName}</span>
|
|
||||||
${showToggle ? `
|
|
||||||
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
|
|
||||||
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
|
|
||||||
<span>${enabled ? 'On' : 'Off'}</span>
|
|
||||||
</label>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="jfm-card-meta">
|
|
||||||
<span>📄 ${filename}</span>
|
|
||||||
<span>📊 ${f.entry_count ?? 0} entries · ${this._fmtSize(f.size || 0)}</span>
|
|
||||||
<span>🕑 ${this._fmtDate(f.modified)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="jfm-card-actions">
|
|
||||||
${hasEdit ? `<button type="button" class="jfm-btn jfm-btn-sm" data-jfm="edit-file">✎ Edit</button>` : ''}
|
|
||||||
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">🗑</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Edit flow ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _openEdit(filename) {
|
|
||||||
this._editFile = filename;
|
|
||||||
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
|
|
||||||
this._clearErr();
|
|
||||||
this._editorEl.value = 'Loading…';
|
|
||||||
this._updateStat();
|
|
||||||
this._editModal.hidden = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await this._api('get', { filename });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
|
|
||||||
this._editorEl.value = JSON.stringify(data.content, null, 2);
|
|
||||||
this._updateStat();
|
|
||||||
this._editorEl.focus();
|
|
||||||
this._editorEl.setSelectionRange(0, 0);
|
|
||||||
this._editorEl.scrollTop = 0;
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Failed to load file: ' + err.message);
|
|
||||||
this._editorEl.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeEdit() {
|
|
||||||
if (this._editModal) this._editModal.hidden = true;
|
|
||||||
this._editFile = null;
|
|
||||||
this._clearErr();
|
|
||||||
}
|
|
||||||
|
|
||||||
_formatJson() {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
this._editorEl.value = JSON.stringify(parsed, null, 2);
|
|
||||||
this._updateStat();
|
|
||||||
this._clearErr();
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Invalid JSON — ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_validateJson() {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
|
|
||||||
this._clearErr();
|
|
||||||
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Invalid JSON — ' + err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doSave() {
|
|
||||||
if (!this._editFile) return;
|
|
||||||
let contentStr;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(this._editorEl.value);
|
|
||||||
contentStr = JSON.stringify(parsed, null, 2);
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Cannot save — fix JSON first: ' + err.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = document.getElementById(`${this._uid}-save-btn`);
|
|
||||||
this._busy(btn, 'Saving…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('save', { filename: this._editFile, content: contentStr });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
|
|
||||||
this._notify('File saved', 'success');
|
|
||||||
this._closeEdit();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._showErr('Save failed: ' + err.message);
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Save');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete flow ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_openDel(filename) {
|
|
||||||
this._deleteFile = filename;
|
|
||||||
const el = document.getElementById(`${this._uid}-del-name`);
|
|
||||||
if (el) el.textContent = filename;
|
|
||||||
if (this._delModal) this._delModal.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeDel() {
|
|
||||||
if (this._delModal) this._delModal.hidden = true;
|
|
||||||
this._deleteFile = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doDelete() {
|
|
||||||
if (!this._deleteFile) return;
|
|
||||||
const btn = document.getElementById(`${this._uid}-del-btn`);
|
|
||||||
this._busy(btn, 'Deleting…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('delete', { filename: this._deleteFile });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
|
|
||||||
this._notify('File deleted', 'success');
|
|
||||||
this._closeDel();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Delete failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Delete');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create flow ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_openCreate() {
|
|
||||||
if (!this._createModal) return;
|
|
||||||
this.createFields.forEach(f => {
|
|
||||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
|
||||||
if (el) el.value = '';
|
|
||||||
});
|
|
||||||
this._createModal.hidden = false;
|
|
||||||
const first = this.createFields[0];
|
|
||||||
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
_closeCreate() {
|
|
||||||
if (this._createModal) this._createModal.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doCreate() {
|
|
||||||
const params = {};
|
|
||||||
for (const f of this.createFields) {
|
|
||||||
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
|
|
||||||
const val = (el?.value || '').trim();
|
|
||||||
// display_name may be blank — auto-derived from category_name below
|
|
||||||
if (!val && f.key !== 'display_name') {
|
|
||||||
this._notify(`"${f.label}" is required`, 'error');
|
|
||||||
el?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (f.pattern && val && el && el.validity.patternMismatch) {
|
|
||||||
this._notify(`"${f.label}" format is invalid`, 'error');
|
|
||||||
el?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (val) params[f.key] = val;
|
|
||||||
}
|
|
||||||
// Auto-derive display_name from category_name when left blank
|
|
||||||
if (!params.display_name && params.category_name) {
|
|
||||||
params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
}
|
|
||||||
const btn = document.getElementById(`${this._uid}-create-btn`);
|
|
||||||
this._busy(btn, 'Creating…');
|
|
||||||
try {
|
|
||||||
const data = await this._api('create', params);
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
|
|
||||||
this._notify('File created', 'success');
|
|
||||||
this._closeCreate();
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Create failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
this._idle(btn, 'Create');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Upload ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _uploadFile(file) {
|
|
||||||
if (!file.name.endsWith('.json')) {
|
|
||||||
this._notify('Please select a .json file', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let content;
|
|
||||||
try {
|
|
||||||
content = await file.text();
|
|
||||||
JSON.parse(content); // client-side validation
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Invalid JSON: ' + err.message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._dropzone) this._dropzone.style.opacity = '.5';
|
|
||||||
try {
|
|
||||||
const data = await this._api('upload', { filename: file.name, content });
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
|
|
||||||
this._notify(`"${file.name}" uploaded`, 'success');
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Upload failed: ' + err.message, 'error');
|
|
||||||
} finally {
|
|
||||||
if (this._dropzone) this._dropzone.style.opacity = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toggle ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async _doToggle(catName, enabled, checkbox) {
|
|
||||||
checkbox.disabled = true;
|
|
||||||
try {
|
|
||||||
const params = { enabled };
|
|
||||||
if (this.toggleKey) params[this.toggleKey] = catName;
|
|
||||||
const data = await this._api('toggle', params);
|
|
||||||
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
|
|
||||||
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
|
|
||||||
this._loadList();
|
|
||||||
} catch (err) {
|
|
||||||
this._notify('Toggle failed: ' + err.message, 'error');
|
|
||||||
checkbox.checked = !enabled; // revert
|
|
||||||
checkbox.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_closeAll() {
|
|
||||||
this._closeEdit();
|
|
||||||
this._closeDel();
|
|
||||||
this._closeCreate();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateStat() {
|
|
||||||
const v = this._editorEl?.value || '';
|
|
||||||
const lines = v ? v.split('\n').length : 0;
|
|
||||||
const el = document.getElementById(`${this._uid}-charcount`);
|
|
||||||
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
|
|
||||||
}
|
|
||||||
|
|
||||||
_showErr(msg) {
|
|
||||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
|
||||||
if (el) { el.textContent = msg; el.hidden = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearErr() {
|
|
||||||
const el = document.getElementById(`${this._uid}-edit-err`);
|
|
||||||
if (el) { el.textContent = ''; el.hidden = true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
_notify(msg, type) {
|
|
||||||
if (typeof window.showNotification === 'function') {
|
|
||||||
window.showNotification(msg, type || 'info');
|
|
||||||
} else {
|
|
||||||
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_busy(btn, label) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn._jfmOrigText = btn.textContent;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '';
|
|
||||||
const spin = document.createElement('span');
|
|
||||||
spin.className = 'jfm-spin';
|
|
||||||
btn.appendChild(spin);
|
|
||||||
btn.appendChild(document.createTextNode(' ' + label));
|
|
||||||
}
|
|
||||||
|
|
||||||
_idle(btn, label) {
|
|
||||||
if (!btn) return;
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label;
|
|
||||||
delete btn._jfmOrigText;
|
|
||||||
}
|
|
||||||
|
|
||||||
_esc(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = String(str ?? '');
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
_fmtSize(bytes) {
|
|
||||||
if (!bytes) return '0 B';
|
|
||||||
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
|
|
||||||
const unit = ['B', 'KB', 'MB'][i];
|
|
||||||
const val = bytes / Math.pow(1024, i);
|
|
||||||
return (i ? val.toFixed(1) : val) + ' ' + unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
_fmtDate(str) {
|
|
||||||
if (!str) return '—';
|
|
||||||
try {
|
|
||||||
return new Date(str).toLocaleDateString(undefined, {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric'
|
|
||||||
});
|
|
||||||
} catch { return str; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Widget registry integration ──────────────────────────────────────────
|
|
||||||
|
|
||||||
window.JsonFileManager = JsonFileManager;
|
|
||||||
|
|
||||||
if (typeof window.LEDMatrixWidgets !== 'undefined') {
|
|
||||||
window.LEDMatrixWidgets.register('json-file-manager', {
|
|
||||||
name: 'JSON File Manager',
|
|
||||||
version: '1.0.0',
|
|
||||||
render(container, config, _value, options) {
|
|
||||||
new JsonFileManager(container, config || {}, options?.pluginId || '');
|
|
||||||
},
|
|
||||||
getValue() { return null; },
|
|
||||||
setValue() {}
|
|
||||||
});
|
|
||||||
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
|
|
||||||
} else {
|
|
||||||
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -3446,28 +3446,6 @@ function generateFieldHtml(key, prop, value, prefix = '') {
|
|||||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
html += `<option value="${option}" ${selected}>${option}</option>`;
|
||||||
});
|
});
|
||||||
html += `</select>`;
|
html += `</select>`;
|
||||||
} else if (prop['x-widget'] === 'json-file-manager') {
|
|
||||||
// Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
|
|
||||||
const widgetConfig = prop['x-widget-config'] || {};
|
|
||||||
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
|
|
||||||
const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
||||||
|
|
||||||
html += `<div id="${safeFieldId}_jfm_mount"></div>`;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
|
|
||||||
if (!mount) return;
|
|
||||||
// Destroy the previous instance for this mount only — leave other instances intact
|
|
||||||
window.__jfmInstances = window.__jfmInstances || {};
|
|
||||||
const prev = window.__jfmInstances[safeFieldId];
|
|
||||||
if (prev?._destroy) prev._destroy();
|
|
||||||
if (typeof JsonFileManager !== 'undefined') {
|
|
||||||
window.__jfmInstances[safeFieldId] = new JsonFileManager(mount, widgetConfig, pluginId);
|
|
||||||
} else {
|
|
||||||
window.__jfmInstances[safeFieldId] = null;
|
|
||||||
mount.innerHTML = '<p style="color:#dc2626;font-size:.875rem;">json-file-manager widget not loaded. Check base.html includes json-file-manager.js.</p>';
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
} else if (prop['x-widget'] === 'custom-html') {
|
} else if (prop['x-widget'] === 'custom-html') {
|
||||||
// Custom HTML widget - load HTML from plugin directory
|
// Custom HTML widget - load HTML from plugin directory
|
||||||
const htmlFile = prop['x-html-file'];
|
const htmlFile = prop['x-html-file'];
|
||||||
|
|||||||
@@ -4625,9 +4625,6 @@
|
|||||||
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
||||||
|
|
||||||
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
|
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
|
|
||||||
|
|
||||||
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
||||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user