from __future__ import annotations import json import re import sys from collections import defaultdict from functools import cache from pathlib import Path @cache def read_lines(path: Path) -> tuple[str, ...]: return tuple(path.read_text().splitlines()) ROW_RE = re.compile( r"^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{" ) LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)') ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"') CHECKED_RE = re.compile(r"^\s*checked:\s*(?:Config)\.([\w.]+)\s*$") ONTOGGLED_RE = re.compile( r"^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$" ) ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"') SKIP_LABELS = {"Muted", "None"} FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4} STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"} def find_pages_dir(settings: Path) -> Path: return settings / "Pages" def discover_files(settings: Path) -> dict[str, Path]: files: dict[str, Path] = {} for p in find_pages_dir(settings).rglob("*.qml"): files[p.stem] = p return files PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)') PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"') def parse_page_registry(settings: Path) -> list[tuple[str, str]]: text = (settings / "PageRegistry.qml").read_text().splitlines() start = next( i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line) ) out: list[tuple[str, str]] = [] i = start + 1 while i < len(text): line = text[i].strip() if line.startswith("]"): break if line.startswith("//") or not line: i += 1 continue if line.startswith("{"): name = None icon = None i += 1 while i < len(text): s = text[i].strip() if s.startswith("}"): if name is not None: out.append((icon or "tune", name)) break if name is None: m = PAGE_NAME_RE.match(text[i]) if m: name = m.group(1) if icon is None: mi = PAGE_ICON_RE.match(text[i]) if mi: icon = mi.group(1) i += 1 i += 1 return out BLOCK_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$") def _strip_comment(line: str) -> str: return line.split("//", 1)[0].rstrip() def parse_block( lines: list[str], i: int ) -> tuple[str, list[tuple[str, list]], int]: line = _strip_comment(lines[i]).strip() m = BLOCK_RE.match(line) if not m: raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}") name = m.group(1) i += 1 children: list[tuple[str, list]] = [] while i < len(lines): s = _strip_comment(lines[i]).strip() if not s: i += 1 continue if s.startswith("}"): return name, children, i + 1 if BLOCK_RE.match(s): child_name, child_children, i = parse_block(lines, i) children.append((child_name, child_children)) continue i += 1 raise ValueError(f"Unterminated block: {name}") def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: name, children = block if name != "Component": return [name] for child_name, child_children in children: if child_name == "StackPage": out: list[str] = [] for grand_name, grand_children in child_children: if grand_name == "Component": out.extend(collect_page_names((grand_name, grand_children))) return out if child_name != "Component": return [child_name] return [] def parse_page_comps(settings: Path) -> list[list[str]]: text = (settings / "PageCompRegistry.qml").read_text().splitlines() start = next( i for i, line in enumerate(text) if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line)) ) comps: list[list[str]] = [] i = start + 1 while i < len(text): s = _strip_comment(text[i]).strip() if not s: i += 1 continue if s.startswith("]"): break if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component": block = parse_block(text, i) names = collect_page_names((block[0], block[1])) if names: comps.append(names) i = block[2] continue i += 1 return comps def dedup_crumbs( labels: list[str], icons: list[str] ) -> tuple[list[str], list[str]]: out_labels: list[str] = [] out_icons: list[str] = [] for lbl, ico in zip(labels, icons, strict=False): if out_labels and out_labels[-1] == lbl: continue out_labels.append(lbl) out_icons.append(ico) return out_labels, out_icons def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: comps = parse_page_comps(settings) registry = parse_page_registry(settings) top_meta: dict[int, tuple[str, str]] = {} for i, (icon, label) in enumerate(registry): top_meta[i] = (icon, label) nav_children: dict[str, dict[int, tuple[str, str, str]]] = {} for names in comps: for name in names: pf = files.get(name) if not pf: continue pending_icon = pending_label = None section = "" expect_section = False for ln in read_lines(pf): if SECTION_RE.match(ln): expect_section = True continue ml = LABEL_RE.match(ln) if ml: if expect_section: section = ml.group(1) expect_section = False else: pending_label = ml.group(1) continue mi = ICON_RE.match(ln) if mi: pending_icon = mi.group(1) mo = re.search(r"openSubPage\((\d+)\)", ln) if mo: pos = int(mo.group(1)) nav_children.setdefault(name, {})[pos] = ( pending_icon or "tune", pending_label or "", section, ) pending_icon = pending_label = None nav: dict[str, dict] = {} for top_idx, names in enumerate(comps): if not names: continue main = names[0] main_icon, main_label = top_meta.get(top_idx, ("tune", main)) nav[main] = { "pageIdx": top_idx, "subPath": [], "crumbIcons": [main_icon], "crumbLabels": [main_label], } children = dict(nav_children.get(main, {})) opened_via_subpage = set() for owner, kids in nav_children.items(): owner_group = next((ns for ns in comps if owner in ns), None) if not owner_group: continue for kpos in kids: if kpos < len(owner_group): opened_via_subpage.add(owner_group[kpos]) for pos in range(1, len(names)): if pos not in children and names[pos] not in opened_via_subpage: label = re.sub(r"(Detail)?Page$", "", names[pos]) label = re.sub(r"(?= len(names): continue child = names[pos] labels = [main_label] + ([section] if section else []) + [label] icons = [main_icon] + ([icon] if section else []) + [icon] labels, icons = dedup_crumbs(labels, icons) nav[child] = { "pageIdx": top_idx, "subPath": [pos], "crumbIcons": icons, "crumbLabels": labels, } for gpos, (gicon, glabel, gsection) in nav_children.get( child, {} ).items(): if gpos >= len(names): continue glabels = labels + ([gsection] if gsection else []) + [glabel] gicons = icons + ([gicon] if gsection else []) + [gicon] glabels, gicons = dedup_crumbs(glabels, gicons) nav[names[gpos]] = { "pageIdx": top_idx, "subPath": [pos, gpos], "crumbIcons": gicons, "crumbLabels": glabels, } return nav def tokenize(text: str) -> list[str]: toks: list[str] = [] for word in text.lower().split(): parts = [p for p in re.split(r"[^a-z0-9]+", word) if p] for p in parts: if p not in STOPWORDS and p not in toks: toks.append(p) if len(parts) > 1: joined = "".join(parts) if joined not in toks: toks.append(joined) return toks SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)') SECTION_RE = re.compile(r"^\s*SectionHeader\s*\{") def extract_settings( files: dict[str, Path], nav: dict[str, dict] ) -> list[dict]: entries: list[dict] = [] for comp, meta in nav.items(): pf = files.get(comp) if not pf: continue lines = read_lines(pf) section = "" i = 0 while i < len(lines): if SECTION_RE.match(lines[i]): for j in range(i + 1, min(i + 4, len(lines))): m = LABEL_RE.match(lines[j]) if m: section = m.group(1) break row_match = ROW_RE.match(lines[i]) if row_match: row_type = row_match.group(1) label = anchor = subtext = None checked_path = toggled_path = None for j in range(i + 1, min(i + 12, len(lines))): if label is None: m = LABEL_RE.match(lines[j]) if m: label = m.group(1) if anchor is None: a = ANCHOR_RE.match(lines[j]) if a: anchor = a.group(1) if subtext is None: st = SUBTEXT_RE.match(lines[j]) if st: subtext = st.group(1) if checked_path is None: ch = CHECKED_RE.match(lines[j]) if ch: checked_path = ch.group(1) if toggled_path is None: tg = ONTOGGLED_RE.match(lines[j]) if tg: toggled_path = tg.group(1) toggle_path = ( checked_path if row_type == "ToggleRow" and checked_path and checked_path == toggled_path else "" ) if label and label not in SKIP_LABELS and anchor: extra = ( " ".join(meta["crumbLabels"]) + " " + section + " " + (subtext or "") ) entries.append( { "pageIdx": meta["pageIdx"], "subPath": meta["subPath"], "crumbIcons": meta["crumbIcons"], "crumbLabels": meta["crumbLabels"], "title": label, "anchor": anchor, "section": section, "subtext": subtext or "", "togglePath": toggle_path, "keywords": " ".join( sorted(set(tokenize(label + " " + extra))) ), } ) i += 1 return entries def build_inverted_and_ranking(entries: list[dict]): inverted: dict[str, list[int]] = defaultdict(list) ranking: dict[str, dict[int, float]] = defaultdict(dict) for idx, e in enumerate(entries): fields = {"title": e["title"], "keywords": e["keywords"]} seen: set[str] = set() for field, text in fields.items(): weight = FIELD_WEIGHT.get(field, 0.2) for tok in tokenize(text): if idx not in inverted[tok]: inverted[tok].append(idx) ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight) seen.add(tok) for tok, ids in inverted.items(): ids.sort(key=lambda i: ranking[tok][i], reverse=True) return inverted, { t: {str(k): v for k, v in d.items()} for t, d in ranking.items() } def main() -> int: if len(sys.argv) != 3: print(__doc__) return 1 settings = Path(sys.argv[1]) out = Path(sys.argv[2]) files = discover_files(settings) nav = build_nav_map(settings, files) entries = extract_settings(files, nav) inverted, ranking = build_inverted_and_ranking(entries) for e in entries: e.pop("keywords", None) out.write_text( json.dumps( { "version": 2, "entries": entries, "inverted": inverted, "ranking": ranking, }, ensure_ascii=False, indent=2, ) ) print( f"settings index: {len(entries)} entries, " f"{len(inverted)} tokens -> {out}" ) print("files:", len(files)) print("comps:", len(parse_page_comps(settings))) print("registry:", len(parse_page_registry(settings))) print("nav:", len(nav)) print("entries:", len(entries)) return 0 if __name__ == "__main__": sys.exit(main())