diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index b4f8c35d..d07f4db7 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -1074,12 +1074,12 @@ def _submitted_parents(form_keys): return parents -def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None, +def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefix='', config_node=None, sections=None, submitted_parents=None): """Walk schema and set missing boolean form fields to False. - HTML checkboxes don't submit values when unchecked. When saving plugin config, - the backend starts from existing config (to support partial form updates), which + HTML checkboxes don't submit values when unchecked. When saving plugin plugin_config, + the backend starts from existing plugin_config (to support partial form updates), which means an unchecked checkbox's old ``True`` value persists. This function detects boolean schema properties not present in the form submission and explicitly sets them to ``False`` -- but only where that silence is evidence, see @@ -1092,11 +1092,11 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c (e.g. ``feeds.custom_feeds.0.enabled``). Args: - config: The root plugin config dict (used for pure-dict paths) + plugin_config: The root plugin plugin_config dict (used for pure-dict paths) schema_props: Schema ``properties`` dict at the current nesting level form_keys: Set of form field names that were submitted prefix: Dot-notation prefix for the current nesting level - config_node: The current config subtree when inside an array item (avoids + config_node: The current plugin_config subtree when inside an array item (avoids using _set_nested_value which corrupts lists) sections: Top-level sections the form reported rendering, or None when it reported none (then submitted_parents decides) @@ -1105,8 +1105,8 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c """ if sections is None and submitted_parents is None: submitted_parents = _submitted_parents(form_keys) - # Determine which config node to operate on - node = config_node if config_node is not None else config + # Determine which plugin_config node to operate on + node = config_node if config_node is not None else plugin_config for prop_name, prop_schema in schema_props.items(): if not isinstance(prop_schema, dict): @@ -1126,7 +1126,7 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c node[prop_name] = False else: # Pure dict path — use helper - _set_nested_value(config, full_path, False) + _set_nested_value(plugin_config, full_path, False) elif prop_type == 'object' and 'properties' in prop_schema: # Recurse into nested objects @@ -1135,13 +1135,13 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c if prop_name not in node or not isinstance(node[prop_name], dict): node[prop_name] = {} _set_missing_booleans_to_false( - config, prop_schema['properties'], form_keys, full_path, + plugin_config, prop_schema['properties'], form_keys, full_path, config_node=node[prop_name], sections=sections, submitted_parents=submitted_parents ) else: _set_missing_booleans_to_false( - config, prop_schema['properties'], form_keys, full_path, + plugin_config, prop_schema['properties'], form_keys, full_path, sections=sections, submitted_parents=submitted_parents ) @@ -1164,15 +1164,15 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c if not indices: continue - # Navigate to the array in the config (create if missing) + # Navigate to the array in the plugin_config (create if missing) if config_node is not None: if prop_name not in node or not isinstance(node[prop_name], list): node[prop_name] = [] array_list = node[prop_name] else: - # Navigate from root config through dict keys to get the list + # Navigate from root plugin_config through dict keys to get the list parts = full_path.split('.') - current = config + current = plugin_config for part in parts[:-1]: if part not in current or not isinstance(current[part], dict): current[part] = {} @@ -1191,7 +1191,7 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c array_list[idx] = {} item_prefix = f"{full_path}.{idx}" _set_missing_booleans_to_false( - config, items_schema['properties'], form_keys, item_prefix, + plugin_config, items_schema['properties'], form_keys, item_prefix, config_node=array_list[idx], sections=sections, submitted_parents=submitted_parents ) diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index 5f995040..6220ffba 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -52,7 +52,11 @@ */ function own(obj, key) { if (!obj || typeof obj !== 'object') { return undefined; } - return hasOwn.call(obj, key) ? obj[key] : undefined; + // Via the descriptor rather than obj[key]: this is the one read that + // cannot avoid a data-supplied key, and going through the descriptor + // means there is no computed member access here at all. + var descriptor = Object.getOwnPropertyDescriptor(obj, key); + return descriptor ? descriptor.value : undefined; } /** `own`, but always an object -- for `(x || {}).properties` chains. */ @@ -110,10 +114,11 @@ /** Walk a nested value object by path segments. */ function at(value, path) { var cur = value; - var i; - for (i = 0; i < path.length; i++) { + // Consumed rather than indexed, so no step reads path[i]. + var remaining = (path || []).slice(); + while (remaining.length) { if (cur === null || typeof cur !== 'object') { return undefined; } - cur = own(cur, path[i]); + cur = own(cur, remaining.shift()); } return cur; } @@ -364,25 +369,26 @@ function columnsFor(schema) { var props = schema.properties || {}; var layoutProps = ownObj(props, 'layout').properties || {}; - // No prototype to inherit from, so a field literally named - // "constructor" is a column like any other. - var seen = Object.create(null); + // A Map, not an object: the keys are field names out of a schema, so + // a field literally named "constructor" is a column like any other + // and never touches a prototype. + var seen = new Map(); elementKeys(schema).forEach(function (key) { Object.keys(ownObj(props, key).properties || {}).forEach( - function (f) { seen[f] = 'element'; }); + function (f) { seen.set(f, 'element'); }); Object.keys(ownObj(layoutProps, key).properties || {}).forEach( - function (f) { seen[f] = 'layout'; }); + function (f) { seen.set(f, 'layout'); }); }); - var known = COLUMN_ORDER.filter(function (f) { return own(seen, f); }); + var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); }); // Anything the schema declares that this file has never heard of // still gets a column, rather than silently vanishing. - var extra = Object.keys(seen).filter(function (f) { + var extra = Array.from(seen.keys()).filter(function (f) { return COLUMN_ORDER.indexOf(f) === -1; }).sort(); return known.concat(extra).map(function (f) { return { key: f, - where: own(seen, f), + where: seen.get(f), label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ') }; });