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:
ChuckBuilds
2026-03-26 17:55:03 -04:00
parent 2c2fca2219
commit a0873806db
2 changed files with 141 additions and 220 deletions

View File

@@ -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

View File

@@ -2265,29 +2265,39 @@ 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++) {
const part = parts[i]; while (i < parts.length) {
if (current && current[part]) { let matched = false;
if (i === parts.length - 1) { // Try progressively longer candidates, longest first
// Last part - return the property for (let j = parts.length; j > i; j--) {
return current[part]; const candidate = parts.slice(i, j).join('.');
} else if (current[part].properties) { if (current && current[candidate]) {
// Navigate into nested object if (j === parts.length) {
current = current[part].properties; // Consumed all remaining parts — done
} else { return current[candidate];
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;
} }
} }
return null; return null;
} }
@@ -2311,23 +2321,58 @@ 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;
for (let i = 0; i < parts.length - 1; i++) { let i = 0;
if (!current[parts[i]]) {
current[parts[i]] = {}; while (i < parts.length - 1) {
let matched = false;
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);