From 14b6a0c6a3e3451ff1c002bdfc87d16f6f539b49 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:21:19 -0500 Subject: [PATCH] fix(web): handle dotted keys in schema/config path helpers (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): handle dotted keys in schema/config path helpers Schema property names containing dots (e.g. "eng.1" for Premier League in soccer-scoreboard) were being incorrectly split on the dot separator in two path-navigation helpers: - _get_schema_property: split "leagues.eng.1.favorite_teams" into 4 segments and looked for "eng" in leagues.properties, which doesn't exist (the key is literally "eng.1"). Returned None, so the field type was unknown and values were not parsed correctly. - _set_nested_value: split the same path into 4 segments and created config["leagues"]["eng"]["1"]["favorite_teams"] instead of the correct config["leagues"]["eng.1"]["favorite_teams"]. Both functions now use a greedy longest-match approach: at each level they try progressively longer dot-joined candidates first (e.g. "eng.1" before "eng"), so dotted property names are handled transparently. Fixes favorite_teams (and other per-league fields) not saving via the soccer-scoreboard plugin config UI. Co-Authored-By: Claude Sonnet 4.6 * chore: remove debug artifacts from merged branches - Replace print() with logger.warning() for three error handlers in api_v3.py that bypassed the structured logging infrastructure - Simplify dead if/else in loadInstalledPlugins() — both branches did the same window.installedPlugins assignment; collapse to single line - Remove console.log registration line from schedule-picker widget that fired unconditionally on every page load Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Chuck Co-authored-by: Claude Sonnet 4.6 --- web_interface/blueprints/api_v3.py | 93 +++++++++++++------ .../static/v3/js/widgets/schedule-picker.js | 1 - web_interface/static/v3/plugins_manager.js | 12 +-- 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index fa364146..d743e636 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -885,7 +885,7 @@ def save_main_config(): if 'properties' in schema: secret_fields = find_secret_fields(schema['properties']) except Exception as e: - print(f"Error reading schema for secret detection: {e}") + logger.warning(f"Error reading schema for secret detection: {e}") # Separate secrets from regular config (same logic as save_plugin_config) def separate_secrets(config, secrets_set, prefix=''): @@ -923,7 +923,7 @@ def save_main_config(): if 'enabled' not in regular_config: regular_config['enabled'] = True except Exception as e: - print(f"Error preserving enabled state for {plugin_id}: {e}") + logger.warning(f"Error preserving enabled state for {plugin_id}: {e}") # Default to True on error to avoid disabling plugins regular_config['enabled'] = True @@ -958,7 +958,7 @@ def save_main_config(): plugin_instance.on_config_change(plugin_full_config) except Exception as hook_err: # Don't fail the save if hook fails - print(f"Warning: on_config_change failed for {plugin_id}: {hook_err}") + logger.warning(f"on_config_change failed for {plugin_id}: {hook_err}") # Remove processed plugin keys from data (they're already in current_config) for key in plugin_keys_to_remove: @@ -3488,9 +3488,14 @@ def _get_schema_property(schema, key_path): """ Get the schema property for a given key path (supports dot notation). + Handles schema keys that themselves contain dots (e.g., "eng.1" in soccer + league configs) by trying progressively longer segment combinations when an + exact match for the current segment is not found. + Args: schema: The JSON schema dict key_path: Dot-separated path like "customization.time_text.font" + or "leagues.eng.1.favorite_teams" where "eng.1" is one key. Returns: The property schema dict or None if not found @@ -3500,21 +3505,27 @@ def _get_schema_property(schema, key_path): parts = key_path.split('.') current = schema['properties'] + i = 0 - for i, part in enumerate(parts): - if part not in current: - return None - - prop = current[part] - - # If this is the last part, return the property - if i == len(parts) - 1: - return prop - - # If this is an object with properties, navigate deeper - if isinstance(prop, dict) and 'properties' in prop: - current = prop['properties'] - else: + while i < len(parts): + # Try progressively longer candidate keys starting at position i, + # longest first, to greedily match dotted property names (e.g. "eng.1"). + matched = False + for j in range(len(parts), i, -1): + candidate = '.'.join(parts[i:j]) + if candidate in current: + prop = current[candidate] + if j == len(parts): + return prop # Consumed all remaining parts — done + # Navigate deeper if this is an object with properties + if isinstance(prop, dict) and 'properties' in prop: + current = prop['properties'] + i = j + matched = True + break + else: + return None # Can't navigate deeper + if not matched: return None return None @@ -3691,30 +3702,52 @@ def _set_nested_value(config, key_path, value): Set a value in a nested dict using dot notation path. Handles existing nested dicts correctly by merging instead of replacing. + Handles config keys that themselves contain dots (e.g., "eng.1" in soccer + league configs) by trying progressively longer segment combinations against + existing dict keys before falling back to single-segment creation. + Args: config: The config dict to modify - key_path: Dot-separated path (e.g., "customization.period_text.font") + key_path: Dot-separated path (e.g., "customization.period_text.font" + or "leagues.eng.1.favorite_teams" where "eng.1" is one key) value: The value to set (or _SKIP_FIELD to skip setting) """ # Skip setting if value is the sentinel if value is _SKIP_FIELD: return - + parts = key_path.split('.') current = config + i = 0 - # Navigate/create intermediate dicts - for i, part in enumerate(parts[:-1]): - if part not in current: - current[part] = {} - elif not isinstance(current[part], dict): - # If the existing value is not a dict, replace it with a dict - current[part] = {} - current = current[part] + # Navigate/create intermediate dicts, greedily matching dotted keys. + # We stop before the final part so we can set it as the leaf value. + while i < len(parts) - 1: + # Try progressively longer candidate keys (longest first) to match + # dict keys that contain dots themselves (e.g. "eng.1"). + # Never consume the very last part (that's the leaf value key). + matched = False + for j in range(len(parts) - 1, i, -1): + candidate = '.'.join(parts[i:j]) + if candidate in current and isinstance(current[candidate], dict): + current = current[candidate] + i = j + matched = True + break + if not matched: + # No existing dotted key matched; use single segment and create if needed + part = parts[i] + if part not in current: + current[part] = {} + elif not isinstance(current[part], dict): + current[part] = {} + current = current[part] + i += 1 - # Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure) - if value is not None or parts[-1] not in current: - current[parts[-1]] = value + # The remaining parts form the final key (may itself be dotted, e.g. "eng.1") + final_key = '.'.join(parts[i:]) + if value is not None or final_key not in current: + current[final_key] = value def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None): diff --git a/web_interface/static/v3/js/widgets/schedule-picker.js b/web_interface/static/v3/js/widgets/schedule-picker.js index 514170cb..80cc058f 100644 --- a/web_interface/static/v3/js/widgets/schedule-picker.js +++ b/web_interface/static/v3/js/widgets/schedule-picker.js @@ -567,5 +567,4 @@ window.LEDMatrixWidgets.get('schedule-picker').getDefaultSchedule = getDefaultSchedule; window.LEDMatrixWidgets.get('schedule-picker').normalizeSchedule = normalizeSchedule; - console.log('[SchedulePickerWidget] Schedule picker widget registered'); })(); diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 3a8216ef..01bc1df9 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -1330,17 +1330,7 @@ function loadInstalledPlugins(forceRefresh = false) { pluginLoadCache.timestamp = Date.now(); // Always update window.installedPlugins to ensure Alpine component can detect changes - const currentPlugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : []; - const currentIds = currentPlugins.map(p => p.id).sort().join(','); - const newIds = installedPlugins.map(p => p.id).sort().join(','); - const pluginsChanged = currentIds !== newIds; - - if (pluginsChanged) { - window.installedPlugins = installedPlugins; - } else { - // Even if IDs haven't changed, update the array reference to trigger Alpine reactivity - window.installedPlugins = installedPlugins; - } + window.installedPlugins = installedPlugins; // Dispatch event to notify Alpine component to update tabs document.dispatchEvent(new CustomEvent('pluginsUpdated', {