diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 4f36386a..26d2bb4d 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -1117,39 +1117,6 @@ List uploaded images for a plugin. } ``` -### Authenticate Spotify - -**POST** `/api/v3/plugins/authenticate/spotify` - -Spotify OAuth for the music plugin (`ledmatrix-music`; the plugin is fixed, -not taken from the body). Two steps: call with an empty body to get the -authorization URL, then call again with the URL Spotify redirected to. - -**Request Body** (step 2): -```json -{ - "redirect_url": "http://127.0.0.1:8888/callback?code=..." -} -``` - -**Response** (step 1, fields at the top level): -```json -{ - "status": "success", - "message": "Authorization URL generated", - "auth_url": "https://accounts.spotify.com/authorize?..." -} -``` - -Step 2 returns `status`, `message` and the script's `output`. - -### Authenticate YouTube Music - -**POST** `/api/v3/plugins/authenticate/ytm` - -Run the music plugin's YouTube Music authentication script. No body. Returns -`status`, `message` and the script's `output`. - ### Upload Calendar Credentials **POST** `/api/v3/plugins/calendar/upload-credentials` @@ -1967,7 +1934,10 @@ restarted to pick up changes). See ## Plugin-specific endpoints -A handful of endpoints belong to individual plugins. +A handful of endpoints belong to individual plugins. The music plugin's +Spotify and YouTube Music sign-in and the Of-The-Day data files go through the +plugin's own web UI actions ([Execute Plugin Action](#execute-plugin-action)) +rather than dedicated routes. ### Calendar @@ -1977,23 +1947,6 @@ List the calendars on the authenticated Google account. Used by the calendar plugin's config UI. Returns `calendars` at the top level. The upload and authenticate endpoints are under [Plugins](#upload-calendar-credentials). -### Of The Day - -**POST** `/api/v3/plugins/of-the-day/json/upload` - -Upload JSON data files (multipart field `files`) as Of-The-Day categories. -Returns `uploaded_files` and `total_files` at the top level. - -**POST** `/api/v3/plugins/of-the-day/json/delete` - -Delete an uploaded data file. - -```json -{ - "file_id": "category_name" -} -``` - ### Plugin Static Assets **GET** `/api/v3/plugins//static/` diff --git a/src/web_interface/validators.py b/src/web_interface/validators.py index fa1772ef..0a7ee6ef 100644 --- a/src/web_interface/validators.py +++ b/src/web_interface/validators.py @@ -1,94 +1,10 @@ """ Input validation utilities for the web interface. -Provides validation functions for user inputs to prevent XSS, invalid data, and security issues. """ -import re from typing import Optional, Tuple, List -from urllib.parse import urlparse from pathlib import Path -def escape_html(text: str) -> str: - """Escape HTML entities in text to prevent XSS.""" - if not isinstance(text, str): - text = str(text) - # Use basic HTML entity escaping - text = text.replace('&', '&') - text = text.replace('<', '<') - text = text.replace('>', '>') - text = text.replace('"', '"') - text = text.replace("'", ''') - return text - - -def validate_image_url(url: str) -> Tuple[bool, Optional[str]]: - """ - Validate and sanitize image URLs to prevent XSS and protocol injection. - - Returns: - Tuple of (is_valid, error_message) - """ - if not url or not isinstance(url, str): - return False, "URL must be a non-empty string" - - url_lower = url.lower().strip() - - # Reject dangerous protocols - dangerous_protocols = ['javascript:', 'data:', 'vbscript:', 'file:'] - for protocol in dangerous_protocols: - if url_lower.startswith(protocol): - return False, f"Dangerous protocol '{protocol}' not allowed" - - # Reject event handlers - if any(handler in url_lower for handler in ['onerror=', 'onload=', 'onclick=']): - return False, "Event handlers not allowed in URLs" - - # Reject directory traversal anywhere, not only in relative paths: - # http://host/../secret is as much a traversal attempt as /../secret. - if '..' in url: - return False, "Invalid path: directory traversal not allowed" - - # Allow relative paths starting with / - if url.startswith('/'): - # // would be a protocol-relative URL, not a local path - if url.startswith('//'): - return False, "Invalid relative path" - return True, None - - # Validate absolute URLs - try: - parsed = urlparse(url) - allowed_protocols = ['http', 'https'] - if parsed.scheme not in allowed_protocols: - return False, "Only http:// and https:// protocols are allowed" - return True, None - except Exception as e: - return False, f"Invalid URL format: {str(e)}" - - -def validate_font_awesome_class(class_name: str) -> Tuple[bool, Optional[str]]: - """ - Validate Font Awesome class names to prevent XSS. - - Returns: - Tuple of (is_valid, error_message) - """ - if not isinstance(class_name, str): - return False, "Class name must be a string" - - # Whitelist pattern: only allow alphanumeric, dash, underscore, and spaces - # Must contain 'fa-' for Font Awesome - fa_pattern = re.compile(r'^[a-zA-Z0-9\s_-]*fa-[a-zA-Z0-9-]+[a-zA-Z0-9\s_-]*$') - - if not fa_pattern.match(class_name): - return False, "Invalid Font Awesome class name format" - - if 'fa-' not in class_name: - return False, "Font Awesome class must contain 'fa-'" - - return True, None - - def validate_file_upload(filename: str, max_size_mb: int = 10, allowed_extensions: Optional[List[str]] = None) -> Tuple[bool, Optional[str]]: """ @@ -119,117 +35,6 @@ def validate_file_upload(filename: str, max_size_mb: int = 10, return True, None -def validate_mime_type(file_path: str, allowed_types: List[str]) -> Tuple[bool, Optional[str]]: - """ - Validate file MIME type. - - Args: - file_path: Path to the file - allowed_types: List of allowed MIME types (e.g., ['image/png', 'image/jpeg']) - - Returns: - Tuple of (is_valid, error_message) - """ - try: - import mimetypes - mime_type, _ = mimetypes.guess_type(file_path) - - if not mime_type: - return False, "Could not determine file type" - - if mime_type not in allowed_types: - return False, f"File type '{mime_type}' not allowed. Allowed types: {', '.join(allowed_types)}" - - return True, None - except Exception as e: - return False, f"Error validating MIME type: {str(e)}" - - -def validate_numeric_range(value: float, min_val: Optional[float] = None, - max_val: Optional[float] = None) -> Tuple[bool, Optional[str]]: - """ - Validate numeric value is within range. - - Returns: - Tuple of (is_valid, error_message) - """ - # bool is an int subclass, so True would otherwise validate as 1. - if not isinstance(value, (int, float)) or isinstance(value, bool): - return False, "Value must be a number" - - if min_val is not None and value < min_val: - return False, f"Value must be at least {min_val}" - - if max_val is not None and value > max_val: - return False, f"Value must be at most {max_val}" - - return True, None - - -def validate_string_length(text: str, min_length: Optional[int] = None, - max_length: Optional[int] = None) -> Tuple[bool, Optional[str]]: - """ - Validate string length. - - Returns: - Tuple of (is_valid, error_message) - """ - if not isinstance(text, str): - return False, "Value must be a string" - - length = len(text) - - if min_length is not None and length < min_length: - return False, f"String must be at least {min_length} characters" - - if max_length is not None and length > max_length: - return False, f"String must be at most {max_length} characters" - - return True, None - - -def sanitize_plugin_config(config: dict) -> dict: - """ - Restrict a plugin config to safe key names and value types. - - Drops keys that are not plain identifiers and values that are not - JSON-ish scalars, lists, or dicts, recursing into the latter two. - - String values are returned **unescaped**: output escaping is the - template layer's job, and escaping here would store the escaped form - in config.json. Do not read this function as XSS protection for - rendered output. - - Args: - config: Configuration dictionary - - Returns: - Sanitized configuration dictionary - """ - sanitized = {} - - for key, value in config.items(): - # Sanitize keys (no special characters) - if not isinstance(key, str) or not re.match(r'^[a-zA-Z0-9_]+$', key): - continue # Skip invalid keys - - # Sanitize values based on type - if isinstance(value, str): - # For string values, escape HTML but preserve the string - sanitized[key] = value # Don't escape - let templates handle it - elif isinstance(value, (int, float, bool)): - sanitized[key] = value - elif isinstance(value, list): - sanitized[key] = [sanitize_plugin_config(item) if isinstance(item, dict) else item for item in value] - elif isinstance(value, dict): - sanitized[key] = sanitize_plugin_config(value) - else: - # Skip unknown types - continue - - return sanitized - - def dedup_unique_arrays(cfg: dict, schema_node: dict) -> None: """Recursively deduplicate arrays with uniqueItems constraint. diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index c5f496ac..7c2b46a0 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -358,22 +358,6 @@ "POST" ] ], - [ - "/api/v3/plugins/authenticate/spotify", - "api_v3.authenticate_spotify", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/authenticate/ytm", - "api_v3.authenticate_ytm", - [ - "OPTIONS", - "POST" - ] - ], [ "/api/v3/plugins/calendar/authenticate", "api_v3.authenticate_calendar", @@ -511,22 +495,6 @@ "POST" ] ], - [ - "/api/v3/plugins/of-the-day/json/delete", - "api_v3.delete_of_the_day_json", - [ - "OPTIONS", - "POST" - ] - ], - [ - "/api/v3/plugins/of-the-day/json/upload", - "api_v3.upload_of_the_day_json", - [ - "OPTIONS", - "POST" - ] - ], [ "/api/v3/plugins/operation/", "api_v3.get_operation_status", diff --git a/test/test_api_v3_lazy_plugin_discovery.py b/test/test_api_v3_lazy_plugin_discovery.py index 19c1e8fc..1dffae81 100644 --- a/test/test_api_v3_lazy_plugin_discovery.py +++ b/test/test_api_v3_lazy_plugin_discovery.py @@ -76,13 +76,12 @@ def fresh_web_process(api_v3_module, plugins_dir): @pytest.fixture -def display_service(): +def display_service(api_v3_module): """Keep on-demand start away from systemctl and the real cache.""" - with patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') as cache, \ - patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status: - cache.return_value = MagicMock() + cache = api_v3_module.api_v3.cache_manager = MagicMock() + with patch('web_interface.blueprints.api_v3.display._get_display_service_status') as status: status.return_value = {'active': True} - yield cache.return_value + yield cache def _start(client, **body): diff --git a/test/test_api_v3_music_auth_endpoints.py b/test/test_api_v3_music_auth_endpoints.py deleted file mode 100644 index f7c709af..00000000 --- a/test/test_api_v3_music_auth_endpoints.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -Endpoint tests for /plugins/authenticate/spotify and .../ytm. - -The Spotify step-2 handler writes a Python wrapper script to a temp file -with the user's redirect URL embedded in it, then runs that file through -subprocess. That is the most dangerous shape in the blueprint and had no -tests: the URL is user input reaching generated source code. - -The two endpoints are NOT symmetrical, despite the matching names. Only -Spotify has a two-step flow, a wrapper script, and a redirect_url; YTM -just runs its script directly. - -Regression coverage for one fixed bug: the wrapper file was unlinked in -the success/failure branch and again in the TimeoutExpired handler, so -any other failure from subprocess.run — the interpreter missing, a fork -failure, an interrupted call — left a temp file containing the user's -redirect URL behind. -""" - -import ast -import json -import os -import subprocess -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 - - -@pytest.fixture -def plugin_dir(tmp_path, api_v3_module): - """A plugin directory containing both auth scripts.""" - directory = tmp_path / "plugins" / "ledmatrix-music" - directory.mkdir(parents=True) - (directory / "authenticate_spotify.py").write_text("print('spotify')\n") - (directory / "authenticate_ytm.py").write_text("print('ytm')\n") - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(directory) - return directory - - -def completed(returncode=0, stdout="ok", stderr=""): - return subprocess.CompletedProcess( - args=["python3"], returncode=returncode, stdout=stdout, stderr=stderr) - - -class TestSpotifyPreconditions: - URL = "/api/v3/plugins/authenticate/spotify" - - def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path): - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str( - tmp_path / "not-installed") - response = api_v3_client.post(self.URL, json={}) - assert response.status_code == 404 - assert response.get_json()["message"] == "Plugin not found" - - def test_none_plugin_directory_is_404(self, api_v3_client, api_v3_module): - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = None - assert api_v3_client.post(self.URL, json={}).status_code == 404 - - def test_missing_auth_script_is_404(self, api_v3_client, plugin_dir): - (plugin_dir / "authenticate_spotify.py").unlink() - response = api_v3_client.post(self.URL, json={}) - assert response.status_code == 404 - assert "script not found" in response.get_json()["message"] - - -class TestSpotifyStepTwo: - """redirect_url present — the wrapper-script path.""" - - URL = "/api/v3/plugins/authenticate/spotify" - - def test_success(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed(0, "done")): - response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - assert response.status_code == 200 - body = response.get_json() - assert body["status"] == "success" - assert body["output"] == "done" - - def test_script_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed(1, "out", "err")): - response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - assert response.status_code == 400 - assert response.get_json()["output"] == "outerr" - - def test_timeout_is_a_408(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", - side_effect=subprocess.TimeoutExpired("python3", 120)): - response = api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - assert response.status_code == 408 - assert "timed out" in response.get_json()["message"] - - def test_runs_a_list_argv_never_a_shell(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed()) as run: - api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - args, kwargs = run.call_args - assert isinstance(args[0], list) - assert args[0][0] == "python3" - assert kwargs.get("shell") in (None, False) - - def test_timeout_is_bounded(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed()) as run: - api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - assert run.call_args.kwargs["timeout"] == 120 - - -class TestSpotifyWrapperCleanup: - URL = "/api/v3/plugins/authenticate/spotify" - - def _wrapper_paths_after(self, api_v3_client, run_mock): - """Run the endpoint and return the wrapper path subprocess saw.""" - seen = {} - - def capture(args, **kwargs): - seen["path"] = args[1] - return run_mock(args, **kwargs) - - with patch.object(subprocess, "run", side_effect=capture): - api_v3_client.post(self.URL, json={"redirect_url": "http://cb/?code=x"}) - return seen["path"] - - def test_removed_after_success(self, api_v3_client, plugin_dir): - path = self._wrapper_paths_after(api_v3_client, lambda *a, **kw: completed()) - assert not os.path.exists(path) - - def test_removed_after_script_failure(self, api_v3_client, plugin_dir): - path = self._wrapper_paths_after( - api_v3_client, lambda *a, **kw: completed(1, "out", "err")) - assert not os.path.exists(path) - - def test_removed_after_timeout(self, api_v3_client, plugin_dir): - def raise_timeout(*a, **kw): - raise subprocess.TimeoutExpired("python3", 120) - path = self._wrapper_paths_after(api_v3_client, raise_timeout) - assert not os.path.exists(path) - - def test_removed_when_subprocess_cannot_start(self, api_v3_client, plugin_dir): - # Regression: cleanup lived in the success/failure branch and in the - # TimeoutExpired handler only. An OSError from subprocess.run itself - # — no interpreter, fork failure — skipped both and left the wrapper, - # which contains the user's redirect URL, on disk. - def raise_oserror(*a, **kw): - raise OSError("[Errno 12] Cannot allocate memory") - path = self._wrapper_paths_after(api_v3_client, raise_oserror) - assert not os.path.exists(path) - - -class TestSpotifyRedirectUrlIsNotInjectable: - """The wrapper embeds redirect_url into generated Python source.""" - - URL = "/api/v3/plugins/authenticate/spotify" - - ADVERSARIAL = [ - '''http://cb/?code=x"''', - """http://cb/?code=x'""", - 'http://cb/?code=x\\', - 'http://cb/?code=x\nimport os; os.system("id")', - 'http://cb/?code=x"""\nimport os\n"""', - "http://cb/?code=x'''", - 'http://cb/?code=x\\"\\n', - '"; import os; os.system("id"); "', - ] - - def _wrapper_source(self, api_v3_client, redirect_url): - captured = {} - - def capture(args, **kwargs): - captured["source"] = Path(args[1]).read_text() - return completed() - - with patch.object(subprocess, "run", side_effect=capture): - api_v3_client.post(self.URL, json={"redirect_url": redirect_url}) - return captured["source"] - - @pytest.mark.parametrize("redirect_url", ADVERSARIAL) - def test_wrapper_is_still_valid_python(self, api_v3_client, plugin_dir, redirect_url): - # If escaping failed, the generated file would not parse at all. - source = self._wrapper_source(api_v3_client, redirect_url) - ast.parse(source) - - @pytest.mark.parametrize("redirect_url", ADVERSARIAL) - def test_url_survives_as_one_string_literal( - self, api_v3_client, plugin_dir, redirect_url): - # Stronger than "it parses": the URL must still be a single string - # assigned to redirect_url, not code that escaped into statements. - source = self._wrapper_source(api_v3_client, redirect_url) - tree = ast.parse(source) - assigned = [ - node.value.value for node in ast.walk(tree) - if isinstance(node, ast.Assign) - and isinstance(node.value, ast.Constant) - and any(getattr(t, "id", None) == "redirect_url" for t in node.targets) - ] - assert assigned == [redirect_url.strip()] - - def test_injected_call_does_not_become_a_statement(self, api_v3_client, plugin_dir): - source = self._wrapper_source( - api_v3_client, 'http://cb/\nimport os; os.system("id")') - tree = ast.parse(source) - imported = { - alias.name for node in ast.walk(tree) - if isinstance(node, ast.Import) for alias in node.names - } - # The wrapper legitimately imports sys, subprocess and os; what it - # must not gain is a *call* smuggled in through the URL. - calls = [ - node for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "system" - ] - assert calls == [] - - -class TestSpotifyStepOne: - """No redirect_url — the OAuth-URL path, which imports the script.""" - - URL = "/api/v3/plugins/authenticate/spotify" - - def test_script_without_credentials_helper_is_an_error( - self, api_v3_client, plugin_dir): - # The stub script defines neither get_auth_url nor - # load_spotify_credentials, so no URL can be produced. - response = api_v3_client.post(self.URL, json={}) - assert response.status_code in (400, 500) - assert response.get_json()["status"] == "error" - - def test_unusable_credentials_do_not_leak_into_the_response( - self, api_v3_client, plugin_dir): - (plugin_dir / "authenticate_spotify.py").write_text( - "def load_spotify_credentials():\n" - " return ('id-abc', 'super-secret-value', None)\n" - ) - response = api_v3_client.post(self.URL, json={}) - assert "super-secret-value" not in response.get_data(as_text=True) - - def test_script_raising_on_import_is_handled(self, api_v3_client, plugin_dir): - (plugin_dir / "authenticate_spotify.py").write_text("raise RuntimeError('boom')\n") - response = api_v3_client.post(self.URL, json={}) - assert response.status_code == 500 - assert response.get_json()["status"] == "error" - - def test_bodyless_post_reaches_step_one(self, api_v3_client, plugin_dir): - # Covered by the silent=True fix: previously a 500 from body parsing. - response = api_v3_client.post(self.URL) - assert response.status_code in (400, 500) - assert response.get_json()["status"] == "error" - - def test_whitespace_redirect_url_is_treated_as_absent( - self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed()) as run: - api_v3_client.post(self.URL, json={"redirect_url": " "}) - # Step 2 never runs, so no wrapper is executed. - run.assert_not_called() - - -class TestYouTubeMusic: - """No wrapper script and no redirect_url — deliberately not symmetric.""" - - URL = "/api/v3/plugins/authenticate/ytm" - - def test_missing_plugin_directory_is_404(self, api_v3_client, api_v3_module, tmp_path): - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str( - tmp_path / "not-installed") - assert api_v3_client.post(self.URL).status_code == 404 - - def test_missing_script_is_404(self, api_v3_client, plugin_dir): - (plugin_dir / "authenticate_ytm.py").unlink() - response = api_v3_client.post(self.URL) - assert response.status_code == 404 - assert "script not found" in response.get_json()["message"] - - def test_success(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed(0, "authorized")): - response = api_v3_client.post(self.URL) - assert response.status_code == 200 - assert response.get_json()["output"] == "authorized" - - def test_failure_is_a_400_with_combined_output(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed(1, "out", "err")): - response = api_v3_client.post(self.URL) - assert response.status_code == 400 - assert response.get_json()["output"] == "outerr" - - def test_timeout_is_a_408(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", - side_effect=subprocess.TimeoutExpired("python3", 60)): - assert api_v3_client.post(self.URL).status_code == 408 - - def test_runs_the_script_directly_without_a_shell(self, api_v3_client, plugin_dir): - with patch.object(subprocess, "run", return_value=completed()) as run: - api_v3_client.post(self.URL) - args, kwargs = run.call_args - assert args[0][0] == "python3" - assert args[0][1].endswith("authenticate_ytm.py") - assert kwargs.get("shell") in (None, False) - assert kwargs["timeout"] == 60 diff --git a/test/test_api_v3_on_demand_restart.py b/test/test_api_v3_on_demand_restart.py index c203fbe4..83f87834 100644 --- a/test/test_api_v3_on_demand_restart.py +++ b/test/test_api_v3_on_demand_restart.py @@ -24,7 +24,7 @@ and reported success. import sys from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -41,7 +41,8 @@ def restart_path(api_v3_module): plugin_manager and config_manager are set to None so the route takes the simplest path to that branch rather than tripping over unrelated - MagicMock plumbing; _ensure_cache_manager, _get_display_service_status, + MagicMock plumbing. The cache is the blueprint's cache_manager, which + api_v3_module already set to a MagicMock. _get_display_service_status, _stop_display_service and _ensure_display_service_running are bound by value in display.py (see its own docstring), so they are patched on that submodule rather than on the package. @@ -49,16 +50,13 @@ def restart_path(api_v3_module): api_v3_module.api_v3.plugin_manager = None api_v3_module.api_v3.config_manager = None - with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \ - patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \ + with patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \ patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \ patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running: - ensure_cache.return_value = MagicMock() # Active before the request: service_was_running becomes True. get_status.return_value = {"active": True} ensure_running.return_value = {"active": True} yield { - "ensure_cache": ensure_cache, "get_status": get_status, "stop_service": stop_service, "ensure_running": ensure_running, diff --git a/test/test_api_v3_optional_body.py b/test/test_api_v3_optional_body.py index fe1abc5e..921538c1 100644 --- a/test/test_api_v3_optional_body.py +++ b/test/test_api_v3_optional_body.py @@ -54,16 +54,6 @@ class TestResetPluginConfig: assert api_v3_client.post(self.URL, json={}).status_code != 500 -class TestDeleteOfTheDayJson: - URL = "/api/v3/plugins/of-the-day/json/delete" - - def test_bodyless_post_is_not_a_server_error(self, api_v3_client, api_v3_module): - assert api_v3_client.post(self.URL).status_code != 500 - - def test_json_body_still_works(self, api_v3_client, api_v3_module): - assert api_v3_client.post(self.URL, json={}).status_code != 500 - - class TestPluginLimits: URL = "/api/v3/plugins/clock/limits" diff --git a/test/test_path_traversal_guards.py b/test/test_path_traversal_guards.py index bce107cc..bc60ed18 100644 --- a/test/test_path_traversal_guards.py +++ b/test/test_path_traversal_guards.py @@ -182,57 +182,6 @@ class TestServePluginStatic: assert response.status_code == 404 -class TestDeleteOfTheDayJson: - """POST /api/v3/plugins/of-the-day/json/delete - - file_id came from the request body and was interpolated into - ``f"{file_id}.json"`` and then unlinked, with no validation at all. A - file_id of "../../../../etc/something" deleted that file. This is the one - finding in the batch that destroyed data rather than exposing it. - """ - - @pytest.fixture - def plugin_tree(self, tmp_path, api_v3_module): - plugin_dir = tmp_path / "plugin-repos" / "ledmatrix-of-the-day" - (plugin_dir / "of_the_day").mkdir(parents=True) - (plugin_dir / "of_the_day" / "quotes.json").write_text("{}", encoding="utf-8") - outside = tmp_path / "victim.json" - outside.write_text("important", encoding="utf-8") - - api_v3_module.api_v3.plugin_manager = MagicMock() - api_v3_module.api_v3.plugin_manager.get_plugin_directory.return_value = str(plugin_dir) - return plugin_dir, outside - - URL = "/api/v3/plugins/of-the-day/json/delete" - - def test_a_real_file_in_the_plugin_is_still_deleted( - self, api_v3_client, plugin_tree - ): - plugin_dir, _ = plugin_tree - target = plugin_dir / "of_the_day" / "quotes.json" - response = api_v3_client.post(self.URL, json={"file_id": "quotes"}) - assert response.status_code == 200 - assert not target.exists() - - def test_a_traversing_file_id_deletes_nothing(self, api_v3_client, plugin_tree): - _, outside = plugin_tree - response = api_v3_client.post( - self.URL, json={"file_id": "../../../victim"} - ) - assert response.status_code == 400 - assert outside.exists(), "file outside the plugin directory was deleted" - assert outside.read_text(encoding="utf-8") == "important" - - @pytest.mark.parametrize("file_id", ["..", "a/b", "/etc/x", "x" + BACKSLASH + "y"]) - def test_other_shapes_of_traversal_are_refused( - self, api_v3_client, plugin_tree, file_id - ): - _, outside = plugin_tree - response = api_v3_client.post(self.URL, json={"file_id": file_id}) - assert response.status_code == 400 - assert outside.exists() - - class TestDiskCacheKeys: """The cache key becomes a filename, and POST /api/v3/cache/delete passes the request body's key straight through CacheManager.clear_cache to diff --git a/test/test_web_api.py b/test/test_web_api.py index 9a1bd1f7..ebc0cfdd 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -527,13 +527,11 @@ class TestDisplayAPI: if response.status_code in [200, 201]: assert api_v3.cache_manager.set.called - @patch('web_interface.blueprints.api_v3.display._ensure_cache_manager') - def test_stop_on_demand_display(self, mock_ensure_cache, client): + def test_stop_on_demand_display(self, client): """Test stopping on-demand display.""" - - # Mock the cache manager returned by _ensure_cache_manager - mock_cache_manager = MagicMock() - mock_ensure_cache.return_value = mock_cache_manager + from web_interface.blueprints.api_v3 import api_v3 + + mock_cache_manager = api_v3.cache_manager = MagicMock() response = client.post('/api/v3/display/on-demand/stop') diff --git a/test/test_web_display_state_freshness.py b/test/test_web_display_state_freshness.py index 3349db00..15d0c9f2 100644 --- a/test/test_web_display_state_freshness.py +++ b/test/test_web_display_state_freshness.py @@ -23,7 +23,7 @@ def two_processes(tmp_path, monkeypatch): monkeypatch.setattr(CacheManager, '_get_writable_cache_dir', lambda self: str(tmp_path)) monkeypatch.setattr(CacheManager, 'start_cleanup_thread', lambda self: None) display, web = CacheManager(), CacheManager() - monkeypatch.setattr(api_pkg, 'cache_manager', web) + monkeypatch.setattr(api_pkg.api_v3, 'cache_manager', web, raising=False) monkeypatch.setattr(api_pkg, '_get_display_service_status', lambda: {'active': True}) return display diff --git a/test/web_interface/test_api_v3_backup_restore.py b/test/web_interface/test_api_v3_backup_restore.py index 4c4a8304..3a0dd2ce 100644 --- a/test/web_interface/test_api_v3_backup_restore.py +++ b/test/web_interface/test_api_v3_backup_restore.py @@ -282,8 +282,7 @@ class TestPluginReinstall: def test_missing_store_manager_is_reported_per_plugin(self, client, restore): restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}]) api_v3.plugin_store_manager = None - with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None): - body = post(client).get_json() + body = post(client).get_json() assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable" diff --git a/test/web_interface/test_validators.py b/test/web_interface/test_validators.py index 5f655d00..9d211b6e 100644 --- a/test/web_interface/test_validators.py +++ b/test/web_interface/test_validators.py @@ -1,135 +1,16 @@ """ -Tests for src/web_interface/validators.py. +Tests for validate_file_upload in src/web_interface/validators.py. -dedup_unique_arrays is already covered by test_dedup_unique_arrays.py and -is not repeated here; this file covers the other eight functions, none of -which had any tests. +dedup_unique_arrays is covered by test_dedup_unique_arrays.py. -Regression coverage for three fixed bugs: -- validate_numeric_range accepted True/False, since bool subclasses int. -- validate_file_upload lowercased the filename's extension but not the - caller's allowed_extensions list, so ['.TTF'] rejected 'font.ttf'. -- validate_image_url only checked for '..' inside the relative-path - branch, so http://host/../secret passed validation untouched. +Regression coverage: validate_file_upload lowercased the filename's +extension but not the caller's allowed_extensions list, so ['.TTF'] +rejected 'font.ttf'. """ import pytest -from src.web_interface.validators import ( - escape_html, - sanitize_plugin_config, - validate_file_upload, - validate_font_awesome_class, - validate_image_url, - validate_mime_type, - validate_numeric_range, - validate_string_length, -) - - -class TestEscapeHtml: - def test_escapes_all_five_entities(self): - assert escape_html("""O'Neill & co""") == ( - "<a href="x">O'Neill & co</a>") - - def test_ampersand_is_escaped_first_so_nothing_double_escapes(self): - # If '<' were replaced before '&', the '&' of '<' would be - # escaped again into '&lt;'. - assert escape_html("<") == "<" - assert escape_html("&") == "&" - assert escape_html("&<") == "&<" - - def test_plain_text_unchanged(self): - assert escape_html("hello world") == "hello world" - - def test_non_string_is_coerced(self): - assert escape_html(42) == "42" - assert escape_html(None) == "None" - - def test_script_tag_neutralized(self): - assert "") - - -class TestValidateImageUrl: - @pytest.mark.parametrize("url", [ - "javascript:alert(1)", - "JavaScript:alert(1)", - "JAVASCRIPT:alert(1)", - "data:text/html;base64,PHNjcmlwdD4=", - "vbscript:msgbox(1)", - "file:///etc/passwd", - ]) - def test_dangerous_protocols_rejected(self, url): - valid, error = validate_image_url(url) - assert valid is False and "protocol" in error.lower() - - @pytest.mark.parametrize("url", [ - "http://x/a.png?onerror=alert(1)", - "http://x/a.png#onload=alert(1)", - "http://x/onclick=alert(1).png", - ]) - def test_event_handlers_rejected(self, url): - valid, error = validate_image_url(url) - assert valid is False and "Event handlers" in error - - @pytest.mark.parametrize("url", ["", None, 123, []]) - def test_empty_or_non_string_rejected(self, url): - assert validate_image_url(url)[0] is False - - def test_http_and_https_allowed(self): - assert validate_image_url("http://example.com/logo.png") == (True, None) - assert validate_image_url("https://example.com/logo.png") == (True, None) - - def test_other_schemes_rejected(self): - valid, error = validate_image_url("ftp://example.com/logo.png") - assert valid is False and "http://" in error - - def test_relative_path_allowed(self): - assert validate_image_url("/static/logo.png") == (True, None) - - def test_protocol_relative_url_rejected(self): - assert validate_image_url("//evil.com/logo.png")[0] is False - - def test_relative_traversal_rejected(self): - assert validate_image_url("/static/../../etc/passwd")[0] is False - - def test_absolute_url_traversal_rejected(self): - # Regression: the '..' check used to sit inside the leading-slash - # branch, so an absolute URL skipped it entirely. - valid, error = validate_image_url("http://example.com/../secret") - assert valid is False and "traversal" in error.lower() - - def test_bare_traversal_rejected(self): - assert validate_image_url("../../etc/passwd")[0] is False - - -class TestValidateFontAwesomeClass: - @pytest.mark.parametrize("cls", ["fa-star", "fas fa-star", "fa-solid fa-house"]) - def test_valid_classes_accepted(self, cls): - assert validate_font_awesome_class(cls) == (True, None) - - @pytest.mark.parametrize("cls", ["star", "glyphicon-star", ""]) - def test_classes_without_fa_prefix_rejected(self, cls): - assert validate_font_awesome_class(cls)[0] is False - - def test_injection_attempt_rejected(self): - assert validate_font_awesome_class('fa-star" onload="alert(1)')[0] is False - - def test_angle_brackets_rejected(self): - assert validate_font_awesome_class("")[0] is False - - def test_non_string_rejected(self): - valid, error = validate_font_awesome_class(None) - assert valid is False and "string" in error - - def test_explicit_fa_check_is_unreachable_but_harmless(self): - # Characterized, not fixed: the regex already requires 'fa-', so the - # follow-up `if 'fa-' not in class_name` can never fire. Anything - # lacking 'fa-' is rejected by the pattern first, with the pattern's - # own message. - valid, error = validate_font_awesome_class("star") - assert valid is False - assert error == "Invalid Font Awesome class name format" +from src.web_interface.validators import validate_file_upload class TestValidateFileUpload: @@ -164,121 +45,3 @@ class TestValidateFileUpload: def test_no_extension_list_skips_the_check(self): assert validate_file_upload("anything.xyz") == (True, None) - - -class TestValidateMimeType: - def test_known_type_accepted(self): - assert validate_mime_type("logo.png", ["image/png"]) == (True, None) - - def test_mismatched_type_rejected(self): - valid, error = validate_mime_type("logo.png", ["image/jpeg"]) - assert valid is False and "not allowed" in error - - def test_undeterminable_type_rejected(self): - valid, error = validate_mime_type("mystery.zzz", ["image/png"]) - assert valid is False and "Could not determine" in error - - def test_guess_type_failure_is_caught(self, monkeypatch): - import mimetypes - monkeypatch.setattr(mimetypes, "guess_type", - lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("boom"))) - valid, error = validate_mime_type("logo.png", ["image/png"]) - assert valid is False and "Error validating MIME type" in error - - -class TestValidateNumericRange: - def test_value_in_range(self): - assert validate_numeric_range(5, min_val=0, max_val=10) == (True, None) - - def test_boundaries_are_inclusive(self): - assert validate_numeric_range(0, min_val=0, max_val=10) == (True, None) - assert validate_numeric_range(10, min_val=0, max_val=10) == (True, None) - - def test_below_minimum_rejected(self): - valid, error = validate_numeric_range(-1, min_val=0) - assert valid is False and "at least" in error - - def test_above_maximum_rejected(self): - valid, error = validate_numeric_range(11, max_val=10) - assert valid is False and "at most" in error - - def test_floats_accepted(self): - assert validate_numeric_range(2.5, min_val=0, max_val=10) == (True, None) - - def test_no_bounds_accepts_any_number(self): - assert validate_numeric_range(-9999) == (True, None) - - @pytest.mark.parametrize("value", ["5", None, [], {}]) - def test_non_numeric_rejected(self, value): - valid, error = validate_numeric_range(value, min_val=0, max_val=10) - assert valid is False and error == "Value must be a number" - - @pytest.mark.parametrize("value", [True, False]) - def test_booleans_rejected(self, value): - # Regression: bool subclasses int, so True passed the isinstance - # check and then compared as 1 against the range. - valid, error = validate_numeric_range(value, min_val=0, max_val=10) - assert valid is False and error == "Value must be a number" - - -class TestValidateStringLength: - def test_within_range(self): - assert validate_string_length("hello", min_length=1, max_length=10) == (True, None) - - def test_boundaries_are_inclusive(self): - assert validate_string_length("abc", min_length=3, max_length=3) == (True, None) - - def test_too_short_rejected(self): - valid, error = validate_string_length("", min_length=1) - assert valid is False and "at least" in error - - def test_too_long_rejected(self): - valid, error = validate_string_length("abcdef", max_length=3) - assert valid is False and "at most" in error - - def test_non_string_rejected(self): - valid, error = validate_string_length(123, max_length=10) - assert valid is False and "must be a string" in error - - def test_no_bounds_accepts_anything(self): - assert validate_string_length("") == (True, None) - - -class TestSanitizePluginConfig: - def test_valid_keys_and_scalars_kept(self): - config = {"enabled": True, "count": 3, "ratio": 1.5, "name": "clock"} - assert sanitize_plugin_config(config) == config - - @pytest.mark.parametrize("key", ["has space", "has-dash", "has.dot", "has/slash", ""]) - def test_invalid_key_names_dropped(self, key): - assert sanitize_plugin_config({key: "value", "good": 1}) == {"good": 1} - - def test_non_string_keys_dropped(self): - assert sanitize_plugin_config({1: "a", "good": 2}) == {"good": 2} - - def test_nested_dicts_recursed(self): - result = sanitize_plugin_config({"outer": {"inner": 1, "bad key": 2}}) - assert result == {"outer": {"inner": 1}} - - def test_list_of_scalars_preserved(self): - assert sanitize_plugin_config({"teams": ["PHI", "NYG"]})["teams"] == ["PHI", "NYG"] - - def test_list_of_dicts_recursed(self): - result = sanitize_plugin_config({"items": [{"ok": 1, "bad key": 2}]}) - assert result["items"] == [{"ok": 1}] - - def test_unknown_value_types_dropped(self): - assert sanitize_plugin_config({"weird": {1, 2, 3}, "good": 1}) == {"good": 1} - - def test_none_values_dropped(self): - assert sanitize_plugin_config({"nothing": None, "good": 1}) == {"good": 1} - - def test_strings_are_not_html_escaped(self): - # Pinned, not a bug: escaping here would persist the escaped form in - # config.json. Output escaping belongs to the template layer, which - # the function's docstring now says explicitly. - payload = "" - assert sanitize_plugin_config({"title": payload})["title"] == payload - - def test_empty_config(self): - assert sanitize_plugin_config({}) == {} diff --git a/web_interface/README.md b/web_interface/README.md index 9dd13376..d826cdbe 100644 --- a/web_interface/README.md +++ b/web_interface/README.md @@ -17,7 +17,6 @@ This directory contains the active V3 web interface with the following features: web_interface/ ├── app.py # Main Flask application ├── start.py # Startup script -├── run.sh # Shell runner script ├── requirements.txt # Python dependencies ├── blueprints/ # Flask blueprints │ ├── api_v3/ # API endpoints (package: config, display, @@ -27,7 +26,6 @@ web_interface/ ├── templates/ # HTML templates │ └── v3/ │ ├── base.html -│ ├── index.html │ └── partials/ └── static/ # CSS/JS assets └── v3/ @@ -49,11 +47,6 @@ From the project root: python3 web_interface/start.py ``` -Or using the shell script: -```bash -./web_interface/run.sh -``` - ### As a Service (Production) The web interface can run as a systemd service that starts automatically based on the `web_display_autostart` configuration setting: diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index 17ca4c3f..4119a1d8 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -85,20 +85,12 @@ def _scrub_git_remote_url(url: str) -> str: except Exception: pass return url -# Will be initialized when blueprint is registered # NOTE: the managers live on the blueprint object (app.py sets -# api_v3.config_manager / api_v3.plugin_manager). Deliberately not -# mirrored as module globals: a bare `config_manager` used to resolve to -# a None that was never assigned, which silently disabled the /health -# checks and made /display/current fall back to a hardcoded 128x64. -plugin_store_manager = None -saved_repositories_manager = None -cache_manager = None -schema_manager = None -operation_queue = None -plugin_state_manager = None -operation_history = None -sync_manager = None # Optional DisplaySyncManager instance (set by app.py if available) +# api_v3.config_manager, api_v3.plugin_manager, api_v3.cache_manager and +# the rest). Deliberately not mirrored as module globals: a bare +# `config_manager` used to resolve to a None that was never assigned, which +# silently disabled the /health checks and made /display/current fall back +# to a hardcoded 128x64. # Get project root directory (web_interface/../..) # web_interface/blueprints/api_v3/_common.py -> up four to the project root. # This was three levels when everything lived in web_interface/blueprints/api_v3.py; @@ -156,13 +148,6 @@ def _is_plugin_update_available(installed_version: str, latest_version: str) -> """ from src.plugin_system.compatibility import is_update_available return is_update_available(installed_version, latest_version) -def _ensure_cache_manager(): - """Ensure cache manager is initialized.""" - global cache_manager - if cache_manager is None: - from src.cache_manager import CacheManager - cache_manager = CacheManager() - return cache_manager def _save_config_atomic(config_manager, config_data, create_backup=True): """ Save configuration using atomic save if available, fallback to regular save. diff --git a/web_interface/blueprints/api_v3/backup.py b/web_interface/blueprints/api_v3/backup.py index 53e3a12d..f15220ea 100644 --- a/web_interface/blueprints/api_v3/backup.py +++ b/web_interface/blueprints/api_v3/backup.py @@ -5,8 +5,7 @@ endpoint names are unchanged by living here. """ from web_interface.blueprints.api_v3 import ( PROJECT_ROOT, Path, _coerce_to_bool, _safe_backup_path, api_v3, - datetime, json, jsonify, logger, os, plugin_store_manager, request, - tempfile, + datetime, json, jsonify, logger, os, request, tempfile, ) import web_interface.blueprints.api_v3 as _pkg # Read through the module rather than bound by value: tests patch these @@ -151,7 +150,7 @@ def backup_restore(): # Reinstall plugins if requested and store manager available if options.reinstall_plugins and result.plugins_to_install: - psm = getattr(api_v3, 'plugin_store_manager', None) or plugin_store_manager + psm = getattr(api_v3, 'plugin_store_manager', None) for plug in result.plugins_to_install: pid = plug.get('plugin_id') if not pid: diff --git a/web_interface/blueprints/api_v3/display.py b/web_interface/blueprints/api_v3/display.py index 7b632c94..dcb69a50 100644 --- a/web_interface/blueprints/api_v3/display.py +++ b/web_interface/blueprints/api_v3/display.py @@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their endpoint names are unchanged by living here. """ from web_interface.blueprints.api_v3 import ( - _ensure_cache_manager, _ensure_display_service_running, + _ensure_display_service_running, _get_display_service_status, _stop_display_service, api_v3, describe_exception, jsonify, logger, os, request, uuid, ) @@ -15,6 +15,19 @@ import web_interface.blueprints.api_v3 as _pkg # package is the only patch point that covers every caller. +def _cache_manager(): + """The web process's CacheManager, the one app.py puts on the blueprint. + + Created on first use when nothing set it (a test app, an embedder), and + stored back on the blueprint so every route shares that one instance. + """ + cache = getattr(api_v3, 'cache_manager', None) + if cache is None: + from src.cache_manager import CacheManager + cache = api_v3.cache_manager = CacheManager() + return cache + + @api_v3.route('/display/current', methods=['GET']) def get_display_current(): """Get current display state""" @@ -137,7 +150,7 @@ def get_display_modes(): def get_on_demand_status(): """Return the current on-demand display state.""" try: - cache = _ensure_cache_manager() + cache = _cache_manager() # memory_ttl=0: the display service writes this key, so only the file # is current. This process's memory tier would keep serving the first # copy it read for the full max_age -- "active" for two minutes after @@ -209,7 +222,7 @@ def start_on_demand_display(): # Set the on-demand request in cache FIRST (before starting service) # This ensures the request is available when the service starts/restarts - cache = _ensure_cache_manager() + cache = _cache_manager() request_id = data.get('request_id') or str(uuid.uuid4()) request_payload = { 'request_id': request_id, @@ -277,7 +290,7 @@ def stop_on_demand_display(): # Set the stop request in cache FIRST # The display controller will poll this and restart without the on-demand filter - cache = _ensure_cache_manager() + cache = _cache_manager() request_id = data.get('request_id') or str(uuid.uuid4()) request_payload = { 'request_id': request_id, @@ -313,7 +326,7 @@ def get_current_display_status(): process directly. """ try: - cache = _ensure_cache_manager() + cache = _cache_manager() # memory_ttl=0: written by the display service; see get_on_demand_status. state = cache.get('display_current_state', max_age=120, memory_ttl=0) if state is None: diff --git a/web_interface/blueprints/api_v3/plugins.py b/web_interface/blueprints/api_v3/plugins.py index 3ccf1a11..eba6b56e 100644 --- a/web_interface/blueprints/api_v3/plugins.py +++ b/web_interface/blueprints/api_v3/plugins.py @@ -16,7 +16,7 @@ from web_interface.blueprints.api_v3 import ( api_v3, datetime, deep_merge, describe_exception, error_response, find_secret_fields, hashlib, json, jsonify, logger, logging, merge_secrets, os, redact_text, remove_empty_secrets, request, - separate_secrets, shutil, stat, subprocess, success_response, sys, + separate_secrets, shutil, stat, subprocess, success_response, tempfile, uuid, validate_request_json, ) from src.common.path_safety import ( @@ -3181,195 +3181,6 @@ sys.exit(proc.returncode) except Exception as e: logger.error('Error in execute_plugin_action', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/plugins/authenticate/spotify', methods=['POST']) -def authenticate_spotify(): - """Run Spotify authentication script""" - try: - data = request.get_json(silent=True) or {} - redirect_url = data.get('redirect_url', '').strip() - - # Get plugin directory - plugin_id = 'ledmatrix-music' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - auth_script = Path(plugin_dir) / 'authenticate_spotify.py' - if not auth_script.exists(): - return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404 - - # Set LEDMATRIX_ROOT environment variable - env = os.environ.copy() - env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - if redirect_url: - # Step 2: Complete authentication with redirect URL - # Create a wrapper script that provides the redirect URL as input - import tempfile - - # Create a wrapper script that provides the redirect URL - import json - redirect_url_escaped = json.dumps(redirect_url) # Properly escape the URL - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as wrapper: - wrapper.write(f'''import sys -import subprocess -import os - -# Set LEDMATRIX_ROOT -os.environ['LEDMATRIX_ROOT'] = r"{PROJECT_ROOT}" - -# Run the auth script and provide redirect URL -proc = subprocess.Popen( - [sys.executable, r"{auth_script}"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=os.environ -) - -# Send redirect URL to stdin -redirect_url = {redirect_url_escaped} -stdout, _ = proc.communicate(input=redirect_url + "\\n", timeout=120) -print(stdout) -sys.exit(proc.returncode) -''') - wrapper_path = wrapper.name - - try: - result = subprocess.run( - ['python3', wrapper_path], - capture_output=True, - text=True, - timeout=120, - env=env - ) - - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': 'Spotify authentication completed successfully', - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'Spotify authentication failed', - 'output': result.stdout + result.stderr - }), 400 - except subprocess.TimeoutExpired: - return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 - finally: - # The wrapper carries the user's redirect URL, so it must not - # survive the request on any path — including a failure to - # launch, which the previous per-branch unlinks missed. - if os.path.exists(wrapper_path): - os.unlink(wrapper_path) - else: - # Step 1: Get authorization URL - # Import the script's functions directly to get the auth URL - import sys - import importlib.util - - # Load the authentication script as a module - spec = importlib.util.spec_from_file_location("auth_spotify", auth_script) - auth_module = importlib.util.module_from_spec(spec) - sys.modules["auth_spotify"] = auth_module - - # Set LEDMATRIX_ROOT before loading - os.environ['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - try: - spec.loader.exec_module(auth_module) - - # Get credentials and create OAuth object - client_id, client_secret, redirect_uri = auth_module.load_spotify_credentials() - if not all([client_id, client_secret, redirect_uri]): - return jsonify({ - 'status': 'error', - 'message': 'Could not load Spotify credentials. Please check config/config_secrets.json.' - }), 400 - - from spotipy.oauth2 import SpotifyOAuth - sp_oauth = SpotifyOAuth( - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - scope=auth_module.SCOPE, - cache_path=auth_module.SPOTIFY_AUTH_CACHE_PATH, - open_browser=False - ) - - auth_url = sp_oauth.get_authorize_url() - - return jsonify({ - 'status': 'success', - 'message': 'Authorization URL generated', - 'auth_url': auth_url - }) - except Exception as e: - logger.error("Error getting Spotify auth URL", exc_info=True) - return jsonify({ - 'status': 'error', - 'message': 'An error occurred; see logs for details', 'details': describe_exception(e) - }), 500 - - except Exception as e: - logger.error('Error in authenticate_spotify', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/plugins/authenticate/ytm', methods=['POST']) -def authenticate_ytm(): - """Run YouTube Music authentication script""" - try: - # Get plugin directory - plugin_id = 'ledmatrix-music' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - auth_script = Path(plugin_dir) / 'authenticate_ytm.py' - if not auth_script.exists(): - return jsonify({'status': 'error', 'message': 'Authentication script not found'}), 404 - - # Set LEDMATRIX_ROOT environment variable - env = os.environ.copy() - env['LEDMATRIX_ROOT'] = str(PROJECT_ROOT) - - # Run the authentication script - result = subprocess.run( - ['python3', str(auth_script)], - capture_output=True, - text=True, - timeout=60, - env=env - ) - - if result.returncode == 0: - return jsonify({ - 'status': 'success', - 'message': 'YouTube Music authentication completed successfully', - 'output': result.stdout - }) - else: - return jsonify({ - 'status': 'error', - 'message': 'YouTube Music authentication failed', - 'output': result.stdout + result.stderr - }), 400 - - except subprocess.TimeoutExpired: - return jsonify({'status': 'error', 'message': 'Authentication timed out'}), 408 - except Exception as e: - logger.error('Error in authenticate_ytm', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 def _plugin_uploads_dir(plugin_id): """assets/plugins//uploads for a request-supplied id, or None. @@ -3527,210 +3338,6 @@ def upload_plugin_asset(): 'total_files': len(metadata) }) - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/plugins/of-the-day/json/upload', methods=['POST']) -def upload_of_the_day_json(): - """Upload JSON files for of-the-day plugin""" - try: - if 'files' not in request.files: - return jsonify({'status': 'error', 'message': 'No files provided'}), 400 - - files = request.files.getlist('files') - if not files or all(not f.filename for f in files): - return jsonify({'status': 'error', 'message': 'No files provided'}), 400 - - # Get plugin directory - plugin_id = 'ledmatrix-of-the-day' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - # Setup of_the_day directory - data_dir = Path(plugin_dir) / 'of_the_day' - data_dir.mkdir(parents=True, exist_ok=True) - - uploaded_files = [] - max_size_per_file = 5 * 1024 * 1024 # 5MB - - for file in files: - if not file.filename: - continue - - # Validate file extension - if not file.filename.lower().endswith('.json'): - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} must be a JSON file (.json)' - }), 400 - - # Read and validate file size - file.seek(0, os.SEEK_END) - file_size = file.tell() - file.seek(0) - - if file_size > max_size_per_file: - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} exceeds 5MB limit' - }), 400 - - # Read and validate JSON content - try: - file_content = file.read().decode('utf-8') - json_data = json.loads(file_content) - except json.JSONDecodeError as e: - return jsonify({ - 'status': 'error', - 'message': 'Invalid JSON in request body' - }), 400 - except UnicodeDecodeError: - return jsonify({ - 'status': 'error', - 'message': f'File {file.filename} is not valid UTF-8 text' - }), 400 - - # Validate JSON structure (must be object with day number keys) - if not isinstance(json_data, dict): - return jsonify({ - 'status': 'error', - 'message': f'JSON in {file.filename} must be an object with day numbers (1-365) as keys' - }), 400 - - # Check if keys are valid day numbers - for key in json_data.keys(): - try: - day_num = int(key) - if day_num < 1 or day_num > 365: - return jsonify({ - 'status': 'error', - 'message': f'Day number {day_num} in {file.filename} is out of range (must be 1-365)' - }), 400 - except ValueError: - return jsonify({ - 'status': 'error', - 'message': f'Invalid key "{key}" in {file.filename}: must be a day number (1-365)' - }), 400 - - # Generate safe filename from original (preserve user's filename) - original_filename = file.filename - safe_filename = original_filename.lower().replace(' ', '_') - # Ensure it's a valid filename - safe_filename = ''.join(c for c in safe_filename if c.isalnum() or c in '._-') - if not safe_filename.endswith('.json'): - safe_filename += '.json' - - file_path = data_dir / safe_filename - - # If file exists, add counter - counter = 1 - base_name = safe_filename.replace('.json', '') - while file_path.exists(): - safe_filename = f"{base_name}_{counter}.json" - file_path = data_dir / safe_filename - counter += 1 - - # Save file - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(json_data, f, indent=2, ensure_ascii=False) - - # Make file readable - os.chmod(file_path, 0o644) - - # Extract category name from filename (remove .json extension) - category_name = safe_filename.replace('.json', '') - display_name = category_name.replace('_', ' ').title() - - # Update plugin config to add category - try: - sys.path.insert(0, str(plugin_dir)) - from scripts.update_config import add_category_to_config - add_category_to_config(category_name, f'of_the_day/{safe_filename}', display_name) - except Exception as e: - logger.warning("Could not update config: %s", e) - # Continue anyway - file is uploaded - - # Generate file ID (use category name as ID for simplicity) - file_id = category_name - - uploaded_files.append({ - 'id': file_id, - 'filename': safe_filename, - 'original_filename': original_filename, - 'path': f'of_the_day/{safe_filename}', - 'size': file_size, - 'uploaded_at': datetime.utcnow().isoformat() + 'Z', - 'category_name': category_name, - 'display_name': display_name, - 'entry_count': len(json_data) - }) - - return jsonify({ - 'status': 'success', - 'uploaded_files': uploaded_files, - 'total_files': len(uploaded_files) - }) - - except Exception as e: - logger.error('Unhandled exception', exc_info=True) - return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 -@api_v3.route('/plugins/of-the-day/json/delete', methods=['POST']) -def delete_of_the_day_json(): - """Delete a JSON file from of-the-day plugin""" - try: - data = request.get_json(silent=True) or {} - file_id = data.get('file_id') # This is the category_name - - if not file_id: - return jsonify({'status': 'error', 'message': 'file_id is required'}), 400 - - # file_id names a file that is about to be unlinked, and it arrives - # straight from the request body. Unvalidated, a file_id of - # "../../../../etc/cron" made this endpoint delete any .json file on - # the device the service could write to. - safe_file_id = safe_path_component(file_id) - if not safe_file_id: - return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400 - - # Get plugin directory - plugin_id = 'ledmatrix-of-the-day' - if api_v3.plugin_manager: - plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id) - else: - plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id - - if not plugin_dir or not Path(plugin_dir).exists(): - return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404 - - filename = f"{safe_file_id}.json" - file_path = resolve_under(Path(plugin_dir) / 'of_the_day', filename) - if file_path is None: - return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400 - - if not file_path.exists(): - return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404 - - # Delete file - file_path.unlink() - - # Update config to remove category - try: - sys.path.insert(0, str(plugin_dir)) - from scripts.update_config import remove_category_from_config - remove_category_from_config(safe_file_id) - except Exception as e: - logger.warning("Could not update config: %s", e) - - return jsonify({ - 'status': 'success', - 'message': f'File {filename} deleted successfully' - }) - except Exception as e: logger.error('Unhandled exception', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 6f3072ff..e2b82c3d 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -1,4 +1,4 @@ -from flask import Blueprint, Response, render_template, flash, jsonify, url_for +from flask import Blueprint, Response, render_template, jsonify, url_for from jinja2 import TemplateNotFound from markupsafe import escape from html.parser import HTMLParser @@ -20,12 +20,8 @@ from web_interface import widget_bundle logger = logging.getLogger(__name__) -# Will be initialized when blueprint is registered -config_manager = None -plugin_manager = None -plugin_store_manager = None -schema_manager = None - +# The managers live on the blueprint object: app.py sets +# pages_v3.config_manager, pages_v3.plugin_manager and the rest. pages_v3 = Blueprint('pages_v3', __name__) @@ -162,37 +158,8 @@ _SEARCH_INDEX_CACHE = {'sig': None, 'fields': None} @pages_v3.route('/') def index(): - """Main v3 interface page""" - try: - if pages_v3.config_manager: - # Load configuration data - main_config = pages_v3.config_manager.load_config() - schedule_config = main_config.get('schedule', {}) - - # Get raw config files for JSON editor - main_config_data = pages_v3.config_manager.get_raw_file_content('main') - secrets_config_data = pages_v3.config_manager.get_raw_file_content('secrets') - main_config_json = json.dumps(main_config_data, indent=4) - secrets_config_json = json.dumps(secrets_config_data, indent=4) - else: - raise Exception("Config manager not initialized") - - except Exception as e: - flash(f"Error loading configuration: {e}", "error") - schedule_config = {} - main_config_json = "{}" - secrets_config_json = "{}" - main_config_data = {} - secrets_config_data = {} - - return render_template('v3/index.html', - schedule_config=schedule_config, - main_config_json=main_config_json, - secrets_config_json=secrets_config_json, - main_config_path=pages_v3.config_manager.get_config_path() if pages_v3.config_manager else "", - secrets_config_path=pages_v3.config_manager.get_secrets_path() if pages_v3.config_manager else "", - main_config=main_config_data, - secrets_config=secrets_config_data) + """Main v3 interface page: the app shell. Every tab loads as a partial.""" + return render_template('v3/base.html') @pages_v3.route('/partials/') def load_partial(partial_name): diff --git a/web_interface/logging_config.py b/web_interface/logging_config.py index 70a06c75..07c2f267 100644 --- a/web_interface/logging_config.py +++ b/web_interface/logging_config.py @@ -108,29 +108,3 @@ def log_api_request(method: str, path: str, status_code: int, duration_ms: float logger.warning(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra) else: logger.info(f"{method} {path} - {status_code} ({duration_ms}ms)", extra=extra) - - -def log_config_change(change_type: str, target: str, success: bool, **kwargs): - """ - Log a configuration change. - - Args: - change_type: Type of change (save, delete, update) - target: What was changed (e.g., 'main_config', 'plugin_config:football-scoreboard') - success: Whether the change was successful - **kwargs: Additional context - """ - logger = logging.getLogger('web_interface.config') - - extra = { - 'change_type': change_type, - 'target': target, - 'success': success, - **kwargs - } - - if success: - logger.info(f"Config {change_type}: {target}", extra=extra) - else: - logger.error(f"Config {change_type} failed: {target}", extra=extra) - diff --git a/web_interface/run.sh b/web_interface/run.sh deleted file mode 100755 index c606bf88..00000000 --- a/web_interface/run.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -# LED Matrix Web Interface V3 Runner -# This script runs the web interface using system Python - -set -e - -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -cd "$PROJECT_ROOT" - -echo "Starting LED Matrix Web Interface V3..." - -# Run the web interface from project root -python3 web_interface/start.py - diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index 31d770bb..51b6f377 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -1,30 +1,7 @@ -/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */ +/* global showNotification */ // LED Matrix v3 JavaScript // Additional helpers for HTMX and Alpine.js integration -// Global notification system — implemented by widgets/notification.js. -// This fallback only exists if nothing defined showNotification earlier; it -// hands off to the widget when registered, otherwise queues the message for -// the widget to show once it loads. -if (typeof window.showNotification !== 'function') { - window.showNotification = function(message, type = 'info') { - const registry = window.LEDMatrixWidgets; - const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null; - if (widget && typeof widget.show === 'function') { - return widget.show(message, typeof type === 'string' ? { type: type } : (type || {})); - } - if (!Array.isArray(window.__pendingNotifications)) { - window.__pendingNotifications = []; - } - window.__pendingNotifications.push([message, type]); - document.dispatchEvent(new CustomEvent('show-notification', { - detail: { message, type } - })); - // User-facing last resort, so never gated - console.info(`${(type && type.type) || type}: ${message}`); - }; -} - // HTMX response handlers document.body.addEventListener('htmx:beforeRequest', function(event) { // Show loading states for buttons @@ -191,37 +168,6 @@ document.addEventListener('DOMContentLoaded', function() { // SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in // js/app-shell.js — do not open EventSources for stats/display here. -// Utility functions -window.hexToRgb = function(hex) { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : null; -}; - -window.rgbToHex = function(r, g, b) { - return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); -}; - -// Form validation helpers -window.validateForm = function(form) { - const inputs = form.querySelectorAll('input[required], select[required], textarea[required]'); - let isValid = true; - - inputs.forEach(input => { - if (!input.value.trim()) { - input.classList.add('border-red-500'); - isValid = false; - } else { - input.classList.remove('border-red-500'); - } - }); - - return isValid; -}; - // Auto-resize textareas document.addEventListener('DOMContentLoaded', function() { const textareas = document.querySelectorAll('textarea'); @@ -251,144 +197,12 @@ document.addEventListener('keydown', function(e) { } }); -// Plugin management helpers -window.installPlugin = function(pluginId) { - fetch('/api/v3/plugins/install', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plugin_id: pluginId }) - }) - .then(response => response.json()) - .then(data => { - showNotification(data.message, data.status); - if (data.status === 'success') { - // Refresh plugin list - htmx.ajax('GET', '/v3/partials/plugins', '#plugins-content'); - } - }) - .catch(error => { - showNotification('Error installing plugin: ' + error.message, 'error'); - }); -}; - -// Font management helpers -window.uploadFont = function(fileInput) { - const file = fileInput.files[0]; - if (!file) return; - - const formData = new FormData(); - formData.append('font_file', file); - formData.append('font_family', file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9]/g, '_')); - - fetch('/api/v3/fonts/upload', { - method: 'POST', - body: formData - }) - .then(response => response.json()) - .then(data => { - showNotification(data.message, data.status); - if (data.status === 'success') { - // Refresh fonts list - htmx.ajax('GET', '/v3/partials/fonts', '#fonts-content'); - } - }) - .catch(error => { - showNotification('Error uploading font: ' + error.message, 'error'); - }); -}; - -// Tab switching helper -window.switchTab = function(tabName) { - // Update Alpine.js active tab if available - if (window.Alpine) { - // Dispatch event for Alpine.js - const event = new CustomEvent('switch-tab', { - detail: { tab: tabName } - }); - document.dispatchEvent(event); - } -}; - // Error handling for unhandled promise rejections window.addEventListener('unhandledrejection', function(event) { console.error('Unhandled promise rejection:', event.reason); showNotification('An unexpected error occurred', 'error'); }); -// Performance monitoring -window.performanceMonitor = { - startTime: performance.now(), - - mark: function(name) { - if (window.performance.mark) { - performance.mark(name); - } - }, - - measure: function(name, start, end) { - if (window.performance.measure) { - performance.measure(name, start, end); - } - }, - - getMeasures: function() { - if (window.performance && window.performance.getEntriesByType) { - return window.performance.getEntriesByType('measure'); - } - return []; - }, - - getMetrics: function() { - if (!window.performance || !window.performance.getEntriesByType) { - return {}; - } - - const navigation = window.performance.getEntriesByType('navigation')[0]; - const paint = window.performance.getEntriesByType('paint'); - const resources = window.performance.getEntriesByType('resource'); - - return { - domContentLoaded: navigation ? navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart : 0, - loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0, - firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0, - firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0, - resourceCount: resources.length, - totalResourceSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0), - measures: this.measures - }; - }, - - logMetrics: function() { - const metrics = this.getMetrics(); - console.group('Performance Metrics'); - debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms'); - debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms'); - debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms'); - debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms'); - debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB'); - if (Object.keys(metrics.measures || {}).length > 0) { - debugLog('Custom Measures:', metrics.measures); - } - console.groupEnd(); - } -}; - -// Initialize performance monitoring -document.addEventListener('DOMContentLoaded', function() { - window.performanceMonitor.mark('app-start'); - - // Log metrics after page load - window.addEventListener('load', function() { - setTimeout(() => { - window.performanceMonitor.mark('app-loaded'); - window.performanceMonitor.measure('app-load-time', 'app-start', 'app-loaded'); - if (window.location.search.includes('debug=perf')) { - window.performanceMonitor.logMetrics(); - } - }, 100); - }); -}); - // ===== Floating live preview ===== // A mini preview of the display, available on every tab except Overview // (which has the full-size one). Open/closed state persists per browser; diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js index ab865447..5e815997 100644 --- a/web_interface/static/v3/js/app-shell.js +++ b/web_interface/static/v3/js/app-shell.js @@ -2572,23 +2572,6 @@ // Assign to window for global access window.uninstallPlugin = uninstallPlugin; - async function refreshPlugin(pluginId) { - try { - // Switch to the plugin manager tab briefly to refresh - const originalTab = app.activeTab; - app.activeTab = 'plugins'; - - // Wait a moment then switch back - setTimeout(() => { - app.activeTab = originalTab; - app.showNotification(`Refreshed ${pluginId}`, 'success'); - }, 100); - - } catch (error) { - app.showNotification('Error refreshing plugin: ' + error.message, 'error'); - } - } - // Format commit information for display function formatCommitInfo(commit, branch) { if (!commit && !branch) return 'Unknown'; diff --git a/web_interface/static/v3/js/config/diff_viewer.js b/web_interface/static/v3/js/config/diff_viewer.js deleted file mode 100644 index baa0eee4..00000000 --- a/web_interface/static/v3/js/config/diff_viewer.js +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Configuration diff viewer. - * - * Shows what changed in configuration before saving. - */ - -const ConfigDiffViewer = { - /** - * Original configuration state (before changes). - */ - originalConfigs: new Map(), - - /** - * Store original configuration for a plugin. - * - * @param {string} pluginId - Plugin identifier - * @param {Object} config - Original configuration - */ - storeOriginal(pluginId, config) { - this.originalConfigs.set(pluginId, JSON.parse(JSON.stringify(config))); - }, - - /** - * Get original configuration for a plugin. - * - * @param {string} pluginId - Plugin identifier - * @returns {Object|null} Original configuration - */ - getOriginal(pluginId) { - return this.originalConfigs.get(pluginId) || null; - }, - - /** - * Clear stored original configuration. - * - * @param {string} pluginId - Plugin identifier - */ - clearOriginal(pluginId) { - this.originalConfigs.delete(pluginId); - }, - - /** - * Compare two configuration objects and return differences. - * - * @param {Object} oldConfig - Old configuration - * @param {Object} newConfig - New configuration - * @returns {Object} Differences object with added, removed, and changed keys - */ - compare(oldConfig, newConfig) { - const differences = { - added: {}, - removed: {}, - changed: {}, - unchanged: {} - }; - - // Get all keys from both configs - const allKeys = new Set([ - ...Object.keys(oldConfig || {}), - ...Object.keys(newConfig || {}) - ]); - - for (const key of allKeys) { - const oldValue = oldConfig?.[key]; - const newValue = newConfig?.[key]; - - if (!(key in (oldConfig || {}))) { - // Key was added - differences.added[key] = newValue; - } else if (!(key in (newConfig || {}))) { - // Key was removed - differences.removed[key] = oldValue; - } else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { - // Key was changed - differences.changed[key] = { - old: oldValue, - new: newValue - }; - } else { - // Key unchanged - differences.unchanged[key] = oldValue; - } - } - - return differences; - }, - - /** - * Check if there are any differences. - * - * @param {Object} differences - Differences object from compare() - * @returns {boolean} True if there are changes - */ - hasChanges(differences) { - return Object.keys(differences.added).length > 0 || - Object.keys(differences.removed).length > 0 || - Object.keys(differences.changed).length > 0; - }, - - /** - * Format differences for display. - * - * @param {Object} differences - Differences object - * @returns {string} HTML formatted diff - */ - formatDiff(differences) { - const parts = []; - - // Added keys - if (Object.keys(differences.added).length > 0) { - parts.push(` -
-

- Added -

-
- ${Object.entries(differences.added).map(([key, value]) => ` -
- ${this.escapeHtml(key)} - = -
${this.escapeHtml(JSON.stringify(value, null, 2))}
-
- `).join('')} -
-
- `); - } - - // Removed keys - if (Object.keys(differences.removed).length > 0) { - parts.push(` -
-

- Removed -

-
- ${Object.entries(differences.removed).map(([key, value]) => ` -
- ${this.escapeHtml(key)} - = -
${this.escapeHtml(JSON.stringify(value, null, 2))}
-
- `).join('')} -
-
- `); - } - - // Changed keys - if (Object.keys(differences.changed).length > 0) { - parts.push(` -
-

- Changed -

-
- ${Object.entries(differences.changed).map(([key, change]) => ` -
- ${this.escapeHtml(key)} -
-
-
Old Value:
-
${this.escapeHtml(JSON.stringify(change.old, null, 2))}
-
-
-
New Value:
-
${this.escapeHtml(JSON.stringify(change.new, null, 2))}
-
-
-
- `).join('')} -
-
- `); - } - - if (parts.length === 0) { - return '
No changes detected
'; - } - - return parts.join(''); - }, - - /** - * Show diff modal before saving. - * - * @param {string} pluginId - Plugin identifier - * @param {Object} newConfig - New configuration - * @returns {Promise} Promise resolving to true if user confirms, false if cancelled - */ - async showDiffModal(pluginId, newConfig) { - return new Promise((resolve) => { - const original = this.getOriginal(pluginId); - if (!original) { - // No original to compare, proceed without diff - resolve(true); - return; - } - - const differences = this.compare(original, newConfig); - - if (!this.hasChanges(differences)) { - // No changes, proceed without showing modal - resolve(true); - return; - } - - // Create modal - const modalContainer = document.createElement('div'); - modalContainer.id = 'config-diff-modal-container'; - modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto'; - modalContainer.innerHTML = ` -
- - -
-
-
- -
-

Review Configuration Changes

-
- ${this.formatDiff(differences)} -
-
-
-
-
- - -
-
-
- `; - - document.body.appendChild(modalContainer); - - let release = null; - let settled = false; - // Single close path: remove the modal, release the focus trap - // (returning focus to where the user was) and settle the promise. - function finish(result) { - if (settled) return; - settled = true; - modalContainer.remove(); - if (window.__configDiffResolve === finish) window.__configDiffResolve = undefined; - if (release) release(); - resolve(result); - } - - // Kept for backwards compatibility with code that calls it directly. - window.__configDiffResolve = finish; - - // Attach event listeners - const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn'); - const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn'); - const backdrop = modalContainer.querySelector('[data-diff-backdrop]'); - - confirmBtn.addEventListener('click', () => finish(true)); - cancelBtn.addEventListener('click', () => finish(false)); - if (backdrop) backdrop.addEventListener('click', () => finish(false)); - - if (window.LEDDialog) { - release = window.LEDDialog.trap(modalContainer.querySelector('#config-diff-modal-panel'), { - labelledBy: 'config-diff-modal-title', - initialFocus: confirmBtn, - onEscape: () => finish(false) - }); - } - }); - }, - - /** - * Escape HTML to prevent XSS. - */ - escapeHtml(text) { - if (typeof text !== 'string') { - text = String(text); - } - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } -}; - -// Export -if (typeof module !== 'undefined' && module.exports) { - module.exports = ConfigDiffViewer; -} else { - window.ConfigDiffViewer = ConfigDiffViewer; -} - diff --git a/web_interface/static/v3/js/htmx-config.js b/web_interface/static/v3/js/htmx-config.js index 8b9d6109..9b0f16a9 100644 --- a/web_interface/static/v3/js/htmx-config.js +++ b/web_interface/static/v3/js/htmx-config.js @@ -53,67 +53,6 @@ } }); - // Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy - const originalError = console.error; - const originalWarn = console.warn; - - console.error = function(...args) { - const errorStr = args.join(' '); - const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || ''; - - // Suppress HTMX insertBefore errors (comprehensive check) - // These occur when HTMX tries to swap content but the target element is null - // Usually happens due to timing/race conditions and is harmless - if (errorStr.includes("insertBefore") || - errorStr.includes("Cannot read properties of null") || - errorStr.includes("reading 'insertBefore'")) { - // Check if it's from HTMX by looking at stack trace or error string - // Also check the call stack if available - const isHtmxError = errorStr.includes('htmx') || - errorStack.includes('htmx') || - args.some(arg => { - if (typeof arg === 'string') { - return arg.includes('htmx'); - } - // Check error objects for stack traces - if (arg && typeof arg === 'object' && arg.stack) { - return arg.stack.includes('htmx'); - } - return false; - }); - - if (isHtmxError) { - return; // Suppress - this is a harmless HTMX timing/race condition issue - } - } - - // Suppress script execution errors from malformed HTML - if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") || - errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) { - if (errorStr.includes('Unexpected token')) { - return; // Suppress malformed HTML errors - } - } - originalError.apply(console, args); - }; - - console.warn = function(...args) { - const warnStr = args.join(' '); - // Suppress Permissions-Policy warnings (harmless browser warnings) - if (warnStr.includes('Permissions-Policy header') || - warnStr.includes('Unrecognized feature') || - warnStr.includes('Origin trial controlled feature') || - warnStr.includes('browsing-topics') || - warnStr.includes('run-ad-auction') || - warnStr.includes('join-ad-interest-group') || - warnStr.includes('private-state-token') || - warnStr.includes('private-aggregation') || - warnStr.includes('attribution-reporting')) { - return; // Suppress - these are harmless browser feature warnings - } - originalWarn.apply(console, args); - }; - // Handle HTMX errors gracefully with detailed logging document.body.addEventListener('htmx:responseError', function(event) { const detail = event.detail; diff --git a/web_interface/static/v3/js/htmx-sse.js b/web_interface/static/v3/js/htmx-sse.js deleted file mode 100644 index 49fe1b8f..00000000 --- a/web_interface/static/v3/js/htmx-sse.js +++ /dev/null @@ -1,356 +0,0 @@ -/* global htmx */ -/* -Server Sent Events Extension -============================ -This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. - -*/ - -(function() { - - /** @type {import("../htmx").HtmxInternalApi} */ - var api; - - htmx.defineExtension("sse", { - - /** - * Init saves the provided reference to the internal HTMX API. - * - * @param {import("../htmx").HtmxInternalApi} api - * @returns void - */ - init: function(apiRef) { - // store a reference to the internal API. - api = apiRef; - - // set a function in the public API for creating new EventSource objects - if (htmx.createEventSource == undefined) { - htmx.createEventSource = createEventSource; - } - }, - - /** - * onEvent handles all events passed to this extension. - * - * @param {string} name - * @param {Event} evt - * @returns void - */ - onEvent: function(name, evt) { - - switch (name) { - - case "htmx:beforeCleanupElement": - var internalData = api.getInternalData(evt.target) - // Try to remove remove an EventSource when elements are removed - if (internalData.sseEventSource) { - internalData.sseEventSource.close(); - } - - return; - - // Try to create EventSources when elements are processed - case "htmx:afterProcessNode": - ensureEventSourceOnElement(evt.target); - registerSSE(evt.target); - } - } - }); - - /////////////////////////////////////////////// - // HELPER FUNCTIONS - /////////////////////////////////////////////// - - - /** - * createEventSource is the default method for creating new EventSource objects. - * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. - * - * @param {string} url - * @returns EventSource - */ - function createEventSource(url) { - return new EventSource(url, { withCredentials: true }); - } - - function splitOnWhitespace(trigger) { - return trigger.trim().split(/\s+/); - } - - function getLegacySSEURL(elt) { - var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); - if (legacySSEValue) { - var values = splitOnWhitespace(legacySSEValue); - for (var i = 0; i < values.length; i++) { - var value = values[i].split(/:(.+)/); - if (value[0] === "connect") { - return value[1]; - } - } - } - } - - function getLegacySSESwaps(elt) { - var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); - var returnArr = []; - if (legacySSEValue != null) { - var values = splitOnWhitespace(legacySSEValue); - for (var i = 0; i < values.length; i++) { - var value = values[i].split(/:(.+)/); - if (value[0] === "swap") { - returnArr.push(value[1]); - } - } - } - return returnArr; - } - - /** - * registerSSE looks for attributes that can contain sse events, right - * now hx-trigger and sse-swap and adds listeners based on these attributes too - * the closest event source - * - * @param {HTMLElement} elt - */ - function registerSSE(elt) { - // Find closest existing event source - var sourceElement = api.getClosestMatch(elt, hasEventSource); - if (sourceElement == null) { - // api.triggerErrorEvent(elt, "htmx:noSSESourceError") - return null; // no eventsource in parentage, orphaned element - } - - // Set internalData and source - var internalData = api.getInternalData(sourceElement); - var source = internalData.sseEventSource; - - // Add message handlers for every `sse-swap` attribute - queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) { - - var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); - if (sseSwapAttr) { - var sseEventNames = sseSwapAttr.split(","); - } else { - var sseEventNames = getLegacySSESwaps(child); - } - - for (var i = 0; i < sseEventNames.length; i++) { - var sseEventName = sseEventNames[i].trim(); - var listener = function(event) { - - // If the source is missing then close SSE - if (maybeCloseSSESource(sourceElement)) { - return; - } - - // If the body no longer contains the element, remove the listener - if (!api.bodyContains(child)) { - source.removeEventListener(sseEventName, listener); - } - - // swap the response into the DOM and trigger a notification - swap(child, event.data); - api.triggerEvent(elt, "htmx:sseMessage", event); - }; - - // Register the new listener - api.getInternalData(child).sseEventListener = listener; - source.addEventListener(sseEventName, listener); - } - }); - - // Add message handlers for every `hx-trigger="sse:*"` attribute - queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) { - - var sseEventName = api.getAttributeValue(child, "hx-trigger"); - if (sseEventName == null) { - return; - } - - // Only process hx-triggers for events with the "sse:" prefix - if (sseEventName.slice(0, 4) != "sse:") { - return; - } - - // remove the sse: prefix from here on out - sseEventName = sseEventName.substr(4); - - var listener = function() { - if (maybeCloseSSESource(sourceElement)) { - return - } - - if (!api.bodyContains(child)) { - source.removeEventListener(sseEventName, listener); - } - } - }); - } - - /** - * ensureEventSourceOnElement creates a new EventSource connection on the provided element. - * If a usable EventSource already exists, then it is returned. If not, then a new EventSource - * is created and stored in the element's internalData. - * @param {HTMLElement} elt - * @param {number} retryCount - * @returns {EventSource | null} - */ - function ensureEventSourceOnElement(elt, retryCount) { - - if (elt == null) { - return null; - } - - // handle extension source creation attribute - queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { - var sseURL = api.getAttributeValue(child, "sse-connect"); - if (sseURL == null) { - return; - } - - ensureEventSource(child, sseURL, retryCount); - }); - - // handle legacy sse, remove for HTMX2 - queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) { - var sseURL = getLegacySSEURL(child); - if (sseURL == null) { - return; - } - - ensureEventSource(child, sseURL, retryCount); - }); - - } - - function ensureEventSource(elt, url, retryCount) { - var source = htmx.createEventSource(url); - - source.onerror = function(err) { - - // Log an error event - api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); - - // If parent no longer exists in the document, then clean up this EventSource - if (maybeCloseSSESource(elt)) { - return; - } - - // Otherwise, try to reconnect the EventSource - if (source.readyState === EventSource.CLOSED) { - retryCount = retryCount || 0; - var timeout = Math.random() * (2 ^ retryCount) * 500; - window.setTimeout(function() { - ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); - }, timeout); - } - }; - - source.onopen = function(evt) { - api.triggerEvent(elt, "htmx:sseOpen", { source: source }); - } - - api.getInternalData(elt).sseEventSource = source; - } - - /** - * maybeCloseSSESource confirms that the parent element still exists. - * If not, then any associated SSE source is closed and the function returns true. - * - * @param {HTMLElement} elt - * @returns boolean - */ - function maybeCloseSSESource(elt) { - if (!api.bodyContains(elt)) { - var source = api.getInternalData(elt).sseEventSource; - if (source != undefined) { - source.close(); - // source = null - return true; - } - } - return false; - } - - /** - * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. - * - * @param {HTMLElement} elt - * @param {string} attributeName - */ - function queryAttributeOnThisOrChildren(elt, attributeName) { - - var result = []; - - // If the parent element also contains the requested attribute, then add it to the results too. - if (api.hasAttribute(elt, attributeName)) { - result.push(elt); - } - - // Search all child nodes that match the requested attribute - elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) { - result.push(node); - }); - - return result; - } - - /** - * @param {HTMLElement} elt - * @param {string} content - */ - function swap(elt, content) { - - api.withExtensions(elt, function(extension) { - content = extension.transformResponse(content, null, elt); - }); - - var swapSpec = api.getSwapSpecification(elt); - var target = api.getTarget(elt); - var settleInfo = api.makeSettleInfo(elt); - - api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); - - settleInfo.elts.forEach(function(elt) { - if (elt.classList) { - elt.classList.add(htmx.config.settlingClass); - } - api.triggerEvent(elt, 'htmx:beforeSettle'); - }); - - // Handle settle tasks (with delay if requested) - if (swapSpec.settleDelay > 0) { - setTimeout(doSettle(settleInfo), swapSpec.settleDelay); - } else { - doSettle(settleInfo)(); - } - } - - /** - * doSettle mirrors much of the functionality in htmx that - * settles elements after their content has been swapped. - * TODO: this should be published by htmx, and not duplicated here - * @param {import("../htmx").HtmxSettleInfo} settleInfo - * @returns () => void - */ - function doSettle(settleInfo) { - - return function() { - settleInfo.tasks.forEach(function(task) { - task.call(); - }); - - settleInfo.elts.forEach(function(elt) { - if (elt.classList) { - elt.classList.remove(htmx.config.settlingClass); - } - api.triggerEvent(elt, 'htmx:afterSettle'); - }); - } - } - - function hasEventSource(node) { - return api.getInternalData(node).sseEventSource != null; - } - -})(); diff --git a/web_interface/static/v3/js/plugins/store_manager.js b/web_interface/static/v3/js/plugins/store_manager.js deleted file mode 100644 index b223c087..00000000 --- a/web_interface/static/v3/js/plugins/store_manager.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Plugin store management. - * - * Handles plugin store browsing, searching, and installation. - */ - -const PluginStoreManager = { - /** - * Cache for plugin store data. - */ - cache: null, - cacheTimestamp: null, - CACHE_DURATION: 5 * 60 * 1000, // 5 minutes - - /** - * Load plugin store. - * - * @param {boolean} useCache - Whether to use cached data - * @returns {Promise} List of plugins - */ - async loadStore(useCache = true) { - // Check cache - if (useCache && this.cache && this.cacheTimestamp) { - const age = Date.now() - this.cacheTimestamp; - if (age < this.CACHE_DURATION) { - return this.cache; - } - } - - try { - const plugins = await window.PluginAPI.getPluginStore(); - this.cache = plugins; - this.cacheTimestamp = Date.now(); - return plugins; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, 'Failed to load plugin store'); - } - throw error; - } - }, - - /** - * Search plugin store. - * - * @param {string} query - Search query - * @returns {Promise} Filtered list of plugins - */ - async searchStore(query) { - const plugins = await this.loadStore(); - - if (!query || query.trim() === '') { - return plugins; - } - - const lowerQuery = query.toLowerCase(); - return plugins.filter(plugin => { - const name = (plugin.name || '').toLowerCase(); - const description = (plugin.description || '').toLowerCase(); - const author = (plugin.author || '').toLowerCase(); - const category = (plugin.category || '').toLowerCase(); - - return name.includes(lowerQuery) || - description.includes(lowerQuery) || - author.includes(lowerQuery) || - category.includes(lowerQuery); - }); - }, - - /** - * Install plugin from store. - * - * @param {string} pluginId - Plugin identifier - * @param {string} branch - Optional branch name to install from - * @returns {Promise} Installation result - */ - async installPlugin(pluginId, branch = null) { - try { - const result = await window.PluginAPI.installPlugin(pluginId, branch); - - // Clear cache - this.cache = null; - this.cacheTimestamp = null; - - return result; - } catch (error) { - if (window.errorHandler) { - window.errorHandler.displayError(error, `Failed to install plugin ${pluginId}`); - } - throw error; - } - } -}; - -// Export -if (typeof module !== 'undefined' && module.exports) { - module.exports = PluginStoreManager; -} else { - window.PluginStoreManager = PluginStoreManager; -} - diff --git a/web_interface/static/v3/js/widgets/array-table.js b/web_interface/static/v3/js/widgets/array-table.js index 3c39b892..629f24ee 100644 --- a/web_interface/static/v3/js/widgets/array-table.js +++ b/web_interface/static/v3/js/widgets/array-table.js @@ -32,7 +32,7 @@ version: '2.0.0', render: function(container, config, value, options) { - console.log('[ArrayTableWidget] Render called (server-side rendered)'); + if (window.debugLog) window.debugLog('[ArrayTableWidget] Render called (server-side rendered)'); }, getValue: function(fieldId) { @@ -918,6 +918,4 @@ } else { initArrayTableButtons(); } - - console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)'); })(); diff --git a/web_interface/static/v3/js/widgets/base-widget.js b/web_interface/static/v3/js/widgets/base-widget.js index 93c56b4c..fa9e7438 100644 --- a/web_interface/static/v3/js/widgets/base-widget.js +++ b/web_interface/static/v3/js/widgets/base-widget.js @@ -201,6 +201,4 @@ if (typeof window !== 'undefined') { window.BaseWidget = BaseWidget; } - - console.log('[BaseWidget] Base widget class loaded'); })(); diff --git a/web_interface/static/v3/js/widgets/checkbox-group.js b/web_interface/static/v3/js/widgets/checkbox-group.js index cbf02ad4..44014d63 100644 --- a/web_interface/static/v3/js/widgets/checkbox-group.js +++ b/web_interface/static/v3/js/widgets/checkbox-group.js @@ -31,7 +31,7 @@ render: function(container, config, value, options) { // For now, widgets are server-side rendered // This function is a placeholder for future client-side rendering - console.log('[CheckboxGroupWidget] Render called (server-side rendered)'); + if (window.debugLog) window.debugLog('[CheckboxGroupWidget] Render called (server-side rendered)'); }, /** @@ -116,6 +116,4 @@ }); hiddenInput.dispatchEvent(event); }; - - console.log('[CheckboxGroupWidget] Checkbox group widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/color-picker.js b/web_interface/static/v3/js/widgets/color-picker.js index 16bfd6a0..8802b28e 100644 --- a/web_interface/static/v3/js/widgets/color-picker.js +++ b/web_interface/static/v3/js/widgets/color-picker.js @@ -258,6 +258,4 @@ } } }); - - console.log('[ColorPickerWidget] Color picker widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/custom-feeds.js b/web_interface/static/v3/js/widgets/custom-feeds.js index c14c92ea..753931e5 100644 --- a/web_interface/static/v3/js/widgets/custom-feeds.js +++ b/web_interface/static/v3/js/widgets/custom-feeds.js @@ -31,7 +31,7 @@ render: function(container, config, value, options) { // For now, widgets are server-side rendered // This function is a placeholder for future client-side rendering - console.log('[CustomFeedsWidget] Render called (server-side rendered)'); + if (window.debugLog) window.debugLog('[CustomFeedsWidget] Render called (server-side rendered)'); }, /** @@ -523,6 +523,4 @@ event.target.value = ''; }); }; - - console.log('[CustomFeedsWidget] Custom feeds widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/date-picker.js b/web_interface/static/v3/js/widgets/date-picker.js index 44bca5f1..f0b4c848 100644 --- a/web_interface/static/v3/js/widgets/date-picker.js +++ b/web_interface/static/v3/js/widgets/date-picker.js @@ -189,6 +189,4 @@ } } }); - - console.log('[DatePickerWidget] Date picker widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/day-selector.js b/web_interface/static/v3/js/widgets/day-selector.js index 7ce18598..3a14ee85 100644 --- a/web_interface/static/v3/js/widgets/day-selector.js +++ b/web_interface/static/v3/js/widgets/day-selector.js @@ -254,6 +254,4 @@ // Expose DAYS constant for external use window.LEDMatrixWidgets.get('day-selector').DAYS = DAYS; window.LEDMatrixWidgets.get('day-selector').DAY_LABELS = DAY_LABELS; - - console.log('[DaySelectorWidget] Day selector widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/email-input.js b/web_interface/static/v3/js/widgets/email-input.js index 7efb9d39..f5beed4e 100644 --- a/web_interface/static/v3/js/widgets/email-input.js +++ b/web_interface/static/v3/js/widgets/email-input.js @@ -167,6 +167,4 @@ } } }); - - console.log('[EmailInputWidget] Email input widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/example-color-picker.js b/web_interface/static/v3/js/widgets/example-color-picker.js index a05e95f0..d94b2308 100644 --- a/web_interface/static/v3/js/widgets/example-color-picker.js +++ b/web_interface/static/v3/js/widgets/example-color-picker.js @@ -197,6 +197,4 @@ } } }); - - console.log('[ColorPickerWidget] Color picker widget registered (example)'); })(); diff --git a/web_interface/static/v3/js/widgets/file-upload-single.js b/web_interface/static/v3/js/widgets/file-upload-single.js index ff4e70c8..eb487e60 100644 --- a/web_interface/static/v3/js/widgets/file-upload-single.js +++ b/web_interface/static/v3/js/widgets/file-upload-single.js @@ -286,6 +286,4 @@ } } }); - - console.log('[FileUploadSingleWidget] File upload single widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/file-upload.js b/web_interface/static/v3/js/widgets/file-upload.js index fd4093cd..3cd026dc 100644 --- a/web_interface/static/v3/js/widgets/file-upload.js +++ b/web_interface/static/v3/js/widgets/file-upload.js @@ -32,7 +32,7 @@ render: function(container, config, value, options) { // For now, widgets are server-side rendered // This function is a placeholder for future client-side rendering - console.log('[FileUploadWidget] Render called (server-side rendered)'); + if (window.debugLog) window.debugLog('[FileUploadWidget] Render called (server-side rendered)'); }, /** @@ -1136,6 +1136,4 @@ window.updateImageList(fieldId, currentImages); } }; - - console.log('[FileUploadWidget] File upload widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/font-selector.js b/web_interface/static/v3/js/widgets/font-selector.js index c8c84d1d..f4cc4b89 100644 --- a/web_interface/static/v3/js/widgets/font-selector.js +++ b/web_interface/static/v3/js/widgets/font-selector.js @@ -311,6 +311,4 @@ generateDisplayName: generateDisplayName } }); - - console.log('[FontSelectorWidget] Font selector widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/google-calendar-picker.js b/web_interface/static/v3/js/widgets/google-calendar-picker.js index 21e772b9..05f32cca 100644 --- a/web_interface/static/v3/js/widgets/google-calendar-picker.js +++ b/web_interface/static/v3/js/widgets/google-calendar-picker.js @@ -192,6 +192,4 @@ .replace(/>/g, '>') .replace(/"/g, '"'); } - - console.log('[GoogleCalendarPickerWidget] registered'); })(); diff --git a/web_interface/static/v3/js/widgets/json-file-manager.js b/web_interface/static/v3/js/widgets/json-file-manager.js index ffa8bdf1..51aab85f 100644 --- a/web_interface/static/v3/js/widgets/json-file-manager.js +++ b/web_interface/static/v3/js/widgets/json-file-manager.js @@ -828,8 +828,5 @@ getValue() { return null; }, setValue() {} }); - console.log('[JsonFileManager] Registered with LEDMatrixWidgets'); - } else { - console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)'); } })(); diff --git a/web_interface/static/v3/js/widgets/notification.js b/web_interface/static/v3/js/widgets/notification.js index a854c766..cc684b1d 100644 --- a/web_interface/static/v3/js/widgets/notification.js +++ b/web_interface/static/v3/js/widgets/notification.js @@ -472,6 +472,4 @@ } else { flushPending(); } - - console.log('[NotificationWidget] Notification widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/number-input.js b/web_interface/static/v3/js/widgets/number-input.js index e7b90ab5..1b0cef3c 100644 --- a/web_interface/static/v3/js/widgets/number-input.js +++ b/web_interface/static/v3/js/widgets/number-input.js @@ -239,6 +239,4 @@ } } }); - - console.log('[NumberInputWidget] Number input widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/password-input.js b/web_interface/static/v3/js/widgets/password-input.js index e1aec8b3..88f0e904 100644 --- a/web_interface/static/v3/js/widgets/password-input.js +++ b/web_interface/static/v3/js/widgets/password-input.js @@ -316,6 +316,4 @@ } } }); - - console.log('[PasswordInputWidget] Password input 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 index 63672753..7a1af3a6 100644 --- a/web_interface/static/v3/js/widgets/plugin-file-manager.js +++ b/web_interface/static/v3/js/widgets/plugin-file-manager.js @@ -866,6 +866,4 @@ 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/plugin-loader.js b/web_interface/static/v3/js/widgets/plugin-loader.js index 77e6d5ce..bc3aa587 100644 --- a/web_interface/static/v3/js/widgets/plugin-loader.js +++ b/web_interface/static/v3/js/widgets/plugin-loader.js @@ -29,7 +29,7 @@ // Check if widget is already registered if (this.has(widgetName)) { - console.log(`[PluginWidgetLoader] Widget ${widgetName} already registered`); + if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Widget ${widgetName} already registered`); return; } @@ -45,7 +45,7 @@ try { // Dynamic import of plugin widget await import(widgetPath); - console.log(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`); + if (window.debugLog) window.debugLog(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`); // Verify widget was registered if (this.has(widgetName)) { @@ -124,6 +124,4 @@ return loadedWidgets; }; - - console.log('[PluginWidgetLoader] Plugin widget loader initialized'); })(); diff --git a/web_interface/static/v3/js/widgets/radio-group.js b/web_interface/static/v3/js/widgets/radio-group.js index d7e488a3..726c7e0d 100644 --- a/web_interface/static/v3/js/widgets/radio-group.js +++ b/web_interface/static/v3/js/widgets/radio-group.js @@ -144,6 +144,4 @@ } } }); - - console.log('[RadioGroupWidget] Radio group widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/registry.js b/web_interface/static/v3/js/widgets/registry.js index a3687121..85f811b4 100644 --- a/web_interface/static/v3/js/widgets/registry.js +++ b/web_interface/static/v3/js/widgets/registry.js @@ -50,7 +50,7 @@ this._handlers.set(widgetName, definition.handlers); } - console.log(`[WidgetRegistry] Registered widget: ${widgetName}`); + if (window.debugLog) window.debugLog(`[WidgetRegistry] Registered widget: ${widgetName}`); return true; }, @@ -212,6 +212,4 @@ }))); }; } - - console.log('[WidgetRegistry] Widget registry initialized'); })(); diff --git a/web_interface/static/v3/js/widgets/select-dropdown.js b/web_interface/static/v3/js/widgets/select-dropdown.js index cdbc5e68..f8ad5f16 100644 --- a/web_interface/static/v3/js/widgets/select-dropdown.js +++ b/web_interface/static/v3/js/widgets/select-dropdown.js @@ -128,6 +128,4 @@ } } }); - - console.log('[SelectDropdownWidget] Select dropdown widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/slider.js b/web_interface/static/v3/js/widgets/slider.js index 3359dcd5..80135a1e 100644 --- a/web_interface/static/v3/js/widgets/slider.js +++ b/web_interface/static/v3/js/widgets/slider.js @@ -173,6 +173,4 @@ } } }); - - console.log('[SliderWidget] Slider widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index c495cd6d..8f847c8e 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -768,6 +768,4 @@ return null; } }); - - console.log('[StyleEditor] widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/text-input.js b/web_interface/static/v3/js/widgets/text-input.js index 97c5fa09..a61638f9 100644 --- a/web_interface/static/v3/js/widgets/text-input.js +++ b/web_interface/static/v3/js/widgets/text-input.js @@ -239,6 +239,4 @@ } } }); - - console.log('[TextInputWidget] Text input widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/textarea.js b/web_interface/static/v3/js/widgets/textarea.js index c8d22fb4..d81e7359 100644 --- a/web_interface/static/v3/js/widgets/textarea.js +++ b/web_interface/static/v3/js/widgets/textarea.js @@ -175,6 +175,4 @@ } } }); - - console.log('[TextareaWidget] Textarea widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/time-picker.js b/web_interface/static/v3/js/widgets/time-picker.js index ad118805..2d9bf6bf 100644 --- a/web_interface/static/v3/js/widgets/time-picker.js +++ b/web_interface/static/v3/js/widgets/time-picker.js @@ -166,6 +166,4 @@ } } }); - - console.log('[TimePickerWidget] Time picker widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/time-range.js b/web_interface/static/v3/js/widgets/time-range.js index fddc3fa3..5b6f13f9 100644 --- a/web_interface/static/v3/js/widgets/time-range.js +++ b/web_interface/static/v3/js/widgets/time-range.js @@ -370,6 +370,4 @@ // Expose utility functions for external use window.LEDMatrixWidgets.get('time-range').parseTimeToMinutes = parseTimeToMinutes; window.LEDMatrixWidgets.get('time-range').calculateDuration = calculateDuration; - - console.log('[TimeRangeWidget] Time range widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/timezone-selector.js b/web_interface/static/v3/js/widgets/timezone-selector.js index 51c54127..1496c5dc 100644 --- a/web_interface/static/v3/js/widgets/timezone-selector.js +++ b/web_interface/static/v3/js/widgets/timezone-selector.js @@ -414,6 +414,4 @@ }, 50); }); })(); - - console.log('[TimezoneSelectorWidget] Timezone selector widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/toggle-switch.js b/web_interface/static/v3/js/widgets/toggle-switch.js index f4cc0dd2..eac77b85 100644 --- a/web_interface/static/v3/js/widgets/toggle-switch.js +++ b/web_interface/static/v3/js/widgets/toggle-switch.js @@ -220,6 +220,4 @@ } } }); - - console.log('[ToggleSwitchWidget] Toggle switch widget registered'); })(); diff --git a/web_interface/static/v3/js/widgets/url-input.js b/web_interface/static/v3/js/widgets/url-input.js index b2b09207..9931db5f 100644 --- a/web_interface/static/v3/js/widgets/url-input.js +++ b/web_interface/static/v3/js/widgets/url-input.js @@ -284,6 +284,4 @@ } } }); - - console.log('[UrlInputWidget] URL input widget registered'); })(); diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index b49e0d09..990d3e2a 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -41,164 +41,6 @@ const safeLocalStorage = { const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true'; if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...'); -// Expose on-demand functions early as stubs (will be replaced when IIFE runs) -window.openOnDemandModal = function(pluginId) { - console.warn('openOnDemandModal called before initialization, waiting...'); - // Wait for the real function to be available - let attempts = 0; - const maxAttempts = 50; // 2.5 seconds - const checkInterval = setInterval(() => { - attempts++; - if (window.__openOnDemandModalImpl) { - clearInterval(checkInterval); - window.__openOnDemandModalImpl(pluginId); - } else if (attempts >= maxAttempts) { - clearInterval(checkInterval); - console.error('openOnDemandModal not available after waiting'); - if (typeof showNotification === 'function') { - showNotification('On-demand modal unavailable. Please refresh the page.', 'error'); - } - } - }, 50); -}; - -window.requestOnDemandStop = function({ stopService = false } = {}) { - console.warn('requestOnDemandStop called before initialization, waiting...'); - // Wait for the real function to be available - let attempts = 0; - const maxAttempts = 50; // 2.5 seconds - const checkInterval = setInterval(() => { - attempts++; - if (window.__requestOnDemandStopImpl) { - clearInterval(checkInterval); - return window.__requestOnDemandStopImpl({ stopService }); - } else if (attempts >= maxAttempts) { - clearInterval(checkInterval); - console.error('requestOnDemandStop not available after waiting'); - if (typeof showNotification === 'function') { - showNotification('On-demand stop unavailable. Please refresh the page.', 'error'); - } - return Promise.reject(new Error('Function not available')); - } - }, 50); - return Promise.resolve(); -}; - -// Define updatePlugin early as a stub to ensure it's always available -window.updatePlugin = window.updatePlugin || function(pluginId) { - if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] updatePlugin called for', pluginId); - - // Validate pluginId - if (!pluginId || typeof pluginId !== 'string') { - console.error('Invalid pluginId:', pluginId); - if (typeof showNotification === 'function') { - showNotification('Invalid plugin ID', 'error'); - } - return Promise.reject(new Error('Invalid plugin ID')); - } - - // Show immediate feedback - if (typeof showNotification === 'function') { - showNotification(`Updating ${pluginId}...`, 'info'); - } - - // Prepare request body - const requestBody = { plugin_id: pluginId }; - const requestBodyJson = JSON.stringify(requestBody); - - debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson }); - - // Make the API call directly - return fetch('/api/v3/plugins/update', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: requestBodyJson - }) - .then(async response => { - // Check if response is OK before parsing - if (!response.ok) { - // Try to parse error response - let errorData; - try { - const text = await response.text(); - console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text }); - errorData = JSON.parse(text); - } catch (e) { - errorData = { message: `Server error: ${response.status} ${response.statusText}` }; - } - - if (typeof showNotification === 'function') { - showNotification(errorData.message || `Update failed: ${response.status}`, 'error'); - } - throw new Error(errorData.message || `Update failed: ${response.status}`); - } - - // Parse successful response - return response.json(); - }) - .then(data => { - if (typeof showNotification === 'function') { - showNotification(data.message || 'Update initiated', data.status || 'info'); - } - // Refresh installed plugins if available - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') { - window.pluginManager.loadInstalledPlugins(); - } - return data; - }) - .catch(error => { - console.error('[UPDATE] Error updating plugin:', error); - if (typeof showNotification === 'function') { - showNotification('Error updating plugin: ' + error.message, 'error'); - } - throw error; - }); -}; - -// Define uninstallPlugin early as a stub -window.uninstallPlugin = window.uninstallPlugin || function(pluginId) { - if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] uninstallPlugin called for', pluginId); - - if (!confirm(`Are you sure you want to uninstall ${pluginId}?`)) { - return Promise.resolve({ cancelled: true }); - } - - if (typeof showNotification === 'function') { - showNotification(`Uninstalling ${pluginId}...`, 'info'); - } - - return fetch('/api/v3/plugins/uninstall', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plugin_id: pluginId }) - }) - .then(response => response.json()) - .then(data => { - if (typeof showNotification === 'function') { - showNotification(data.message || 'Uninstall initiated', data.status || 'info'); - } - // Refresh installed plugins if available - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') { - window.pluginManager.loadInstalledPlugins(); - } - return data; - }) - .catch(error => { - console.error('Error uninstalling plugin:', error); - if (typeof showNotification === 'function') { - showNotification('Error uninstalling plugin: ' + error.message, 'error'); - } - throw error; - }); -}; - // Define configurePlugin early to ensure it's always available window.configurePlugin = window.configurePlugin || async function(pluginId) { if (_PLUGIN_DEBUG_EARLY) debugLog('[PLUGINS STUB] configurePlugin called for', pluginId); @@ -2122,7 +1964,6 @@ window.__openOnDemandModalImpl = function(pluginId) { }); }; -// Replace the stub with the real implementation window.openOnDemandModal = window.__openOnDemandModalImpl; // Release handle for the on-demand modal's focus trap (window.LEDDialog). @@ -2260,8 +2101,6 @@ function stopOnDemand(event) { requestOnDemandStop({ stopService }); } -// Store the real implementation and replace the stub -window.__requestOnDemandStopImpl = requestOnDemandStop; window.requestOnDemandStop = requestOnDemandStop; function closeOnDemandModalOnBackdrop(event) { @@ -2518,98 +2357,6 @@ window.updateKeyValuePairData = function(fieldId, fullKey) { hiddenInput.value = JSON.stringify(pairs); }; -// Functions to handle array-of-objects -window.addArrayObjectItem = function(fieldId, fullKey, maxItems) { - const itemsContainer = document.getElementById(fieldId + '_items'); - const hiddenInput = document.getElementById(fieldId + '_data'); - if (!itemsContainer || !hiddenInput) return; - - const currentItems = itemsContainer.querySelectorAll('.array-object-item'); - if (currentItems.length >= maxItems) { - alert(`Maximum ${maxItems} items allowed`); - return; - } - - // Get schema for item properties from the hidden input's data attribute or currentPluginConfig - const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema); - if (!schema) return; - - // Navigate to the items schema - const keys = fullKey.split('.'); - let itemsSchema = schema.properties; - for (const key of keys) { - if (itemsSchema && itemsSchema[key]) { - itemsSchema = itemsSchema[key]; - if (itemsSchema.type === 'array' && itemsSchema.items) { - itemsSchema = itemsSchema.items; - break; - } - } - } - - if (!itemsSchema || !itemsSchema.properties) return; - - const newIndex = currentItems.length; - const itemHtml = renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema); - itemsContainer.insertAdjacentHTML('beforeend', itemHtml); - updateArrayObjectData(fieldId); - - // Update add button state - const addButton = itemsContainer.nextElementSibling; - if (addButton && currentItems.length + 1 >= maxItems) { - addButton.disabled = true; - addButton.style.opacity = '0.5'; - addButton.style.cursor = 'not-allowed'; - } -}; - -window.removeArrayObjectItem = function(fieldId, index) { - const itemsContainer = document.getElementById(fieldId + '_items'); - if (!itemsContainer) return; - - const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`); - if (item) { - item.remove(); - // Re-index remaining items - const remainingItems = itemsContainer.querySelectorAll('.array-object-item'); - remainingItems.forEach((itemEl, newIndex) => { - itemEl.setAttribute('data-index', newIndex); - // Update the id attribute to match new index (used by file upload selectors) - const newItemId = `${fieldId}_item_${newIndex}`; - itemEl.id = newItemId; - // Update all inputs within this item - need to update name/id attributes - itemEl.querySelectorAll('input, select, textarea').forEach(input => { - const name = input.getAttribute('name') || input.id; - if (name) { - // Update name/id attribute with new index - const newName = name.replace(/\[\d+\]/, `[${newIndex}]`); - if (input.getAttribute('name')) input.setAttribute('name', newName); - if (input.id) input.id = input.id.replace(/\d+/, newIndex); - } - }); - // Update button onclick attributes - itemEl.querySelectorAll('button[onclick]').forEach(button => { - const onclick = button.getAttribute('onclick'); - if (onclick) { - button.setAttribute('onclick', onclick.replace(/\d+/, newIndex)); - } - }); - }); - updateArrayObjectData(fieldId); - - // Update add button state - const addButton = itemsContainer.nextElementSibling; - if (addButton) { - const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]); - if (remainingItems.length < maxItems) { - addButton.disabled = false; - addButton.style.opacity = '1'; - addButton.style.cursor = 'pointer'; - } - } - } -}; - window.updateArrayObjectData = function(fieldId) { const itemsContainer = document.getElementById(fieldId + '_items'); const hiddenInput = document.getElementById(fieldId + '_data'); @@ -3323,78 +3070,6 @@ window.executePluginAction = function(actionId, actionIndex, pluginIdParam = nul // togglePlugin is already defined at the top of the script - no need to redefine -// Only override updatePlugin if it doesn't already have improved error handling -if (!window.updatePlugin || window.updatePlugin.toString().includes('[UPDATE]')) { - window.updatePlugin = function(pluginId) { - // Validate pluginId - if (!pluginId || typeof pluginId !== 'string') { - console.error('[UPDATE] Invalid pluginId:', pluginId); - if (typeof showNotification === 'function') { - showNotification('Invalid plugin ID', 'error'); - } - return Promise.reject(new Error('Invalid plugin ID')); - } - - showNotification(`Updating ${pluginId}...`, 'info'); - - // Prepare request body - const requestBody = { plugin_id: pluginId }; - const requestBodyJson = JSON.stringify(requestBody); - - debugLog('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson }); - - return fetch('/api/v3/plugins/update', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: requestBodyJson - }) - .then(async response => { - // Check if response is OK before parsing - if (!response.ok) { - // Try to parse error response - let errorData; - try { - const text = await response.text(); - console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text }); - errorData = JSON.parse(text); - } catch (e) { - errorData = { message: `Server error: ${response.status} ${response.statusText}` }; - } - - if (typeof showNotification === 'function') { - showNotification(errorData.message || `Update failed: ${response.status}`, 'error'); - } - throw new Error(errorData.message || `Update failed: ${response.status}`); - } - - // Parse successful response - return response.json(); - }) - .then(data => { - showNotification(data.message || 'Update initiated', data.status || 'info'); - if (data.status === 'success') { - // Refresh the list - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') { - window.pluginManager.loadInstalledPlugins(); - } - } - return data; - }) - .catch(error => { - console.error('[UPDATE] Error updating plugin:', error); - if (typeof showNotification === 'function') { - showNotification('Error updating plugin: ' + error.message, 'error'); - } - throw error; - }); - }; -} - window.uninstallPlugin = function(pluginId) { const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId); const pluginName = plugin ? (plugin.name || pluginId) : pluginId; @@ -4529,34 +4204,6 @@ function jsStringAttr(value) { return escapeAttribute(JSON.stringify(value == null ? '' : String(value))); } -// Format date for display -function formatDate(dateString) { - if (!dateString) return 'Unknown'; - - try { - const date = new Date(dateString); - const now = new Date(); - const diffTime = Math.abs(now - date); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays < 1) { - return 'Today'; - } else if (diffDays < 2) { - return 'Yesterday'; - } else if (diffDays < 7) { - return `${diffDays} days ago`; - } else if (diffDays < 30) { - const weeks = Math.floor(diffDays / 7); - return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; - } else { - // Return formatted date for older items - return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - } - } catch (e) { - return dateString; - } -} - function isNewPlugin(lastUpdated) { if (!lastUpdated) return false; @@ -4915,10 +4562,6 @@ window.handleCredentialsUpload = async function(event, fieldId, uploadEndpoint, // handleFiles is now defined exclusively in file-upload.js widget -window.deleteUploadedImage = async function(fieldId, imageId, pluginId) { - return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null); -} - window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) { const fileTypeLabel = fileType === 'json' ? 'file' : 'image'; if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) { @@ -4986,18 +4629,6 @@ window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, // getUploadConfig is defined in file-upload.js widget which loads first. // No override needed here — file-upload.js owns this function. -window.getCurrentImages = function(fieldId) { - const hiddenInput = document.getElementById(`${fieldId}_images_data`); - if (hiddenInput && hiddenInput.value) { - try { - return JSON.parse(hiddenInput.value); - } catch (e) { - console.error('Error parsing images data:', e); - } - } - return []; -} - window.updateImageList = function(fieldId, images) { const hiddenInput = document.getElementById(`${fieldId}_images_data`); if (hiddenInput) { @@ -5060,17 +4691,6 @@ window.updateImageList = function(fieldId, images) { } } -window.showUploadProgress = function(fieldId, totalFiles) { - const dropZone = document.getElementById(`${fieldId}_drop_zone`); - if (dropZone) { - dropZone.innerHTML = ` - -

Uploading ${totalFiles} file(s)...

- `; - dropZone.style.pointerEvents = 'none'; - } -} - window.hideUploadProgress = function(fieldId) { const uploadConfig = window.getUploadConfig(fieldId); const maxFiles = uploadConfig.max_files || 10; @@ -5088,14 +4708,6 @@ window.hideUploadProgress = function(fieldId) { } } -window.formatFileSize = function(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; -} - function formatDate(dateString) { if (!dateString) return 'Unknown date'; try { @@ -5106,30 +4718,6 @@ function formatDate(dateString) { } } -window.getScheduleSummary = function(schedule) { - if (!schedule || !schedule.enabled || schedule.mode === 'always') { - return 'Always shown'; - } - - if (schedule.mode === 'time_range') { - return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`; - } - - if (schedule.mode === 'per_day' && schedule.days) { - const enabledDays = Object.entries(schedule.days) - .filter(([day, config]) => config && config.enabled) - .map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3)); - - if (enabledDays.length === 0) { - return 'Never shown'; - } - - return enabledDays.join(', ') + ' only'; - } - - return 'Scheduled'; -} - window.openImageSchedule = function(fieldId, imageId, imageIdx) { const currentImages = getCurrentImages(fieldId); const image = currentImages[imageIdx]; @@ -5516,14 +5104,6 @@ if (typeof window !== 'undefined') { } }; - // updateArrayObjectData is defined earlier in the file (line ~3596) - // Only define stub if it doesn't already exist (defensive fallback) - if (typeof window.updateArrayObjectData === 'undefined') { - window.updateArrayObjectData = function(fieldId) { - console.warn('updateArrayObjectData stub called - implementation should be defined earlier'); - }; - } - window.updateCheckboxGroupData = function(fieldId) { // Update hidden _data input with currently checked values const hiddenInput = document.getElementById(fieldId + '_data'); @@ -5542,22 +5122,6 @@ if (typeof window !== 'undefined') { hiddenInput.value = JSON.stringify(selectedValues); }; - // handleArrayObjectFileUpload and removeArrayObjectFile are defined earlier in the file - // Only define stubs if they don't already exist (defensive fallback) - if (typeof window.handleArrayObjectFileUpload === 'undefined') { - window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) { - console.warn('handleArrayObjectFileUpload stub called - implementation should be defined earlier'); - window.updateArrayObjectData(fieldId); - }; - } - - if (typeof window.removeArrayObjectFile === 'undefined') { - window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) { - console.warn('removeArrayObjectFile stub called - implementation should be defined earlier'); - window.updateArrayObjectData(fieldId); - }; - } - // Debug logging (only if pluginDebug is enabled) if (_PLUGIN_DEBUG_EARLY) { debugLog('[ARRAY-OBJECTS] Functions defined on window:', { @@ -5576,23 +5140,6 @@ window.currentPluginConfig = null; // Force initialization immediately when script loads (for HTMX swapped content) debugLog('Plugins script loaded, checking for elements...'); -// Ensure all functions are globally available (in case IIFE didn't expose them properly) -// These should already be set inside the IIFE, but this ensures they're available -if (typeof initializePluginPageWhenReady !== 'undefined') { - window.initializePluginPageWhenReady = initializePluginPageWhenReady; -} -if (typeof initializePlugins !== 'undefined') { - window.initializePlugins = initializePlugins; -} -if (typeof loadInstalledPlugins !== 'undefined') { - window.loadInstalledPlugins = loadInstalledPlugins; -} -if (typeof renderInstalledPlugins !== 'undefined') { - window.renderInstalledPlugins = renderInstalledPlugins; -} -// GitHub install handlers are now exposed inside the IIFE (see above). -// searchPluginStore is also exposed inside the IIFE after its definition. - // Verify critical functions are available if (_PLUGIN_DEBUG_EARLY) { debugLog('Plugin functions available:', { @@ -5610,18 +5157,6 @@ if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning window.checkGitHubAuthStatus(); } -// Initialize on-demand modal immediately since it's in base.html -if (typeof initializeOnDemandModal === 'function') { - // Run immediately and also after DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeOnDemandModal); - } else { - initializeOnDemandModal(); - } - // Also try after a short delay to ensure elements are available - setTimeout(initializeOnDemandModal, 100); -} - setTimeout(function() { const installedGrid = document.getElementById('installed-plugins-grid'); if (installedGrid) { diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 81556ad2..39371dac 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -110,10 +110,8 @@ - - - - diff --git a/web_interface/templates/v3/index.html b/web_interface/templates/v3/index.html deleted file mode 100644 index c064dfd1..00000000 --- a/web_interface/templates/v3/index.html +++ /dev/null @@ -1,161 +0,0 @@ -{% extends "v3/base.html" %} - -{% block content %} -
-
-

System Overview

-

Monitor system status and manage your LED matrix display.

-
- - -
-
-
-
- -
-
-
-
CPU Usage
-
--%
-
-
-
-
- -
-
-
- -
-
-
-
Memory Usage
-
--%
-
-
-
-
- -
-
-
- -
-
-
-
CPU Temperature
-
--°C
-
-
-
-
- -
-
-
- -
-
-
-
Display Status
-
Unknown
-
-
-
-
-
- - -
-

Quick Actions

-
- - - - - - - -
-
- - -
-

Display Preview

-
-
- -

Display preview will appear here

-

Connect to see live updates

-
-
-
-
- - - -{% endblock %} diff --git a/web_interface/templates/v3/partials/fonts.html b/web_interface/templates/v3/partials/fonts.html index 5039a5ab..3994bd5d 100644 --- a/web_interface/templates/v3/partials/fonts.html +++ b/web_interface/templates/v3/partials/fonts.html @@ -152,7 +152,7 @@ function initializeFontsTab() { console.error('Fonts tab elements not found after max retries, giving up'); return; } - console.log('Fonts tab elements not found, retrying...', { + debugLog('Fonts tab elements not found, retrying...', { availableFonts: !!availableEl, attempt: initRetryCount }); @@ -178,7 +178,7 @@ function initializeFontsTab() { }; } - console.log('Initializing font management...'); + debugLog('Initializing font management...'); initializeFontManagement(); // Event listeners (use event delegation or ensure elements exist) @@ -229,7 +229,7 @@ function initializeFontsTab() { }); } - console.log('Fonts tab initialized successfully'); + debugLog('Fonts tab initialized successfully'); } // Expose initializeFontsTab to window for re-initialization after HTMX reload @@ -244,7 +244,7 @@ window.initializeFontsTab = initializeFontsTab; const fontsContent = document.getElementById('fonts-content'); if (fontsContent) { - console.log('Fonts content detected, initializing...'); + debugLog('Fonts content detected, initializing...'); setTimeout(() => { initializeFontsTab(); }, 50); @@ -257,7 +257,7 @@ window.initializeFontsTab = initializeFontsTab; // Check if the event target is the fonts-content container or contains it const target = event.target; if (target && (target.id === 'fonts-content' || target.querySelector && target.querySelector('#fonts-content'))) { - console.log('HTMX loaded fonts content, initializing...', target.id); + debugLog('HTMX loaded fonts content, initializing...', target.id); tryInitializeFontsTab(); } }); @@ -334,7 +334,7 @@ async function loadFontData() { // Update displays updateAvailableFontsDisplay(); - console.log('Font data loaded successfully', { + debugLog('Font data loaded successfully', { catalogSize: Object.keys(fontCatalog).length, tokensSize: Object.keys(fontTokens).length }); @@ -490,7 +490,7 @@ function populateFontSelects() { previewSelect.value = fontEntries[0].filename; } - console.log(`Populated font selects with ${fontEntries.length} fonts`); + debugLog(`Populated font selects with ${fontEntries.length} fonts`); } async function updateFontPreview() { diff --git a/web_interface/templates/v3/partials/wifi.html b/web_interface/templates/v3/partials/wifi.html index f460a1e1..e87b7e09 100644 --- a/web_interface/templates/v3/partials/wifi.html +++ b/web_interface/templates/v3/partials/wifi.html @@ -286,11 +286,11 @@ function wifiSetup() { try { const response = await fetch('/api/v3/wifi/scan'); const data = await response.json(); - console.log('WiFi scan response:', data); // Debug log + debugLog('WiFi scan response:', data); if (data.status === 'success') { // Ensure data.data is an array const networksArray = Array.isArray(data.data) ? data.data : []; - console.log('WiFi scan found networks:', networksArray.length); // Debug log + debugLog('WiFi scan found networks:', networksArray.length); // Set the networks array - Alpine.js will automatically update the select dropdown via x-for this.networks = networksArray;