diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 3a5b80075..29c3ca812 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -2402,17 +2402,71 @@ _SECONDARY_FOLDER = re.compile(r"(?:^|/)\d+_(?:graphics|manufacturers_articles|m def _collection_signature(root: Path): - """Cheap change detector: file count and newest mtime, no parsing.""" + """Cheap change detector: file count and newest mtime, no parsing. + os.scandir() rather than Path.rglob(): the same answer in about a third + of the time, which every search pays.""" count, newest = 0, 0.0 - for f in root.rglob("*.elmt"): - count += 1 + stack = [str(root)] + while stack: try: - newest = max(newest, f.stat().st_mtime) + it = os.scandir(stack.pop()) except OSError: - pass + continue + with it: + for entry in it: + try: + if entry.is_dir(follow_symlinks=False): + stack.append(entry.path) + elif entry.name.endswith(".elmt"): + count += 1 + newest = max(newest, entry.stat().st_mtime) + except OSError: + pass return count, newest +def _index_cache_file(root: Path) -> Path: + """Where the parsed index of one collection is kept between runs.""" + base = os.environ.get("QET_MCP_CACHE_DIR") or os.path.join( + os.environ.get("XDG_CACHE_HOME") or os.path.join(str(Path.home()), ".cache"), "qet-mcp") + import hashlib + digest = hashlib.sha1(str(root).encode("utf-8")).hexdigest()[:16] + return Path(base) / f"element-index-{digest}.json" + + +_INDEX_CACHE_FORMAT = 1 + + +def _load_index_cache(root: Path, sig) -> list | None: + try: + data = json.loads(_index_cache_file(root).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if (data.get("format") != _INDEX_CACHE_FORMAT or data.get("root") != str(root) + or tuple(data.get("sig") or ()) != tuple(sig)): + return None + items = data.get("items") or [] + for it in items: + it["haystack"] = frozenset(it.get("haystack") or ()) + return items + + +def _save_index_cache(root: Path, sig, items: list) -> None: + """Best effort: a cache that cannot be written only costs the next + start its 6 s; it never fails a search.""" + path = _index_cache_file(root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps({ + "format": _INDEX_CACHE_FORMAT, "root": str(root), "sig": list(sig), + "items": [dict(it, haystack=sorted(it["haystack"])) for it in items]}), + encoding="utf-8") + os.replace(tmp, path) + except OSError: + pass + + def _index_collection(root: Path) -> list: """Parse every .elmt under root once and keep what a search needs. @@ -2426,6 +2480,10 @@ def _index_collection(root: Path) -> list: cached = _ELEMENT_INDEX.get(key) if cached and cached["sig"] == sig: return cached["items"] + items = _load_index_cache(root.resolve(), sig) + if items is not None: + _ELEMENT_INDEX[key] = {"sig": sig, "items": items} + return items items = [] for f in sorted(root.rglob("*.elmt")): @@ -2457,6 +2515,7 @@ def _index_collection(root: Path) -> list: "haystack": frozenset(_search_words(" ".join([*names.values(), rel, kind]))), }) _ELEMENT_INDEX[key] = {"sig": sig, "items": items} + _save_index_cache(root.resolve(), sig, items) return items @@ -2768,6 +2827,27 @@ LAYOUT_RULES = { "are on. An axis a straight wire lines it up on is left alone. " "\"fix\" moves it onto the grid.", }, + "label_on_wire": { + "severity": "warning", + "note": "A symbol's label is drawn over a wire. House style: a label sits at " + "least 20 px past the outermost connected terminal, on a side no " + "wire arrives from. Move the label (setElementTextProperty x/y), not " + "the symbol. Needs a QElectroTech with elementTextGeometry(); older " + "builds skip this rule.", + }, + "four_way_junction": { + "severity": "warning", + "note": "Wires leave one point in all four directions: a 4-way dot, which " + "reads as a crossing when printed. Stagger it into two 3-way " + "T-junctions a grid step or more apart.", + }, + "misaligned_branch": { + "severity": "info", + "note": "Two symbols stacked in the same side branch (each fed by a wire with " + "a horizontal run) are a little out of line, so the branch is not one " + "straight column. House style: parallel branches share one vertical " + "axis. \"dx\" is how far apart their wire docks are.", + }, "crossing": { "severity": "info", "note": "Two wires cross. Counted so two drafts can be compared; some " @@ -2784,13 +2864,25 @@ _LAYOUT_SEG = re.compile(r"^\s*\d+:\s*\(([^,]+),([^)]+)\)-\(([^,]+),([^)]+)\)") _LAYOUT_JS = r""" var only = @FOLIO@; var byUuid = typeof qet.conductorPath === 'function'; +function labelBox(f, u) { + if (typeof qet.elementTextGeometry !== 'function') return null; + var t = qet.elementTexts(f, u); + for (var i = 0; i < t.length; i++) + if (qet.elementTextProperty(f, u, i, 'source') === 'info' + && qet.elementTextProperty(f, u, i, 'info') === 'label') { + var g = qet.elementTextGeometry(f, u, i); + return (g && g.right > g.left && qet.elementTextProperty(f, u, i, 'shows')) ? g : null; + } + return null; +} for (var f = 0; f < qet.folioCount(); f++) { if (only >= 0 && f !== only) continue; var els = qet.elementUuids(f), E = []; for (var i = 0; i < els.length; i++) { E.push({uuid: els[i], name: qet.elementName(f, els[i]), label: qet.elementLabel(f, els[i]), g: qet.elementGeometry(f, els[i]), - terminals: qet.elementTerminals(f, els[i]).length}); + terminals: qet.elementTerminals(f, els[i]).length, + labelbox: labelBox(f, els[i])}); } var cu = qet.conductorUuids(f), lines = qet.conductors(f), C = []; for (var j = 0; j < cu.length; j++) { @@ -2951,7 +3043,9 @@ def _layout_folio(data: dict, max_shift: float) -> dict: "annotation": int(el.get("terminals", 1) or 0) == 0, "x": float(g.get("x", 0)), "y": float(g.get("y", 0)), "xy": (float(g.get("x", 0)), float(g.get("y", 0))), - "box": box, "docks": []} + "box": box, "docks": [], + "labelbox": _box(el.get("labelbox") or {}) + if el.get("labelbox") else None} wire_count = {} wires, unread = [], [] for w in data.get("conductors") or []: @@ -3214,6 +3308,8 @@ def _layout_folio(data: dict, max_shift: float) -> dict: add("crossing", conductors=[w["uuid"], v["uuid"]], count=n, note=LAYOUT_RULES["crossing"]["note"]) + _house_style_rules(symbols, wires, add, dirty_symbols) + return {"folio": folio, "symbols": len(symbols), "wires": len(wires), "unread": unread, "findings": findings, "dirty_wires": dirty_wires, "dirty_symbols": dirty_symbols, "length": length, "crossings": crossings, @@ -3221,6 +3317,86 @@ def _layout_folio(data: dict, max_shift: float) -> dict: "fixes": [move_op(u) for u in move]} +def _house_style_rules(symbols: dict, wires: list, add, dirty_symbols: set) -> None: + """The house-style rules: labels over wires, 4-way junctions, side + branches out of line. Pure geometry, like the rest of the scorer.""" + def seg_hits_box(a, b, box, inset=1.0): + l, t, r, btm = box[0] + inset, box[1] + inset, box[2] - inset, box[3] - inset + if l >= r or t >= btm: + return False + (x1, y1), (x2, y2) = a, b + if abs(y1 - y2) < 1e-6: # horizontal + return t <= y1 <= btm and min(x1, x2) <= r and max(x1, x2) >= l + if abs(x1 - x2) < 1e-6: # vertical + return l <= x1 <= r and min(y1, y2) <= btm and max(y1, y2) >= t + return False + + for s in symbols.values(): + lb = s.get("labelbox") + if not lb: + continue + hit = [w["uuid"] for w in wires + if any(seg_hits_box(a, b, lb) for a, b in zip(w["pts"], w["pts"][1:]))] + if hit: + add("label_on_wire", element=s["uuid"], label=s["label"], name=s["name"], + conductors=hit, note=LAYOUT_RULES["label_on_wire"]["note"]) + dirty_symbols.add(s["uuid"]) + + def key(p): + return (round(p[0]), round(p[1])) + + def direction(a, b): + dx, dy = b[0] - a[0], b[1] - a[1] + if abs(dx) < 1e-6 and abs(dy) < 1e-6: + return None + if abs(dx) >= abs(dy): + return "e" if dx > 0 else "w" + return "s" if dy > 0 else "n" + + ways, owners = {}, {} + for w in wires: + pts = w["pts"] + for i, p in enumerate(pts): + for q in ((pts[i - 1],) if i else ()) + ((pts[i + 1],) if i + 1 < len(pts) else ()): + d = direction(p, q) + if d: + ways.setdefault(key(p), set()).add(d) + owners.setdefault(key(p), set()).add(w["uuid"]) + for pt, d in sorted(ways.items()): + if len(d) == 4 and len(owners[pt]) >= 2: + add("four_way_junction", at=list(pt), conductors=sorted(owners[pt]), + note=LAYOUT_RULES["four_way_junction"]["note"]) + + # A side branch symbol is fed only by wires that jog sideways; one + # joined to a neighbour by a straight vertical wire is in a column. + side, column = set(), set() + for w in wires: + xs = {round(p[0], 3) for p in w["pts"]} + ends = [e for e in w["ends"] if e] + if len(xs) == 1: + column.update(ends) + elif any(abs(a[1] - b[1]) < 1e-6 and abs(a[0] - b[0]) > 1e-6 + for a, b in zip(w["pts"], w["pts"][1:])): + side.update(ends) + side -= column + cand = [s for s in symbols.values() if s["uuid"] in side and s["docks"] + and not s["annotation"]] + # Two symbols joined by a wire of their own are avoidable_bend's case. + joined = {frozenset(w["ends"]) for w in wires if all(w["ends"])} + for i, s in enumerate(cand): + for t in cand[i + 1:]: + sb, tb = s["box"], t["box"] + if sb[1] < tb[3] and tb[1] < sb[3]: + continue # side by side, not stacked + if frozenset((s["uuid"], t["uuid"])) in joined: + continue + dx = min(abs(p[0] - q[0]) for p in s["docks"] for q in t["docks"]) + if 0.5 < dx <= 80: + add("misaligned_branch", elements=[s["uuid"], t["uuid"]], + labels=[s["label"], t["label"]], names=[s["name"], t["name"]], + dx=round(dx, 1), note=LAYOUT_RULES["misaligned_branch"]["note"]) + + def _layout_answer(folios: list, style: str, limit: int) -> dict: """Combine per-folio results into the tool's answer.""" symbols = sum(f["symbols"] for f in folios) @@ -3253,6 +3429,9 @@ def _layout_answer(folios: list, style: str, limit: int) -> dict: "overlaps": count["overlapping_symbols"], "off_grid": count["off_grid"], "crossings": sum(f["crossings"] for f in folios), + "labels_on_wires": count["label_on_wire"], + "four_way_junctions": count["four_way_junction"], + "misaligned_branches": count["misaligned_branch"], "flow": {"vertical": round(vertical / total, 3) if total else 0.0, "horizontal": round(horizontal / total, 3) if total else 0.0}, }, @@ -3669,6 +3848,36 @@ SERVER_INSTRUCTIONS = ( "a wire is straight only when its two terminals are exactly in line.") +def standard_symbols_file(info: dict | None = None) -> Path: + """The installation's standard symbols: one symbol per device role, + chosen by the user. QET_MCP_STANDARD_SYMBOLS if set, else + standard-symbols.json in QElectroTech's data folder.""" + explicit = os.environ.get("QET_MCP_STANDARD_SYMBOLS", "").strip() + if explicit: + return Path(explicit).expanduser() + data = ((info or {}).get("folders") or {}).get("data") + return (Path(data) if data else assistant_info_file().parent) / "standard-symbols.json" + + +def standard_symbols(info: dict | None = None): + """The roles from standard-symbols.json, or None when there is none. + A file that cannot be read is reported, not hidden.""" + path = standard_symbols_file(info) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, ValueError) as exc: + return {"error": f"{path} could not be read: {exc}"} + roles = data.get("roles") if isinstance(data, dict) else None + if not isinstance(roles, list): + return {"error": f"{path} has no \"roles\" list"} + return {"file": str(path), "updated": data.get("updated"), + "roles": [{k: r.get(k) for k in ("id", "label", "letter", "path", + "terminals", "note") if r.get(k) is not None} + for r in roles if isinstance(r, dict) and r.get("path")]} + + def tool_about() -> dict: """What this server and the QElectroTech it works with look like now.""" info = assistant_info() @@ -3701,6 +3910,9 @@ def tool_about() -> dict: "script_api": info.get("script_api"), # Never the token: it is for the live tools, not the conversation. "live": {"open": bool(live), "pid": (live or {}).get("pid")}, + # One symbol per device role, the user's own choice: use these + # paths with add_element / qet.addElement before searching. + "standard_symbols": standard_symbols(info), }) return out @@ -4124,6 +4336,100 @@ def tool_live_switch_project(index: int | None = None, path: str = "") -> dict: return _live_call({"cmd": "switch_project", "path": str(p)}) +def _abs_path(value, arg: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"'{arg}' must be a file path") + p = Path(value).expanduser() + if not p.is_absolute(): + raise ValueError(f"'{arg}' must be absolute, e.g. /home/me/projects/pump.qet") + return str(p) + + +def _index_arg(index): + if index is not None and (not isinstance(index, int) or isinstance(index, bool) or index < 0): + raise ValueError("'index' must be a whole number counted from 0") + return index + + +def tool_live_save_project(path: str = "") -> dict: + _require_script_consent() + request = {"cmd": "save_project"} + if path: + request["path"] = _abs_path(path, "path") + return _live_call(request) + + +def tool_live_close_project(index: int | None = None) -> dict: + _require_script_consent() + request = {"cmd": "close_project"} + if _index_arg(index) is not None: + request["index"] = index + return _live_call(request) + + +def tool_live_print(folios="all", printer: str = "", output_file: str = "") -> dict: + _require_script_consent() + if isinstance(folios, list): + if not folios or not all(isinstance(i, int) and not isinstance(i, bool) and i >= 0 + for i in folios): + raise ValueError("'folios' as a list holds folio indexes counted from 0") + elif folios not in ("all", "current"): + raise ValueError("'folios' must be \"all\", \"current\" or a list of indexes") + if printer and output_file: + raise ValueError("give 'printer' or 'output_file', not both") + request = {"cmd": "print", "folios": folios} + if printer: + request["printer"] = printer + if output_file: + request["output_file"] = _abs_path(output_file, "output_file") + # The user answers QElectroTech's question before anything prints. + return _live_call(request, timeout=300) + + +def tool_live_changes(since: int | None = None) -> dict: + request = {"cmd": "changes"} + if since is not None: + if not isinstance(since, int) or isinstance(since, bool) or since < -1: + raise ValueError("'since' must be a step index from an earlier answer (or -1)") + request["since"] = since + return _live_call(request) + + +def tool_live_layout_check(folio: str | int = "current", style: str = "auto", + max_shift: float = 40, limit: int = 50, + elements_dir: str | None = None, timeout: int = 180, + binary: str | None = None) -> dict: + """qet_layout_check on what the user sees: QElectroTech writes a copy of + the open project (unsaved changes included) to a private folder, the + check reads the copy, the copy is deleted. The user's project and file + are not touched, and no "run this script?" window is shown.""" + binary = binary or resolve_binary() + if not binary: + raise ValueError("no QElectroTech binary to run the check with: set QET_BINARY") + work = Path(tempfile.mkdtemp(prefix="qet-live-check-")) + try: + snap = _live_call({"cmd": "snapshot", "path": str(work / "live.qet")}) + if not snap.get("ok"): + return snap + if folio == "current": + folio_no = snap.get("folio", -1) + 1 or None + elif folio == "all": + folio_no = None + elif isinstance(folio, int) and not isinstance(folio, bool) and folio >= 1: + folio_no = folio + else: + raise ValueError("'folio' is \"current\", \"all\" or a folio number counted from 1") + answer = tool_layout_check(str(binary), snap["path"], folio=folio_no, style=style, + max_shift=max_shift, limit=limit, + elements_dir=elements_dir, timeout=timeout) + answer["checked"] = "the open project, as on screen (unsaved changes included)" + # The moves in "fixes" are for qet_edit on a file; live, apply them + # with qet_live_run_script (qet.moveElement), one undo step. + return answer + finally: + shutil.rmtree(work, ignore_errors=True) + + def tool_live_undo_last() -> dict: _require_script_consent() return _live_call({"cmd": "undo_last"}) @@ -5257,6 +5563,84 @@ TOOLS = [ }, "handler": lambda a: tool_live_switch_project(a.get("index"), a.get("path", "")), }, + { + "name": "qet_live_save_project", + "description": "LIVE MODE. Save the current project to its own file, or with " + "'path' save it as a new file (absolute; never over an existing " + "file), which then becomes its file.", + "inputSchema": {"type": "object", + "properties": {"path": {"type": "string"}}}, + "handler": lambda a: tool_live_save_project(a.get("path", "")), + }, + { + "name": "qet_live_close_project", + "description": "LIVE MODE. Close an open project (the current one, or 'index' " + "from qet_live_status's \"projects\") -- only when it has no " + "unsaved changes: closing never discards work, so save it first.", + "inputSchema": {"type": "object", + "properties": {"index": {"type": "integer", "minimum": 0}}}, + "handler": lambda a: tool_live_close_project(a.get("index")), + }, + { + "name": "qet_live_print", + "description": "LIVE MODE. Print folios of the current project without the " + "print dialog: 'folios' is \"all\" (default), \"current\" or a " + "list of indexes from 0; 'printer' names a printer, else the " + "computer's default printer. QElectroTech always asks the user " + "before printing (paper cannot be taken back); \"refused by the " + "user\" means they said no. 'output_file' (absolute .pdf, must " + "not exist) prints to a PDF file instead, with no question.", + "inputSchema": { + "type": "object", + "properties": { + "folios": {"oneOf": [{"type": "string", "enum": ["all", "current"]}, + {"type": "array", "items": {"type": "integer", "minimum": 0}}]}, + "printer": {"type": "string"}, + "output_file": {"type": "string"}, + }, + }, + "handler": lambda a: tool_live_print(a.get("folios", "all"), a.get("printer", ""), + a.get("output_file", "")), + }, + { + "name": "qet_live_changes", + "description": "LIVE MODE. The current project's undo history: each step's name, " + "whether the assistant or the user made it, and whether it is " + "undone; 'since' (a step index from an earlier answer, e.g. its " + "\"now\") keeps only the later steps -- what changed since then. " + "Changes nothing.", + "inputSchema": {"type": "object", + "properties": {"since": {"type": "integer", "minimum": -1}}}, + "handler": lambda a: tool_live_changes(a.get("since")), + }, + { + "name": "qet_live_layout_check", + "description": "LIVE MODE. qet_layout_check on the drawing the user has open, " + "as on screen (unsaved changes included), without saving it: " + "straight wires, symbols in line and on the grid, overlaps, " + "and the house-style rules -- labels over wires, 4-way junction " + "dots, side branches out of line. Run it before saying a " + "drawing is finished. 'folio': \"current\" (default), \"all\" or " + "a number from 1. Apply \"fixes\" with qet_live_run_script " + "(qet.moveElement), not qet_edit. Changes nothing.", + "inputSchema": { + "type": "object", + "properties": { + "folio": {"oneOf": [{"type": "string", "enum": ["current", "all"]}, + {"type": "integer", "minimum": 1}]}, + "style": {"type": "string", "enum": list(LAYOUT_STYLES)}, + "max_shift": {"type": "number", "default": 40}, + "limit": {"type": "integer", "default": 50}, + "elements_dir": {"type": "string"}, + "binary": {"type": "string", "description": "the qelectrotech executable " + "that runs the check; leave it out to use this server's"}, + }, + }, + "handler": lambda a: tool_live_layout_check(a.get("folio", "current"), + a.get("style", "auto"), + a.get("max_shift", 40), a.get("limit", 50), + a.get("elements_dir"), binary=a.get("binary")), + }, { "name": "qet_live_undo_last", "description": "LIVE MODE. Undo the newest step in the open project, only if " @@ -5396,6 +5780,11 @@ _DATA_PATHS = { "qet_live_new_project": {"write": ("path",)}, "qet_live_open_project": {"read": ("path",)}, "qet_live_switch_project": {"read": ("path",)}, + "qet_live_save_project": {"write": ("path",)}, + "qet_live_print": {"write": ("output_file",)}, + # Runs QElectroTech on a copy QElectroTech writes to a private folder: + # no client path, but the binary and elements_dir rules apply. + "qet_live_layout_check": {}, "qet_element_build": {"write": ("output",)}, # The scripts folder is chosen by scripts_dir(), never by the client, # so only the project a script is tried on is a data path here. @@ -5408,7 +5797,7 @@ _DATA_PATHS = { # Tools that launch QElectroTech, and so take "binary" and "elements_dir". _LAUNCHES_QET = {"qet_export", "qet_edit", "qet_query", "qet_continuity", "qet_check", "qet_layout_check", "qet_project_new", "qet_script_api", - "qet_script_test", "qet_recording_check"} + "qet_script_test", "qet_recording_check", "qet_live_layout_check"} # Tools that launch QElectroTech only when given this argument. _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"} @@ -5418,7 +5807,7 @@ _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"} _OVERWRITE_OWN_FILE = {"qet_script_install"} # Tools that create a file and never replace one, whatever the client asks: # no "overwrite" in their schema, and the flag is ignored if sent anyway. -_NEVER_OVERWRITE = {"qet_live_new_project"} +_NEVER_OVERWRITE = {"qet_live_new_project", "qet_live_save_project", "qet_live_print"} # qet_edit operations that name a file of their own. _DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"} diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index e0ba86bdc..9cd59ab32 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -185,7 +185,9 @@ class ToolRegistry(unittest.TestCase): "qet_live_undo_last", "qet_live_screenshot", "qet_about", "qet_recording_list", "qet_recording_read", "qet_recording_check", "qet_recording_remove", "qet_layout_check", "qet_live_new_project", - "qet_live_open_project", "qet_live_switch_project"}) + "qet_live_open_project", "qet_live_switch_project", "qet_live_save_project", + "qet_live_close_project", "qet_live_print", "qet_live_changes", + "qet_live_layout_check"}) class EditValidation(unittest.TestCase): @@ -3582,6 +3584,48 @@ class LiveClient(unittest.TestCase): m.tool_live_open_project(bad) self.assertEqual(len(self.seen), 3) + def test_save_close_print_changes_send_only_what_was_given(self): + self.session() + m.tool_live_save_project() + m.tool_live_save_project("/tmp/x/as.qet") + m.tool_live_close_project() + m.tool_live_close_project(2) + m.tool_live_print() + m.tool_live_print([0, 2], printer="HP") + m.tool_live_print("current", output_file="/tmp/x/out.pdf") + m.tool_live_changes() + m.tool_live_changes(4) + self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")} + for r in self.seen], [ + {"cmd": "save_project"}, {"cmd": "save_project", "path": "/tmp/x/as.qet"}, + {"cmd": "close_project"}, {"cmd": "close_project", "index": 2}, + {"cmd": "print", "folios": "all"}, + {"cmd": "print", "folios": [0, 2], "printer": "HP"}, + {"cmd": "print", "folios": "current", "output_file": "/tmp/x/out.pdf"}, + {"cmd": "changes"}, {"cmd": "changes", "since": 4}]) + bad = [lambda: m.tool_live_save_project("rel.qet"), + lambda: m.tool_live_close_project(-1), + lambda: m.tool_live_close_project(True), + lambda: m.tool_live_print("some"), + lambda: m.tool_live_print([]), + lambda: m.tool_live_print([0, -1]), + lambda: m.tool_live_print(printer="HP", output_file="/tmp/a.pdf"), + lambda: m.tool_live_print(output_file="out.pdf"), + lambda: m.tool_live_changes(-2)] + for call in bad: + with self.assertRaises(ValueError): + call() + self.assertEqual(len(self.seen), 9) + + def test_writing_live_tools_need_script_consent(self): + self.session() + with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}): + for call in (m.tool_live_save_project, m.tool_live_close_project, + m.tool_live_print): + with self.assertRaises(ValueError): + call() + self.assertEqual(self.seen, []) + def test_new_project_needs_script_consent(self): self.session() with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}): @@ -3593,6 +3637,106 @@ class LiveClient(unittest.TestCase): self.info({"socket": self.sock_path + "-gone", "token": "T0K"}) with self.assertRaisesRegex(ValueError, "could not reach"): m.tool_live_status() +class HouseStyleLayoutRules(unittest.TestCase): + """The house-style rules of the layout check, on made-up geometry.""" + + def el(self, uuid, x, y, w=20, h=40, label="", labelbox=None): + return {"uuid": uuid, "name": uuid, "label": label, "terminals": 2, + "g": {"x": x, "y": y, "left": x - w / 2, "top": y - h / 2, + "right": x + w / 2, "bottom": y + h / 2}, "labelbox": labelbox} + + def wire(self, uuid, a, b, path): + return {"uuid": uuid, "ends": [a + " terminal 0", b + " terminal 1"], + "path": [{"x": x, "y": y} for x, y in path]} + + def rules(self, data): + return sorted(f["rule"] for f in m._layout_folio(dict(folio=0, **data), 40)["findings"] + if f["rule"] in ("label_on_wire", "four_way_junction", "misaligned_branch")) + + def test_label_over_a_wire_and_clear_of_it(self): + els = [self.el("a", 100, 100, label="-K1", + labelbox={"left": 95, "top": 150, "right": 120, "bottom": 160}), + self.el("b", 100, 300)] + over = [self.wire("w", "a", "b", [(100, 120), (100, 280)])] + self.assertEqual(self.rules({"elements": els, "conductors": over}), ["label_on_wire"]) + els[0]["labelbox"] = {"left": 120, "top": 150, "right": 145, "bottom": 160} + self.assertEqual(self.rules({"elements": els, "conductors": over}), []) + + def test_four_way_dot_but_not_a_t(self): + els = [self.el(k, x, y) for k, x, y in + (("n", 200, 100), ("s", 200, 300), ("e", 300, 200), ("w", 100, 200))] + four = [self.wire("1", "n", "s", [(200, 120), (200, 200), (200, 280)]), + self.wire("2", "w", "e", [(110, 200), (200, 200), (290, 200)])] + # Two straight wires through one point have no vertex there... + self.assertEqual(self.rules({"elements": els, "conductors": four}), []) + star = [self.wire("1", "n", "s", [(200, 120), (200, 200)]), + self.wire("2", "s", "n", [(200, 280), (200, 200)]), + self.wire("3", "w", "e", [(110, 200), (200, 200)]), + self.wire("4", "e", "w", [(290, 200), (200, 200)])] + self.assertEqual(self.rules({"elements": els, "conductors": star}), ["four_way_junction"]) + self.assertEqual(self.rules({"elements": els, "conductors": star[:3]}), []) + + def test_side_branch_out_of_line_like_the_motor_starter(self): + # main column at x 500, branch symbols at 580 (hold-in) and 660 (lamp) + els = [self.el("s2", 500, 470), self.el("hold", 580, 470), self.el("coil", 500, 580), + self.el("lamp", 660, 580)] + wires = [self.wire("1", "s2", "hold", [(500, 450), (580, 450)]), + self.wire("2", "coil", "lamp", [(500, 560), (660, 560)]), + self.wire("3", "s2", "coil", [(500, 490), (500, 560)])] + self.assertEqual(self.rules({"elements": els, "conductors": wires}), ["misaligned_branch"]) + els[3] = self.el("lamp", 580, 580) + wires[1] = self.wire("2", "coil", "lamp", [(500, 560), (580, 560)]) + self.assertEqual(len(wires), 3) + self.assertEqual(self.rules({"elements": els, "conductors": wires}), []) + + +class StandardSymbols(unittest.TestCase): + def test_absent_listed_and_broken(self): + with tempfile.TemporaryDirectory() as d: + f = Path(d) / "standard-symbols.json" + with mock.patch.dict(os.environ, {"QET_MCP_STANDARD_SYMBOLS": str(f)}): + self.assertIsNone(m.standard_symbols()) + f.write_text(json.dumps({"updated": "2026-10-05", "roles": [ + {"id": "coil", "label": "Coil", "letter": "K", "terminals": ["A1", "A2"], + "path": "common://10_electric/x/bobine3.elmt", "score": 99}, + {"id": "none_fit", "label": "Nothing", "path": None}]})) + got = m.standard_symbols() + self.assertEqual(got["roles"], [{"id": "coil", "label": "Coil", "letter": "K", + "path": "common://10_electric/x/bobine3.elmt", + "terminals": ["A1", "A2"]}]) + f.write_text("{not json") + self.assertIn("could not be read", m.standard_symbols()["error"]) + + def test_default_location_is_qet_data_folder(self): + with mock.patch.dict(os.environ, {"QET_MCP_STANDARD_SYMBOLS": ""}): + self.assertEqual(m.standard_symbols_file({"folders": {"data": "/x/data"}}), + Path("/x/data/standard-symbols.json")) + + +class ElementIndexCache(unittest.TestCase): + def test_second_process_reads_the_cache_and_a_change_rebuilds(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) / "elements" + root.mkdir() + elmt = ('' + '{}' + '') + (root / "a.elmt").write_text(elmt.format("Alpha coil")) + with mock.patch.dict(os.environ, {"QET_MCP_CACHE_DIR": str(Path(d) / "cache")}): + m._ELEMENT_INDEX.clear() + first = m._index_collection(root) + self.assertTrue(m._index_cache_file(root.resolve()).is_file()) + m._ELEMENT_INDEX.clear() # a new server process + with mock.patch.object(m.ET, "parse", side_effect=AssertionError("parsed")): + again = m._index_collection(root) + self.assertEqual([i["path"] for i in again], [i["path"] for i in first]) + self.assertEqual(again[0]["haystack"], first[0]["haystack"]) + (root / "b.elmt").write_text(elmt.format("Beta lamp")) + m._ELEMENT_INDEX.clear() + self.assertEqual(len(m._index_collection(root)), 2) + m._ELEMENT_INDEX.clear() + + class AssistantInfoFile(unittest.TestCase): """qet-assistant.json: QElectroTech says where things are; the server believes it over its own per-platform guess.""" diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index f1f87a659..1939b61a0 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -23,6 +23,7 @@ #include "scriptlibrary.h" #include "../diagram.h" #include "../diagramview.h" +#include "../qet.h" #include "../qetapp.h" #include "../qetdiagrameditor.h" #include "../qetproject.h" @@ -51,6 +52,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include "../projectview.h" @@ -260,6 +265,16 @@ void LiveServer::handle(const QJsonObject &request) answer = openProject(request.value(QStringLiteral("path")).toString()); } else if (cmd == QLatin1String("switch_project")) { answer = switchProject(request); + } else if (cmd == QLatin1String("save_project")) { + answer = saveProject(request); + } else if (cmd == QLatin1String("close_project")) { + answer = closeProject(request); + } else if (cmd == QLatin1String("print")) { + answer = print(request); + } else if (cmd == QLatin1String("snapshot")) { + answer = snapshot(request.value(QStringLiteral("path")).toString()); + } else if (cmd == QLatin1String("changes")) { + answer = changes(request); } else if (cmd == QLatin1String("show_folio")) { answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1)); } else if (cmd == QLatin1String("undo_last")) { @@ -596,6 +611,228 @@ QJsonObject LiveServer::switchProject(const QJsonObject &request) return status(); } +/** + @brief LiveServer::saveProject + Save the current project to its own file, or, with "path", to a new + file that becomes its file (Save As) -- never over an existing one. +*/ +QJsonObject LiveServer::saveProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) return failure(QStringLiteral("no project is open in QElectroTech")); + if (project->isReadOnly()) return failure(QStringLiteral("the project is read-only")); + + const QString path = request.value(QStringLiteral("path")).toString().trimmed(); + if (path.isEmpty()) { + if (project->filePath().isEmpty()) + return failure(QStringLiteral("the project has no file yet: give \"path\" to save it as one")); + } else { + const QFileInfo info(path); + if (info.isRelative()) return failure(QStringLiteral("path must be absolute: %1").arg(path)); + if (info.exists()) + return failure(QStringLiteral("%1 already exists; save as never replaces a file").arg(path)); + if (!info.dir().exists()) + return failure(QStringLiteral("the folder %1 does not exist").arg(info.absolutePath())); + project->setFilePath(info.absoluteFilePath()); + } + const QETResult result = project->write(); + if (!result.isOk()) return failure(QStringLiteral("not saved: %1").arg(result.errorMessage())); + QJsonObject answer = status(); + answer.insert(QStringLiteral("saved"), project->filePath()); + return answer; +} + +/** + @brief LiveServer::closeProject + Close an open project (the current one, or "index"), only when it has + no unsaved changes: closing never discards the user's work, and never + asks them a question on the assistant's behalf. +*/ +QJsonObject LiveServer::closeProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + const QList views = e->openedProjects(); + ProjectView *pv = nullptr; + if (request.contains(QStringLiteral("index"))) { + const int index = request.value(QStringLiteral("index")).toInt(-1); + if (index < 0 || index >= views.count()) + return failure(QStringLiteral("no open project %1: %2 are open, counted from 0") + .arg(index).arg(views.count())); + pv = views.at(index); + } else { + pv = e->currentProjectView(); + if (!pv) return failure(QStringLiteral("no project is open in QElectroTech")); + } + QETProject *project = pv->project(); + if (project->projectWasModified()) + return failure(QStringLiteral("\"%1\" has unsaved changes; save it first " + "(save_project) -- closing never discards them") + .arg(project->title())); + const QString title = project->title(); + if (!e->closeProject(pv)) return failure(QStringLiteral("QElectroTech did not close \"%1\"").arg(title)); + QJsonObject answer = status(); + answer.insert(QStringLiteral("closed"), title); + return answer; +} + +/** + @brief LiveServer::print + Print folios of the current project with no print dialog: "folios" is + "all" (default), "current" or a list of indexes; "printer" names one, + else the system's default printer. Paper cannot be taken back, so the + user is always asked first, whatever "always" says for scripts. + "output_file" prints to a new PDF file instead (no paper, so no + question; never over an existing file). +*/ +QJsonObject LiveServer::print(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + ProjectView *pv = e ? e->currentProjectView() : nullptr; + if (!pv) return failure(QStringLiteral("no project is open in QElectroTech")); + QETProject *project = pv->project(); + const QList all = project->diagrams(); + + QList folios; + const QJsonValue which = request.value(QStringLiteral("folios")); + if (which.isUndefined() || which.toString() == QLatin1String("all")) { + folios = all; + } else if (which.toString() == QLatin1String("current")) { + if (DiagramView *dv = e->currentDiagramView()) folios << dv->diagram(); + } else if (which.isArray()) { + for (const QJsonValue &v : which.toArray()) { + const int i = v.toInt(-1); + if (i < 0 || i >= all.count()) + return failure(QStringLiteral("no folio %1: the project has %2, counted from 0") + .arg(v.toVariant().toString()).arg(all.count())); + folios << all.at(i); + } + } else { + return failure(QStringLiteral("folios must be \"all\", \"current\" or a list of indexes")); + } + if (folios.isEmpty()) return failure(QStringLiteral("no folio to print")); + + const QString file = request.value(QStringLiteral("output_file")).toString().trimmed(); + QPrinterInfo info; + if (file.isEmpty()) { + const QString name = request.value(QStringLiteral("printer")).toString().trimmed(); + info = name.isEmpty() ? QPrinterInfo::defaultPrinter() : QPrinterInfo::printerInfo(name); + if (info.isNull()) + return failure(name.isEmpty() + ? QStringLiteral("this computer has no default printer") + : QStringLiteral("no printer called \"%1\" (printers: %2)") + .arg(name, QPrinterInfo::availablePrinterNames().join(QStringLiteral(", ")))); + QMessageBox box(QMessageBox::Question, tr("Impression"), + tr("L'assistant veut imprimer %n folio(s) de « %1 » sur « %2 ».", "", int(folios.count())) + .arg(project->title(), info.printerName()), + QMessageBox::NoButton, e); + QPushButton *go = box.addButton(tr("&Imprimer"), QMessageBox::AcceptRole); + box.addButton(tr("&Annuler"), QMessageBox::RejectRole); + box.setDefaultButton(go); + box.exec(); + if (box.clickedButton() != go) return failure(QStringLiteral("refused by the user")); + } else { + const QFileInfo fi(file); + if (fi.isRelative()) return failure(QStringLiteral("output_file must be absolute: %1").arg(file)); + if (fi.exists()) return failure(QStringLiteral("%1 already exists; printing never replaces a file").arg(file)); + } + + QPrinter printer = file.isEmpty() ? QPrinter(info, QPrinter::HighResolution) + : QPrinter(QPrinter::HighResolution); + if (!file.isEmpty()) { + printer.setOutputFormat(QPrinter::PdfFormat); + printer.setOutputFileName(file); + } + printer.setDocName(project->title().isEmpty() ? QStringLiteral("QElectroTech") : project->title()); + const auto folioRect = [](Diagram *d) { + QRectF r = d->border_and_titleblock.borderAndTitleBlockRect(); + r.adjust(0, 0, 1, 1); + return r.toAlignedRect(); + }; + //One orientation for the job: some printer drivers ignore a change + //between pages. The first folio decides. + const QRect first = folioRect(folios.first()); + printer.setPageOrientation(first.width() > first.height() ? QPageLayout::Landscape + : QPageLayout::Portrait); + QPainter painter; + if (!painter.begin(&printer)) return failure(QStringLiteral("the printer could not be opened")); + for (int i = 0; i < folios.count(); ++i) { + Diagram *d = folios.at(i); + if (i) printer.newPage(); + //As the PDF export draws a folio: no grid, guides or terminals + const bool grid = d->displayGrid(), guides = d->displayGuides(); + const bool terms = d->drawTerminals(), names = d->drawTerminalNames(); + d->setDisplayGrid(false); d->setDisplayGuides(false); + d->setDrawTerminals(false); d->setDrawTerminalNames(false); + d->render(&painter, printer.pageLayout().paintRectPixels(printer.resolution()), + folioRect(d), Qt::KeepAspectRatio); + d->setDisplayGrid(grid); d->setDisplayGuides(guides); + d->setDrawTerminals(terms); d->setDrawTerminalNames(names); + } + painter.end(); + return {{QStringLiteral("ok"), true}, {QStringLiteral("printed"), int(folios.count())}, + {QStringLiteral("to"), file.isEmpty() ? info.printerName() : file}}; +} + +/** + @brief LiveServer::snapshot + Write a copy of the current project, as it is on screen (unsaved + changes included), to a new file -- for a check to read -- without + saving the project or changing its file. Never over an existing file. +*/ +QJsonObject LiveServer::snapshot(const QString &raw_path) +{ + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) return failure(QStringLiteral("no project is open in QElectroTech")); + const QFileInfo info(raw_path.trimmed()); + if (raw_path.trimmed().isEmpty() || info.isRelative()) + return failure(QStringLiteral("path must be absolute")); + if (info.exists()) return failure(QStringLiteral("%1 already exists").arg(raw_path)); + QDomDocument xml(project->toXml()); + QString error; + if (!QET::writeXmlFile(xml, info.absoluteFilePath(), &error)) + return failure(QStringLiteral("snapshot not written: %1").arg(error)); + return {{QStringLiteral("ok"), true}, {QStringLiteral("path"), info.absoluteFilePath()}, + {QStringLiteral("folio"), e->currentDiagramView() + ? int(project->diagrams().indexOf(e->currentDiagramView()->diagram())) : -1}}; +} + +/** + @brief LiveServer::changes + The current project's undo history: each step's name, whether the + assistant made it, and whether it is undone. "since" (an index from an + earlier answer) keeps only the steps after it, so an assistant can say + what it changed since a point. Changes nothing. +*/ +QJsonObject LiveServer::changes(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + QETProject *project = e ? e->currentProject() : nullptr; + if (!project) return failure(QStringLiteral("no project is open in QElectroTech")); + QUndoStack *stack = project->undoStack(); + const QString prefix = tr("Assistant : %1").arg(QString()); + const int since = request.value(QStringLiteral("since")).toInt(-1); + QJsonArray steps; + int assistant = 0, user = 0; + for (int i = qMax(0, since + 1); i < stack->count(); ++i) { + const QString text = stack->text(i); + const bool mine = text.startsWith(prefix); + const bool undone = i >= stack->index(); + if (!undone) (mine ? assistant : user)++; + steps.append(QJsonObject{{QStringLiteral("index"), i}, {QStringLiteral("step"), text}, + {QStringLiteral("by"), mine ? QStringLiteral("assistant") + : QStringLiteral("user")}, + {QStringLiteral("undone"), undone}}); + } + return {{QStringLiteral("ok"), true}, {QStringLiteral("project"), project->title()}, + {QStringLiteral("steps"), steps}, + {QStringLiteral("now"), stack->index() - 1}, + {QStringLiteral("saved_at"), stack->cleanIndex() - 1}, + {QStringLiteral("done_by_assistant"), assistant}, {QStringLiteral("done_by_user"), user}}; +} + /** @brief LiveServer::undoLast Undo the newest step, only if the assistant made it: what the user did diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 93ba325fa..882d86bbc 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -89,6 +89,11 @@ class LiveServer : public QObject QJsonObject openProject(const QString &path); QJsonObject switchProject(const QJsonObject &request); QJsonArray openProjects() const; + QJsonObject saveProject(const QJsonObject &request); + QJsonObject closeProject(const QJsonObject &request); + QJsonObject print(const QJsonObject &request); + QJsonObject changes(const QJsonObject &request); + QJsonObject snapshot(const QString &path); QJsonObject undoLast(); QJsonObject screenshot(); bool confirm(const QString &name, const QString &source); diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 0de2b7497..78c0833d1 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -3489,6 +3489,25 @@ bool QetScriptApi::deleteImage(int folioIndex, int imageIndex) return true; } +/** + @brief QetScriptApi::elementTextGeometry + Where one of a symbol's text fields is drawn on the folio, in folio + coordinates: left, top, right, bottom of the text as shown. A label + that sits on a wire is visible here, not from x/y alone (x/y are + relative to the symbol and say nothing about the text's size). + @return an empty map when the text does not exist +*/ +QVariantMap QetScriptApi::elementTextGeometry(int folioIndex, const QString &elementUuid, + int textIndex) const +{ + DynamicElementTextItem *t = findElementText(folioIndex, elementUuid, textIndex, + QStringLiteral("elementTextGeometry")); + if (!t) return {}; + const QRectF r = t->sceneBoundingRect(); + return {{QStringLiteral("left"), r.left()}, {QStringLiteral("top"), r.top()}, + {QStringLiteral("right"), r.right()}, {QStringLiteral("bottom"), r.bottom()}}; +} + /** @brief QetScriptApi::addPdfPage Render one page of a PDF file to an image and place it, as the "add diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 572168a0f..6b9e6bb9b 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -556,6 +556,8 @@ class QetScriptApi : public QObject Q_INVOKABLE QString elementTextProperty(int folioIndex, const QString &elementUuid, int textIndex, const QString &property) const; Q_INVOKABLE bool deleteElementText(int folioIndex, const QString &elementUuid, int textIndex); + Q_INVOKABLE QVariantMap elementTextGeometry(int folioIndex, const QString &elementUuid, + int textIndex) const; // -- copy elements (with the conductors between them) to a position -- Q_INVOKABLE QStringList duplicateElements(int fromFolioIndex, const QStringList &elementUuids,