408 lines
14 KiB
Python
408 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
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):
|
|
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"(?<!^)(?=[A-Z])", " ", label)
|
|
children[pos] = (main_icon, label, "")
|
|
for pos, (icon, label, section) in children.items():
|
|
if pos >= 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())
|