Serve the settings search index from the server as JSON

Move index building off the client so there is no client-side HTML fetching
or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the
read-only, same-origin index build).

- Add GET /v3/settings/search-index (pages_v3.py): renders the settings
  partials server-side and extracts each field's anchor id, key, label,
  tooltip, and section with a small stdlib HTMLParser, then caches the result
  keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor
  ids identical to the live DOM, so the index cannot drift.
- settings-search.js: buildIndex() now does a single fetch of the literal
  endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc,
  CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation,
  and the per-tab filter are unchanged.

Net: fewer requests and no client-side HTML parsing. Verified with a new
endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium
test (tooltip, filter, search navigate + flash all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz
This commit is contained in:
Claude
2026-07-08 16:11:11 +00:00
parent c0d785967b
commit a2fb3d180f
3 changed files with 202 additions and 81 deletions
+25
View File
@@ -115,3 +115,28 @@ def test_display_tooltip_carries_rich_text(client):
body = client.get("/v3/partials/display").get_data(as_text=True) body = client.get("/v3/partials/display").get_data(as_text=True)
assert 'id="setting-display-brightness"' in body assert 'id="setting-display-brightness"' in body
assert "Recommended:" in body # rich detail authored into a tooltip assert "Recommended:" in body # rich detail authored into a tooltip
def test_search_index_endpoint(client):
"""The server-built search index powers the global settings search."""
resp = client.get("/v3/settings/search-index")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict) and isinstance(data.get("fields"), list)
fields = data["fields"]
assert len(fields) >= 40, "expected the core settings fields to be indexed"
by_id = {f["anchorId"]: f for f in fields}
# Representative fields across tabs must be present with usable text.
for anchor in ("setting-general-timezone", "setting-display-brightness",
"setting-wifi-password"):
assert anchor in by_id, f"{anchor} missing from search index"
entry = by_id[anchor]
assert entry["label"], f"{anchor} has no label"
assert entry["help"], f"{anchor} has no tooltip help"
assert entry["tab"] and entry["tabLabel"]
# Every entry must carry a non-empty label and a stable anchor id.
assert all(f["label"] and f["anchorId"].startswith("setting-") for f in fields)
# Section context is captured for grouped fields (e.g. Display hardware).
assert by_id["setting-display-brightness"]["section"] == "Hardware Configuration"
+159 -1
View File
@@ -1,6 +1,7 @@
from flask import Blueprint, render_template, flash from flask import Blueprint, render_template, flash, jsonify
from jinja2 import TemplateNotFound from jinja2 import TemplateNotFound
from markupsafe import escape from markupsafe import escape
from html.parser import HTMLParser
import json import json
import logging import logging
import os import os
@@ -22,6 +23,114 @@ plugin_store_manager = None
pages_v3 = Blueprint('pages_v3', __name__) pages_v3 = Blueprint('pages_v3', __name__)
class _SettingsIndexParser(HTMLParser):
"""Extract searchable settings fields from a rendered partial's HTML.
Captures one entry per ``<div class="form-group" id="setting-…">``: the
anchor id, ``data-setting-key``, the field's ``<label>`` text, the
``.help-tip`` tooltip text (``data-tooltip``), and the nearest preceding
``<h3>``/``<h4>`` section heading. Parsing the *rendered* HTML (rather than
the schema) guarantees the anchor ids match the live DOM exactly, so the
search index cannot drift from what users actually see.
"""
def __init__(self, tab, tab_label):
super().__init__(convert_charrefs=True)
self.tab = tab
self.tab_label = tab_label
self.fields = []
self._section = ''
self._field = None
self._depth = 0 # open-div depth within the current field
self._in_label = False
self._label_parts = []
self._in_heading = False
self._heading_parts = []
def handle_starttag(self, tag, attrs):
a = {k: (v or '') for k, v in attrs}
classes = a.get('class', '').split()
# Section headings (only when not already inside a field)
if tag in ('h3', 'h4') and self._field is None:
self._in_heading = True
self._heading_parts = []
if tag == 'div':
fid = a.get('id', '')
if self._field is None and 'form-group' in classes and fid.startswith('setting-'):
self._field = {
'anchorId': fid,
'key': a.get('data-setting-key', '') or fid[len('setting-'):],
'label': '',
'help': '',
'section': self._section,
'tab': self.tab,
'tabLabel': self.tab_label,
}
self._depth = 1
return
if self._field is not None:
self._depth += 1
if self._field is not None:
if tag == 'label' and not self._field['label']:
self._in_label = True
self._label_parts = []
if tag == 'button' and 'help-tip' in classes and not self._field['help']:
self._field['help'] = a.get('data-tooltip', '')
def handle_data(self, data):
if self._in_label:
self._label_parts.append(data)
elif self._in_heading:
self._heading_parts.append(data)
def handle_endtag(self, tag):
if tag in ('h3', 'h4') and self._in_heading:
self._in_heading = False
self._section = ' '.join(''.join(self._heading_parts).split()).strip()
return
if self._field is None:
return
if tag == 'label' and self._in_label:
self._in_label = False
self._field['label'] = ' '.join(''.join(self._label_parts).split()).strip()
elif tag == 'div':
self._depth -= 1
if self._depth <= 0:
if self._field['label']:
self.fields.append(self._field)
self._field = None
self._depth = 0
def _partial_html(loader):
"""Run a partial loader and return its HTML string ('' on error)."""
try:
result = loader()
except Exception:
logger.warning("search-index: partial render failed", exc_info=True)
return ''
if isinstance(result, str):
return result
if isinstance(result, tuple): # loaders return (msg, status) on error
return ''
try:
return result.get_data(as_text=True)
except Exception:
return ''
def _extract_settings_fields(html, tab, tab_label):
parser = _SettingsIndexParser(tab, tab_label)
parser.feed(html)
return parser.fields
# Cache the built index keyed on the installed-plugin set. Core labels/tooltips
# are static template text, so only a change in installed plugins invalidates it.
_SEARCH_INDEX_CACHE = {'sig': None, 'fields': None}
@pages_v3.route('/') @pages_v3.route('/')
def index(): def index():
"""Main v3 interface page""" """Main v3 interface page"""
@@ -111,6 +220,55 @@ def load_plugin_config_partial(plugin_id):
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500 return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
@pages_v3.route('/settings/search-index')
def settings_search_index():
"""Return a flat JSON index of every searchable setting (core + plugin).
Powers the web UI's global settings search. Built by rendering the settings
partials server-side and extracting field metadata, then cached per
installed-plugin set so it is off the display's hot path.
"""
# Core settings tabs: (activeTab value, human label, loader).
core_tabs = [
('general', 'General', _load_general_partial),
('display', 'Display', _load_display_partial),
('schedule', 'Schedule', _load_schedule_partial),
('wifi', 'WiFi', _load_wifi_partial),
]
try:
plugin_ids = []
if pages_v3.plugin_manager:
try:
pages_v3.plugin_manager.discover_plugins()
plugin_ids = sorted(
pi.get('id') for pi in pages_v3.plugin_manager.get_all_plugin_info()
if pi.get('id')
)
except Exception:
logger.warning("search-index: could not enumerate plugins", exc_info=True)
sig = tuple(plugin_ids)
if _SEARCH_INDEX_CACHE['sig'] == sig and _SEARCH_INDEX_CACHE['fields'] is not None:
return jsonify({'fields': _SEARCH_INDEX_CACHE['fields']})
fields = []
for tab, label, loader in core_tabs:
fields.extend(_extract_settings_fields(_partial_html(loader), tab, label))
for pid in plugin_ids:
info = pages_v3.plugin_manager.get_plugin_info(pid) or {}
label = info.get('name', pid)
html = _partial_html(lambda pid=pid: _load_plugin_config_partial(pid))
fields.extend(_extract_settings_fields(html, pid, label))
_SEARCH_INDEX_CACHE['sig'] = sig
_SEARCH_INDEX_CACHE['fields'] = fields
return jsonify({'fields': fields})
except Exception:
logger.error("Error building settings search index", exc_info=True)
return jsonify({'fields': []}), 500
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>') @pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
def serve_plugin_web_ui(plugin_id, filename): def serve_plugin_web_ui(plugin_id, filename):
"""Serve a plugin's web_ui/ HTML fragment as a standalone page. """Serve a plugin's web_ui/ HTML fragment as a standalone page.
+18 -80
View File
@@ -22,24 +22,8 @@
if (window._settingsSearchInit) return; if (window._settingsSearchInit) return;
window._settingsSearchInit = true; window._settingsSearchInit = true;
// Core settings tabs to index. Only tabs that contain real settings fields
// (a .form-group[id^="setting-"]) are listed. Operational/action tabs
// (overview, logs, history, cache, tools, fonts, backup/restore, raw config
// editor, plugin store) have nothing to navigate to and are excluded.
var CORE_TABS = [
{ tab: 'general', label: 'General', url: '/v3/partials/general' },
{ tab: 'display', label: 'Display', url: '/v3/partials/display' },
{ tab: 'schedule', label: 'Schedule', url: '/v3/partials/schedule' },
{ tab: 'wifi', label: 'WiFi', url: '/v3/partials/wifi' }
];
var MAX_RESULTS = 25; var MAX_RESULTS = 25;
// Plugin ids are constrained to this allowlist (mirrors the server's
// _SAFE_PLUGIN_ID_RE in pages_v3.py) before they can appear in a request
// path, so a fetched URL can never be influenced by untrusted input.
var PLUGIN_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
function debounce(fn, ms) { function debounce(fn, ms) {
var t; var t;
return function () { return function () {
@@ -60,75 +44,29 @@
// --- Index building ------------------------------------------------------- // --- Index building -------------------------------------------------------
// Extract one index entry per settings field from a parsed document.
function scanDoc(doc, tab, tabLabel) {
var entries = [];
var nodes = doc.querySelectorAll('h2, h3, h4, .form-group[id^="setting-"]');
var section = '';
nodes.forEach(function (node) {
var tag = node.tagName.toLowerCase();
if (tag === 'h3' || tag === 'h4') {
section = textOf(node);
return;
}
if (tag === 'h2') { section = ''; return; }
// A settings .form-group
var labelEl = node.querySelector('label');
var label = textOf(labelEl) || node.id.replace(/^setting-/, '');
var tipEl = node.querySelector('.help-tip');
var help = tipEl ? (tipEl.getAttribute('data-tooltip') || '') : '';
var key = node.getAttribute('data-setting-key') || node.id.replace(/^setting-/, '');
entries.push({
tab: tab,
tabLabel: tabLabel,
section: section,
key: key,
label: label,
help: help,
anchorId: node.id,
hay: [label, help, key, tabLabel, section].join(' ').toLowerCase()
});
});
return entries;
}
function fetchAndScan(url, tab, tabLabel) {
// `url` is always one of our own same-origin settings partial paths
// (CORE_TABS literals or a plugin path built from an allowlisted id).
return fetch(url, { headers: { 'X-Requested-With': 'settings-search' } })
.then(function (r) { return r.ok ? r.text() : ''; })
.then(function (html) {
if (!html) return [];
// Parsed into an inert document: scripts do not run and it is
// never inserted into the live DOM — we only read labels and
// tooltip text from it to build the search index.
var doc = new DOMParser().parseFromString(html, 'text/html');
return scanDoc(doc, tab, tabLabel);
})
.catch(function () { return []; });
}
var buildPromise = null; var buildPromise = null;
// Fetch the prebuilt index from the server (one literal-URL JSON request)
// and cache it for the session. Each entry gets a lowercased `hay` haystack
// for matching. The server owns which tabs/plugins are included.
function buildIndex(force) { function buildIndex(force) {
if (window._settingsIndex && !force) return Promise.resolve(window._settingsIndex); if (window._settingsIndex && !force) return Promise.resolve(window._settingsIndex);
if (buildPromise && !force) return buildPromise; if (buildPromise && !force) return buildPromise;
var jobs = CORE_TABS.map(function (t) { return fetchAndScan(t.url, t.tab, t.label); }); buildPromise = fetch('/v3/settings/search-index', { headers: { 'X-Requested-With': 'settings-search' } })
.then(function (r) { return r.ok ? r.json() : { fields: [] }; })
var plugins = (window.installedPlugins || []); .then(function (data) {
plugins.forEach(function (p) { var fields = (data && data.fields) || [];
// Skip ids that don't match the strict allowlist so the request fields.forEach(function (f) {
// path is always built from safe, validated components. f.hay = [f.label, f.help, f.key, f.tabLabel, f.section].join(' ').toLowerCase();
if (!p || !p.id || !PLUGIN_ID_RE.test(p.id)) return; });
jobs.push(fetchAndScan('/v3/partials/plugin-config/' + p.id, p.id, p.name || p.id)); window._settingsIndex = fields;
}); return fields;
})
buildPromise = Promise.all(jobs).then(function (lists) { .catch(function () {
var index = []; window._settingsIndex = [];
lists.forEach(function (l) { index = index.concat(l); }); return window._settingsIndex;
window._settingsIndex = index; });
return index;
});
return buildPromise; return buildPromise;
} }