mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 13:23:00 +00:00
fix: handle dotted schema keys in plugin settings save (issue #254)
The soccer plugin uses dotted keys like "eng.1" for league identifiers. PR #260 fixed backend helpers but the JS frontend still corrupted these keys by naively splitting on dots. This fixes both the JS and remaining Python code paths: - JS getSchemaProperty(): greedy longest-match for dotted property names - JS dotToNested(): schema-aware key grouping to preserve "eng.1" as one key - Python fix_array_structures(): remove broken prefix re-navigation in recursion - Python ensure_array_defaults(): same prefix navigation fix Closes #254 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4024,225 +4024,101 @@ def save_plugin_config():
|
|||||||
|
|
||||||
# Post-process: Fix array fields that might have been incorrectly structured
|
# Post-process: Fix array fields that might have been incorrectly structured
|
||||||
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
|
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
|
||||||
def fix_array_structures(config_dict, schema_props, prefix=''):
|
def fix_array_structures(config_dict, schema_props):
|
||||||
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)"""
|
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues).
|
||||||
|
config_dict is always the dict at the current nesting level."""
|
||||||
for prop_key, prop_schema in schema_props.items():
|
for prop_key, prop_schema in schema_props.items():
|
||||||
prop_type = prop_schema.get('type')
|
prop_type = prop_schema.get('type')
|
||||||
|
|
||||||
if prop_type == 'array':
|
if prop_type == 'array':
|
||||||
# Navigate to the field location
|
if prop_key in config_dict:
|
||||||
if prefix:
|
current_value = config_dict[prop_key]
|
||||||
parent_parts = prefix.split('.')
|
# If it's a dict with numeric string keys, convert to array
|
||||||
parent = config_dict
|
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
||||||
for part in parent_parts:
|
try:
|
||||||
if isinstance(parent, dict) and part in parent:
|
keys = list(current_value.keys())
|
||||||
parent = parent[part]
|
if keys and all(str(k).isdigit() for k in keys):
|
||||||
else:
|
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
||||||
parent = None
|
array_value = [current_value[k] for k in sorted_keys]
|
||||||
break
|
# Convert array elements to correct types based on schema
|
||||||
|
items_schema = prop_schema.get('items', {})
|
||||||
if parent is not None and isinstance(parent, dict) and prop_key in parent:
|
item_type = items_schema.get('type')
|
||||||
current_value = parent[prop_key]
|
if item_type in ('number', 'integer'):
|
||||||
# If it's a dict with numeric string keys, convert to array
|
converted_array = []
|
||||||
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
for v in array_value:
|
||||||
try:
|
if isinstance(v, str):
|
||||||
# Check if all keys are numeric strings (array indices)
|
try:
|
||||||
keys = [k for k in current_value.keys()]
|
if item_type == 'integer':
|
||||||
if all(k.isdigit() for k in keys):
|
converted_array.append(int(v))
|
||||||
# Convert to sorted array by index
|
else:
|
||||||
sorted_keys = sorted(keys, key=int)
|
converted_array.append(float(v))
|
||||||
array_value = [current_value[k] for k in sorted_keys]
|
except (ValueError, TypeError):
|
||||||
# Convert array elements to correct types based on schema
|
|
||||||
items_schema = prop_schema.get('items', {})
|
|
||||||
item_type = items_schema.get('type')
|
|
||||||
if item_type in ('number', 'integer'):
|
|
||||||
converted_array = []
|
|
||||||
for v in array_value:
|
|
||||||
if isinstance(v, str):
|
|
||||||
try:
|
|
||||||
if item_type == 'integer':
|
|
||||||
converted_array.append(int(v))
|
|
||||||
else:
|
|
||||||
converted_array.append(float(v))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
converted_array.append(v)
|
|
||||||
else:
|
|
||||||
converted_array.append(v)
|
converted_array.append(v)
|
||||||
array_value = converted_array
|
else:
|
||||||
parent[prop_key] = array_value
|
|
||||||
current_value = array_value # Update for length check below
|
|
||||||
except (ValueError, KeyError, TypeError):
|
|
||||||
# Conversion failed, check if we should use default
|
|
||||||
pass
|
|
||||||
|
|
||||||
# If it's an array, ensure correct types and check minItems
|
|
||||||
if isinstance(current_value, list):
|
|
||||||
# First, ensure array elements are correct types
|
|
||||||
items_schema = prop_schema.get('items', {})
|
|
||||||
item_type = items_schema.get('type')
|
|
||||||
if item_type in ('number', 'integer'):
|
|
||||||
converted_array = []
|
|
||||||
for v in current_value:
|
|
||||||
if isinstance(v, str):
|
|
||||||
try:
|
|
||||||
if item_type == 'integer':
|
|
||||||
converted_array.append(int(v))
|
|
||||||
else:
|
|
||||||
converted_array.append(float(v))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
converted_array.append(v)
|
converted_array.append(v)
|
||||||
else:
|
array_value = converted_array
|
||||||
|
config_dict[prop_key] = array_value
|
||||||
|
current_value = array_value # Update for length check below
|
||||||
|
except (ValueError, KeyError, TypeError) as e:
|
||||||
|
logger.debug(f"Failed to convert {prop_key} to array: {e}")
|
||||||
|
|
||||||
|
# If it's an array, ensure correct types and check minItems
|
||||||
|
if isinstance(current_value, list):
|
||||||
|
# First, ensure array elements are correct types
|
||||||
|
items_schema = prop_schema.get('items', {})
|
||||||
|
item_type = items_schema.get('type')
|
||||||
|
if item_type in ('number', 'integer'):
|
||||||
|
converted_array = []
|
||||||
|
for v in current_value:
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
if item_type == 'integer':
|
||||||
|
converted_array.append(int(v))
|
||||||
|
else:
|
||||||
|
converted_array.append(float(v))
|
||||||
|
except (ValueError, TypeError):
|
||||||
converted_array.append(v)
|
converted_array.append(v)
|
||||||
parent[prop_key] = converted_array
|
else:
|
||||||
current_value = converted_array
|
converted_array.append(v)
|
||||||
|
config_dict[prop_key] = converted_array
|
||||||
|
current_value = converted_array
|
||||||
|
|
||||||
# Then check minItems
|
# Then check minItems
|
||||||
min_items = prop_schema.get('minItems')
|
min_items = prop_schema.get('minItems')
|
||||||
if min_items is not None and len(current_value) < min_items:
|
if min_items is not None and len(current_value) < min_items:
|
||||||
# Use default if available, otherwise keep as-is (validation will catch it)
|
default = prop_schema.get('default')
|
||||||
default = prop_schema.get('default')
|
if default and isinstance(default, list) and len(default) >= min_items:
|
||||||
if default and isinstance(default, list) and len(default) >= min_items:
|
config_dict[prop_key] = default
|
||||||
parent[prop_key] = default
|
|
||||||
else:
|
|
||||||
# Top-level field
|
|
||||||
if prop_key in config_dict:
|
|
||||||
current_value = config_dict[prop_key]
|
|
||||||
# If it's a dict with numeric string keys, convert to array
|
|
||||||
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
|
||||||
try:
|
|
||||||
keys = list(current_value.keys())
|
|
||||||
if keys and all(str(k).isdigit() for k in keys):
|
|
||||||
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
|
||||||
array_value = [current_value[k] for k in sorted_keys]
|
|
||||||
# Convert array elements to correct types based on schema
|
|
||||||
items_schema = prop_schema.get('items', {})
|
|
||||||
item_type = items_schema.get('type')
|
|
||||||
if item_type in ('number', 'integer'):
|
|
||||||
converted_array = []
|
|
||||||
for v in array_value:
|
|
||||||
if isinstance(v, str):
|
|
||||||
try:
|
|
||||||
if item_type == 'integer':
|
|
||||||
converted_array.append(int(v))
|
|
||||||
else:
|
|
||||||
converted_array.append(float(v))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
converted_array.append(v)
|
|
||||||
else:
|
|
||||||
converted_array.append(v)
|
|
||||||
array_value = converted_array
|
|
||||||
config_dict[prop_key] = array_value
|
|
||||||
current_value = array_value # Update for length check below
|
|
||||||
except (ValueError, KeyError, TypeError) as e:
|
|
||||||
logger.debug(f"Failed to convert {prop_key} to array: {e}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
# If it's an array, ensure correct types and check minItems
|
|
||||||
if isinstance(current_value, list):
|
|
||||||
# First, ensure array elements are correct types
|
|
||||||
items_schema = prop_schema.get('items', {})
|
|
||||||
item_type = items_schema.get('type')
|
|
||||||
if item_type in ('number', 'integer'):
|
|
||||||
converted_array = []
|
|
||||||
for v in current_value:
|
|
||||||
if isinstance(v, str):
|
|
||||||
try:
|
|
||||||
if item_type == 'integer':
|
|
||||||
converted_array.append(int(v))
|
|
||||||
else:
|
|
||||||
converted_array.append(float(v))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
converted_array.append(v)
|
|
||||||
else:
|
|
||||||
converted_array.append(v)
|
|
||||||
config_dict[prop_key] = converted_array
|
|
||||||
current_value = converted_array
|
|
||||||
|
|
||||||
# Then check minItems
|
|
||||||
min_items = prop_schema.get('minItems')
|
|
||||||
if min_items is not None and len(current_value) < min_items:
|
|
||||||
default = prop_schema.get('default')
|
|
||||||
if default and isinstance(default, list) and len(default) >= min_items:
|
|
||||||
config_dict[prop_key] = default
|
|
||||||
|
|
||||||
# Recurse into nested objects
|
# Recurse into nested objects
|
||||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||||
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
nested_dict = config_dict.get(prop_key)
|
||||||
if prefix:
|
|
||||||
parent_parts = prefix.split('.')
|
|
||||||
parent = config_dict
|
|
||||||
for part in parent_parts:
|
|
||||||
if isinstance(parent, dict) and part in parent:
|
|
||||||
parent = parent[part]
|
|
||||||
else:
|
|
||||||
parent = None
|
|
||||||
break
|
|
||||||
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
|
|
||||||
else:
|
|
||||||
nested_dict = config_dict.get(prop_key)
|
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
if isinstance(nested_dict, dict):
|
||||||
fix_array_structures(nested_dict, prop_schema['properties'], nested_prefix)
|
fix_array_structures(nested_dict, prop_schema['properties'])
|
||||||
|
|
||||||
# Also ensure array fields that are None get converted to empty arrays
|
# Also ensure array fields that are None get converted to empty arrays
|
||||||
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
def ensure_array_defaults(config_dict, schema_props):
|
||||||
"""Recursively ensure array fields have defaults if None"""
|
"""Recursively ensure array fields have defaults if None.
|
||||||
|
config_dict is always the dict at the current nesting level."""
|
||||||
for prop_key, prop_schema in schema_props.items():
|
for prop_key, prop_schema in schema_props.items():
|
||||||
prop_type = prop_schema.get('type')
|
prop_type = prop_schema.get('type')
|
||||||
|
|
||||||
if prop_type == 'array':
|
if prop_type == 'array':
|
||||||
if prefix:
|
if prop_key not in config_dict or config_dict[prop_key] is None:
|
||||||
parent_parts = prefix.split('.')
|
default = prop_schema.get('default', [])
|
||||||
parent = config_dict
|
config_dict[prop_key] = default if default else []
|
||||||
for part in parent_parts:
|
|
||||||
if isinstance(parent, dict) and part in parent:
|
|
||||||
parent = parent[part]
|
|
||||||
else:
|
|
||||||
parent = None
|
|
||||||
break
|
|
||||||
|
|
||||||
if parent is not None and isinstance(parent, dict):
|
|
||||||
if prop_key not in parent or parent[prop_key] is None:
|
|
||||||
default = prop_schema.get('default', [])
|
|
||||||
parent[prop_key] = default if default else []
|
|
||||||
else:
|
|
||||||
if prop_key not in config_dict or config_dict[prop_key] is None:
|
|
||||||
default = prop_schema.get('default', [])
|
|
||||||
config_dict[prop_key] = default if default else []
|
|
||||||
|
|
||||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||||
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
nested_dict = config_dict.get(prop_key)
|
||||||
if prefix:
|
|
||||||
parent_parts = prefix.split('.')
|
|
||||||
parent = config_dict
|
|
||||||
for part in parent_parts:
|
|
||||||
if isinstance(parent, dict) and part in parent:
|
|
||||||
parent = parent[part]
|
|
||||||
else:
|
|
||||||
parent = None
|
|
||||||
break
|
|
||||||
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
|
|
||||||
else:
|
|
||||||
nested_dict = config_dict.get(prop_key)
|
|
||||||
|
|
||||||
if nested_dict is None:
|
if nested_dict is None:
|
||||||
if prefix:
|
if prop_key not in config_dict:
|
||||||
parent_parts = prefix.split('.')
|
config_dict[prop_key] = {}
|
||||||
parent = config_dict
|
nested_dict = config_dict[prop_key]
|
||||||
for part in parent_parts:
|
|
||||||
if part not in parent:
|
|
||||||
parent[part] = {}
|
|
||||||
parent = parent[part]
|
|
||||||
if prop_key not in parent:
|
|
||||||
parent[prop_key] = {}
|
|
||||||
nested_dict = parent[prop_key]
|
|
||||||
else:
|
|
||||||
if prop_key not in config_dict:
|
|
||||||
config_dict[prop_key] = {}
|
|
||||||
nested_dict = config_dict[prop_key]
|
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
if isinstance(nested_dict, dict):
|
||||||
ensure_array_defaults(nested_dict, prop_schema['properties'], nested_prefix)
|
ensure_array_defaults(nested_dict, prop_schema['properties'])
|
||||||
|
|
||||||
if schema and 'properties' in schema:
|
if schema and 'properties' in schema:
|
||||||
# First, fix any dict structures that should be arrays
|
# First, fix any dict structures that should be arrays
|
||||||
|
|||||||
@@ -2265,25 +2265,35 @@ window.showPluginConfigModal = function(pluginId, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get the full property object from schema
|
// Helper function to get the full property object from schema
|
||||||
|
// Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1")
|
||||||
function getSchemaProperty(schema, path) {
|
function getSchemaProperty(schema, path) {
|
||||||
if (!schema || !schema.properties) return null;
|
if (!schema || !schema.properties) return null;
|
||||||
|
|
||||||
const parts = path.split('.');
|
const parts = path.split('.');
|
||||||
let current = schema.properties;
|
let current = schema.properties;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
while (i < parts.length) {
|
||||||
const part = parts[i];
|
let matched = false;
|
||||||
if (current && current[part]) {
|
// Try progressively longer candidates, longest first
|
||||||
if (i === parts.length - 1) {
|
for (let j = parts.length; j > i; j--) {
|
||||||
// Last part - return the property
|
const candidate = parts.slice(i, j).join('.');
|
||||||
return current[part];
|
if (current && current[candidate]) {
|
||||||
} else if (current[part].properties) {
|
if (j === parts.length) {
|
||||||
// Navigate into nested object
|
// Consumed all remaining parts — done
|
||||||
current = current[part].properties;
|
return current[candidate];
|
||||||
} else {
|
}
|
||||||
return null;
|
if (current[candidate].properties) {
|
||||||
|
current = current[candidate].properties;
|
||||||
|
i = j;
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
return null; // Can't navigate deeper
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
if (!matched) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2311,21 +2321,56 @@ function escapeCssSelector(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to convert dot notation to nested object
|
// Helper function to convert dot notation to nested object
|
||||||
function dotToNested(obj) {
|
// Uses schema-aware greedy matching to preserve dotted keys (e.g., "eng.1")
|
||||||
|
function dotToNested(obj, schema) {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
const parts = key.split('.');
|
const parts = key.split('.');
|
||||||
let current = result;
|
let current = result;
|
||||||
|
let currentSchema = (schema && schema.properties) ? schema.properties : null;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
while (i < parts.length - 1) {
|
||||||
if (!current[parts[i]]) {
|
let matched = false;
|
||||||
current[parts[i]] = {};
|
if (currentSchema) {
|
||||||
|
// Try progressively longer candidates (longest first) to greedily
|
||||||
|
// match dotted property names like "eng.1"
|
||||||
|
for (let j = parts.length - 1; j > i; j--) {
|
||||||
|
const candidate = parts.slice(i, j).join('.');
|
||||||
|
if (candidate in currentSchema) {
|
||||||
|
if (!current[candidate]) {
|
||||||
|
current[candidate] = {};
|
||||||
|
}
|
||||||
|
current = current[candidate];
|
||||||
|
const schemaProp = currentSchema[candidate];
|
||||||
|
currentSchema = (schemaProp && schemaProp.properties) ? schemaProp.properties : null;
|
||||||
|
i = j;
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
// No schema match or no schema — use single segment
|
||||||
|
const part = parts[i];
|
||||||
|
if (!current[part]) {
|
||||||
|
current[part] = {};
|
||||||
|
}
|
||||||
|
current = current[part];
|
||||||
|
if (currentSchema) {
|
||||||
|
const schemaProp = currentSchema[part];
|
||||||
|
currentSchema = (schemaProp && schemaProp.properties) ? schemaProp.properties : null;
|
||||||
|
} else {
|
||||||
|
currentSchema = null;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
current = current[parts[i]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
current[parts[parts.length - 1]] = obj[key];
|
// Set the final key (remaining parts joined — may itself be dotted)
|
||||||
|
const finalKey = parts.slice(i).join('.');
|
||||||
|
current[finalKey] = obj[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -2571,7 +2616,7 @@ function handlePluginConfigSubmit(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert dot notation to nested object
|
// Convert dot notation to nested object
|
||||||
const config = dotToNested(flatConfig);
|
const config = dotToNested(flatConfig, schema);
|
||||||
|
|
||||||
console.log('Flat config:', flatConfig);
|
console.log('Flat config:', flatConfig);
|
||||||
console.log('Nested config to save:', config);
|
console.log('Nested config to save:', config);
|
||||||
|
|||||||
Reference in New Issue
Block a user