mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
fix: handle dotted schema keys in plugin settings save (#295)
* 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> * fix: address review findings for dotted-key handling - ensure_array_defaults: replace None nodes with {} so recursion proceeds into nested objects (was skipping when key existed as None) - dotToNested: add tail-matching that checks the full remaining dotted tail against the current schema level before greedy intermediate matching, preventing leaf dotted keys from being split - syncFormToJson: replace naive key.split('.') reconstruction with dotToNested(flatConfig, schema) and schema-aware getSchemaProperty() so the JSON tab save path produces the same correct nesting as the form submit path - Add regression tests for dotted-key array normalization and None array default replacement Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address second round of review findings - Tests: replace conditional `if response.status_code == 200` guards with unconditional `assert response.status_code == 200` so failures are not silently swallowed - dotToNested: guard finalKey write with `if (i < parts.length)` to prevent empty-string key pollution when tail-matching consumed all parts - Extract normalizeFormDataForConfig() helper from handlePluginConfigSubmit and call it from both handlePluginConfigSubmit and syncFormToJson so the JSON tab sync uses the same robust FormData processing (including _data JSON inputs, bracket-notation checkboxes, array-of-objects, file-upload widgets, checkbox DOM detection, and unchecked boolean handling via collectBooleanFields) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -583,3 +583,130 @@ class TestAPIErrorHandling:
|
|||||||
response = client.get('/api/v3/display/on-demand/start')
|
response = client.get('/api/v3/display/on-demand/start')
|
||||||
|
|
||||||
assert response.status_code in [200, 405] # Depends on implementation
|
assert response.status_code in [200, 405] # Depends on implementation
|
||||||
|
|
||||||
|
|
||||||
|
class TestDottedKeyNormalization:
|
||||||
|
"""Regression tests for fix_array_structures / ensure_array_defaults with dotted schema keys."""
|
||||||
|
|
||||||
|
def test_save_plugin_config_dotted_key_arrays(self, client, mock_config_manager):
|
||||||
|
"""Nested dotted-key objects with numeric-keyed dicts are converted to arrays."""
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|
||||||
|
api_v3.config_manager = mock_config_manager
|
||||||
|
mock_config_manager.load_config.return_value = {}
|
||||||
|
|
||||||
|
schema_mgr = MagicMock()
|
||||||
|
schema = {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'leagues': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'eng.1': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'enabled': {'type': 'boolean', 'default': True},
|
||||||
|
'favorite_teams': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {'type': 'string'},
|
||||||
|
'default': [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
schema_mgr.load_schema.return_value = schema
|
||||||
|
schema_mgr.generate_default_config.return_value = {
|
||||||
|
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
|
||||||
|
}
|
||||||
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||||
|
schema_mgr.validate_config_against_schema.return_value = []
|
||||||
|
api_v3.schema_manager = schema_mgr
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
'plugin_id': 'soccer-scoreboard',
|
||||||
|
'config': {
|
||||||
|
'leagues': {
|
||||||
|
'eng.1': {
|
||||||
|
'enabled': True,
|
||||||
|
'favorite_teams': ['Arsenal', 'Chelsea'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/plugins/config',
|
||||||
|
data=json.dumps(request_data),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
|
||||||
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||||
|
soccer_cfg = saved.get('soccer-scoreboard', {})
|
||||||
|
leagues = soccer_cfg.get('leagues', {})
|
||||||
|
assert 'eng.1' in leagues, f"Expected 'eng.1' key, got: {list(leagues.keys())}"
|
||||||
|
assert isinstance(leagues['eng.1'].get('favorite_teams'), list)
|
||||||
|
assert leagues['eng.1']['favorite_teams'] == ['Arsenal', 'Chelsea']
|
||||||
|
|
||||||
|
def test_save_plugin_config_none_array_gets_default(self, client, mock_config_manager):
|
||||||
|
"""None array fields under dotted-key parents are replaced with defaults."""
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|
||||||
|
api_v3.config_manager = mock_config_manager
|
||||||
|
mock_config_manager.load_config.return_value = {}
|
||||||
|
|
||||||
|
schema_mgr = MagicMock()
|
||||||
|
schema = {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'leagues': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'eng.1': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'favorite_teams': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {'type': 'string'},
|
||||||
|
'default': [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
schema_mgr.load_schema.return_value = schema
|
||||||
|
schema_mgr.generate_default_config.return_value = {
|
||||||
|
'leagues': {'eng.1': {'favorite_teams': []}},
|
||||||
|
}
|
||||||
|
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
|
||||||
|
schema_mgr.validate_config_against_schema.return_value = []
|
||||||
|
api_v3.schema_manager = schema_mgr
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
'plugin_id': 'soccer-scoreboard',
|
||||||
|
'config': {
|
||||||
|
'leagues': {
|
||||||
|
'eng.1': {
|
||||||
|
'favorite_teams': None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/api/v3/plugins/config',
|
||||||
|
data=json.dumps(request_data),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
|
||||||
|
saved = mock_config_manager.save_config_atomic.call_args[0][0]
|
||||||
|
soccer_cfg = saved.get('soccer-scoreboard', {})
|
||||||
|
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
|
||||||
|
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
|
||||||
|
assert teams == [], f"Expected empty default list, got: {teams}"
|
||||||
|
|||||||
@@ -4024,225 +4024,100 @@ 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:
|
config_dict[prop_key] = {}
|
||||||
parent_parts = prefix.split('.')
|
nested_dict = config_dict[prop_key]
|
||||||
parent = config_dict
|
|
||||||
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,68 @@ 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) {
|
||||||
|
// First, check if the full remaining tail is a leaf property
|
||||||
|
// (e.g., "eng.1" as a complete dotted key with no sub-properties)
|
||||||
|
const tailCandidate = parts.slice(i).join('.');
|
||||||
|
if (tailCandidate in currentSchema) {
|
||||||
|
current[tailCandidate] = obj[key];
|
||||||
|
matched = true;
|
||||||
|
i = parts.length; // consumed all parts
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
// Skip if tail-matching already consumed all parts and wrote the value
|
||||||
|
if (i < parts.length) {
|
||||||
|
const finalKey = parts.slice(i).join('.');
|
||||||
|
current[finalKey] = obj[key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -2350,42 +2407,20 @@ function collectBooleanFields(schema, prefix = '') {
|
|||||||
return boolFields;
|
return boolFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePluginConfigSubmit(e) {
|
/**
|
||||||
e.preventDefault();
|
* Normalize FormData from a plugin config form into a nested config object.
|
||||||
console.log('Form submitted');
|
* Handles _data JSON inputs, bracket-notation checkboxes, array-of-objects,
|
||||||
|
* file-upload widgets, proper checkbox DOM detection, unchecked boolean
|
||||||
if (!currentPluginConfig) {
|
* handling, and schema-aware dotted-key nesting.
|
||||||
showNotification('Plugin configuration not loaded', 'error');
|
*
|
||||||
return;
|
* @param {HTMLFormElement} form - The form element (needed for checkbox DOM detection)
|
||||||
}
|
* @param {Object|null} schema - The plugin's JSON Schema
|
||||||
|
* @returns {Object} Nested config object ready for saving
|
||||||
const pluginId = currentPluginConfig.pluginId;
|
*/
|
||||||
const schema = currentPluginConfig.schema;
|
function normalizeFormDataForConfig(form, schema) {
|
||||||
const form = e.target;
|
|
||||||
|
|
||||||
// Fix invalid hidden fields before submission
|
|
||||||
// This prevents "invalid form control is not focusable" errors
|
|
||||||
const allInputs = form.querySelectorAll('input[type="number"]');
|
|
||||||
allInputs.forEach(input => {
|
|
||||||
const min = parseFloat(input.getAttribute('min'));
|
|
||||||
const max = parseFloat(input.getAttribute('max'));
|
|
||||||
const value = parseFloat(input.value);
|
|
||||||
|
|
||||||
if (!isNaN(value)) {
|
|
||||||
if (!isNaN(min) && value < min) {
|
|
||||||
input.value = min;
|
|
||||||
} else if (!isNaN(max) && value > max) {
|
|
||||||
input.value = max;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const flatConfig = {};
|
const flatConfig = {};
|
||||||
|
|
||||||
console.log('Schema loaded:', schema ? 'Yes' : 'No');
|
|
||||||
|
|
||||||
// Process form data with type conversion (using dot notation for nested fields)
|
|
||||||
for (const [key, value] of formData.entries()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
// Check if this is a patternProperties or array-of-objects hidden input (contains JSON data)
|
// Check if this is a patternProperties or array-of-objects hidden input (contains JSON data)
|
||||||
// Only match keys ending with '_data' to avoid false positives like 'meta_data_field'
|
// Only match keys ending with '_data' to avoid false positives like 'meta_data_field'
|
||||||
@@ -2397,7 +2432,6 @@ function handlePluginConfigSubmit(e) {
|
|||||||
// Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript)
|
// Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript)
|
||||||
if (jsonValue !== null && typeof jsonValue === 'object') {
|
if (jsonValue !== null && typeof jsonValue === 'object') {
|
||||||
flatConfig[baseKey] = jsonValue;
|
flatConfig[baseKey] = jsonValue;
|
||||||
console.log(`JSON data field ${baseKey}: parsed ${Array.isArray(jsonValue) ? 'array' : 'object'}`, jsonValue);
|
|
||||||
continue; // Skip normal processing for JSON data fields
|
continue; // Skip normal processing for JSON data fields
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2458,7 +2492,6 @@ function handlePluginConfigSubmit(e) {
|
|||||||
const jsonValue = JSON.parse(decodedValue);
|
const jsonValue = JSON.parse(decodedValue);
|
||||||
if (Array.isArray(jsonValue)) {
|
if (Array.isArray(jsonValue)) {
|
||||||
flatConfig[actualKey] = jsonValue;
|
flatConfig[actualKey] = jsonValue;
|
||||||
console.log(`File upload array field ${actualKey}: parsed JSON array with ${jsonValue.length} items`);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to comma-separated
|
// Fallback to comma-separated
|
||||||
const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
||||||
@@ -2468,13 +2501,11 @@ function handlePluginConfigSubmit(e) {
|
|||||||
// Not JSON, use comma-separated
|
// Not JSON, use comma-separated
|
||||||
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
||||||
flatConfig[actualKey] = arrayValue;
|
flatConfig[actualKey] = arrayValue;
|
||||||
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular array: convert comma-separated string to array
|
// Regular array: convert comma-separated string to array
|
||||||
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
||||||
flatConfig[actualKey] = arrayValue;
|
flatConfig[actualKey] = arrayValue;
|
||||||
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
|
|
||||||
}
|
}
|
||||||
} else if (propType === 'integer') {
|
} else if (propType === 'integer') {
|
||||||
flatConfig[actualKey] = parseInt(actualValue, 10);
|
flatConfig[actualKey] = parseInt(actualValue, 10);
|
||||||
@@ -2492,7 +2523,6 @@ function handlePluginConfigSubmit(e) {
|
|||||||
} else {
|
} else {
|
||||||
// Element not found - normalize string booleans and check FormData value
|
// Element not found - normalize string booleans and check FormData value
|
||||||
// Checkboxes send "on" when checked, nothing when unchecked
|
// Checkboxes send "on" when checked, nothing when unchecked
|
||||||
// Normalize string representations of booleans
|
|
||||||
if (typeof actualValue === 'string') {
|
if (typeof actualValue === 'string') {
|
||||||
const lowerValue = actualValue.toLowerCase().trim();
|
const lowerValue = actualValue.toLowerCase().trim();
|
||||||
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
||||||
@@ -2500,13 +2530,11 @@ function handlePluginConfigSubmit(e) {
|
|||||||
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
||||||
flatConfig[actualKey] = false;
|
flatConfig[actualKey] = false;
|
||||||
} else {
|
} else {
|
||||||
// Non-empty string that's not a boolean representation - treat as truthy
|
|
||||||
flatConfig[actualKey] = true;
|
flatConfig[actualKey] = true;
|
||||||
}
|
}
|
||||||
} else if (actualValue === undefined || actualValue === null) {
|
} else if (actualValue === undefined || actualValue === null) {
|
||||||
flatConfig[actualKey] = false;
|
flatConfig[actualKey] = false;
|
||||||
} else {
|
} else {
|
||||||
// Non-string value - coerce to boolean
|
|
||||||
flatConfig[actualKey] = Boolean(actualValue);
|
flatConfig[actualKey] = Boolean(actualValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2526,7 +2554,6 @@ function handlePluginConfigSubmit(e) {
|
|||||||
|
|
||||||
const parsed = JSON.parse(decodedValue);
|
const parsed = JSON.parse(decodedValue);
|
||||||
flatConfig[actualKey] = parsed;
|
flatConfig[actualKey] = parsed;
|
||||||
console.log(`No schema for ${actualKey}, but parsed as JSON:`, parsed);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not valid JSON, save as string
|
// Not valid JSON, save as string
|
||||||
flatConfig[actualKey] = actualValue;
|
flatConfig[actualKey] = actualValue;
|
||||||
@@ -2537,10 +2564,8 @@ function handlePluginConfigSubmit(e) {
|
|||||||
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
|
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
|
||||||
|
|
||||||
if (formElement && formElement.type === 'checkbox') {
|
if (formElement && formElement.type === 'checkbox') {
|
||||||
// Found checkbox element - use its checked state
|
|
||||||
flatConfig[actualKey] = formElement.checked;
|
flatConfig[actualKey] = formElement.checked;
|
||||||
} else {
|
} else {
|
||||||
// Not a checkbox or element not found - normalize string booleans
|
|
||||||
if (typeof actualValue === 'string') {
|
if (typeof actualValue === 'string') {
|
||||||
const lowerValue = actualValue.toLowerCase().trim();
|
const lowerValue = actualValue.toLowerCase().trim();
|
||||||
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
||||||
@@ -2548,11 +2573,9 @@ function handlePluginConfigSubmit(e) {
|
|||||||
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
||||||
flatConfig[actualKey] = false;
|
flatConfig[actualKey] = false;
|
||||||
} else {
|
} else {
|
||||||
// Non-empty string that's not a boolean representation - keep as string
|
|
||||||
flatConfig[actualKey] = actualValue;
|
flatConfig[actualKey] = actualValue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-string value - use as-is
|
|
||||||
flatConfig[actualKey] = actualValue;
|
flatConfig[actualKey] = actualValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2571,9 +2594,41 @@ function handlePluginConfigSubmit(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert dot notation to nested object
|
// Convert dot notation to nested object
|
||||||
const config = dotToNested(flatConfig);
|
return dotToNested(flatConfig, schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePluginConfigSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('Form submitted');
|
||||||
|
|
||||||
|
if (!currentPluginConfig) {
|
||||||
|
showNotification('Plugin configuration not loaded', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginId = currentPluginConfig.pluginId;
|
||||||
|
const schema = currentPluginConfig.schema;
|
||||||
|
const form = e.target;
|
||||||
|
|
||||||
|
// Fix invalid hidden fields before submission
|
||||||
|
// This prevents "invalid form control is not focusable" errors
|
||||||
|
const allInputs = form.querySelectorAll('input[type="number"]');
|
||||||
|
allInputs.forEach(input => {
|
||||||
|
const min = parseFloat(input.getAttribute('min'));
|
||||||
|
const max = parseFloat(input.getAttribute('max'));
|
||||||
|
const value = parseFloat(input.value);
|
||||||
|
|
||||||
|
if (!isNaN(value)) {
|
||||||
|
if (!isNaN(min) && value < min) {
|
||||||
|
input.value = min;
|
||||||
|
} else if (!isNaN(max) && value > max) {
|
||||||
|
input.value = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = normalizeFormDataForConfig(form, schema);
|
||||||
|
|
||||||
console.log('Flat config:', flatConfig);
|
|
||||||
console.log('Nested config to save:', config);
|
console.log('Nested config to save:', config);
|
||||||
|
|
||||||
// Save the configuration
|
// Save the configuration
|
||||||
@@ -4419,41 +4474,8 @@ function syncFormToJson() {
|
|||||||
const form = document.getElementById('plugin-config-form');
|
const form = document.getElementById('plugin-config-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
const config = {};
|
|
||||||
|
|
||||||
// Get schema for type conversion
|
|
||||||
const schema = currentPluginConfigState.schema;
|
const schema = currentPluginConfigState.schema;
|
||||||
|
const config = normalizeFormDataForConfig(form, schema);
|
||||||
for (let [key, value] of formData.entries()) {
|
|
||||||
if (key === 'enabled') continue; // Skip enabled, managed separately
|
|
||||||
|
|
||||||
// Handle nested keys (dot notation)
|
|
||||||
const keys = key.split('.');
|
|
||||||
let current = config;
|
|
||||||
for (let i = 0; i < keys.length - 1; i++) {
|
|
||||||
if (!current[keys[i]]) {
|
|
||||||
current[keys[i]] = {};
|
|
||||||
}
|
|
||||||
current = current[keys[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalKey = keys[keys.length - 1];
|
|
||||||
const prop = schema?.properties?.[finalKey] || (keys.length > 1 ? null : schema?.properties?.[key]);
|
|
||||||
|
|
||||||
// Type conversion based on schema
|
|
||||||
if (prop?.type === 'array') {
|
|
||||||
current[finalKey] = value.split(',').map(item => item.trim()).filter(item => item.length > 0);
|
|
||||||
} else if (prop?.type === 'integer' || key === 'display_duration') {
|
|
||||||
current[finalKey] = parseInt(value) || 0;
|
|
||||||
} else if (prop?.type === 'number') {
|
|
||||||
current[finalKey] = parseFloat(value) || 0;
|
|
||||||
} else if (prop?.type === 'boolean') {
|
|
||||||
current[finalKey] = value === 'true' || value === true;
|
|
||||||
} else {
|
|
||||||
current[finalKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep merge with existing config to preserve nested structures
|
// Deep merge with existing config to preserve nested structures
|
||||||
function deepMerge(target, source) {
|
function deepMerge(target, source) {
|
||||||
|
|||||||
Reference in New Issue
Block a user