main
John Lauer v0.1.9: full source tree (handlers/agent/extension/tools) + #2 ADDevelop detection + #3 busy-notice reaper 343fc40 1mo ago
"""rcs_catalog.py — parse Altium's *.rcs process-launcher tables into a command catalog.

Every menu/toolbar action in Altium is a "process launcher" line like

  PL PLPCB:Undo   Command='PCB:Undo'  Caption='&Undo'  Image='UNDO.PNG'  Shortcut1='Ctrl+Z'
     Shortcut2='Alt+Backspace'  Params=''  Description='Undo'  End

The Command is what RunProcess / RunProcessWithParameters takes; Params is the
Name=Value|Name=Value string. AD 26.9.1 ships 47 .rcs files -> 3,205 launchers ->
1,109 distinct commands (PCB 211, Sch 190, WorkspaceManager 87, Client 50 …).
"""
from __future__ import annotations

import json
import re
import time
from pathlib import Path

_LINE = re.compile(r"^\s*PL\w*\s+(?P<id>\S+)\s+(?P<rest>.*)End\s*$")
_ATTR = re.compile(r"(\w+)='((?:[^']|'')*)'")

_cache: dict = {}


def parse_dir(system_dir: str) -> dict:
    key = system_dir.lower()
    c = _cache.get(key)
    if c and time.time() - c["at"] < 3600:
        return c["data"]
    commands: dict[str, dict] = {}
    files = 0
    launchers = 0
    for rcs in sorted(Path(system_dir).glob("*.rcs")) + sorted(Path(system_dir).glob("*.RCS")):
        files += 1
        try:
            text = rcs.read_text(encoding="latin-1", errors="replace")
        except Exception:
            continue
        for line in text.splitlines():
            if line.lstrip().startswith("//"):
                continue
            m = _LINE.match(line)
            if not m:
                continue
            launchers += 1
            attrs = {k: v.replace("''", "'") for k, v in _ATTR.findall(m.group("rest"))}
            cmd = attrs.get("Command")
            if not cmd:
                continue
            e = commands.setdefault(cmd, {"command": cmd, "server": cmd.split(":", 1)[0],
                                          "captions": [], "shortcuts": [], "params": [], "descriptions": [],
                                          "sources": []})
            for k, lst in (("Caption", "captions"), ("Description", "descriptions"), ("Params", "params")):
                v = attrs.get(k, "")
                v = v.replace("&", "") if k == "Caption" else v
                if v and v not in e[lst]:
                    e[lst].append(v)
            for k in ("Shortcut1", "Shortcut2"):
                v = attrs.get(k, "")
                if v and v not in e["shortcuts"]:
                    e["shortcuts"].append(v)
            if rcs.name not in e["sources"]:
                e["sources"].append(rcs.name)
    servers: dict[str, int] = {}
    for c in commands.values():
        servers[c["server"]] = servers.get(c["server"], 0) + 1
    data = {"systemDir": system_dir, "files": files, "launchers": launchers,
            "distinctCommands": len(commands), "servers": dict(sorted(servers.items(), key=lambda kv: -kv[1])),
            "commands": commands}
    _cache[key] = {"at": time.time(), "data": data}
    return data


def search(system_dir: str, query: str = "", server: str | None = None, limit: int = 50) -> dict:
    data = parse_dir(system_dir)
    q = (query or "").lower()
    hits = []
    for cmd, e in data["commands"].items():
        if server and e["server"].lower() != server.lower():
            continue
        hay = " ".join([cmd] + e["captions"] + e["descriptions"] + e["params"]).lower()
        if q and q not in hay:
            continue
        hits.append({k: e[k] for k in ("command", "server", "captions", "shortcuts", "params", "descriptions")})
        if len(hits) >= limit:
            break
    return {"query": query, "server": server, "count": len(hits), "total": data["distinctCommands"],
            "servers": data["servers"], "hits": hits}