fix(web): handle dotted keys in schema/config path helpers (#260)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-23 17:21:19 -05:00
committed by GitHub
parent c2763d6447
commit 14b6a0c6a3
3 changed files with 64 additions and 42 deletions

View File

@@ -885,7 +885,7 @@ def save_main_config():
if 'properties' in schema: if 'properties' in schema:
secret_fields = find_secret_fields(schema['properties']) secret_fields = find_secret_fields(schema['properties'])
except Exception as e: 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) # Separate secrets from regular config (same logic as save_plugin_config)
def separate_secrets(config, secrets_set, prefix=''): def separate_secrets(config, secrets_set, prefix=''):
@@ -923,7 +923,7 @@ def save_main_config():
if 'enabled' not in regular_config: if 'enabled' not in regular_config:
regular_config['enabled'] = True regular_config['enabled'] = True
except Exception as e: 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 # Default to True on error to avoid disabling plugins
regular_config['enabled'] = True regular_config['enabled'] = True
@@ -958,7 +958,7 @@ def save_main_config():
plugin_instance.on_config_change(plugin_full_config) plugin_instance.on_config_change(plugin_full_config)
except Exception as hook_err: except Exception as hook_err:
# Don't fail the save if hook fails # 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) # Remove processed plugin keys from data (they're already in current_config)
for key in plugin_keys_to_remove: 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). 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: Args:
schema: The JSON schema dict schema: The JSON schema dict
key_path: Dot-separated path like "customization.time_text.font" key_path: Dot-separated path like "customization.time_text.font"
or "leagues.eng.1.favorite_teams" where "eng.1" is one key.
Returns: Returns:
The property schema dict or None if not found The property schema dict or None if not found
@@ -3500,21 +3505,27 @@ def _get_schema_property(schema, key_path):
parts = key_path.split('.') parts = key_path.split('.')
current = schema['properties'] current = schema['properties']
i = 0
for i, part in enumerate(parts): while i < len(parts):
if part not in current: # Try progressively longer candidate keys starting at position i,
return None # longest first, to greedily match dotted property names (e.g. "eng.1").
matched = False
prop = current[part] for j in range(len(parts), i, -1):
candidate = '.'.join(parts[i:j])
# If this is the last part, return the property if candidate in current:
if i == len(parts) - 1: prop = current[candidate]
return prop if j == len(parts):
return prop # Consumed all remaining parts — done
# If this is an object with properties, navigate deeper # Navigate deeper if this is an object with properties
if isinstance(prop, dict) and 'properties' in prop: if isinstance(prop, dict) and 'properties' in prop:
current = prop['properties'] current = prop['properties']
else: i = j
matched = True
break
else:
return None # Can't navigate deeper
if not matched:
return None return None
return None return None
@@ -3691,9 +3702,14 @@ def _set_nested_value(config, key_path, value):
Set a value in a nested dict using dot notation path. Set a value in a nested dict using dot notation path.
Handles existing nested dicts correctly by merging instead of replacing. 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: Args:
config: The config dict to modify 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) value: The value to set (or _SKIP_FIELD to skip setting)
""" """
# Skip setting if value is the sentinel # Skip setting if value is the sentinel
@@ -3702,19 +3718,36 @@ def _set_nested_value(config, key_path, value):
parts = key_path.split('.') parts = key_path.split('.')
current = config current = config
i = 0
# Navigate/create intermediate dicts # Navigate/create intermediate dicts, greedily matching dotted keys.
for i, part in enumerate(parts[:-1]): # We stop before the final part so we can set it as the leaf value.
if part not in current: while i < len(parts) - 1:
current[part] = {} # Try progressively longer candidate keys (longest first) to match
elif not isinstance(current[part], dict): # dict keys that contain dots themselves (e.g. "eng.1").
# If the existing value is not a dict, replace it with a dict # Never consume the very last part (that's the leaf value key).
current[part] = {} matched = False
current = current[part] 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) # The remaining parts form the final key (may itself be dotted, e.g. "eng.1")
if value is not None or parts[-1] not in current: final_key = '.'.join(parts[i:])
current[parts[-1]] = value 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): def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):

View File

@@ -567,5 +567,4 @@
window.LEDMatrixWidgets.get('schedule-picker').getDefaultSchedule = getDefaultSchedule; window.LEDMatrixWidgets.get('schedule-picker').getDefaultSchedule = getDefaultSchedule;
window.LEDMatrixWidgets.get('schedule-picker').normalizeSchedule = normalizeSchedule; window.LEDMatrixWidgets.get('schedule-picker').normalizeSchedule = normalizeSchedule;
console.log('[SchedulePickerWidget] Schedule picker widget registered');
})(); })();

View File

@@ -1330,17 +1330,7 @@ function loadInstalledPlugins(forceRefresh = false) {
pluginLoadCache.timestamp = Date.now(); pluginLoadCache.timestamp = Date.now();
// Always update window.installedPlugins to ensure Alpine component can detect changes // Always update window.installedPlugins to ensure Alpine component can detect changes
const currentPlugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : []; window.installedPlugins = 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;
}
// Dispatch event to notify Alpine component to update tabs // Dispatch event to notify Alpine component to update tabs
document.dispatchEvent(new CustomEvent('pluginsUpdated', { document.dispatchEvent(new CustomEvent('pluginsUpdated', {