diff --git a/changedetectionio/blueprint/ui/browser_config.py b/changedetectionio/blueprint/ui/browser_config.py
index 09c8925f3..e8199ae4a 100644
--- a/changedetectionio/blueprint/ui/browser_config.py
+++ b/changedetectionio/blueprint/ui/browser_config.py
@@ -80,6 +80,13 @@ def _caps_for(base_name):
return FetcherCapabilities.from_fetcher(getattr(content_fetchers, base_name, None)).model_dump()
+def _applicable_fields_for(capabilities):
+ """The FetcherConfig fields an engine with these capabilities may carry - the one set that
+ both the form template and the save path use, so they cannot drift apart."""
+ from changedetectionio.model.browser_config import FetcherConfig
+ return FetcherConfig.applicable_fields(capabilities)
+
+
def _entry_to_formdata(entry):
"""Flatten a browsers.json entry into flat form field values (base is contextual, not a field)."""
data = {'label': entry.get('label')}
@@ -127,11 +134,16 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return True
return False
- def _validate_and_build_config(form):
- """Return a validated FetcherConfig, or None (with errors attached to form)."""
+ def _validate_and_build_config(form, capabilities):
+ """Return a validated FetcherConfig, or None (with errors attached to form).
+
+ `capabilities` is the base engine's capability set and acts as the allowlist: only fields
+ that engine can honour are taken from the submitted form (FetcherConfig.from_submitted),
+ so nothing this browser ignores can be written into browsers.json.
+ """
from changedetectionio.model.browser_config import FetcherConfig
try:
- return FetcherConfig(**form.to_fetcher_config_dict())
+ return FetcherConfig.from_submitted(form.to_fetcher_config_dict(), capabilities)
except ValidationError as e:
for err in e.errors():
loc = err['loc'][0] if err['loc'] else ''
@@ -167,7 +179,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
if _label_is_taken(form.label.data):
form.label.errors.append(gettext("A browser with this name already exists"))
else:
- cfg = _validate_and_build_config(form)
+ cfg = _validate_and_build_config(form, caps)
if cfg is not None:
datastore.browser_config_store.add(
label=form.label.data,
@@ -179,7 +191,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
locale_choices, timezone_choices = _autocomplete_choices()
return render_template("browser-config-form.html", form=form, mode='add',
- base_fetcher=base_fetcher, base_label=base_label, caps=caps.model_dump(),
+ base_fetcher=base_fetcher, base_label=base_label,
+ applicable=_applicable_fields_for(caps),
locale_choices=locale_choices, timezone_choices=timezone_choices,
form_action=url_for('ui.browser_config.browser_config_add', base_fetcher=base_fetcher))
@@ -211,7 +224,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
if (not is_builtin) and _label_is_taken(form.label.data, exclude_id=config_id):
form.label.errors.append(gettext("A browser with this name already exists"))
else:
- cfg = _validate_and_build_config(form)
+ cfg = _validate_and_build_config(form, _caps_for(base) if base else None)
if cfg is not None:
datastore.browser_config_store.upsert(
config_id,
@@ -228,7 +241,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return render_template("browser-config-form.html", form=form, mode='edit',
config_id=config_id, is_builtin=is_builtin,
base_fetcher=base, base_label=base_label,
- caps=_caps_for(base) if base else {},
+ applicable=_applicable_fields_for(_caps_for(base) if base else None),
locale_choices=locale_choices, timezone_choices=timezone_choices,
form_action=url_for('ui.browser_config.browser_config_edit', config_id=config_id))
diff --git a/changedetectionio/blueprint/ui/templates/_browser_config_fields.html b/changedetectionio/blueprint/ui/templates/_browser_config_fields.html
index f5e31cf43..c1f53b4fe 100644
--- a/changedetectionio/blueprint/ui/templates/_browser_config_fields.html
+++ b/changedetectionio/blueprint/ui/templates/_browser_config_fields.html
@@ -1,7 +1,12 @@
-{# Browser config form fields for the add/edit page. Expects `form`, `caps` (the base engine's
- capability dict) and `base_label` (read-only base engine name). The base engine is fixed by
- the page (chosen via the "Add variation" link, or the existing config's base), so it's shown
- read-only, not as a picker. Fields are gated server-side by `caps`. #}
+{# Browser config form fields for the add/edit page. Expects `form`, `applicable` (the set of
+ FetcherConfig fields this base engine can honour - FetcherConfig.applicable_fields()) and
+ `base_label` (read-only base engine name). The base engine is fixed by the page (chosen via
+ the "Add variation" link, or the existing config's base), so it's shown read-only, not as a
+ picker.
+
+ Gating on `applicable` rather than on capability flags directly is deliberate: it is the same
+ set the save path allows (FetcherConfig.from_submitted), so a field can never be rendered but
+ unsaveable, or hidden but written from its widget default. #}
{% from '_helpers.html' import render_field, render_checkbox_field %}
-{% if caps.supports_screenshots %}
+{% if 'viewport_width' in applicable %}