diff --git a/.gitignore b/.gitignore index 3c7ad902a..f4a66720c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.snap .flatpak-builder +__pycache__/ +*.pyc # Qt build output *.user # doxygen Doxyfile output diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index d995a4411..f4a869f33 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -30,14 +30,20 @@ here read the model. | `qet_project_info` | title, format version, folios, element and conductor counts | | `qet_elements` | placed elements: uuid, type, position, label, information bag | | `qet_conductors` | conductors and their documentation fields; filter by attribute | -| `qet_diff` | **what an edit actually changed** — moves with deltas, adds, removes, relabels, conductor field changes | +| `qet_diff` | **what an edit actually changed** — element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips | | `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute | | `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts | | `qet_export` | run a headless export (pdf, png, svg, bom, cables, wires, wiring, nets, links, info) | +| `qet_edit` | **change a project** — place, move, rotate, label, wire, number, cross-reference, add text, shapes and images, restyle a symbol's text fields, delete; then diff the result | +| `qet_element_build` | **author a `.elmt`** — draw a new symbol, with terminals to wire it by | +| `qet_project_new` | **start from nothing** — an empty project with a title and folios | +| `qet_element_search` | **find a symbol** in a collection by name (any language), type or terminal count | +| `qet_check` | **design-rule checks** — duplicate labels, unlabelled masters, unnumbered conductors, empty folios | +| `qet_query` | **ask the project database** — read-only SQL over the views and tables | -Only `qet_export` launches QElectroTech. Everything else parses the file -directly, which is faster, needs no display, and cannot be confused by a -dialog. +`qet_export` and `qet_edit` launch QElectroTech. Everything else parses the +file directly, which is faster, needs no display, and cannot be confused by +a dialog. ## Running it @@ -56,12 +62,80 @@ Register it with an MCP client, for example: "mcpServers": { "qet": { "command": "python3", - "args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"] + "args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"], + "env": { + "QET_MCP_WORKSPACE": "/home/you/drawings", + "QET_ENABLE_SCRIPTING": "1" + } } } } ``` +## Five tools need scripting switched on + +A QElectroTech with JavaScript scripting switched off refuses `--run`, and +off is the default from +[#984](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/984) +onwards. Five tools here drive it that way and stop working until it is +turned on: + +| | | +|---|---| +| need `QET_ENABLE_SCRIPTING=1` | `qet_query`, `qet_continuity`, `qet_check`, `qet_project_new`, `qet_edit` | +| unaffected | everything else — they read the `.qet` directly, or, in `qet_export`'s case, use a plain CLI flag | + +The variable goes in the environment this server is started in, which for an +MCP client is the `env` block above; the server passes its environment +straight through to QElectroTech. It does not set the variable itself, on +purpose — a switch a program turns on for itself is not a switch. Whoever +configured this server and pointed it at a QElectroTech binary made that +choice, and their interactive QElectroTech keeps whatever its own setting +says. + +Without it, those five come back `"ok": false` with a `hint` naming the +variable. Older builds, from before the setting existed, need nothing. + +## What the server is allowed to touch + +Every path in a tool call is chosen by the model, so without a policy this +server would be a read/write primitive for anything the operating system +lets the process reach: read any project on the disk, export one somewhere +else, overwrite an unrelated file, embed an arbitrary local image or PDF. + +So **data paths are confined to a workspace**: + +| | | +|---|---| +| `QET_MCP_WORKSPACE` | the directories tool calls may read and write, separated by `:` (`;` on Windows) | +| unset | the directory the server was started in | +| `QET_MCP_ALLOW_ANY_PATH=1` | turns the check off entirely | + +Set the workspace to the folder your drawings live in. A path outside it is +refused with an error naming what was allowed; symlinks are resolved first, +so a link planted inside the workspace is judged by where it points. + +Two arguments are deliberately **not** confined: `binary` (the +`qelectrotech` executable) and `elements_dir` (the element collection). +Those are configuration, chosen once by whoever runs the server, and both +normally live in `/usr` or a build tree — outside any sensible workspace. +Confining them would reject the ordinary case while stopping nothing. + +`QET_MCP_ALLOW_ANY_PATH=1` is equivalent to granting the client local +filesystem access with this process's privileges. It exists so that is a +deliberate choice rather than the default. + +**Nothing is overwritten unasked.** `qet_export`, `qet_edit`, +`qet_project_new` and `qet_element_build` refuse an `output` that already +exists unless the call passes `"overwrite": true`. Replacing a file is the +one step this server cannot undo, so it is the one step it will not take on +its own. + +The confinement is applied where tool arguments enter the server, not inside +each tool. Importing `qet_mcp` and calling `tool_export()` from your own +Python is not confined and is not meant to be — that is your code calling a +library, and you already chose the paths. + ## Worked examples **What did that edit change?** @@ -79,6 +153,74 @@ Register it with an MCP client, for example: Four elements moved by one uniform delta; nothing was relabelled. That is the answer a screenshot gave wrongly. +**Draw something, and check it landed** + +```json +{"name": "qet_edit", "arguments": { + "binary": "/path/to/qelectrotech", + "project": "in.qet", "output": "out.qet", + "elements_dir": "/path/to/qelectrotech/elements", + "operations": [ + {"op": "add_folio", "id": "f"}, + {"op": "set_folio_title", "folio": "$f", "title": "Starter"}, + {"op": "add_element", "id": "k1", "folio": "$f", "path": "common://.../coil.elmt", "x": 100, "y": 100}, + {"op": "add_element", "id": "k2", "folio": "$f", "path": "common://.../coil.elmt", "x": 320, "y": 100}, + {"op": "add_conductor", "folio": "$f", "from": "$k1", "from_terminal": 0, "to": "$k2", "to_terminal": 0}, + {"op": "set_conductor", "folio": "$f", "element": "$k1", "terminal": 0, "property": "num", "value": "W7"}, + {"op": "set_label", "folio": "$f", "element": "$k1", "label": "KM1"} + ]}} +``` + +An op that creates something takes an `"id"`; later ops name it as `"$id"`. +Terminals are addressed by index — top to bottom, then left to right, **not** +the order the `.elmt` lists them. `qet_element_info` and `qet_element_search` +both report that index order. The answer carries a per-operation result +*and* a `qet_diff`, because "addConductor → true" says the call was +accepted, not that the file came out right: + +```json +"diff": {"elements": {"before": 11, "after": 13, "added": ["{0aa3…}", "{6f63…}"]}, + "conductors": {"before": 47, "after": 48, "added": ["4:{0aa3…}/{2904…}--{6f63…}/{2904…}"], + "removed": []}} +``` + +**Draw a symbol that does not exist yet** + +```json +{"name": "qet_element_build", "arguments": { + "output": "/path/to/collection/99_custom/my_resistor.elmt", + "names": {"en": "Test resistor", "fr": "Résistance de test"}, + "parts": [ + {"type": "rect", "x": -10, "y": -20, "width": 20, "height": 40}, + {"type": "line", "x1": 0, "y1": -30, "x2": 0, "y2": -20}, + {"type": "line", "x1": 0, "y1": 20, "x2": 0, "y2": 30}, + {"type": "text", "x": 14, "y": -4, "text": "R"} + ], + "terminals": [{"x": 0, "y": -30, "orientation": "n", "name": "1"}, + {"x": 0, "y": 30, "orientation": "s", "name": "2"}]}} +``` + +Then place it with `qet_edit` like any catalogue element. Unlike a +project, a `.elmt` is not rewritten by QElectroTech on a round trip, so +generating one here is safe in a way that generating a `.qet` would not +be — there is no `toXml()` waiting to drop what this writer did not know +to emit. + +**Ask a question the XML cannot answer** + +```json +{"name": "qet_query", "arguments": { + "binary": "/path/to/qelectrotech", "project": "industrial.qet", + "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view WHERE label <> '' GROUP BY label HAVING n > 1 ORDER BY n DESC"}} +``` + +```json +"rows": [{"label": "V6", "n": 7}, {"label": "V5", "n": 6}, {"label": "V4", "n": 6}] +``` + +Duplicate element labels in a shipped example — a design-rule question, +answered by the database that already knew it. + **How much of a corpus uses a field?** ```json @@ -92,14 +234,43 @@ the answer a screenshot gave wrongly. Across the shipped examples: 3190 conductors, not one with a cable value. +## Testing + +```bash +python3 test_qet_mcp.py # unit + protocol, no QElectroTech needed +QET_BINARY=/path/to/qelectrotech \ +QET_ELEMENTS=/path/to/qelectrotech/elements \ +QET_EXAMPLES=/path/to/qelectrotech/examples \ +QET_ENABLE_SCRIPTING=1 \ + python3 test_qet_mcp.py # everything +``` + +`QET_ENABLE_SCRIPTING=1` matters from #984 onwards: without it the +integration tests that drive QElectroTech through a script all fail, and +they fail as "the edit did nothing" rather than as "scripting is off", which +reads like a regression in the thing under test. + +176 tests in three layers: unit (validation, script generation, the terminal +order rule, the diff, the part schema), the real stdio transport, and +integration against a built QElectroTech. Several exist because the +behaviour they pin was once wrong and looked right, and say so in their +docstrings. To check the suite itself rather than trust it, each of those +bugs was reintroduced in turn and the suite confirmed to fail: ten in the +Python, plus the hang guard on `addConductor` and the database refresh in +`ConductorCreator` in the C++. + ## Notes and limits -- **The project database is not reachable from outside the application.** - `projectDataBase::newQuery()` and `isReadOnlySelect()` are C++-internal - and the JavaScript scripting API exposes no SQL binding, so structural - queries here are done over the XML. A `--query` CLI verb, or a scripting - binding, would let this server expose the guarded read-only SQL surface - instead, and would be a better foundation. +- **The project database is reachable now, through `qet_query`.** It was + not when this server was written, which is why every other structural + tool here re-derives its answer from the XML. Prefer the views — + `element_nomenclature_view`, `project_summary_view`, `wiring_list_view` + — which exist to be queried; the underlying tables are how the cache is + arranged today and a column may move. Call `qet_query` with no `sql` to + list both. Only `SELECT` and `WITH` are accepted, which is the rule + QElectroTech applies to its own custom-query box, not one invented here. + An empty result and a failed query are told apart: `row_count` 0 with no + `error` means nothing matched, and a typo'd column name says so. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* @@ -115,16 +286,64 @@ Across the shipped examples: 3190 conductors, not one with a cable value. its ends with `terminal1`/`terminal2`, and the project format has two schemes: folio-scoped integer ids in older files, terminal-definition uuids plus `element1`/`element2` in newer ones. The integer ids are - **renumbered on every save**, so keying on them made all 47 conductors of - an untouched `ArduinoLCD.qet` read as 29 removed and 29 re-added the - moment the other side had been through QElectroTech. Ends are now keyed - by owning element uuid plus terminal, which is stable across a save: - measured at 0 colliding keys over 3190 conductors in the 24 shipped - examples, and 0 churn on a re-saved but otherwise untouched project. - Where an element predates persisted uuids the end cannot be resolved and - keeps a `#`-marked unstable key; the diff then reports `unstable_keys` - and says so rather than pretending to be comparable. + **renumbered on every save**, so keying on them — which this tool did at + first — made all 47 conductors of an untouched folio read as removed and + re-added the moment the other side had been through QElectroTech, which + is exactly what `qet_edit` produces. They are now keyed by owning element + uuid plus terminal, which is stable across a save: measured at 0 colliding + keys over 3190 conductors in the 24 shipped examples, and 0 churn on a + no-op edit. Where an element predates persisted uuids the end cannot be + resolved and keeps a `#`-marked unstable key; the diff then reports + `unstable_keys` and says so rather than pretending to be comparable. +- **Texts, shapes and images have no uuid**, so `qet_diff` cannot say "the same + text, edited": an edited text reads as the old one removed and a new one + added, both shown. Shapes and images are keyed by position, so a restyle + or rescale *is* reported as a change to that item, but a move reads as a + removal plus an addition. The folio `version` attribute is left out of the + comparison on purpose: QElectroTech rewrites it on every save, and + including it made every folio of any re-saved project look edited. - **Elements** written before persisted uuids fall back to a positional key, - which makes a move in such a file read as a remove plus an add rather - than as a move. -- Read-only by design. Nothing here writes to a project. + which makes a move in such a file read as a remove plus an add. +- **`qet_edit` needs a build whose scripting API carries the drawing verbs.** + Against an older one it reports exactly which methods are missing and + changes nothing. `addElement` and the move/delete verbs shipped with the + scripting API; `addConductor`, `rotateElement`, `setElementLabel`, + `setElementInfo` and `setFolioTitle` are newer. +- **`elements_dir` is not optional for `common://` paths.** The sandboxed + run has its own empty HOME, so QElectroTech falls back to the compiled-in + collection path, which on a machine that never ran `make install` does not + exist. The only symptom is `addElement` reporting that a file plainly + present "does not resolve to an element". An absolute `.elmt` path works + without it. +- **`set_conductor` changes the whole potential, not one segment.** That is + what the application does — a wire number describes a potential — so name + a terminal carrying exactly one conductor and the change reaches every + conductor electrically joined to it. A terminal several conductors meet + at names none of them and is refused, so address a potential from one of + its leaves. Property names are the file's own, so `qet_conductors` reads + back exactly what was set. +- **`link_elements` takes a folio for each end**, because a master and its + slave are normally on different folios. Whether a pair may be linked is + decided by QElectroTech's own `isLinkable()`, so a script cannot make a + link the GUI would refuse. +- **An element must live inside a collection to be placeable.** This is + not about the path syntax: an absolute `.elmt` path works, but only if + the file sits under a directory QElectroTech knows as a collection. + Write it under the tree you pass as `elements_dir` and `qet_edit` can + place it, by absolute path or as `common://…`; write it anywhere else + and `add_element` reports only "does not resolve to an element". +- **`qet_element_build` computes the `.elmt` size header, and checks it.** + `width`/`height`/`hotspot_x`/`hotspot_y` relate to the drawing by a + containment constraint, not a formula — the declared box runs from + `(-hotspot_x, -hotspot_y)` to `(width - hotspot_x, height - hotspot_y)` + and the drawing must fit inside it. The shipped collection shows authors + picking their own margins (one element pads 2 units left and 3 right, + another 8 and 2), so there is no convention to copy, only an invariant + to satisfy. A drawing that escaped its box is the classic way a + hand-written element renders clipped in the collection panel while + looking fine in XML. +- **QElectroTech interrupts a script at 30 s** of its own accord, separately + from this tool's `timeout`. A very long operation list will hit that + first. +- **`qet_edit` never writes the input.** It saves to a separate file and + diffs the two, so the original is always the thing the diff is against. diff --git a/misc/qet-mcp/__pycache__/qet_mcp.cpython-314.pyc b/misc/qet-mcp/__pycache__/qet_mcp.cpython-314.pyc deleted file mode 100644 index bb43f416c..000000000 Binary files a/misc/qet-mcp/__pycache__/qet_mcp.cpython-314.pyc and /dev/null differ diff --git a/misc/qet-mcp/fixtures/plc_master_test.elmt b/misc/qet-mcp/fixtures/plc_master_test.elmt new file mode 100644 index 000000000..39a140262 --- /dev/null +++ b/misc/qet-mcp/fixtures/plc_master_test.elmt @@ -0,0 +1,44 @@ + + + + ملف KA بمحتفظ مغناطيسي + Bobina + Bistabilní remanentní relé + Remanenzrelais + Πηνίο με μανδάλωση + Coil + Bobina KA de remanencia + Bobine KA à rémanence + Tekercs + Bobina + spoel remanent + Spoel KA remanent + Cewka przekaźnika remanencyjnego + Bobina + Обмотка + 剩磁保持线圈 + + + plc + + Author: The QElectroTech team +License: see http://qelectrotech.org/wiki/doc/elements_license + + + + + A2 + + + A1 + + + + + label + + + + + + diff --git a/misc/qet-mcp/fixtures/plc_slave_test.elmt b/misc/qet-mcp/fixtures/plc_slave_test.elmt new file mode 100644 index 000000000..c5ecac2b3 --- /dev/null +++ b/misc/qet-mcp/fixtures/plc_slave_test.elmt @@ -0,0 +1,16 @@ + + + + PLC Slave Test + + + plc + NO + 1 + + + + + + + diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 3e1966d02..c76d5275c 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -52,6 +52,7 @@ No third-party dependencies — the MCP SDK is not assumed to be present. from __future__ import annotations import json +import re import os import shutil import subprocess @@ -148,8 +149,8 @@ def _terminal_index(diagram: ET.Element) -> dict: save and terminal1="34" terminal2="15" after one. Keying a conductor on that pair, which this tool used to do, made every conductor in the file read as removed-and-re-added whenever the "after" side had been through - QElectroTech -- which is the common case for "what did that edit change", - so the conductor half of the diff was noise precisely when it was needed. + QElectroTech -- which is exactly the case qet_edit produces, so the + conductor half of the diff was noise precisely when it was needed. So resolve each id to (owning element uuid, terminal position and orientation inside that element). Element uuids are persisted and @@ -288,6 +289,144 @@ def tool_conductors(path: str, folio: int | None = None, "conductors": rows[:limit]} +# No "version": that attribute is the file-format stamp QElectroTech rewrites +# on every save, so diffing it made every folio of any re-saved project look +# edited, and it is not something a script can set (see setFolioProperty). +_FOLIO_FIELDS = ("title", "author", "plant", "locmach", "indexrev", + "folio", "filename", + # the frame: attribute names as the file writes them + "cols", "colsize", "rows", "rowsize", "displaycols", "displayrows", + "titleblocktemplate") + + +def _plain_text(html: str) -> str: + """The visible text of an independent text's HTML, which is what a + person means by "the text". The file stores a whole HTML document.""" + import re + body = re.search(r"]*>(.*)", html or "", re.S) + inner = body.group(1) if body else (html or "") + inner = re.sub(r"<[^>]+>", "", inner) + for a, b in (("<", "<"), (">", ">"), ("&", "&"), (""", '"'), ("'", "'")): + inner = inner.replace(a, b) + return " ".join(inner.split()) + + +def _extras(root: ET.Element) -> dict: + """Everything a folio holds besides elements and conductors. + + Independent texts, shapes and images have no uuid, so nothing here can + say "this is the same text, edited". A change to one therefore reads as + the old one removed and a new one added, with both shown -- accurate, + if less tidy than a move. Position is the identity they have. + """ + folios, texts, shapes, images = {}, {}, {}, {} + for n, d in _folios(root): + folios[n] = {f: d.get(f, "") for f in _FOLIO_FIELDS} + for t in d.iter("input"): + key = (n, t.get("x", ""), t.get("y", ""), _plain_text(t.get("text", ""))) + texts[key] = {"rotation": t.get("rotation", "0"), + "font": t.get("font", ""), "color": t.get("color", "")} + for sh in d.iter("shape"): + pen, brush = sh.find("pen"), sh.find("brush") + key = (n, sh.get("type", ""), sh.get("x1", ""), sh.get("y1", ""), + sh.get("x2", ""), sh.get("y2", "")) + shapes[key] = { + "line_color": pen.get("color", "") if pen is not None else "", + "line_style": pen.get("style", "") if pen is not None else "", + "line_width": pen.get("widthF", "") if pen is not None else "", + "fill": (brush.get("color", "") if brush is not None and + brush.get("style", "") != "NoBrush" else "none"), + "rotation": sh.get("rotation", "0")} + for im in d.iter("image"): + key = (n, im.get("x", ""), im.get("y", "")) + images[key] = {"scale": im.get("size", ""), "rotation": im.get("rotation", "")} + + element_texts = {} + for n, d in _folios(root): + for el in d.iter("element"): + uuid = el.get("uuid", "") + seen = {} + for t in el.iter("dynamic_elmt_text"): + src = t.get("text_from", "") + what = (t.findtext("info_name") if src == "ElementInfo" + else t.findtext("composite_text") if src == "CompositeText" + else t.findtext("text")) or "" + base = (uuid, src, what) + seen[base] = seen.get(base, 0) + 1 + fs = (t.get("font", "").split(",") + ["", ""])[1] + element_texts[base + (seen[base],)] = { + "x": t.get("x", ""), "y": t.get("y", ""), "size": fs, + "frame": t.get("frame", ""), "rotation": t.get("rotation", ""), + "width": t.get("text_width", ""), + "shows": t.findtext("text") or ""} + + strips = {} + for st in root.iter("terminal_strip"): + data = st.find("terminal_strip_data") + if data is None: + continue + info = {i.get("name"): (i.text or "") for i in data.iter("information")} + strips[data.get("uuid", "")] = { + "installation": info.get("installation", ""), + "location": info.get("location", ""), + "name": info.get("name", ""), + "terminals": sum(1 for _ in st.iter("real_terminal"))} + return {"folios": folios, "texts": texts, "shapes": shapes, + "images": images, "strips": strips, "element_texts": element_texts} + + +def _diff_keyed(a: dict, b: dict, label) -> dict: + """added / removed / changed for two dicts keyed by identity.""" + changed = [] + for k in sorted(set(a) & set(b), key=str): + delta = {f: [a[k][f], b[k][f]] for f in a[k] if a[k][f] != b[k].get(f)} + if delta: + changed.append({"item": label(k), "changed": delta}) + return {"before": len(a), "after": len(b), + "added": [label(k) for k in sorted(set(b) - set(a), key=str)][:50], + "removed": [label(k) for k in sorted(set(a) - set(b), key=str)][:50], + "changed": changed[:50]} + + +def _diff_extras(before: ET.Element, after: ET.Element) -> dict: + a, b = _extras(before), _extras(after) + out = {} + ta, tb = before.get("title", ""), after.get("title", "") + out["project"] = {"changed": {"title": [ta, tb]} if ta != tb else {}} + # Folios are keyed by position. A reorder or a removal in the middle + # shifts every later index, so a folio "changing" its title alongside a + # folio count change can just be the shift -- the count says which. + folio_changes = [] + for n in sorted(set(a["folios"]) & set(b["folios"])): + delta = {f: [a["folios"][n][f], b["folios"][n][f]] for f in _FOLIO_FIELDS + if a["folios"][n][f] != b["folios"][n][f]} + if delta: + folio_changes.append({"folio": n, "changed": delta}) + out["folios"] = {"before": len(a["folios"]), "after": len(b["folios"]), + "changed": folio_changes[:50]} + if len(a["folios"]) != len(b["folios"]) and folio_changes: + out["folios"]["note"] = ("the folio count changed, so changes listed here may be " + "later folios shifting position rather than edits") + out["texts"] = _diff_keyed(a["texts"], b["texts"], + lambda k: {"folio": k[0], "x": k[1], "y": k[2], "text": k[3]}) + out["shapes"] = _diff_keyed(a["shapes"], b["shapes"], + lambda k: {"folio": k[0], "type": k[1], + "from": [k[2], k[3]], "to": [k[4], k[5]]}) + out["images"] = _diff_keyed(a["images"], b["images"], + lambda k: {"folio": k[0], "x": k[1], "y": k[2]}) + # Keyed by element, what the field is bound to, and the nth such field. + # A field's own text is also compared ("shows"), so relabelling an + # element shows up here as well as in the element's information. + out["element_texts"] = _diff_keyed( + a["element_texts"], b["element_texts"], + lambda k: {"element": k[0], "source": k[1], "bound_to": k[2], "n": k[3]}) + out["terminal_strips"] = _diff_keyed( + a["strips"], b["strips"], + lambda k: (lambda v: f"{v['installation']} {v['location']} {v['name']}".strip())( + (b["strips"].get(k) or a["strips"].get(k)))) + return out + + def tool_diff(before: str, after: str) -> dict: """Structural diff of two .qet files. @@ -372,6 +511,7 @@ def tool_diff(before: str, after: str) -> dict: "changed_count": len(conductor_changes), **unstable, }, + **_diff_extras(_root(before), _root(after)), } @@ -421,14 +561,41 @@ def tool_scan(directory: str, tag: str = "conductor", } +def _terminals_in_index_order(terminal_nodes) -> tuple: + """Order an element's terminals the way QElectroTech indexes them. + + Not file order. Element::parseTerminal() re-sorts the terminals every + time it adds one, top to bottom and then left to right, on each + terminal's local (y, x) -- Terminal::dockConductor() is mapToScene() of + its position, evaluated while the element still sits unrotated at the + origin. So the terminal a script reaches as index 0 is the topmost one, + whatever order the .elmt lists them in: bobine_ka_a_remanence.elmt + writes A2 (y=20) before A1 (y=-20), and add_conductor's index 0 is A1. + Getting this wrong wires the wrong end of a coil and nothing complains. + + Returns (nodes in index order, ambiguous). Two terminals at the same + point tie, and the C++ sort is not stable, so which one is index 0 is + not defined; ambiguous says so instead of pretending. + """ + def key(t): + try: + return (float(t.get("y", 0)), float(t.get("x", 0))) + except ValueError: + return (0.0, 0.0) + ordered = sorted(terminal_nodes, key=key) + keys = [key(t) for t in ordered] + return ordered, len(keys) != len(set(keys)) + + def tool_element_info(path: str) -> dict: """Introspect a .elmt: names, terminals, and which info fields it carries.""" root = _root(path) names = {n.get("lang"): (n.text or "") for n in root.iter("name")} - terminals = [{"x": t.get("x"), "y": t.get("y"), + ordered, ambiguous = _terminals_in_index_order(list(root.iter("terminal"))) + terminals = [{"index": i, "x": t.get("x"), "y": t.get("y"), "orientation": t.get("orientation"), "name": t.get("name", ""), "type": t.get("type", "")} - for t in root.iter("terminal")] + for i, t in enumerate(ordered)] info_fields = sorted({(i.text or "").strip() for i in root.iter("info_name") if (i.text or "").strip()}) parts = {} @@ -441,12 +608,18 @@ def tool_element_info(path: str) -> dict: "width": root.get("width"), "height": root.get("height"), "names": names, "terminal_count": len(terminals), "terminals": terminals, + "terminal_order": "index order: top to bottom then left to right, " + "not the file's order" + ( + "; two terminals share a point, so their relative " + "index is undefined" if ambiguous else ""), "info_fields": info_fields, "parts": parts, } -def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict: +def _run_qet(binary: str, args: list[str], timeout: int = 180, + elements_dir: str | None = None, + script: str | None = None, tail: int = 4000) -> dict: """Launch QElectroTech headlessly, carrying the known launch traps. SingleApplication keys its socket on applicationFilePath(), so a second @@ -454,6 +627,31 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict: instance and returns THAT process's answer with no error. Copying the binary to a unique path gives this run its own socket. A symlink will not do: applicationFilePath() resolves it back. + + The sandbox HOME that isolation buys also costs something, and it is + not obvious: with no settings file, QETApp::commonElementsDir() falls + back to the compiled-in QET_COMMON_COLLECTION_PATH, which on a machine + that has never run `make install` does not exist. Every "common://..." + path then fails to resolve and the only symptom is addElement() + reporting "does not resolve to an element" for a file that is plainly + there. elements_dir writes the one setting that fixes it. The file name + is not free-choice: QSettings derives it from the organisation and + application names main.cpp sets before this branch runs, so it must be + QElectroTech/QElectroTech.conf and nothing else. + + script, when given, is written into the sandbox and passed to --run. + It lives inside the temporary directory so it cannot collide with a + concurrent call, and it is returned to the caller on failure, because a + generated script nobody can see is not debuggable. + + The environment is inherited, not rebuilt, so QET_ENABLE_SCRIPTING + reaches QElectroTech from wherever this server was started -- normally + the "env" block of the MCP client's own configuration. That is the + consent: whoever configured this server and pointed it at a + QElectroTech binary made the choice, and their interactive + QElectroTech keeps whatever its own setting says. This server does not + set the variable itself, because a switch a program turns on for + itself is not a switch. """ src = Path(binary).expanduser() if not src.is_file() or not os.access(src, os.X_OK): @@ -465,6 +663,19 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict: home = sandbox / "home" (home / ".config").mkdir(parents=True) (home / ".local" / "share").mkdir(parents=True) + if elements_dir: + coll = Path(elements_dir).expanduser() + if not coll.is_dir(): + raise ValueError(f"no such elements directory: {coll}") + cfg = home / ".config" / "QElectroTech" + cfg.mkdir(parents=True, exist_ok=True) + (cfg / "QElectroTech.conf").write_text( + "[elements-collections]\n" + f"common-collection-path={coll}\n", encoding="utf-8") + if script is not None: + script_path = sandbox / "qet-mcp-edit.js" + script_path.write_text(script, encoding="utf-8") + args = ["--run", str(script_path), *args] env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), @@ -477,8 +688,26 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict: return {"ok": False, "timed_out": True, "timeout_s": timeout, "hint": "a modal dialog during load will hang a headless " "run; check the project's format version"} - return {"ok": p.returncode == 0, "exit_code": p.returncode, - "stdout": p.stdout[-4000:], "stderr": p.stderr[-4000:]} + result = {"ok": p.returncode == 0, "exit_code": p.returncode, + "stdout": p.stdout[-tail:], "stderr": p.stderr[-tail:]} + # QElectroTech refuses --run when scripting is switched off, which + # it is by default. Its own message is clear but French, and it + # names a settings dialog that nobody driving this server is + # looking at -- so say the thing that actually applies here. Keyed + # on QElectroTech naming the variable, with exit 3 as a fallback + # for a future build that words the refusal differently. + if script is not None and not result["ok"] and ( + "QET_ENABLE_SCRIPTING" in p.stderr or p.returncode == 3): + result["hint"] = ( + "this tool drives QElectroTech through a script, and this " + "QElectroTech has scripting switched off. Add " + "QET_ENABLE_SCRIPTING=1 to the environment this server is " + "started in -- in an MCP client that is the \"env\" block of " + "its entry in the client configuration. Only qet_query, " + "qet_continuity, qet_check, qet_project_new and qet_edit " + "need it; every other tool either reads the file directly " + "or uses a plain CLI flag.") + return result def tool_export(binary: str, project: str, format: str, output: str, @@ -505,6 +734,1273 @@ def tool_export(binary: str, project: str, format: str, output: str, return result +# -------------------------------------------------------------------------- +# qet_element_build: author a .elmt definition +# -------------------------------------------------------------------------- + +# Derived from the 6,918 shipped elements rather than from documentation: +# these are the attributes each part tag actually carries. Everything not +# listed here is refused, so a typo becomes an error instead of an +# attribute QElectroTech silently ignores. +PART_SCHEMA = { + "line": {"required": ("x1", "y1", "x2", "y2"), + "optional": ("end1", "end2", "length1", "length2")}, + "rect": {"required": ("x", "y", "width", "height"), "optional": ("rx", "ry")}, + "ellipse": {"required": ("x", "y", "width", "height"), "optional": ()}, + "circle": {"required": ("x", "y", "diameter"), "optional": ()}, + "arc": {"required": ("x", "y", "width", "height", "start", "angle"), + "optional": ()}, + "polygon": {"required": ("points",), "optional": ("closed",)}, + "text": {"required": ("x", "y", "text"), "optional": ("size", "rotation", "color")}, +} + +DEFAULT_STYLE = "line-style:normal;line-weight:normal;filling:none;color:black" + +TERMINAL_ORIENTATIONS = ("n", "s", "e", "w") + +# As used in the collection. "thumbnail" is included because it is the +# second most common value, not because this tool can build a good one. +LINK_TYPES = ("simple", "thumbnail", "master", "slave", "terminal", + "next_report", "previous_report") + + +def _f(value, where: str) -> float: + try: + return float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}") + + +def _part_extent(kind: str, part: dict) -> list: + """The x,y points a part reaches, for the bounding box.""" + g = lambda k: _f(part[k], f"{kind}.{k}") + if kind == "line": + return [(g("x1"), g("y1")), (g("x2"), g("y2"))] + if kind in ("rect", "ellipse", "arc"): + x, y, w, h = g("x"), g("y"), g("width"), g("height") + return [(x, y), (x + w, y + h)] + if kind == "circle": + x, y, d = g("x"), g("y"), g("diameter") + return [(x, y), (x + d, y + d)] + if kind == "polygon": + return [(_f(px, "polygon point"), _f(py, "polygon point")) + for px, py in part["points"]] + if kind == "text": + return [(g("x"), g("y"))] + return [] + + +def _element_geometry(parts: list, terminals: list) -> dict: + """Bounding box, then a declared box that contains it. + + The .elmt header carries width/height/hotspot_x/hotspot_y, and the + relationship to the drawing is a containment constraint rather than a + formula: the declared box runs from (-hotspot_x, -hotspot_y) to + (width - hotspot_x, height - hotspot_y) in the element's own + coordinates, and the drawing has to fit inside it. Checked against the + shipped collection, where authors chose their own margins -- one + element pads 2 units on the left and 3 on the right, another 8 and 2 -- + so there is nothing to copy, only an invariant to satisfy. + + Sizes are rounded out to multiples of 10, which is what every element + sampled from the collection uses and what keeps terminals on the grid. + """ + points = [] + for part in parts: + points += _part_extent(part["type"], part) + for t in terminals: + points.append((_f(t["x"], "terminal.x"), _f(t["y"], "terminal.y"))) + if not points: + raise ValueError("an element needs at least one part or terminal") + + min_x = min(x for x, _ in points) + max_x = max(x for x, _ in points) + min_y = min(y for _, y in points) + max_y = max(y for _, y in points) + + import math + pad = 5.0 + hotspot_x = int(math.ceil((-min_x + pad) / 10.0) * 10) + hotspot_y = int(math.ceil((-min_y + pad) / 10.0) * 10) + width = int(math.ceil((max_x + hotspot_x + pad) / 10.0) * 10) + height = int(math.ceil((max_y + hotspot_y + pad) / 10.0) * 10) + + # The invariant, asserted rather than trusted: an element whose drawing + # escapes its declared box is the classic way a hand-written .elmt + # renders clipped in the collection panel while looking fine in XML. + if not (-hotspot_x <= min_x and max_x <= width - hotspot_x + and -hotspot_y <= min_y and max_y <= height - hotspot_y): + raise ValueError( + f"internal error: declared box ({-hotspot_x}, {-hotspot_y}) to " + f"({width - hotspot_x}, {height - hotspot_y}) does not contain the " + f"drawing ({min_x}, {min_y}) to ({max_x}, {max_y})") + + return {"width": width, "height": height, + "hotspot_x": hotspot_x, "hotspot_y": hotspot_y, + "bbox": [min_x, min_y, max_x, max_y]} + + +def _validate_part(index: int, part) -> str: + if not isinstance(part, dict): + raise ValueError(f"part {index} is not an object: {part!r}") + kind = part.get("type") + if kind not in PART_SCHEMA: + raise ValueError(f"part {index}: unknown type {kind!r}; expected one of " + f"{', '.join(sorted(PART_SCHEMA))}") + spec = PART_SCHEMA[kind] + for key in spec["required"]: + if key not in part: + raise ValueError(f"part {index} ({kind}) is missing {key!r}") + allowed = set(spec["required"]) | set(spec["optional"]) | {"type", "style", "antialias"} + for key in part: + if key not in allowed: + raise ValueError(f"part {index} ({kind}): unexpected {key!r}; " + f"allowed: {', '.join(sorted(allowed))}") + if kind == "polygon": + pts = part["points"] + if not isinstance(pts, list) or len(pts) < 2: + raise ValueError(f"part {index} (polygon) needs at least two points") + for pt in pts: + if not (isinstance(pt, (list, tuple)) and len(pt) == 2): + raise ValueError(f"part {index} (polygon): each point is [x, y], got {pt!r}") + return kind + + +def _part_element(part: dict) -> ET.Element: + kind = part["type"] + node = ET.Element(kind) + if kind == "polygon": + for n, (px, py) in enumerate(part["points"], start=1): + node.set(f"x{n}", _fmt(px)) + node.set(f"y{n}", _fmt(py)) + node.set("closed", "true" if part.get("closed", True) else "false") + elif kind == "text": + node.set("x", _fmt(part["x"])) + node.set("y", _fmt(part["y"])) + node.set("text", str(part["text"])) + node.set("rotation", _fmt(part.get("rotation", 0))) + node.set("font", f"Sans Serif,{int(part.get('size', 9))},-1,5,50,0,0,0,0,0") + node.set("color", str(part.get("color", "#000000"))) + return node + else: + for key in PART_SCHEMA[kind]["required"] + PART_SCHEMA[kind]["optional"]: + if key in part: + node.set(key, _fmt(part[key])) + node.set("antialias", "true" if part.get("antialias", True) else "false") + node.set("style", part.get("style", DEFAULT_STYLE)) + return node + + +def _fmt(v) -> str: + """Numbers the way QElectroTech writes them: no trailing .0.""" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, (int, float)): + f = float(v) + return str(int(f)) if f == int(f) else repr(f) + return str(v) + + +def tool_element_build(output: str, names: dict, parts: list, + terminals: list | None = None, + link_type: str = "simple", + informations: dict | None = None, + uuid: str | None = None) -> dict: + """Write a .elmt element definition. + + Unlike a project, an element definition is not rewritten by + QElectroTech on a round trip, so generating one here is safe in a way + that generating a .qet would not be: there is no toXml() that will + drop what this writer did not know to emit. + + What it will not do is invent geometry. The caller supplies the parts; + this validates them against the schema the shipped collection actually + uses, computes the width/height/hotspot header so the declared box + contains the drawing, and refuses anything it cannot place. + """ + terminals = terminals or [] + if not isinstance(names, dict) or not names: + raise ValueError('names must be a non-empty object, e.g. {"en": "Coil", "fr": "Bobine"}') + if link_type not in LINK_TYPES: + raise ValueError(f"unknown link_type {link_type!r}; expected one of " + f"{', '.join(LINK_TYPES)}") + if not isinstance(parts, list): + raise ValueError("parts must be a list") + for i, part in enumerate(parts): + _validate_part(i, part) + for i, t in enumerate(terminals): + if not isinstance(t, dict): + raise ValueError(f"terminal {i} is not an object: {t!r}") + for key in ("x", "y", "orientation"): + if key not in t: + raise ValueError(f"terminal {i} is missing {key!r}") + if t["orientation"] not in TERMINAL_ORIENTATIONS: + raise ValueError(f"terminal {i}: orientation is one of " + f"{', '.join(TERMINAL_ORIENTATIONS)}, got {t['orientation']!r}") + # A master with no terminal cannot be wired, and a slave with none + # cannot be placed on a rail -- both are silent failures at use time. + if link_type in ("master", "slave", "simple") and not terminals: + raise ValueError(f"a {link_type} element with no terminals cannot be connected; " + "add terminals, or use link_type 'thumbnail' for a drawing-only element") + + geometry = _element_geometry(parts, terminals) + + root = ET.Element("definition", { + "version": "0.100.0", "type": "element", "link_type": link_type, + "width": str(geometry["width"]), "height": str(geometry["height"]), + "hotspot_x": str(geometry["hotspot_x"]), "hotspot_y": str(geometry["hotspot_y"]), + }) + ET.SubElement(root, "uuid", {"uuid": uuid or "{" + str(__import__("uuid").uuid4()) + "}"}) + names_node = ET.SubElement(root, "names") + for lang in sorted(names): + ET.SubElement(names_node, "name", {"lang": lang}).text = str(names[lang]) + if informations: + kind = ET.SubElement(root, "kindInformations") + for key in sorted(informations): + ET.SubElement(kind, "kindInformation", {"name": key}).text = str(informations[key]) + ET.SubElement(root, "informations") + description = ET.SubElement(root, "description") + for part in parts: + description.append(_part_element(part)) + for t in terminals: + attrs = {"x": _fmt(t["x"]), "y": _fmt(t["y"]), + "orientation": t["orientation"], + "type": t.get("type", "Generic"), + "uuid": "{" + str(__import__("uuid").uuid4()) + "}"} + if t.get("name"): + attrs["name"] = str(t["name"]) + ET.SubElement(description, "terminal", attrs) + + out = Path(output).expanduser() + out.parent.mkdir(parents=True, exist_ok=True) + ET.indent(root, space=" ") + out.write_bytes(ET.tostring(root, encoding="utf-8", xml_declaration=True)) + + # Read it back with the same reader every other tool here uses, rather + # than reporting what was intended. + check = tool_element_info(str(out)) + return {"ok": True, "output": str(out), "bytes": out.stat().st_size, + **geometry, + # The order add_conductor will use, which is not the order the + # caller listed them in. + "terminal_index_order": [t["name"] or f"({t['x']},{t['y']})" + for t in check["terminals"]], + "verified": check} + + +# -------------------------------------------------------------------------- +# qet_edit: drive the scripting API, then prove what it did +# -------------------------------------------------------------------------- + +# op name -> (qet method, argument spec). A spec entry is (json key, kind), +# where kind says how the value is turned into JavaScript and, for "folio" +# and "elmt", that it may be a "$name" reference to an earlier op's result. +OPS = { + "add_folio": (None, []), + "set_folio_title": ("setFolioTitle", [("folio", "folio"), ("title", "str")]), + "add_element": ("addElement", [("folio", "folio"), ("path", "str"), + ("x", "num"), ("y", "num")]), + "set_position": ("setElementPosition", [("folio", "folio"), ("element", "elmt"), + ("x", "num"), ("y", "num")]), + "move_element": ("moveElement", [("folio", "folio"), ("element", "elmt"), + ("dx", "num"), ("dy", "num")]), + "rotate_element": ("rotateElement", [("folio", "folio"), ("element", "elmt"), + ("angle", "num")]), + "set_label": ("setElementLabel", [("folio", "folio"), ("element", "elmt"), + ("label", "str")]), + "set_info": ("setElementInfo", [("folio", "folio"), ("element", "elmt"), + ("key", "str"), ("value", "str")]), + "add_conductor": ("addConductor", [("folio", "folio"), + ("from", "elmt"), ("from_terminal", "num"), + ("to", "elmt"), ("to_terminal", "num")]), + "delete_element": ("deleteElement", [("folio", "folio"), ("element", "elmt")]), + "set_conductor": ("setConductorProperty", [("folio", "folio"), ("element", "elmt"), + ("terminal", "num"), ("property", "str"), + ("value", "str")]), + "move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"), + ("terminal", "num"), ("segment", "num"), + ("dx", "num"), ("dy", "num")]), + "link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"), + ("to_folio", "folio"), ("to", "elmt")]), + "link_plc_io": ("linkElements", [("folio", "folio"), ("element", "elmt"), + ("to_folio", "folio"), ("to", "elmt"), + ("io_index", "folio")]), + "unlink_element": ("unlinkElement", [("folio", "folio"), ("element", "elmt")]), + "add_plc_io": ("addPlcIO", [("folio", "folio"), ("element", "elmt"), + ("type", "str"), ("address", "str"), + ("function", "str"), ("comment", "str")]), + "set_plc_io": ("setPlcIO", [("folio", "folio"), ("element", "elmt"), + ("index", "folio"), ("property", "str"), + ("value", "str")]), + "remove_plc_io": ("removePlcIO", [("folio", "folio"), ("element", "elmt"), + ("index", "folio")]), + # Texts and shapes have no uuid; they are addressed by index into a + # position-sorted listing, and add_text/add_shape return that index so + # it can be named as "$id". Indexes shift when one is added or deleted. + "delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"), + ("terminal", "num")]), + "remove_folio": ("removeFolio", [("folio", "folio")]), + "set_folio": ("setFolioProperty", [("folio", "folio"), ("property", "str"), + ("value", "str")]), + "add_terminal_strip": ("addTerminalStrip", [("installation", "str"), ("location", "str"), + ("name", "str")]), + "remove_terminal_strip": ("removeTerminalStrip", [("strip", "folio")]), + "add_to_strip": ("addTerminalToStrip", [("strip", "folio"), ("folio", "folio"), + ("element", "elmt")]), + "group_terminals": ("groupTerminals", [("strip", "folio"), ("indices", "indices")]), + "bridge_terminals": ("bridgeTerminals", [("strip", "folio"), ("indices", "indices")]), + "sort_terminal_strip": ("sortTerminalStrip", [("strip", "folio")]), + "add_autonum": ("addAutoNum", [("kind", "str"), ("name", "str"), + ("parts", "list")]), + "remove_autonum": ("removeAutoNum", [("kind", "str"), ("name", "str")]), + "use_conductor_autonum": ("useConductorAutoNum", [("folio", "folio"), ("name", "str")]), + "use_element_autonum": ("useElementAutoNum", [("name", "str")]), + "number_element": ("numberElement", [("folio", "folio"), ("element", "elmt")]), + # The text fields drawn on a symbol. Indexed within the element's own + # list, which follows its definition and shifts on delete (and undo of a + # delete puts the field back at the end). + "add_element_text": ("addElementText", [("folio", "folio"), ("element", "elmt"), + ("source", "str"), ("value", "str"), + ("x", "num"), ("y", "num")]), + "set_element_text": ("setElementTextProperty", [("folio", "folio"), ("element", "elmt"), + ("index", "folio"), ("property", "str"), + ("value", "str")]), + "delete_element_text": ("deleteElementText", [("folio", "folio"), ("element", "elmt"), + ("index", "folio")]), + # Returns the uuids of the copies IN THE ORDER the elements were named, + # so "$copies[0]" is the copy of the first one. Conductors between the + # copied elements are copied with them; copies arrive without labels or + # wire numbers, as they do on a paste in the application. + "insert_folio": ("insertFolio", [("position", "folio")]), + # Reads, not edits: the result is reported in the operations list, so a + # follow-up call can lay something out relative to it. + "element_geometry": ("elementGeometry", [("folio", "folio"), ("element", "elmt")]), + # Undo/redo act on QElectroTech's undo stack for this run, one command at + # a time. Consecutive edits to the same property or information key merge + # into one command, so one undo can revert several of them. + "undo": ("undo", []), + "redo": ("redo", []), + "search_and_replace": ("searchAndReplace", [("kind", "str"), ("field", "str"), + ("pattern", "str"), ("replacement", "str"), + ("regex", "bool"), ("case_sensitive", "bool")]), + "set_project_title": ("setProjectTitle", [("title", "str")]), + "set_folio_border": ("setFolioBorder", [("folio", "folio"), ("property", "str"), + ("value", "str")]), + "embed_title_block_template": ("embedTitleBlockTemplate", [("name", "str")]), + "duplicate_elements": ("duplicateElements", [("folio", "folio"), ("elements", "elmts"), + ("to_folio", "folio"), ("x", "num"), ("y", "num")]), + "add_text": ("addText", [("folio", "folio"), ("text", "str"), + ("x", "num"), ("y", "num")]), + "set_text": ("setTextContent", [("folio", "folio"), ("index", "folio"), + ("text", "str")]), + "set_text_color": ("setTextColor", [("folio", "folio"), ("index", "folio"), + ("color", "str")]), + "rotate_text": ("setTextRotation", [("folio", "folio"), ("index", "folio"), + ("angle", "num")]), + "delete_text": ("deleteText", [("folio", "folio"), ("index", "folio")]), + "add_shape": ("addShape", [("folio", "folio"), ("shape", "str"), + ("x1", "num"), ("y1", "num"), + ("x2", "num"), ("y2", "num")]), + "set_shape": ("setShapeProperty", [("folio", "folio"), ("index", "folio"), + ("property", "str"), ("value", "str")]), + "add_image": ("addImage", [("folio", "folio"), ("file", "str"), + ("x", "num"), ("y", "num")]), + "add_pdf_page": ("addPdfPage", [("folio", "folio"), ("file", "str"), + ("page", "num"), ("dpi", "num"), + ("x", "num"), ("y", "num")]), + "scale_image": ("setImageScale", [("folio", "folio"), ("index", "folio"), + ("factor", "num")]), + "rotate_image": ("setImageRotation", [("folio", "folio"), ("index", "folio"), + ("angle", "num")]), + "delete_image": ("deleteImage", [("folio", "folio"), ("index", "folio")]), + "delete_shape": ("deleteShape", [("folio", "folio"), ("index", "folio")]), + "add_polygon": ("addPolygon", [("folio", "folio"), ("points", "points"), + ("closed", "bool")]), + "set_shape_polygon": ("setShapePolygon", [("folio", "folio"), ("index", "folio"), + ("points", "points")]), + "add_path": ("addPath", [("folio", "folio"), ("nodes", "nodes"), + ("closed", "bool")]), + "set_shape_path_nodes": ("setShapePathNodes", [("folio", "folio"), ("index", "folio"), + ("nodes", "nodes")]), + "set_shape_closed": ("setShapeClosed", [("folio", "folio"), ("index", "folio"), + ("closed", "bool")]), + "add_table": ("addTable", [("folio", "folio"), ("kind", "str"), + ("name", "str"), ("query", "str")]), + "set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "folio"), + ("x", "num"), ("y", "num")]), + "delete_table": ("deleteTable", [("folio", "folio"), ("table", "folio")]), +} + +SHAPES = ["line", "rectangle", "ellipse", "polygon"] +FOLIO_BORDER_PROPERTIES = ["columns", "column-width", "display-columns", + "rows", "row-height", "display-rows"] +ELEMENT_TEXT_SOURCES = ["text", "info", "composite"] +ELEMENT_TEXT_PROPERTIES = ["text", "source", "info", "composite", "frame", "size", + "x", "y", "rotation", "width"] +SHAPE_PROPERTIES = ["color", "fill", "width", "line-style", "rotation"] +AUTONUM_KINDS = ["conductor", "element", "folio"] +SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"] +FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach", + "indexrev", "folio", "template"] + +# Accepted by set_conductor. The names are the project file's own, so what +# a script sets is what qet_conductors reports back. +CONDUCTOR_PROPERTIES = ["num", "formula", "function", "bus", "cable", + "tension_protocol", "conductor_color", + "conductor_section", "color", "text_color", + # the conductor's look, under the file's own names + "color2", "bicolor", "style", "dash-size", + "condsize", "numsize", "displaytext"] + +# Methods this tool needs that only exist in a build carrying the drawing +# verbs. Probed in the script rather than assumed, because the failure mode +# otherwise is a TypeError on line N of a generated file the caller never +# sees, reported as "the edit failed". +_REQUIRED_METHODS = sorted({m for m, _ in OPS.values() if m} | + {"save", "folioCount", "conductorCount", "elementCount"}) + +_MARKER = "QETEDIT " + + +def _js(value) -> str: + """A JSON literal is a JavaScript literal for every type used here.""" + return json.dumps(value) + + +def _build_script(operations: list, output: str) -> str: + """Turn the operation list into a script, or raise on a bad operation. + + Every op is validated here, before QElectroTech is launched at all: a + typo in an op name should cost nothing, not a process start and a + JavaScript exception. + """ + refs: set[str] = set() + lines = [ + "// generated by qet-mcp; do not edit", + "var R = {};", # $name -> value from an earlier op + "var missing = [];", + f"var need = {_js(_REQUIRED_METHODS)};", + "for (var i = 0; i < need.length; i++) {", + " if (typeof qet[need[i]] !== 'function') missing.push(need[i]);", + "}", + f"qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'capabilities', missing: missing}));", + "var stop = false;", + "if (missing.length === 0) {", + ] + + def ref_or(value, kind: str, op_index: int, key: str) -> str: + if kind == "elmts": + if not isinstance(value, list) or not value or not all(isinstance(v, str) for v in value): + raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of " + f"elements (uuids or \"$id\" references), got {value!r}") + return "[" + ", ".join(ref_or(v, "elmt", op_index, key) for v in value) + "]" + if isinstance(value, str) and value.startswith("$"): + indexed = re.fullmatch(r"\$([A-Za-z0-9_]+)\[(\d+)\]", value) + if indexed: + # one item of a list result, e.g. the second copy from + # duplicate_elements + name, n = indexed.group(1), int(indexed.group(2)) + if name not in refs: + raise ValueError( + f"operation {op_index} refers to {value!r}, which no earlier " + f"operation defined (set \"id\": {name!r} on the op that creates it)") + return f"R[{_js(name)}][{n}]" + name = value[1:] + if name not in refs: + raise ValueError( + f"operation {op_index} refers to {value!r}, which no earlier " + f"operation defined (set \"id\": {name!r} on the op that creates it)") + return f"R[{_js(name)}]" + if kind == "num": + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a number, " + f"got {value!r}") + return _js(value) + if kind == "list": + if not isinstance(value, list) or not all(isinstance(x, str) for x in value): + raise ValueError(f"operation {op_index}: {key!r} must be a list of strings, " + f"got {value!r}") + return _js(value) + if kind == "indices": + if (not isinstance(value, list) or not value + or not all(isinstance(x, int) and not isinstance(x, bool) for x in value)): + raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of " + f"integer indices, got {value!r}") + return _js(value) + if kind == "folio": + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a folio index " + f"or a \"$name\" reference, got {value!r}") + return _js(value) + if kind == "bool": + if not isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be true or false, " + f"got {value!r}") + return _js(value) + if kind in ("points", "nodes"): + def _num(v): + return isinstance(v, (int, float)) and not isinstance(v, bool) + if not isinstance(value, list) or len(value) < 2: + raise ValueError(f"operation {op_index}: {key!r} must be a list of at " + f"least 2 {'points' if kind == 'points' else 'nodes'}, " + f"got {value!r}") + for item in value: + if not isinstance(item, dict) or not _num(item.get("x")) or not _num(item.get("y")): + raise ValueError(f"operation {op_index}: {key!r} entries must be " + f"{{\"x\": num, \"y\": num, ...}}, got {item!r}") + if kind == "nodes": + if "kind" in item and item["kind"] not in ("corner", "smooth", "symmetric"): + raise ValueError(f"operation {op_index}: {key!r} entry kind " + f"{item['kind']!r} must be corner, smooth or symmetric") + for hkey in ("inHandle", "outHandle"): + if hkey in item and (not isinstance(item[hkey], dict) + or not _num(item[hkey].get("x")) + or not _num(item[hkey].get("y"))): + raise ValueError(f"operation {op_index}: {key!r} entry " + f"{hkey!r} must be {{\"x\": num, \"y\": num}}") + return _js(value) + return _js("" if value is None else str(value)) + + for i, op in enumerate(operations): + if not isinstance(op, dict): + raise ValueError(f"operation {i} is not an object: {op!r}") + name = op.get("op") + if name not in OPS: + raise ValueError(f"operation {i}: unknown op {name!r}; " + f"expected one of {', '.join(sorted(OPS))}") + method, spec = OPS[name] + if name == "set_conductor" and op.get("property") not in CONDUCTOR_PROPERTIES: + raise ValueError(f"operation {i}: unknown conductor property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(CONDUCTOR_PROPERTIES)}") + if name == "set_folio" and op.get("property") not in FOLIO_PROPERTIES: + raise ValueError(f"operation {i}: unknown folio property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(FOLIO_PROPERTIES)}") + if name in ("add_autonum", "remove_autonum") and op.get("kind") not in AUTONUM_KINDS: + raise ValueError(f"operation {i}: unknown kind {op.get('kind')!r}; " + f"expected one of {', '.join(AUTONUM_KINDS)}") + if name == "search_and_replace": + if op.get("kind") not in SEARCH_REPLACE_KINDS: + raise ValueError(f"operation {i}: unknown kind {op.get('kind')!r}; " + f"expected one of {', '.join(SEARCH_REPLACE_KINDS)}") + if op.get("kind") == "conductor" and op.get("field") not in CONDUCTOR_PROPERTIES: + raise ValueError(f"operation {i}: unknown conductor field " + f"{op.get('field')!r}; expected one of " + f"{', '.join(CONDUCTOR_PROPERTIES)}") + if op.get("kind") == "element_info" and not op.get("field"): + raise ValueError(f"operation {i}: element_info needs a non-empty " + f"\"field\" (information key)") + if name == "set_folio_border" and op.get("property") not in FOLIO_BORDER_PROPERTIES: + raise ValueError(f"operation {i}: unknown folio border property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(FOLIO_BORDER_PROPERTIES)}") + if name == "add_element_text" and op.get("source") not in ELEMENT_TEXT_SOURCES: + raise ValueError(f"operation {i}: unknown source {op.get('source')!r}; " + f"expected one of {', '.join(ELEMENT_TEXT_SOURCES)}") + if name == "set_element_text" and op.get("property") not in ELEMENT_TEXT_PROPERTIES: + raise ValueError(f"operation {i}: unknown element-text property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(ELEMENT_TEXT_PROPERTIES)}") + if name == "set_shape" and op.get("property") not in SHAPE_PROPERTIES: + raise ValueError(f"operation {i}: unknown shape property " + f"{op.get('property')!r}; expected one of " + f"{', '.join(SHAPE_PROPERTIES)}") + if name == "add_shape" and op.get("shape") not in SHAPES: + raise ValueError(f"operation {i}: unknown shape {op.get('shape')!r}; " + f"expected one of {', '.join(SHAPES)}") + args = [] + for key, kind in spec: + if key not in op: + raise ValueError(f"operation {i} ({name}) is missing {key!r}") + args.append(ref_or(op[key], kind, i, key)) + + ident = op.get("id") + if ident is not None: + if not isinstance(ident, str) or not ident or ident.startswith("$"): + raise ValueError(f"operation {i}: \"id\" must be a non-empty name " + f"without a leading $, got {ident!r}") + if ident in refs: + raise ValueError(f"operation {i}: \"id\" {ident!r} is already used") + + call = "qet.addFolio()" if method is None else f"qet.{method}({', '.join(args)})" + lines.append(" if (!stop) {") + lines.append(f" var v{i} = {call};") + if ident is not None: + lines.append(f" R[{_js(ident)}] = v{i};") + refs.add(ident) + lines.append( + f" qet.log({_js(_MARKER)} + JSON.stringify(" + f"{{kind: 'op', index: {i}, op: {_js(name)}, " + f"id: {_js(ident)}, result: v{i}}}));") + # An op that failed usually invalidates the ones after it -- a + # conductor to an element that was never placed is not a second, + # independent finding, it is noise on top of the first one. The + # three falsey returns are the three the API uses: false for a + # refused edit, "" for an addElement that placed nothing, -1 for an + # addFolio that added none. + # An empty list is a failure too (duplicateElements returns [] when it + # refuses). Duck-typed on .length, not Array.isArray: QJSEngine hands + # an empty QStringList back as an array-like wrapper for which + # Array.isArray is false, so that test never fired and the run went + # on past the failed operation. + lines.append(f" if (v{i} === false || v{i} === '' || v{i} === -1 || " + f"(typeof v{i} === 'object' && v{i} !== null && " + f"(v{i}.length === 0 || (v{i}.length === undefined && " + f"Object.keys(v{i}).length === 0)))) " + "stop = true;") + lines.append(" }") + lines.append(" // ---- end of operations ----") + + # Save even after a failed op: a partial result that can be inspected + # beats no result at all, and the diff is what says how far it got. + lines.append(f" var saved = qet.save({_js(output)});") + lines.append(f" qet.log({_js(_MARKER)} + JSON.stringify(" + "{kind: 'save', result: saved, stopped_early: stop}));") + lines.append("}") + return "\n".join(lines) + "\n" + + +def _parse_script_output(text: str) -> dict: + """Read the marker lines the generated script emits. + + They arrive on stderr, not stdout: QetScriptApi::log() is a + QTextStream(stderr). Read both anyway rather than depending on that -- + the cost is nothing and the failure it prevents is silent (an edit that + worked, reported as having run no operations at all, which is what the + first version of this tool did).""" + caps, ops, saved, stopped = None, [], None, False + for line in text.splitlines(): + idx = line.find(_MARKER) + if idx < 0: + continue + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if rec.get("kind") == "capabilities": + caps = rec.get("missing") or [] + elif rec.get("kind") == "op": + rec.pop("kind", None) + # Not "in (False, ...)": 0 == False in Python, and 0 is a valid + # index/folio result. The script side already uses strict ===. + r = rec.get("result") + rec["succeeded"] = not (r is None or r is False or r == "" or r == [] or r == {} or + (isinstance(r, int) and not isinstance(r, bool) and r == -1)) + ops.append(rec) + elif rec.get("kind") == "save": + saved = bool(rec.get("result")) + stopped = bool(rec.get("stopped_early")) + return {"missing_methods": caps, "operations": ops, "saved": saved, + "stopped_early": stopped} + + +def tool_query(binary: str, project: str, sql: str, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Run a read-only SELECT against the project's SQLite database. + + This is the surface the rest of this server has done without. Every + other structural tool here re-derives its answer from the XML, because + the database was unreachable from outside the application; it is + reachable now, through the same guarded path QElectroTech's own + "Requête SQL personnalisée" box uses, so a structural question can be + asked of the database that already knows it. + + The three *_view names are the surface to depend on -- + element_nomenclature_view, project_summary_view, wiring_list_view. + They exist to be queried. The underlying tables are how the cache is + arranged today and a column may move; qet_query with sql omitted + lists both. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + + if sql: + # Same first-word rule projectDataBase::isReadOnlySelect() applies, + # checked here too so an obvious write is refused without paying + # for a process launch. QET still enforces it; this is not the + # guard, only an early one. + head = sql.strip().lstrip("(").split(None, 1)[0].upper() if sql.strip() else "" + if head not in ("SELECT", "WITH"): + raise ValueError("only read-only queries are allowed: a statement must " + f"begin with SELECT or WITH, not {head or '(nothing)'}") + + script = ("var out = %s ? qet.query(%s) : qet.tables();\n" + "qet.log(%s + JSON.stringify({kind: 'query', rows: out, " + "error: qet.queryError ? qet.queryError() : ''}));\n" + % (json.dumps(bool(sql)), json.dumps(sql or ""), json.dumps(_MARKER))) + + result = _run_qet(binary, [str(proj)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=400_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + rows, error = None, "" + for line in streams.splitlines(): + idx = line.find(_MARKER) + if idx < 0: + continue + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if rec.get("kind") == "query": + rows, error = rec.get("rows"), rec.get("error") or "" + for key in ("stdout", "stderr"): + kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln] + result[key] = "\n".join(kept)[-4000:] + + if rows is None: + result["ok"] = False + result.setdefault("hint", "the query returned nothing at all -- this build's " + "scripting API may predate qet.query()") + return result + if error: + result["ok"] = False + result["error"] = error + result["rows"] = rows + result["row_count"] = len(rows) + result["listing"] = not sql + return result + + +# -------------------------------------------------------------------------- +# qet_continuity: electrical continuity / ERC-style structural checks +# -------------------------------------------------------------------------- + +def tool_continuity(binary: str, project: str, folio: int | None = None, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Run qet.checkContinuity() and get its findings back. + + Three checks, against the live Terminal/Conductor object graph rather + than a heuristic read of the XML (that is qet_check's job, and the two + are complementary, not redundant -- qet_check looks at labels and + numbering conventions, this looks at the electrical graph itself): + unconnected_terminal (info -- routine, not necessarily a mistake), + potential_mismatch (error -- two conductors QElectroTech's own + setConductorProperty() would always keep identical, found disagreeing, + which only happens from hand-edited XML, a legacy file, or an external + tool), and report_link_mismatch (warning -- a next_report/ + previous_report folio-jump pair whose conductors disagree on colour, + style, num, etc.; unlike potential_mismatch this one CAN happen through + ordinary use, since LinkElementCommand::isLinkable() never checks + conductor properties, only type and freedom -- see + qelectrotech/qelectrotech-source-mirror#974, which this check + reproduces exactly: one folio-link conductor drawn in two different + colours on either side of the link). See qet.checkContinuity()'s own + doc comment (qetscriptapi.cpp) for what this deliberately does not + check: pin electrical direction/power conflicts and No/Nc/Common + contact shorts, since QElectroTech's terminal data model does not + carry the information either would need. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + + folio_arg = -1 if folio is None else folio + script = ("var out = qet.checkContinuity(%s);\n" + "qet.log(%s + JSON.stringify({kind: 'continuity', findings: out}));\n" + % (json.dumps(folio_arg), json.dumps(_MARKER))) + + result = _run_qet(binary, [str(proj)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=400_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + findings = None + for line in streams.splitlines(): + idx = line.find(_MARKER) + if idx < 0: + continue + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if rec.get("kind") == "continuity": + findings = rec.get("findings") + for key in ("stdout", "stderr"): + kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln] + result[key] = "\n".join(kept)[-4000:] + + if findings is None: + result["ok"] = False + result.setdefault("hint", "no findings came back at all -- this build's " + "scripting API may predate qet.checkContinuity()") + return result + result["findings"] = findings + result["finding_count"] = len(findings) + result["errors"] = sum(1 for f in findings if f.get("severity") == "error") + result["warnings"] = sum(1 for f in findings if f.get("severity") == "warning") + result["info"] = sum(1 for f in findings if f.get("severity") == "info") + return result + + +# -------------------------------------------------------------------------- +# qet_element_search: find a symbol in the collection +# -------------------------------------------------------------------------- + +_ELEMENT_INDEX: dict = {} + + +def _fold(text: str) -> str: + """Case- and accent-insensitive form, so 'resistance' finds 'Résistance'.""" + import unicodedata + return "".join(c for c in unicodedata.normalize("NFKD", text.lower()) + if not unicodedata.combining(c)) + + +def _collection_signature(root: Path): + """Cheap change detector: file count and newest mtime, no parsing.""" + count, newest = 0, 0.0 + for f in root.rglob("*.elmt"): + count += 1 + try: + newest = max(newest, f.stat().st_mtime) + except OSError: + pass + return count, newest + + +def _index_collection(root: Path) -> list: + """Parse every .elmt under root once and keep what a search needs. + + Cached for the life of the process and rebuilt when the file count or + the newest modification time changes -- which is what makes a symbol + written by qet_element_build findable straight away, without the caller + knowing there is an index at all. + """ + key = str(root.resolve()) + sig = _collection_signature(root) + cached = _ELEMENT_INDEX.get(key) + if cached and cached["sig"] == sig: + return cached["items"] + + items = [] + for f in sorted(root.rglob("*.elmt")): + try: + d = ET.parse(f).getroot() + except (ET.ParseError, OSError): + continue + if d.tag != "definition": + continue + names = {n.get("lang", ""): (n.text or "").strip() for n in d.iter("name")} + kind = "" + for ki in d.iter("kindInformation"): + if ki.get("name") == "type": + kind = (ki.text or "").strip() + ordered, ambiguous = _terminals_in_index_order(list(d.iter("terminal"))) + terminals = [t.get("name") or "" for t in ordered] + rel = f.relative_to(root).as_posix() + items.append({ + "path": "common://" + rel, + "file": str(f), + "name": names.get("en") or names.get("fr") or next(iter(names.values()), ""), + "names": names, + "link_type": d.get("link_type", "simple"), + "kind": kind, + "terminals": len(terminals), + "terminal_names": terminals, # index order, not file order + "terminal_order_ambiguous": ambiguous, + "width": d.get("width"), "height": d.get("height"), + "haystack": _fold(" ".join([*names.values(), rel, kind])), + }) + _ELEMENT_INDEX[key] = {"sig": sig, "items": items} + return items + + +def tool_element_search(directory: str, query: str = "", link_type: str | None = None, + min_terminals: int | None = None, max_terminals: int | None = None, + kind: str | None = None, limit: int = 25) -> dict: + """Search an element collection by name, type and terminal count. + + Matches every word of query against all the translated names, the + element's path and its kind, ignoring case and accents -- so a French + or German search finds the same symbol an English one does. Results + carry a common:// path that qet_edit's add_element takes directly, and + the terminal names in the order add_conductor indexes them -- which is + top to bottom then left to right, not the order the file lists them. + """ + root = Path(directory).expanduser() + if not root.is_dir(): + raise ValueError(f"no such directory: {root}") + if link_type is not None and link_type not in LINK_TYPES: + raise ValueError(f"unknown link_type {link_type!r}; expected one of " + f"{', '.join(LINK_TYPES)}") + if limit < 1: + raise ValueError("limit must be >= 1") + + words = _fold(query).split() + matches = [] + for it in _index_collection(root): + if link_type and it["link_type"] != link_type: + continue + if kind and _fold(kind) not in _fold(it["kind"]): + continue + if min_terminals is not None and it["terminals"] < min_terminals: + continue + if max_terminals is not None and it["terminals"] > max_terminals: + continue + if not all(w in it["haystack"] for w in words): + continue + matches.append(it) + + # Whole-name hits before substring hits, then shorter names first: a + # search for "coil" should offer "Coil" before "Remanence coil, latching". + def rank(it): + name = _fold(it["name"]) + exact = 0 if (words and name == " ".join(words)) else 1 + starts = 0 if (words and name.startswith(words[0])) else 1 + return (exact, starts, len(it["name"]), it["path"]) + matches.sort(key=rank) + + shown = [{k: v for k, v in it.items() if k not in ("haystack", "names", "file")} + | {"languages": sorted(it["names"])} for it in matches[:limit]] + return {"query": query, "total_matches": len(matches), "returned": len(shown), + "indexed": len(_ELEMENT_INDEX[str(root.resolve())]["items"]), + "results": shown} + + +# Design-rule checks, each one a read-only query over the project database. +# +# Every check is a SELECT that returns the offending rows, so "no rows" is a +# pass and the same query is what a human would write by hand. The severity +# and the note say how much to trust a hit, because these are heuristics +# tuned against the 24 shipped examples, not standards: +# +# - Every text comparison is COALESCE'd. A value that was never set is NULL +# in the database when the element was placed in this session and an empty +# string when it was loaded from a file, and `col = ''` matches only the +# second -- which made the first version of these checks silently pass on +# exactly the freshly-edited projects qet_edit produces. The JavaScript +# side renders both as "", so the difference is invisible until a check +# fails to fire. Likewise exclude_from_bom is text, not a number. +# - An unnumbered conductor is '' in some files and '_' in others: '_' is the +# placeholder QElectroTech assigns when no numbering is configured, so +# testing for '' alone passed on industrial.qet's 36 placeholder conductors +# and on every project qet_edit builds without a numbering context. +# - Slaves and terminals are excluded from the duplicate-label check on +# purpose. A slave contact carries its master coil's label by design, and +# terminals repeat their numbers from one strip to the next; counting +# either would bury the real findings. +# - "simple" elements are checked for duplicates too but only as a warning: +# industrial.qet reuses V1..V6 across folios on purpose. +CHECKS = { + "duplicate_master_labels": { + "severity": "error", + "note": "Two master elements with the same label are ambiguous in every " + "report that keys on it (BOM, cross-references, wiring list).", + "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view " + "WHERE COALESCE(label,'') <> '' AND element_type = 'master' " + "GROUP BY label HAVING n > 1 ORDER BY n DESC, label", + }, + "duplicate_simple_labels": { + "severity": "warning", + "note": "Legitimate when a label is reused on purpose across folios " + "(industrial.qet does); worth a look otherwise.", + "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view " + "WHERE COALESCE(label,'') <> '' AND element_type = 'simple' " + "GROUP BY label HAVING n > 1 ORDER BY n DESC, label", + }, + "unlabelled_masters": { + "severity": "warning", + "note": "A master with no label cannot be told apart from its slaves' " + "cross-references.", + "sql": "SELECT folio, diagram_position, element_sub_type FROM " + "element_nomenclature_view WHERE element_type = 'master' AND COALESCE(label,'') = '' " + "ORDER BY folio, diagram_position", + }, + "unnumbered_conductors": { + "severity": "info", + "note": "Conductors with no wire number -- empty, or QElectroTech's own " + "'_' placeholder, which is what a conductor gets when no " + "numbering is configured. If every conductor is unnumbered the " + "project simply does not use wire numbering; a few among many " + "numbered ones is the finding.", + "sql": "SELECT COUNT(*) AS unnumbered, (SELECT COUNT(*) FROM wiring_list_view) AS total " + "FROM wiring_list_view WHERE COALESCE(wire_number,'') IN ('', '_') " + "HAVING unnumbered > 0", + }, + "empty_folios": { + "severity": "info", + "note": "Folios with no element on them. Often cover pages, sometimes " + "left behind by a deleted drawing.", + "sql": "SELECT p.pos AS position, p.title AS title FROM diagram d " + "JOIN project_summary_view p ON p.pos = d.pos " + "WHERE NOT EXISTS (SELECT 1 FROM element e WHERE e.diagram_uuid = d.uuid) " + "ORDER BY p.pos", + }, + "masters_without_manufacturer_reference": { + "severity": "info", + "note": "Masters that will show a blank article number in the BOM.", + "sql": "SELECT label, folio, diagram_position FROM element_nomenclature_view " + "WHERE element_type = 'master' AND COALESCE(manufacturer_reference,'') = '' " + "AND COALESCE(exclude_from_bom,'') IN ('', '0', 'false') " + "ORDER BY folio, diagram_position", + }, +} + + +def tool_check(binary: str, project: str, checks: list | None = None, + sample: int = 10, elements_dir: str | None = None, + timeout: int = 180) -> dict: + """Run design-rule checks over a project in one QElectroTech launch. + + Every check is a read-only SELECT over the project database, so this is + qet_query with the questions already written down. It exists because the + useful questions are always the same handful and re-deriving them per + conversation is where the mistakes creep in -- the first draft of the + duplicate-label check counted slave contacts, which share their coil's + label by design and flagged nearly every relay. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + chosen = list(CHECKS) if not checks else list(checks) + for name in chosen: + if name not in CHECKS: + raise ValueError(f"unknown check {name!r}; expected one of " + f"{', '.join(sorted(CHECKS))}") + if sample < 0: + raise ValueError("sample must be >= 0") + + queries = {name: CHECKS[name]["sql"] for name in chosen} + script = ("var Q = %s;\nfor (var k in Q) {\n" + " var rows = qet.query(Q[k]);\n" + " qet.log(%s + JSON.stringify({kind: 'check', name: k, rows: rows, " + "error: qet.queryError()}));\n}\n" % (json.dumps(queries), json.dumps(_MARKER))) + result = _run_qet(binary, [str(proj)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=2_000_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + + got = {} + for line in streams.splitlines(): + idx = line.find(_MARKER) + if idx < 0: + continue + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if rec.get("kind") == "check": + got[rec["name"]] = rec + + findings, errors, passed = [], [], [] + for name in chosen: + rec = got.get(name) + if rec is None: + errors.append({"check": name, "error": "no result came back"}) + continue + if rec.get("error"): + errors.append({"check": name, "error": rec["error"]}) + continue + rows = rec.get("rows") or [] + if not rows: + passed.append(name) + continue + findings.append({"check": name, "severity": CHECKS[name]["severity"], + "count": len(rows), "note": CHECKS[name]["note"], + "rows": rows[:sample]}) + + order = {"error": 0, "warning": 1, "info": 2} + findings.sort(key=lambda f: (order[f["severity"]], f["check"])) + answer = {"ok": not errors and not any(f["severity"] == "error" for f in findings), + "summary": {"errors": sum(f["severity"] == "error" for f in findings), + "warnings": sum(f["severity"] == "warning" for f in findings), + "info": sum(f["severity"] == "info" for f in findings), + "passed": len(passed), "check_failures": len(errors)}, + "findings": findings, "passed": passed, "check_failures": errors} + # This answer is built fresh rather than layered onto the launch result, + # so a reason the launch failed at all has to be carried across + # explicitly. Without it every check reads "no result came back", which + # is true and tells nobody why. + if result.get("hint"): + answer["ok"] = False + answer["hint"] = result["hint"] + answer["exit_code"] = result.get("exit_code") + return answer + + +def tool_project_new(binary: str, output: str, title: str = "Untitled", + folios=1, author: str = "", overwrite: bool = False, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Create a new, empty project so qet_edit has something to start from. + + Every other edit tool needs an existing .qet, which made building a + schematic from nothing impossible. The obvious candidate, + examples/Projet_vierge.qet, is not blank: it is a 600 KB real project + with 23 elements and 15 conductors. + + So this writes the smallest project QElectroTech will open -- one + element with a title, no folios -- and then has QElectroTech itself + add the folios and save. What is left on disk is QElectroTech's own + canonical output, not the hand-written skeleton, which is why this is + not the "write .qet XML directly" route that was rejected: the + skeleton never reaches the result, and the result is checked by + reading it back. + + folios is a count, or a list of folio titles. + """ + out = Path(output).expanduser() + if out.exists() and not overwrite: + raise ValueError(f"{out} already exists; pass overwrite=true to replace it") + if isinstance(folios, bool) or not isinstance(folios, (int, list)): + raise ValueError("folios must be a count or a list of titles") + titles = ([""] * folios) if isinstance(folios, int) else [str(t) for t in folios] + if not 0 <= len(titles) <= 200: + raise ValueError("folios must be between 0 and 200") + if not isinstance(title, str) or not title.strip(): + raise ValueError("title must be a non-empty string") + + from xml.sax.saxutils import quoteattr + script = ["var t = %s;" % json.dumps(titles), "var made = [];", + "for (var i = 0; i < t.length; i++) {", + " var f = qet.addFolio(); made.push(f);", + " if (f >= 0 && t[i]) qet.setFolioTitle(f, t[i]);", + " if (f >= 0 && %s) qet.setFolioProperty(f, 'author', %s);" % + (json.dumps(bool(author)), json.dumps(author)), + "}", + "var saved = qet.save(%s);" % json.dumps(str(out)), + "qet.log(%s + JSON.stringify({kind: 'new', folios: made, saved: saved}));" + % json.dumps(_MARKER)] + + out.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="qet-mcp-new-") as tmp: + skeleton = Path(tmp) / "skeleton.qet" + skeleton.write_text('\n\n' + % quoteattr(title), encoding="utf-8") + result = _run_qet(binary, [str(skeleton)], timeout=timeout, + elements_dir=elements_dir, script="\n".join(script), tail=200_000) + + rec = None + for line in (result.get("stdout", "") + "\n" + result.get("stderr", "")).splitlines(): + idx = line.find(_MARKER) + if idx >= 0: + try: + r = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + continue + if r.get("kind") == "new": + rec = r + for key in ("stdout", "stderr"): + result[key] = "\n".join(l for l in result.get(key, "").splitlines() + if _MARKER not in l)[-2000:] + + if rec is None or not rec.get("saved") or not out.is_file(): + result["ok"] = False + #setdefault: _run_qet() may already have said something more + #specific than this guess -- notably that scripting is switched + #off, in which case "your build is too old" sends the reader + #looking for the wrong thing entirely. + result.setdefault("hint", + "QElectroTech did not write the project; this build's scripting " + "API may predate addFolio()/save()") + return result + if any(f < 0 for f in rec["folios"]): + result["ok"] = False + result["hint"] = "a folio could not be added" + return result + + # Read back what is actually on disk rather than report what was asked for. + info = tool_project_info(str(out)) + if info["title"] != title or info["folio_count"] != len(titles): + result["ok"] = False + result["hint"] = (f"the file on disk has title {info['title']!r} and " + f"{info['folio_count']} folio(s), not what was requested") + result["output"] = str(out) + result["project"] = info + return result + + +def tool_edit(binary: str, project: str, operations: list, output: str, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Apply edits through the scripting API and report what actually changed. + + The point is the last part. The scripting API returns a bool per call, + which says the call was accepted, not that the file came out the way + anyone intended -- so this runs qet_diff between the input project and + the saved result and puts that in the answer. A caller that trusts + "addConductor -> true" and stops there is back to trusting the + screenshot. + + The project is never written in place: output is a separate file, and + the original is what the diff is taken against. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + if not isinstance(operations, list) or not operations: + raise ValueError("operations must be a non-empty list") + out = Path(output).expanduser() + if out.resolve() == proj.resolve(): + raise ValueError("output must differ from project; this tool does not " + "edit a project in place") + + script = _build_script(operations, str(out)) + # QET interrupts a script at 30 s (kScriptTimeoutMs in qetscripting.cpp), + # independently of this timeout. Leaving room above it means a script + # that hits the engine's limit comes back as a script error we can + # report, rather than as our own opaque process timeout. + result = _run_qet(binary, [str(proj)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=200_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + result.update(_parse_script_output(streams)) + # The marker lines have been parsed into "operations"; leaving them in + # the reported streams as well just doubles the size of the answer. + for key in ("stdout", "stderr"): + kept = [ln for ln in result.get(key, "").splitlines() if _MARKER not in ln] + result[key] = "\n".join(kept)[-4000:] + result["output"] = str(out) + result["output_exists"] = out.exists() + + if result.get("missing_methods") is None and not result.get("timed_out"): + # The script's first act is to report which methods exist. No report + # means the script never ran -- a binary with no --run support, one + # that exited early, or the wrong executable -- and exit code 0 from + # something that did nothing is not success. + result["ok"] = False + #setdefault, for the same reason as in tool_project_new(): a + #refusal to run scripts at all also produces no capability + #report, and "is it a build with --run support?" is then the + #wrong question. + result.setdefault("hint", + "the binary never ran the script (no capability report came " + "back), so nothing was changed. Is it a QElectroTech build with " + "--run support?") + result["script"] = script + return result + + missing = result.get("missing_methods") + if missing: + result["ok"] = False + result["hint"] = ( + "this build's scripting API lacks " + ", ".join(missing) + + " -- it predates the drawing verbs, so nothing was changed") + result["script"] = script + return result + + for record in result.get("operations", []): + if not record["succeeded"]: + result["ok"] = False + result.setdefault("hint", + f"operation {record['index']} ({record['op']}) returned " + f"{record['result']!r}; later operations were skipped. " + "qet.log lines in stderr/stdout say why.") + break + + if result.get("saved") is False: + result["ok"] = False + result.setdefault("hint", "the edits were made but save() failed") + + if out.is_file(): + result["output_bytes"] = out.stat().st_size + try: + result["diff"] = tool_diff(str(proj), str(out)) + except ET.ParseError as exc: # a truncated or unwritten save + result["ok"] = False + result["diff_error"] = str(exc) + if not result.get("ok"): + result["script"] = script + return result + + TOOLS = [ { "name": "qet_project_info", @@ -621,6 +2117,9 @@ TOOLS = [ "project": {"type": "string"}, "format": {"type": "string", "enum": sorted(EXPORT_FORMATS)}, "output": {"type": "string"}, + "overwrite": {"type": "boolean", "default": False, + "description": "replace \"output\" if it already exists; " + "without this an existing file is never clobbered"}, "timeout": {"type": "integer", "default": 180}, }, "required": ["binary", "project", "format", "output"], @@ -628,11 +2127,562 @@ TOOLS = [ "handler": lambda a: tool_export(a["binary"], a["project"], a["format"], a["output"], a.get("timeout", 180)), }, + { + "name": "qet_edit", + "description": "Edit a project through QElectroTech's own scripting API " + "and report what actually changed. Places, moves, rotates, " + "labels and deletes elements, wires two terminals together, " + "and adds folios -- each through the same undo command the " + "GUI uses, so the result is undoable and reaches the project " + "database. Writes a new file, never the input, and returns a " + "qet_diff of the two. Needs a build whose scripting API " + "carries the drawing verbs; says so plainly if it does not.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "project": {"type": "string", "description": "the .qet to start from; not modified"}, + "output": {"type": "string", "description": "where to write the edited project"}, + "overwrite": {"type": "boolean", "default": False, + "description": "replace \"output\" if it already exists; " + "without this an existing file is never clobbered"}, + "operations": { + "type": "array", + "minItems": 1, + "description": + "Operations applied in order. Each is an object with \"op\" " + "and that op's arguments. Ops: " + ", ".join(sorted(OPS)) + ". " + "Give an op an \"id\" to name what it produced, then refer to " + "it later as \"$id\" -- that is how an element placed by " + "add_element gets wired by add_conductor, and how a folio made " + "by add_folio is addressed. Terminals are numbered by their " + "index in the element definition; qet_element_info lists them. " + "set_conductor addresses a conductor as the one on a given " + "terminal and applies the change to its whole electrical " + "potential, so name a terminal carrying exactly one conductor; " + "its \"property\" is one of " + ", ".join(CONDUCTOR_PROPERTIES) + + ". move_conductor_segment reroutes the drawn path itself rather " + "than a property of the potential -- addressed the same way (a " + "terminal carrying exactly one conductor), plus a segment index " + "into that conductor's own path. A segment only moves " + "perpendicular to its own direction, the same as dragging its " + "handle in the GUI: dx moves a vertical segment, dy moves a " + "horizontal one, the other of the pair is silently ignored, and " + "the two segments touching a terminal are static (refused, no " + "handle exists on them either). There is no query op to list " + "segments or their indexes first -- a freshly auto-routed " + "conductor between two terminals is a static segment, one or two " + "movable ones, then a static segment, in that order from the " + "first terminal; call with a guessed index and read \"succeeded\" " + "to check it landed on a movable one. " + "link_elements takes a folio for each end, since a master " + "and its slave are usually on different ones. " + "delete_conductor removes only the conductor on the named " + "terminal (which must carry exactly one). remove_folio shifts " + "later folio indexes down. set_folio takes one of " + + ", ".join(FOLIO_PROPERTIES) + ". " + "Auto-numbering: add_autonum defines a named context of kind " + "conductor, element or folio from parts written " + "\"type[:value[:increase]]\" (e.g. [\"string:W\", \"unit:1:1\"]); " + "use_conductor_autonum then makes new conductors on a folio " + "take their number from it, so define and select it BEFORE the " + "add_conductor ops it should number. For elements, " + "use_element_autonum selects the context and number_element applies " + "it to one element AFTER it is placed (add_element does not number " + "what it places); slaves and reports are refused, since they take " + "their label from their master. " + "Terminal strips: add_terminal_strip returns an index (name " + "it \"$id\"); add_to_strip puts a terminal-type element on " + "it, and refuses any other kind. group_terminals/bridge_terminals " + "take \"indices\" (at least two) into that strip's real-terminal " + "listing -- group merges onto whichever named position already has " + "the most terminals, not necessarily the first index given; bridge " + "refuses terminals that are not all at the same level. A group() call " + "can fully reorder the listing, not just shift indices after it -- " + "always re-list before addressing one by index again. " + "sort_terminal_strip reorders it canonically. " + "Images: add_image takes a file path (over 10 MB is refused) " + "and returns an index; the pixels are embedded in the saved " + "project. scale_image/rotate_image can change an image's sort " + "index, so rely on the \"$id\" only until the next scale or " + "rotate. " + "add_pdf_page renders one page of a PDF file to an image and " + "places it, through the same code path as the \"add image\" " + "toolbar action's own PDF support: \"page\" is 1-based, \"dpi\" " + "is the render resolution (the GUI dialog defaults to 150), and " + "the result is an ordinary image afterwards -- scale_image, " + "rotate_image and delete_image all apply to it same as any other. " + "Only reachable in a build with the QtPdf module (Qt >= 6.4); " + "some Qt6 distributions omit it, and the op is refused with a " + "clear reason rather than being absent, so check the op's own " + "\"succeeded\"/result rather than assuming a missing method. " + "insert_folio puts a new folio at a position (0 = first, the " + "folio count = last) and returns its index; element_geometry reads " + "an element's x, y, rotation and the box it occupies " + "(left/top/right/bottom) and reports it in the result -- use it to " + "lay things out relative to each other across calls; undo/redo step " + "QElectroTech's undo stack (consecutive edits to one property merge, " + "so one undo can revert several) and fail if there is nothing to " + "undo. search_and_replace finds and replaces a substring or (with " + "\"regex\": true) a regular expression within one text field, " + "across every folio, as a single undo step -- unlike doing the " + "same with a read op and set_conductor/set_info/set_text in a " + "loop, which would leave one undo entry per item touched. \"kind\" " + "is element_info (\"field\" is an information key such as " + "\"label\"), conductor (\"field\" is one of " + + ", ".join(CONDUCTOR_PROPERTIES) + " -- replacing on one conductor " + "of a potential updates the whole potential, the same as " + "set_conductor always does) or text (independent texts; \"field\" " + "is ignored). This is NOT QElectroTech's own \"Search and replace\" " + "panel: that one is a batch overwrite-with-sentinel template built " + "for picking items from a tree interactively, a poor fit for a " + "script that can already say precisely which items it means. This " + "does what the name says instead -- an actual substring/regex " + "replace within each item's current value, touching only items " + "where it is found. Returns the number of items changed; never " + "matches an empty field. set_project_title renames the project. " + "set_folio_border sets one " + "of the folio frame's " + ", ".join(FOLIO_BORDER_PROPERTIES) + + " (counts 1-99, sizes 1-1000, display-* true/false). " + "embed_title_block_template copies a template into the project from " + "the common/company/custom collection that has it (only reachable if " + "the binary's compiled-in template path resolves to something real -- " + "typically a make install'd QET; there is no per-run override for this " + "one the way elements_dir is for elements, since QElectroTech reads " + "--common-tbt-dir before --run's own argument handling ever sees it, " + "so this tool cannot pass it through). set_folio's \"template\" property " + "then embeds-if-needed and applies it in one call; a template literally " + "named \"default\" reads back as \"\" afterwards, since QElectroTech " + "treats the two as the same thing. " + "duplicate_elements copies elements, with the conductors between " + "them, to a position (the top-left of the copied group's bounding " + "box; (0,0) keeps the source coordinates) on the same or another " + "folio: \"elements\" is a list of uuids or \"$id\" references, " + "and the result lists the copies in that same order, so " + "\"$copies[0]\" is the copy of the first. Copies come without " + "labels or wire numbers, as on a paste in the application. " + "Symbol text fields (the label, terminal names, values drawn on a " + "symbol): add_element_text (source text|info|composite; value is " + "the string, an information key such as \"label\", or a formula; " + "x/y are in the element's own coordinates) returns an index within " + "that element; set_element_text takes " + + ", ".join(ELEMENT_TEXT_PROPERTIES) + ". A field bound with source " + "\"info\" follows set_label/set_info. Indexes shift on delete. " + "Texts and shapes have no uuid: add_text/add_shape return an " + "index you can name as \"$id\", and the other text/shape ops " + "take it as \"index\". Indexes shift when one is added or " + "deleted. Shapes: " + ", ".join(SHAPES) + "; set_shape takes " + ", ".join(SHAPE_PROPERTIES) + + " (fill accepts a colour or \"none\"). " + "add_shape's own \"polygon\" is always the degenerate two-point " + "form (it shares add_shape's p1/p2 shape); add_polygon takes as " + "many points as wanted instead, as [{\"x\":.., \"y\":..}, ...] " + "in scene coordinates (at least 2), plus \"closed\"; " + "set_shape_polygon replaces an existing one's points the same " + "way. add_path places a curved shape -- a polygon's points plus, " + "per node, an optional \"kind\" (corner, the default; smooth; or " + "symmetric) and optional \"inHandle\"/\"outHandle\" bezier " + "control points, the same model the pen tool and node-edit mode " + "build; set_shape_path_nodes replaces an existing path's nodes. " + "set_shape_closed opens or closes a polygon or path (a no-op on " + "any other shape). set_shape_polygon/set_shape_path_nodes refuse " + "a shape of the wrong kind -- a shape made by add_shape is never " + "a valid target for either, and vice versa. A shape's index can " + "shift on any edit that moves it, not only an add or delete: " + "shapes are listed by current on-folio position, so changing one " + "shape's points can reorder it relative to the others -- re-list " + "before addressing one by index again if more than one is being " + "edited in the same run. " + "Tables: add_table places a BOM/nomenclature or summary table " + "(kind is \"nomenclature\" or \"summary\") built from a query " + "against a project database view -- run qet.query() (the " + "query op) against element_nomenclature_view or " + "project_summary_view first to find one that returns real " + "columns; an empty query is refused, since each query widget " + "defaults to zero selected columns and produces a table with " + "no rows. Returns an index you can name as \"$id\". Every new " + "table lands at the same fixed (50, 50), so a folio getting " + "more than one must reposition all but the first with " + "set_table_position or they stack exactly on top of each " + "other. delete_table removes one; indexes shift afterwards. " + "PLC IO: a PLC master (elementData type Master, masterType " + "PLC) carries an IO table -- add_plc_io appends a row (type " + "is one of entree_digitale, sortie_digitale, " + "entree_analogique, sortie_analogique, entree_universelle, " + "sortie_universelle) and returns its index as \"$id\"; " + "set_plc_io changes one field (type, address, function or " + "comment) of an existing row; remove_plc_io deletes one and " + "shifts the indexes after it. None of the three are " + "undoable -- MasterPropertiesWidget's own PLC IO editor " + "isn't either, since it manages PLC linking through the " + "table rather than the ordinary link-tree undo path. " + "link_plc_io is link_elements plus an io_index: it links a " + "PLC slave onto one specific row of a PLC master's IO table " + "(io_index into that table, from add_plc_io's return or a " + "count of prior add_plc_io calls) rather than leaving which " + "row unspecified the way a plain link_elements call would. " + "If an op fails the rest are skipped, since they usually " + "depend on it.", + "items": {"type": "object"}, + }, + "elements_dir": { + "type": "string", + "description": "the common elements collection, e.g. a checkout's " + "elements/ directory. Required for \"common://\" " + "paths: the sandboxed run has no settings of its " + "own and would not find the collection otherwise. " + "An absolute .elmt path works without it.", + }, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "project", "output", "operations"], + }, + "handler": lambda a: tool_edit(a["binary"], a["project"], a["operations"], + a["output"], a.get("elements_dir"), + a.get("timeout", 180)), + }, + { + "name": "qet_query", + "description": "Run a read-only SQL SELECT against the project's SQLite " + "database and get rows back. Prefer the views " + "(element_nomenclature_view, project_summary_view, " + "wiring_list_view) over the raw tables. Omit sql to list what " + "is queryable. Only SELECT and WITH are permitted -- " + "QElectroTech enforces this itself, the same way it does for " + "the custom-query box in its own interface.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "project": {"type": "string", "description": "the .qet to query; never modified"}, + "sql": {"type": "string", + "description": "a single SELECT or WITH...SELECT. " + "Omit to list the tables and views instead."}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "project"], + }, + "handler": lambda a: tool_query(a["binary"], a["project"], a.get("sql", ""), + a.get("elements_dir"), a.get("timeout", 180)), + }, + { + "name": "qet_continuity", + "description": "Electrical continuity / ERC-style checks against the live " + "Terminal/Conductor object graph, not a heuristic read of the " + "XML (that is qet_check; the two are complementary). " + "unconnected_terminal (info -- routine, not necessarily wrong) " + "and potential_mismatch (error -- two conductors on the same " + "electrical potential disagreeing on num/colour/section/" + "function/bus/cable, which QElectroTech's own edits never " + "produce, so it means hand-edited XML, a legacy file, or an " + "external tool); and report_link_mismatch (warning -- a " + "next_report/previous_report folio-jump pair whose conductors " + "disagree, which CAN happen through ordinary use since linking " + "two report elements never checks or syncs conductor " + "properties -- reproduces qelectrotech/qelectrotech-source-" + "mirror#974). Does NOT check pin electrical direction/power " + "conflicts or No/Nc/Common contact shorts -- QElectroTech's " + "terminal data model carries neither. One QElectroTech " + "launch; read-only.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "project": {"type": "string", "description": "the .qet to check; never modified"}, + "folio": {"type": "integer", "description": "check one folio only; omit for the whole project"}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "project"], + }, + "handler": lambda a: tool_continuity(a["binary"], a["project"], a.get("folio"), + a.get("elements_dir"), a.get("timeout", 180)), + }, + { + "name": "qet_project_new", + "description": "Create a new, empty project to start a schematic from: a " + "title and any number of folios, written by QElectroTech " + "itself and read back to check. qet_edit needs an existing " + "project, and the shipped 'blank' example is not blank, so " + "this is the way to begin from nothing. Refuses to overwrite " + "unless told to.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "output": {"type": "string", "description": "where to write the new .qet"}, + "title": {"type": "string", "description": "the project title"}, + "folios": {"description": "how many empty folios, or a list of folio titles", + "oneOf": [{"type": "integer", "minimum": 0, "maximum": 200}, + {"type": "array", "items": {"type": "string"}}], + "default": 1}, + "author": {"type": "string", "description": "set on every folio's title block"}, + "overwrite": {"type": "boolean", "default": False}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "output", "title"], + }, + "handler": lambda a: tool_project_new( + a["binary"], a["output"], a["title"], a.get("folios", 1), a.get("author", ""), + a.get("overwrite", False), a.get("elements_dir"), a.get("timeout", 180)), + }, + { + "name": "qet_element_search", + "description": "Find a symbol in an element collection by name (any " + "language, ignoring case and accents), link type, kind or " + "terminal count. Results carry a common:// path that " + "qet_edit's add_element takes directly, and the terminal " + "names in add_conductor's index order (top-to-bottom, then " + "left-to-right; not file order). Indexes the " + "collection on first use and re-indexes when it changes, so " + "a symbol written by qet_element_build is found at once.", + "inputSchema": { + "type": "object", + "properties": { + "directory": {"type": "string", + "description": "the collection root, e.g. a checkout's elements/ directory"}, + "query": {"type": "string", + "description": "words to find; every word must match some name, the path or the kind"}, + "link_type": {"type": "string", "enum": list(LINK_TYPES)}, + "kind": {"type": "string", "description": "the element's type information, e.g. coil, protection"}, + "min_terminals": {"type": "integer"}, + "max_terminals": {"type": "integer"}, + "limit": {"type": "integer", "default": 25}, + }, + "required": ["directory"], + }, + "handler": lambda a: tool_element_search( + a["directory"], a.get("query", ""), a.get("link_type"), + a.get("min_terminals"), a.get("max_terminals"), a.get("kind"), + a.get("limit", 25)), + }, + { + "name": "qet_check", + "description": "Run design-rule checks over a project and report findings by " + "severity: duplicate master labels (error), duplicate simple " + "labels and unlabelled masters (warning), unnumbered " + "conductors, empty folios and masters missing a manufacturer " + "reference (info). One QElectroTech launch; read-only. " + "These are heuristics tuned against QElectroTech's shipped " + "examples, not standards -- each finding carries a note " + "saying how far to trust it.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "project": {"type": "string"}, + "checks": {"type": "array", "items": {"type": "string", "enum": sorted(CHECKS)}, + "description": "which checks to run; omit for all"}, + "sample": {"type": "integer", "default": 10, + "description": "how many offending rows to return per check"}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["binary", "project"], + }, + "handler": lambda a: tool_check(a["binary"], a["project"], a.get("checks"), + a.get("sample", 10), a.get("elements_dir"), + a.get("timeout", 180)), + }, + { + "name": "qet_element_build", + "description": "Write a .elmt element definition: named in one or more " + "languages, drawn from lines, rectangles, ellipses, circles, " + "arcs, polygons and text, with terminals to wire it by. " + "Computes the width/height/hotspot header so the declared box " + "contains the drawing, validates every part against the schema " + "the shipped collection uses, and reads the result back. " + "Writes the file directly; does not launch QElectroTech.", + "inputSchema": { + "type": "object", + "properties": { + "output": {"type": "string", "description": "path to write, ending .elmt"}, + "overwrite": {"type": "boolean", "default": False, + "description": "replace \"output\" if it already exists; " + "without this an existing file is never clobbered"}, + "names": {"type": "object", + "description": 'translated names by language code, e.g. ' + '{"en": "Coil", "fr": "Bobine"}. French is ' + "QElectroTech's source language; give it if you can."}, + "parts": { + "type": "array", + "description": + 'the drawing. Each part is {"type": ...} plus its own keys: ' + 'line x1,y1,x2,y2; rect/ellipse/arc x,y,width,height ' + "(arc also start,angle); circle x,y,diameter; polygon " + 'points:[[x,y],...] and closed; text x,y,text with optional ' + "size, rotation, color. Any part may carry style and antialias. " + "Coordinates are the element's own, with (0,0) at its origin.", + "items": {"type": "object"}, + }, + "terminals": { + "type": "array", + "description": 'where conductors attach: {"x","y","orientation"} ' + "with orientation n, s, e or w, plus an optional " + 'name such as "A1". Their order here is the order ' + "qet_edit indexes terminals by position, not by this order: " + "top to bottom, then left to right. qet_element_build " + "returns the resulting index order.", + "items": {"type": "object"}, + }, + "link_type": {"type": "string", "enum": list(LINK_TYPES), + "description": "simple for an ordinary symbol, master/slave " + "for a cross-referenced pair, thumbnail for " + "a drawing with no terminals"}, + "informations": {"type": "object", + "description": "kindInformation entries, e.g. {\"type\": \"coil\"}"}, + "uuid": {"type": "string", "description": "reuse an existing uuid; " + "omit to generate one"}, + }, + "required": ["output", "names", "parts"], + }, + "handler": lambda a: tool_element_build( + a["output"], a["names"], a["parts"], a.get("terminals"), + a.get("link_type", "simple"), a.get("informations"), a.get("uuid")), + }, ] _BY_NAME = {t["name"]: t for t in TOOLS} +# -------------------------------------------------------------------------- +# Filesystem policy +# -------------------------------------------------------------------------- +# +# Every path in a tool call arrives from the model, so without a policy this +# server is a read/write primitive for anything the OS lets the process +# touch: read any .qet or .elmt, export a project's contents somewhere else, +# overwrite an unrelated file, embed an arbitrary local image or PDF. The +# sandboxed HOME each QElectroTech launch gets isolates *settings*, not the +# filesystem. +# +# So data paths are confined to a workspace. Two kinds of path are treated +# differently, deliberately: +# +# data chosen by the client per call -- the projects, directories, +# images and outputs below. Confined. +# configuration chosen once by whoever runs the server -- "binary" (the +# qelectrotech executable) and "elements_dir" (the element +# collection). Both normally live in /usr or a build tree, +# i.e. outside any sane workspace, so confining them would +# reject the ordinary case while stopping nothing: they are +# not where a model gets to point the server at /etc. +# +# Enforced here, at the dispatcher, because this is the trust boundary -- +# the point where model-supplied arguments enter. Calling the tool_* helpers +# directly from Python is not confined and is not meant to be: that is the +# server's own code calling itself. +_DATA_PATHS = { + "qet_project_info": {"read": ("path",)}, + "qet_elements": {"read": ("path",)}, + "qet_conductors": {"read": ("path",)}, + "qet_diff": {"read": ("before", "after")}, + "qet_scan": {"read": ("directory",)}, + "qet_element_info": {"read": ("path",)}, + "qet_element_search": {"read": ("directory",)}, + "qet_export": {"read": ("project",), "write": ("output",)}, + "qet_edit": {"read": ("project",), "write": ("output",)}, + "qet_query": {"read": ("project",)}, + "qet_continuity": {"read": ("project",)}, + "qet_check": {"read": ("project",)}, + "qet_project_new": {"write": ("output",)}, + "qet_element_build": {"write": ("output",)}, +} + +# qet_edit operations that name a file of their own. +_DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"} + + +def workspace_roots() -> list: + """The directories tool calls may read and write. + + QET_MCP_WORKSPACE, os.pathsep-separated, or the process's working + directory when unset -- a real confinement either way, and the working + directory is what an MCP host normally starts the server in. Set + QET_MCP_ALLOW_ANY_PATH=1 to turn confinement off entirely, which is + equivalent to granting the client local filesystem access with this + process's privileges; it exists so that is a deliberate, visible choice + rather than the default. + """ + if os.environ.get("QET_MCP_ALLOW_ANY_PATH") == "1": + return [] + raw = os.environ.get("QET_MCP_WORKSPACE", "") + parts = [p for p in raw.split(os.pathsep) if p.strip()] or [os.getcwd()] + roots = [] + for part in parts: + try: + roots.append(Path(part).expanduser().resolve()) + except OSError: + continue + return roots + + +def _within_workspace(path: Path, roots: list) -> bool: + for root in roots: + try: + if path == root or path.is_relative_to(root): + return True + except ValueError: + continue + return False + + +def _check_path(raw, arg: str, mode: str, roots: list) -> Path: + """Resolve one path and refuse it if it leaves the workspace. + + resolve() follows symlinks, so a link planted inside the workspace is + judged by where it actually points, not by where it sits. A path that + does not exist yet still resolves (its parents do), which is what makes + this usable for an output file. + """ + if not isinstance(raw, str) or not raw: + raise ValueError(f"{arg!r} must be a non-empty path") + resolved = Path(raw).expanduser().resolve() + if roots and not _within_workspace(resolved, roots): + raise ValueError( + f"{arg!r} is outside the workspace: {resolved}. Allowed: " + + os.pathsep.join(str(r) for r in roots) + + ". Set QET_MCP_WORKSPACE to widen it, or " + "QET_MCP_ALLOW_ANY_PATH=1 to disable this check " + "(which grants this client local filesystem access)." + ) + return resolved + + +def enforce_path_policy(tool_name: str, arguments: dict) -> None: + """Apply the workspace and overwrite policy to one tool call.""" + spec = _DATA_PATHS.get(tool_name) + if spec is None: + return + roots = workspace_roots() + + for arg in spec.get("read", ()): + if arg in arguments: + _check_path(arguments[arg], arg, "read", roots) + + for arg in spec.get("write", ()): + if arg not in arguments: + continue + out = _check_path(arguments[arg], arg, "write", roots) + # Writing over something that is already there is the one step this + # server cannot undo, so it is the one step it will not take on its + # own. qet_project_new already had this flag; the others now match it. + if out.exists() and not arguments.get("overwrite"): + raise ValueError( + f"{arg!r} already exists: {out}. Pass \"overwrite\": true to " + "replace it, or choose another name." + ) + + if tool_name == "qet_edit": + for i, op in enumerate(arguments.get("operations") or []): + if not isinstance(op, dict): + continue + key = _DATA_PATH_OPS.get(op.get("op")) + if key and key in op: + _check_path(op[key], f"operations[{i}].{key}", "read", roots) + + # -------------------------------------------------------------------------- # JSON-RPC / MCP plumbing # -------------------------------------------------------------------------- @@ -669,7 +2719,9 @@ def handle(msg: dict) -> dict | None: if tool is None: return _err(mid, -32602, f"unknown tool: {name}") try: - result = tool["handler"](params.get("arguments") or {}) + arguments = params.get("arguments") or {} + enforce_path_policy(name, arguments) + result = tool["handler"](arguments) text = json.dumps(result, indent=2, ensure_ascii=False) return _ok(mid, {"content": [{"type": "text", "text": text}]}) except Exception as exc: # surfaced to the model, not the transport diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py new file mode 100644 index 000000000..5a4d3064f --- /dev/null +++ b/misc/qet-mcp/test_qet_mcp.py @@ -0,0 +1,2595 @@ +#!/usr/bin/env python3 +""" +Regression suite for qet_mcp.py. + +Three layers, cheapest first: + + unit no QElectroTech needed: validation, script generation, the + terminal-order rule, the diff, the part schema, the protocol + protocol the real stdio transport, one JSON-RPC message per line + integration drives a built QElectroTech; skipped unless one is found + + python3 test_qet_mcp.py # unit + protocol + QET_BINARY=/path/to/qelectrotech \\ + QET_ELEMENTS=/path/to/qelectrotech/elements \\ + QET_EXAMPLES=/path/to/qelectrotech/examples \\ + python3 test_qet_mcp.py # everything + +Several tests exist because the behaviour they pin was once wrong and +looked right. Those say so in their docstring, so nobody "simplifies" one +away: a check that has never been seen to fail is not evidence. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import qet_mcp as m # noqa: E402 + +BINARY = os.environ.get("QET_BINARY", "") +ELEMENTS = os.environ.get("QET_ELEMENTS", "") +EXAMPLES = os.environ.get("QET_EXAMPLES", "") + +have_binary = bool(BINARY) and os.access(BINARY, os.X_OK) +have_elements = bool(ELEMENTS) and Path(ELEMENTS).is_dir() +have_examples = bool(EXAMPLES) and Path(EXAMPLES).is_dir() + +needs_binary = unittest.skipUnless(have_binary, "set QET_BINARY to a built qelectrotech") +needs_elements = unittest.skipUnless(have_binary and have_elements, + "set QET_BINARY and QET_ELEMENTS") +needs_examples = unittest.skipUnless(have_binary and have_examples, + "set QET_BINARY and QET_EXAMPLES") + +COIL = "common://10_electric/10_allpole/310_relays_contactors_contacts/01_coils/bobine_ka_a_remanence.elmt" +SLAVE = ("common://10_electric/10_allpole/310_relays_contactors_contacts/" + "02_contacts_cross_referencing/15_protection_contacts/contact_relais_nf_esclave.elmt") +# The shipped "going/coming arrow" pair -- a folio-jump link, next_report on +# one folio linked to previous_report on the next, one terminal each. Same +# category of element as issue #974's custom "naechste_folie_rechts.elmt" / +# "vorherige_folie_links.elmt". +NEXT_REPORT = "common://10_electric/10_allpole/100_folio_referencing/02going_arrow.elmt" +PREVIOUS_REPORT = "common://10_electric/10_allpole/100_folio_referencing/01coming_arrow.elmt" +TERMINAL = "common://10_electric/10_allpole/130_terminals_terminal_strips/borne_2.elmt" + +# No shipped element has masterType/slaveType "plc" -- these two minimal +# fixtures (derived from a real coil/slave pair) exist only so PLC IO tests +# have something to place. ElementsLocation only resolves an absolute .elmt +# path when it happens to sit under QETApp::commonElementsDirN(), so they +# are placed via a dedicated elements_dir pointed at this directory, not the +# real QET_ELEMENTS collection. +PLC_FIXTURES = str(HERE / "fixtures") +PLC_MASTER = "common://plc_master_test.elmt" +PLC_SLAVE = "common://plc_slave_test.elmt" + + +def png(path: Path) -> None: + """A real 64x32 PNG from the standard library, so no imaging dependency.""" + import struct + import zlib + w, h = 64, 32 + raw = b"".join(b"\x00" + bytes([255, 0, 0] * w) for _ in range(h)) + + def chunk(t, d): + c = struct.pack(">I", len(d)) + t + d + return c + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF) + path.write_bytes(b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) + + +def pdf(path: Path, page_colors=("1 0 0", "0 0 1")) -> None: + """A minimal, hand-built multi-page PDF -- one filled rectangle per + page, in a different colour each, so a test can tell which page a + render actually came from. No external tool (ghostscript, reportlab) + needed; the xref offsets are computed exactly as each object is + written, since QPdfDocument (pdfium) is not guaranteed to tolerate a + wrong one the way some readers repair-scan for.""" + n = len(page_colors) + page_objs = list(range(3, 3 + n)) # 3 .. 2+n + content_objs = list(range(3 + n, 3 + 2 * n)) # 3+n .. 2+2n + objects = {} + + objects[1] = b"<< /Type /Catalog /Pages 2 0 R >>" + kids = " ".join(f"{o} 0 R" for o in page_objs) + objects[2] = f"<< /Type /Pages /Kids [{kids}] /Count {n} >>".encode() + for i, (page_obj, content_obj) in enumerate(zip(page_objs, content_objs)): + objects[page_obj] = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + f"/Contents {content_obj} 0 R /Resources << >> >>").encode() + stream = f"{page_colors[i]} rg 10 10 150 150 re f".encode() + objects[content_obj] = (f"<< /Length {len(stream)} >>\nstream\n").encode() \ + + stream + b"\nendstream" + + out = bytearray(b"%PDF-1.4\n") + offsets = {} + for num in sorted(objects): + offsets[num] = len(out) + out += f"{num} 0 obj\n".encode() + objects[num] + b"\nendobj\n" + xref_start = len(out) + total = len(objects) + 1 + out += f"xref\n0 {total}\n".encode() + out += b"0000000000 65535 f \n" + for num in sorted(objects): + out += f"{offsets[num]:010d} 00000 n \n".encode() + out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF".encode() + path.write_bytes(bytes(out)) + + +# ========================================================================== +# unit +# ========================================================================== + +class ToolRegistry(unittest.TestCase): + def test_names_are_unique_and_prefixed(self): + names = [t["name"] for t in m.TOOLS] + self.assertEqual(len(names), len(set(names))) + self.assertTrue(all(n.startswith("qet_") for n in names)) + + def test_every_schema_is_well_formed(self): + for t in m.TOOLS: + with self.subTest(tool=t["name"]): + s = t["inputSchema"] + self.assertEqual(s["type"], "object") + self.assertTrue(set(s.get("required", [])) <= set(s["properties"]), + "a required key is missing from properties") + self.assertTrue(callable(t["handler"])) + self.assertTrue(t["description"].strip()) + json.dumps(s) # must be serialisable as-is + + def test_expected_tools_exist(self): + self.assertEqual({t["name"] for t in m.TOOLS}, { + "qet_project_info", "qet_elements", "qet_conductors", "qet_diff", + "qet_scan", "qet_element_info", "qet_export", "qet_edit", "qet_query", + "qet_project_new", "qet_element_search", "qet_check", "qet_element_build", + "qet_continuity"}) + + +class EditValidation(unittest.TestCase): + """Every argument error must be raised before QElectroTech is launched. + + tool_edit is called with a binary that does not exist: if validation + were lazy the failure would be 'not an executable', not the ValueError + asserted here. + """ + + def build(self, ops): + return m._build_script(ops, "/tmp/out.qet") + + def test_every_op_generates_a_script(self): + # one minimal valid instance of every op + f = {"op": "add_folio", "id": "f"} + samples = { + "add_folio": [f], + "set_folio_title": [f, {"op": "set_folio_title", "folio": "$f", "title": "t"}], + "add_element": [f, {"op": "add_element", "id": "e", "folio": "$f", "path": "p", "x": 1, "y": 2}], + } + el = [f, {"op": "add_element", "id": "e", "folio": "$f", "path": "p", "x": 1, "y": 2}] + two = el + [{"op": "add_element", "id": "e2", "folio": "$f", "path": "p", "x": 1, "y": 2}] + samples.update({ + "set_position": el + [{"op": "set_position", "folio": "$f", "element": "$e", "x": 1, "y": 1}], + "move_element": el + [{"op": "move_element", "folio": "$f", "element": "$e", "dx": 1, "dy": 1}], + "rotate_element": el + [{"op": "rotate_element", "folio": "$f", "element": "$e", "angle": 90}], + "set_label": el + [{"op": "set_label", "folio": "$f", "element": "$e", "label": "K"}], + "set_info": el + [{"op": "set_info", "folio": "$f", "element": "$e", "key": "k", "value": "v"}], + "add_conductor": two + [{"op": "add_conductor", "folio": "$f", "from": "$e", "from_terminal": 0, + "to": "$e2", "to_terminal": 0}], + "delete_element": el + [{"op": "delete_element", "folio": "$f", "element": "$e"}], + "set_conductor": el + [{"op": "set_conductor", "folio": "$f", "element": "$e", "terminal": 0, + "property": "num", "value": "1"}], + "move_conductor_segment": el + [{"op": "move_conductor_segment", "folio": "$f", + "element": "$e", "terminal": 0, "segment": 1, + "dx": 10, "dy": 0}], + "delete_conductor": el + [{"op": "delete_conductor", "folio": "$f", "element": "$e", "terminal": 0}], + "link_elements": two + [{"op": "link_elements", "folio": "$f", "element": "$e", + "to_folio": "$f", "to": "$e2"}], + "unlink_element": el + [{"op": "unlink_element", "folio": "$f", "element": "$e"}], + "remove_folio": [f, {"op": "remove_folio", "folio": "$f"}], + "set_folio": [f, {"op": "set_folio", "folio": "$f", "property": "author", "value": "a"}], + "add_text": [f, {"op": "add_text", "id": "t", "folio": "$f", "text": "x", "x": 0, "y": 0}], + "set_text": [f, {"op": "set_text", "folio": "$f", "index": 0, "text": "x"}], + "set_text_color": [f, {"op": "set_text_color", "folio": "$f", "index": 0, "color": "#000"}], + "rotate_text": [f, {"op": "rotate_text", "folio": "$f", "index": 0, "angle": 5}], + "delete_text": [f, {"op": "delete_text", "folio": "$f", "index": 0}], + "add_shape": [f, {"op": "add_shape", "folio": "$f", "shape": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1}], + "set_shape": [f, {"op": "set_shape", "folio": "$f", "index": 0, "property": "fill", "value": "none"}], + "delete_shape": [f, {"op": "delete_shape", "folio": "$f", "index": 0}], + "add_polygon": [f, {"op": "add_polygon", "folio": "$f", + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}], + "closed": True}], + "set_shape_polygon": [f, {"op": "add_polygon", "id": "poly", "folio": "$f", + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}], + "closed": True}, + {"op": "set_shape_polygon", "folio": "$f", "index": "$poly", + "points": [{"x": 2, "y": 2}, {"x": 3, "y": 2}, {"x": 3, "y": 3}]}], + "add_path": [f, {"op": "add_path", "folio": "$f", + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 0, "kind": "smooth", + "inHandle": {"x": 0.5, "y": 0}, "outHandle": {"x": 1.5, "y": 0}}], + "closed": False}], + "set_shape_path_nodes": [f, {"op": "add_path", "id": "path", "folio": "$f", + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 0}], "closed": False}, + {"op": "set_shape_path_nodes", "folio": "$f", "index": "$path", + "nodes": [{"x": 5, "y": 5}, {"x": 6, "y": 5}]}], + "set_shape_closed": [f, {"op": "add_polygon", "id": "poly2", "folio": "$f", + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}], + "closed": True}, + {"op": "set_shape_closed", "folio": "$f", "index": "$poly2", "closed": False}], + "add_image": [f, {"op": "add_image", "folio": "$f", "file": "/x.png", "x": 0, "y": 0}], + "scale_image": [f, {"op": "scale_image", "folio": "$f", "index": 0, "factor": 2}], + "rotate_image": [f, {"op": "rotate_image", "folio": "$f", "index": 0, "angle": 5}], + "delete_image": [f, {"op": "delete_image", "folio": "$f", "index": 0}], + "add_pdf_page": [f, {"op": "add_pdf_page", "folio": "$f", "file": "/x.pdf", + "page": 1, "dpi": 150, "x": 0, "y": 0}], + "add_element_text": el + [{"op": "add_element_text", "id": "t", "folio": "$f", "element": "$e", + "source": "info", "value": "comment", "x": 1, "y": 2}], + "set_element_text": el + [{"op": "set_element_text", "folio": "$f", "element": "$e", "index": 0, + "property": "x", "value": "5"}], + "delete_element_text": el + [{"op": "delete_element_text", "folio": "$f", "element": "$e", "index": 0}], + "add_terminal_strip": [{"op": "add_terminal_strip", "installation": "a", "location": "b", "name": "c"}], + "remove_terminal_strip": [{"op": "remove_terminal_strip", "strip": 0}], + "add_to_strip": el + [{"op": "add_to_strip", "strip": 0, "folio": "$f", "element": "$e"}], + "group_terminals": [{"op": "group_terminals", "strip": 0, "indices": [0, 1]}], + "bridge_terminals": [{"op": "bridge_terminals", "strip": 0, "indices": [0, 1]}], + "sort_terminal_strip": [{"op": "sort_terminal_strip", "strip": 0}], + "add_autonum": [{"op": "add_autonum", "kind": "conductor", "name": "W", "parts": ["string:W"]}], + "remove_autonum": [{"op": "remove_autonum", "kind": "conductor", "name": "W"}], + "use_conductor_autonum": [f, {"op": "use_conductor_autonum", "folio": "$f", "name": "W"}], + "use_element_autonum": [{"op": "use_element_autonum", "name": "EL"}], + "insert_folio": [{"op": "insert_folio", "id": "i", "position": 0}], + "element_geometry": el + [{"op": "element_geometry", "folio": "$f", "element": "$e"}], + "undo": [{"op": "undo"}], + "redo": [{"op": "redo"}], + "search_and_replace": [{"op": "search_and_replace", "kind": "text", "field": "", + "pattern": "x", "replacement": "y", + "regex": False, "case_sensitive": True}], + "set_project_title": [{"op": "set_project_title", "title": "T"}], + "set_folio_border": [f, {"op": "set_folio_border", "folio": "$f", "property": "columns", "value": "10"}], + "embed_title_block_template": [{"op": "embed_title_block_template", "name": "default"}], + "duplicate_elements": el + [{"op": "duplicate_elements", "id": "d", "folio": "$f", + "elements": ["$e"], "to_folio": "$f", "x": 50, "y": 50}], + "number_element": el + [{"op": "number_element", "folio": "$f", "element": "$e"}], + "add_table": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature", + "name": "BOM", "query": "SELECT label FROM element_nomenclature_view"}], + "set_table_position": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature", + "name": "BOM", "query": "SELECT label FROM element_nomenclature_view"}, + {"op": "set_table_position", "folio": "$f", "table": "$t", "x": 1, "y": 1}], + "delete_table": [f, {"op": "add_table", "id": "t", "folio": "$f", "kind": "nomenclature", + "name": "BOM", "query": "SELECT label FROM element_nomenclature_view"}, + {"op": "delete_table", "folio": "$f", "table": "$t"}], + "link_plc_io": two + [{"op": "link_plc_io", "folio": "$f", "element": "$e", + "to_folio": "$f", "to": "$e2", "io_index": 0}], + "add_plc_io": el + [{"op": "add_plc_io", "folio": "$f", "element": "$e", + "type": "entree_digitale", "address": "1.0", + "function": "f", "comment": "c"}], + "set_plc_io": el + [{"op": "set_plc_io", "folio": "$f", "element": "$e", + "index": 0, "property": "address", "value": "1.1"}], + "remove_plc_io": el + [{"op": "remove_plc_io", "folio": "$f", "element": "$e", "index": 0}], + }) + self.assertEqual(set(samples), set(m.OPS), + "an op has no sample here: add one so it is exercised") + for name, ops in samples.items(): + with self.subTest(op=name): + script = self.build(ops) + self.assertIn("qet.save(", script) + self.assertIn(m.OPS[name][0] or "addFolio", script) + + def test_unknown_op(self): + with self.assertRaisesRegex(ValueError, "unknown op"): + self.build([{"op": "draw_a_nice_diagram"}]) + + def test_missing_argument(self): + with self.assertRaisesRegex(ValueError, "missing 'path'"): + self.build([{"op": "add_element", "folio": 0, "x": 1, "y": 2}]) + + def test_dangling_reference(self): + with self.assertRaisesRegex(ValueError, "no earlier"): + self.build([{"op": "set_label", "folio": "$nope", "element": "$x", "label": "a"}]) + + def test_id_rules(self): + for bad in ("$f", "", 3): + with self.subTest(id=bad): + with self.assertRaises(ValueError): + self.build([{"op": "add_folio", "id": bad}]) + with self.assertRaisesRegex(ValueError, "already used"): + self.build([{"op": "add_folio", "id": "f"}, {"op": "add_folio", "id": "f"}]) + + def test_enumerated_arguments_are_checked_up_front(self): + cases = [ + {"op": "set_conductor", "folio": 0, "element": "x", "terminal": 0, "property": "voltage", "value": "1"}, + {"op": "set_folio", "folio": 0, "property": "version", "value": "1"}, + {"op": "set_shape", "folio": 0, "index": 0, "property": "shadow", "value": "1"}, + {"op": "add_shape", "folio": 0, "shape": "hexagon", "x1": 0, "y1": 0, "x2": 1, "y2": 1}, + {"op": "add_autonum", "kind": "wire", "name": "a", "parts": ["string:W"]}, + {"op": "set_element_text", "folio": 0, "element": "x", "index": 0, "property": "shadow", "value": "1"}, + {"op": "add_element_text", "folio": 0, "element": "x", "source": "magic", "value": "v", "x": 0, "y": 0}, + {"op": "set_folio_border", "folio": 0, "property": "thickness", "value": "3"}, + ] + for c in cases: + with self.subTest(op=c["op"], bad=c.get("property") or c.get("shape") or c.get("kind")): + with self.assertRaises(ValueError): + self.build([c]) + + def test_version_is_not_a_settable_folio_property(self): + """setFolioProperty('version') reported success and was overwritten by + the file-format stamp. It must stay refused on every layer.""" + self.assertNotIn("version", m.FOLIO_PROPERTIES) + + def test_list_argument_must_be_list_of_strings(self): + with self.assertRaisesRegex(ValueError, "list of strings"): + self.build([{"op": "add_autonum", "kind": "conductor", "name": "a", "parts": "string:W"}]) + + def test_indexed_references_and_element_lists(self): + script = self.build([ + {"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": "p", "x": 0, "y": 0}, + {"op": "add_element", "id": "b", "folio": "$f", "path": "p", "x": 9, "y": 0}, + {"op": "duplicate_elements", "id": "c", "folio": "$f", "elements": ["$a", "$b"], + "to_folio": "$f", "x": 5, "y": 5}, + {"op": "set_label", "folio": "$f", "element": "$c[1]", "label": "X"}]) + self.assertIn('R["c"][1]', script) + self.assertIn('[R["a"], R["b"]]', script) + for bad in ("$c[", "$c[x]", "$nope[0]"): + with self.subTest(ref=bad): + with self.assertRaises(ValueError): + self.build([{"op": "add_folio", "id": "f"}, + {"op": "set_label", "folio": "$f", "element": bad, "label": "X"}]) + for bad in ([], "a", [1, 2]): + with self.subTest(elements=bad): + with self.assertRaises(ValueError): + self.build([{"op": "add_folio", "id": "f"}, + {"op": "duplicate_elements", "folio": "$f", "elements": bad, + "to_folio": "$f", "x": 0, "y": 0}]) + + def test_an_empty_object_result_is_a_failure(self): + """elementGeometry returns {} for an element it cannot find. An empty + object has no .length, so the empty-list test did not cover it.""" + out = m._MARKER + json.dumps(dict(kind="op", index=0, op="element_geometry", id=None, result={})) + self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"]) + out = m._MARKER + json.dumps(dict(kind="op", index=0, op="element_geometry", id=None, + result={"x": 0, "y": 0})) + self.assertTrue(m._parse_script_output(out)["operations"][0]["succeeded"]) + script = m._build_script([{"op": "add_folio"}], "/o") + self.assertIn("Object.keys(", script) + + def test_an_empty_list_result_is_a_failure(self): + """duplicateElements returns [] on failure, which the success check + did not recognise: neither falsy in JavaScript's === comparisons nor + equal to False in Python.""" + out = m._MARKER + json.dumps(dict(kind="op", index=0, op="duplicate_elements", id="d", result=[])) + self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"]) + # the JavaScript side must not rely on Array.isArray: QJSEngine returns + # an empty QStringList as an array-like wrapper for which it is false + script = m._build_script([{"op": "add_folio"}], "/o") + self.assertIn(".length === 0", script) + self.assertNotIn("Array.isArray", script) + + def test_number_arguments_reject_bool_and_text(self): + for bad in (True, "1", None): + with self.subTest(x=bad): + with self.assertRaises(ValueError): + self.build([{"op": "add_folio", "id": "f"}, + {"op": "add_text", "folio": "$f", "text": "t", "x": bad, "y": 0}]) + + def test_string_values_are_escaped_into_the_script(self): + nasty = 'a"b\\c\n
' + script = self.build([{"op": "add_folio", "id": "f"}, + {"op": "set_folio_title", "folio": "$f", "title": nasty}]) + # the literal must be valid JSON, so JavaScript reads exactly what was sent + literal = re.search(r'setFolioTitle\(R\["f"\], (".*?")\)', script, re.S).group(1) + self.assertEqual(json.loads(literal), nasty) + + def test_edit_refuses_in_place_and_empty(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text("") + with self.assertRaisesRegex(ValueError, "differ from project"): + m.tool_edit("/nonexistent", str(p), [{"op": "add_folio"}], str(p)) + with self.assertRaisesRegex(ValueError, "non-empty"): + m.tool_edit("/nonexistent", str(p), [], str(Path(tmp) / "o.qet")) + + +class ResultParsing(unittest.TestCase): + def line(self, **kw): + return m._MARKER + json.dumps(kw) + + def test_zero_is_a_valid_result(self): + """0 == False in Python. A valid first index (first text, first folio + of an empty project) was read as failure and marked a successful edit + ok=False.""" + out = self.line(kind="op", index=0, op="add_text", id="t", result=0) + rec = m._parse_script_output(out)["operations"][0] + self.assertTrue(rec["succeeded"]) + + def test_failures_are_recognised(self): + for bad in (False, "", -1, None): + with self.subTest(result=bad): + out = self.line(kind="op", index=0, op="x", id=None, result=bad) + self.assertFalse(m._parse_script_output(out)["operations"][0]["succeeded"]) + + def test_true_and_strings_succeed(self): + for good in (True, "{uuid}", 1, 7): + with self.subTest(result=good): + out = self.line(kind="op", index=0, op="x", id=None, result=good) + self.assertTrue(m._parse_script_output(out)["operations"][0]["succeeded"]) + + def test_noise_and_garbage_are_ignored(self): + text = "SQLite version\n" + m._MARKER + "{not json\nplain\n" + \ + self.line(kind="save", result=True, stopped_early=False) + parsed = m._parse_script_output(text) + self.assertTrue(parsed["saved"]) + self.assertEqual(parsed["operations"], []) + + +class TerminalOrder(unittest.TestCase): + """QElectroTech indexes terminals top-to-bottom then left-to-right, not in + file order. Getting it wrong wires the wrong end of a coil with no error; + 619 of the 837 shipped elements with named terminals list them + differently from how they are indexed.""" + + def nodes(self, *specs): + return [ET.Element("terminal", {"name": n, "x": str(x), "y": str(y)}) for n, x, y in specs] + + def test_sorted_by_y_then_x(self): + ordered, amb = m._terminals_in_index_order( + self.nodes(("A2", 0, 20), ("A1", 0, -20))) + self.assertEqual([t.get("name") for t in ordered], ["A1", "A2"]) + self.assertFalse(amb) + + def test_left_to_right_within_a_row(self): + ordered, _ = m._terminals_in_index_order( + self.nodes(("R", 30, 0), ("L", -30, 0), ("M", 0, 0))) + self.assertEqual([t.get("name") for t in ordered], ["L", "M", "R"]) + + def test_y_dominates_x(self): + ordered, _ = m._terminals_in_index_order( + self.nodes(("lowleft", -50, 10), ("highright", 50, -10))) + self.assertEqual([t.get("name") for t in ordered], ["highright", "lowleft"]) + + def test_ties_are_flagged_not_hidden(self): + _, amb = m._terminals_in_index_order(self.nodes(("a", 5, 5), ("b", 5, 5))) + self.assertTrue(amb) + + def test_numeric_not_lexical(self): + ordered, _ = m._terminals_in_index_order( + self.nodes(("nine", 0, 9), ("ten", 0, 10), ("minus", 0, -10))) + self.assertEqual([t.get("name") for t in ordered], ["minus", "nine", "ten"]) + + @unittest.skipUnless(have_elements, "set QET_ELEMENTS") + def test_real_element_disagrees_with_file_order(self): + f = Path(ELEMENTS) / COIL.replace("common://", "") + root = ET.parse(f).getroot() + file_order = [t.get("name") for t in root.iter("terminal")] + ordered, _ = m._terminals_in_index_order(list(root.iter("terminal"))) + self.assertEqual(file_order, ["A2", "A1"]) + self.assertEqual([t.get("name") for t in ordered], ["A1", "A2"]) + + +class ElementBuild(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.out = str(Path(self.tmp.name) / "e.elmt") + self.ok = dict(names={"en": "x"}, + parts=[{"type": "line", "x1": 0, "y1": 0, "x2": 10, "y2": 0}], + terminals=[{"x": 0, "y": 0, "orientation": "n"}]) + + def tearDown(self): + self.tmp.cleanup() + + def build(self, **over): + return m.tool_element_build(self.out, **{**self.ok, **over}) + + def test_rejections(self): + cases = { + "unknown part": dict(parts=[{"type": "squiggle"}]), + "missing key": dict(parts=[{"type": "rect", "x": 0, "y": 0, "width": 5}]), + "typo'd key": dict(parts=[{"type": "circle", "x": 0, "y": 0, "diametre": 5}]), + "bad orientation": dict(terminals=[{"x": 0, "y": 0, "orientation": "up"}]), + "no names": dict(names={}), + "bad link_type": dict(link_type="widget"), + "connectable, no terminals": dict(terminals=[]), + "one-point polygon": dict(parts=[{"type": "polygon", "points": [[0, 0]]}]), + "text coordinate": dict(parts=[{"type": "line", "x1": "left", "y1": 0, "x2": 1, "y2": 0}]), + } + for label, over in cases.items(): + with self.subTest(label): + with self.assertRaises(ValueError): + self.build(**over) + self.assertFalse(Path(self.out).exists(), "nothing may be written on refusal") + + def test_an_unexpected_key_on_a_valid_part_is_refused(self): + """Distinct from a missing key: the part below is complete, and only + the extra attribute is wrong. Without this the 'allowed keys' rule is + never exercised -- the typo case above is refused for lacking + 'diameter', which passes even if unexpected keys are accepted.""" + with self.assertRaisesRegex(ValueError, "unexpected 'colour'"): + self.build(parts=[{"type": "rect", "x": 0, "y": 0, "width": 5, "height": 5, + "colour": "red"}]) + self.assertFalse(Path(self.out).exists()) + + def test_declared_box_contains_the_drawing(self): + """The header is a containment constraint, not a formula, and the + assertion behind it is what stops a clipped element.""" + for parts in ([{"type": "rect", "x": 100, "y": 100, "width": 50, "height": 50}], + [{"type": "rect", "x": -200, "y": -150, "width": 40, "height": 30}], + [{"type": "circle", "x": -25, "y": -25, "diameter": 50}]): + with self.subTest(parts=parts[0]["type"]): + r = self.build(parts=parts) + x0, y0, x1, y1 = r["bbox"] + self.assertLessEqual(-r["hotspot_x"], x0) + self.assertLessEqual(-r["hotspot_y"], y0) + self.assertGreaterEqual(r["width"] - r["hotspot_x"], x1) + self.assertGreaterEqual(r["height"] - r["hotspot_y"], y1) + + def test_reports_terminal_index_order_not_input_order(self): + r = self.build(parts=[{"type": "line", "x1": 0, "y1": -20, "x2": 0, "y2": 20}], + terminals=[{"x": 0, "y": 20, "orientation": "s", "name": "low"}, + {"x": 0, "y": -20, "orientation": "n", "name": "high"}]) + self.assertEqual(r["terminal_index_order"], ["high", "low"]) + + def test_output_reads_back(self): + r = self.build(names={"en": "Coil", "fr": "Bobine"}) + self.assertEqual(r["verified"]["names"], {"en": "Coil", "fr": "Bobine"}) + root = ET.parse(self.out).getroot() + self.assertEqual(root.tag, "definition") + self.assertEqual(root.get("link_type"), "simple") + + def test_special_characters_survive(self): + r = self.build(names={"en": 'Coil "A" & ', "fr": "Résistance"}) + self.assertEqual(r["verified"]["names"]["fr"], "Résistance") + self.assertEqual(r["verified"]["names"]["en"], 'Coil "A" & ') + + +class ElementSearch(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + m._ELEMENT_INDEX.clear() + + def tearDown(self): + self.tmp.cleanup() + + def put(self, rel, names, link="simple", terminals=((0, -10, "n"), (0, 10, "s")), kind=""): + p = self.root / rel + p.parent.mkdir(parents=True, exist_ok=True) + kinds = f'{kind}' if kind else "" + ns = "".join(f'{n}' for l, n in names.items()) + ts = "".join(f'' + for i, (x, y, o) in enumerate(terminals)) + p.write_text(f'{ns}{kinds}' + f'{ts}', encoding="utf-8") + + def test_accents_and_case_are_ignored_across_languages(self): + self.put("a/res.elmt", {"en": "Resistor", "fr": "Résistance", "de": "Widerstand"}) + for q in ("resistance", "RÉSISTANCE", "widerstand", "resistor"): + with self.subTest(q=q): + self.assertEqual(m.tool_element_search(str(self.root), q)["total_matches"], 1) + + def test_every_word_must_match(self): + self.put("a/x.elmt", {"en": "Big red coil"}) + self.assertEqual(m.tool_element_search(str(self.root), "red coil")["total_matches"], 1) + self.assertEqual(m.tool_element_search(str(self.root), "red fuse")["total_matches"], 0) + + def test_filters(self): + self.put("a/m.elmt", {"en": "Coil"}, link="master", kind="coil") + self.put("a/s.elmt", {"en": "Contact"}, link="slave", terminals=((0, 0, "n"),) * 1) + self.put("a/t.elmt", {"en": "Big"}, terminals=[(0, i, "n") for i in range(5)]) + g = lambda **k: {r["name"] for r in m.tool_element_search(str(self.root), **k)["results"]} + self.assertEqual(g(link_type="master"), {"Coil"}) + self.assertEqual(g(min_terminals=5), {"Big"}) + self.assertEqual(g(max_terminals=1), {"Contact"}) + self.assertEqual(g(kind="coil"), {"Coil"}) + + def test_result_is_placeable_path_and_index_ordered_terminals(self): + self.put("dir/x.elmt", {"en": "X"}, terminals=((0, 20, "s"), (0, -20, "n"))) + r = m.tool_element_search(str(self.root), "x")["results"][0] + self.assertEqual(r["path"], "common://dir/x.elmt") + self.assertEqual(r["terminal_names"], ["t1", "t0"]) # t1 is at y=-20: index 0 + + def test_new_file_is_found_without_a_manual_reindex(self): + """A symbol written by qet_element_build must be searchable at once.""" + self.put("a/one.elmt", {"en": "One"}) + self.assertEqual(m.tool_element_search(str(self.root), "two")["total_matches"], 0) + self.put("a/two.elmt", {"en": "Two"}) + self.assertEqual(m.tool_element_search(str(self.root), "two")["total_matches"], 1) + + def test_exact_name_ranks_first(self): + self.put("a/long.elmt", {"en": "Remanence coil, latching"}) + self.put("a/short.elmt", {"en": "Coil"}) + self.assertEqual(m.tool_element_search(str(self.root), "coil")["results"][0]["name"], "Coil") + + def test_bad_arguments(self): + with self.assertRaises(ValueError): + m.tool_element_search("/no/such/dir") + with self.assertRaises(ValueError): + m.tool_element_search(str(self.root), link_type="widget") + with self.assertRaises(ValueError): + m.tool_element_search(str(self.root), limit=0) + + def test_unparseable_files_are_skipped_not_fatal(self): + (self.root / "bad.elmt").write_text("' + for x, y, t in texts) + shp = "".join(f'' + f'' + f'' + for a, b, c, d, pc in shapes) + img = "".join(f'' for x, y, s in images) + st = "".join(f'' + f'{n}' + f'' for u, n in strips) + cond = "".join(f'' + for a, b, n in conductors) + xml = (f'' + f'{cond}{inputs}' + f'{img}{shp}' + f'{st}') + p = self.dir / name + p.write_text(xml) + return str(p) + + def test_identical_projects_have_an_empty_diff_everywhere(self): + a = self.project("a.qet", texts=[(1, 2, "hi")], shapes=[(0, 0, 5, 5, "#000")], + images=[(3, 3, 1)], strips=[("{u}", "X1")]) + d = m.tool_diff(a, a) + for k in ("texts", "shapes", "images", "terminal_strips"): + with self.subTest(section=k): + self.assertFalse(d[k]["added"] or d[k]["removed"] or d[k]["changed"]) + self.assertEqual(d["folios"]["changed"], []) + + def test_edited_text_reads_as_removed_plus_added(self): + a = self.project("a.qet", texts=[(1, 2, "note")]) + b = self.project("b.qet", texts=[(1, 2, "note EDITED")]) + d = m.tool_diff(a, b)["texts"] + self.assertEqual([t["text"] for t in d["removed"]], ["note"]) + self.assertEqual([t["text"] for t in d["added"]], ["note EDITED"]) + + def test_shape_restyle_is_a_change_to_that_item(self): + a = self.project("a.qet", shapes=[(0, 0, 5, 5, "#000000")]) + b = self.project("b.qet", shapes=[(0, 0, 5, 5, "#ff0000")]) + d = m.tool_diff(a, b)["shapes"] + self.assertEqual(d["added"], []) + self.assertEqual(d["changed"][0]["changed"]["line_color"], ["#000000", "#ff0000"]) + + def test_image_rescale_is_a_change(self): + a = self.project("a.qet", images=[(1, 1, 2)]) + b = self.project("b.qet", images=[(1, 1, 3)]) + self.assertEqual(m.tool_diff(a, b)["images"]["changed"][0]["changed"]["scale"], ["2", "3"]) + + def test_folio_author_change(self): + a = self.project("a.qet", author="A") + b = self.project("b.qet", author="B") + self.assertEqual(m.tool_diff(a, b)["folios"]["changed"][0]["changed"]["author"], ["A", "B"]) + + def test_the_file_version_stamp_is_not_a_change(self): + """QElectroTech rewrites every folio's version attribute on save; + comparing it made every folio of a re-saved project look edited.""" + a = self.project("a.qet", version="0.80c") + b = self.project("b.qet", version="0.200.1-dev") + self.assertEqual(m.tool_diff(a, b)["folios"]["changed"], []) + + def test_element_text_field_moves_and_restyles_are_reported(self): + def proj(x, size, frame, extra=""): + return (f'' + f'KM1' + f'label{extra}' + f'') + a = self.dir / "a.qet" + b = self.dir / "b.qet" + a.write_text(proj(30, 9, "false")) + b.write_text(proj(60, 14, "true", 'NEW' + '')) + d = m.tool_diff(str(a), str(b))["element_texts"] + self.assertEqual(len(d["added"]), 1) + self.assertEqual(d["changed"][0]["changed"], + {"x": ["30", "60"], "size": ["9", "14"], "frame": ["false", "true"]}) + self.assertEqual(d["changed"][0]["item"]["bound_to"], "label") + self.assertFalse(m.tool_diff(str(a), str(a))["element_texts"]["changed"]) + + def test_project_title_and_folio_frame_changes_are_reported(self): + def proj(title, cols): + return (f'') + a, b = self.dir / "a.qet", self.dir / "b.qet" + a.write_text(proj("Old", 17)) + b.write_text(proj("New", 10)) + d = m.tool_diff(str(a), str(b)) + self.assertEqual(d["project"]["changed"], {"title": ["Old", "New"]}) + self.assertEqual(d["folios"]["changed"][0]["changed"], {"cols": ["17", "10"]}) + self.assertEqual(m.tool_diff(str(a), str(a))["project"]["changed"], {}) + + def test_strip_added(self): + a = self.project("a.qet") + b = self.project("b.qet", strips=[("{u}", "X9")]) + self.assertEqual(m.tool_diff(a, b)["terminal_strips"]["added"], ["X9"]) + + def test_text_html_is_reduced_to_visible_text(self): + self.assertEqual(m._plain_text("

a & b

"), "a & b") + + +class ConductorKey(unittest.TestCase): + """The key must survive a save. QElectroTech renumbers the folio-scoped + terminal ids on every write, so keying on the raw pair made every + conductor of an untouched folio read as removed and re-added.""" + + def diagram(self, terminals, conductors): + d = ET.Element("diagram") + for uuid, tid, x, y in terminals: + el = ET.SubElement(d, "element", {"uuid": uuid}) + ET.SubElement(el, "terminal", {"id": tid, "x": str(x), "y": str(y), "orientation": "n"}) + for a, b in conductors: + ET.SubElement(d, "conductor", {"terminal1": a, "terminal2": b}) + return d + + def keys(self, d): + ix = m._terminal_index(d) + return {m._conductor_key(1, c, ix) for c in d.iter("conductor")} + + def test_renumbering_the_ids_does_not_change_the_key(self): + before = self.diagram([("{a}", "1", 0, 0), ("{b}", "16", 0, 0)], [("1", "16")]) + after = self.diagram([("{a}", "34", 0, 0), ("{b}", "15", 0, 0)], [("34", "15")]) + self.assertEqual(self.keys(before), self.keys(after)) + + def test_end_order_does_not_matter(self): + one = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0)], [("1", "2")]) + two = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0)], [("2", "1")]) + self.assertEqual(self.keys(one), self.keys(two)) + + def test_different_conductors_get_different_keys(self): + d = self.diagram([("{a}", "1", 0, 0), ("{b}", "2", 0, 0), ("{c}", "3", 0, 0)], + [("1", "2"), ("1", "3")]) + self.assertEqual(len(self.keys(d)), 2) + + def test_elements_without_uuid_stay_apart_and_are_marked(self): + """Keying them on geometry collapsed nine distinct conductors onto + one key, which was worse than the instability being fixed.""" + d = self.diagram([("", "1", 0, 0), ("", "2", 0, 0), ("", "3", 0, 0)], + [("1", "2"), ("2", "3"), ("1", "3")]) + keys = self.keys(d) + self.assertEqual(len(keys), 3) + self.assertTrue(all("#" in k for k in keys)) + + def test_current_format_uses_element_and_terminal(self): + """A terminal uuid belongs to the definition, so two coils of one type + share it; the (instance, terminal) pair is the identity.""" + d = ET.Element("diagram") + for a, b in (("{e1}", "{e2}"), ("{e1}", "{e3}")): + ET.SubElement(d, "conductor", {"element1": a, "terminal1": "{T}", + "element2": b, "terminal2": "{T}"}) + self.assertEqual(len(self.keys(d)), 2) + + +class ChecksDefinition(unittest.TestCase): + def test_all_checks_are_read_only_selects(self): + for name, c in m.CHECKS.items(): + with self.subTest(check=name): + self.assertRegex(c["sql"].lstrip().upper(), r"^(SELECT|WITH)\b") + self.assertNotRegex(c["sql"].upper(), r"\b(DELETE|UPDATE|INSERT|DROP|ALTER)\b") + self.assertIn(c["severity"], ("error", "warning", "info")) + self.assertTrue(c["note"]) + + def test_text_comparisons_treat_null_as_empty(self): + """A value never set is NULL for an element placed in this session + and '' for one loaded from a file; `col = ''` matches only the + latter. Every comparison must go through COALESCE.""" + for name, c in m.CHECKS.items(): + for col in ("label", "manufacturer_reference", "wire_number"): + for m_ in re.finditer(rf"(?)\s*''", c["sql"]): + self.fail(f"{name}: bare {col} comparison at {m_.start()} misses NULL") + + def test_unknown_check_and_bad_sample(self): + with tempfile.NamedTemporaryFile(suffix=".qet") as f: + with self.assertRaises(ValueError): + m.tool_check("/x", f.name, checks=["nope"]) + with self.assertRaises(ValueError): + m.tool_check("/x", f.name, sample=-1) + + +class PathPolicy(unittest.TestCase): + """The workspace confinement on model-supplied paths. + + Both AI security reviews on PR #980 flagged unrestricted filesystem + reach as the headline MCP-side risk, one of them as the single blocker + before merge. These check the control itself rather than the tools + behind it, so they stay fast and hermetic. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "workspace" + self.root.mkdir() + self.outside = Path(self.tmp.name) / "outside" + self.outside.mkdir() + (self.outside / "secret.qet").write_text("") + (self.root / "ok.qet").write_text("") + self._saved = dict(os.environ) + os.environ["QET_MCP_WORKSPACE"] = str(self.root) + os.environ.pop("QET_MCP_ALLOW_ANY_PATH", None) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._saved) + self.tmp.cleanup() + + def test_read_inside_the_workspace_is_allowed(self): + m.enforce_path_policy("qet_project_info", {"path": str(self.root / "ok.qet")}) + + def test_read_outside_the_workspace_is_refused(self): + with self.assertRaisesRegex(ValueError, "outside the workspace"): + m.enforce_path_policy("qet_project_info", + {"path": str(self.outside / "secret.qet")}) + + def test_traversal_out_of_the_workspace_is_refused(self): + sneaky = str(self.root / ".." / "outside" / "secret.qet") + with self.assertRaisesRegex(ValueError, "outside the workspace"): + m.enforce_path_policy("qet_project_info", {"path": sneaky}) + + def test_symlink_escape_is_refused(self): + """A link planted inside the workspace is judged by where it points. + + This is the case a string-prefix check gets wrong, which is why the + policy resolves before comparing. + """ + link = self.root / "innocent.qet" + link.symlink_to(self.outside / "secret.qet") + with self.assertRaisesRegex(ValueError, "outside the workspace"): + m.enforce_path_policy("qet_project_info", {"path": str(link)}) + + def test_write_outside_the_workspace_is_refused(self): + with self.assertRaisesRegex(ValueError, "outside the workspace"): + m.enforce_path_policy("qet_export", { + "binary": "/usr/bin/qelectrotech", + "project": str(self.root / "ok.qet"), + "format": "pdf", + "output": str(self.outside / "exfiltrated.pdf")}) + + def test_existing_output_is_not_clobbered_without_overwrite(self): + target = self.root / "existing.qet" + target.write_text("precious") + args = {"binary": "/usr/bin/qelectrotech", "output": str(target), "title": "T"} + with self.assertRaisesRegex(ValueError, "already exists"): + m.enforce_path_policy("qet_project_new", args) + # ... and goes through once the caller says so explicitly + m.enforce_path_policy("qet_project_new", dict(args, overwrite=True)) + self.assertEqual(target.read_text(), "precious", "policy must not itself write") + + def test_new_output_needs_no_overwrite_flag(self): + m.enforce_path_policy("qet_project_new", { + "binary": "/usr/bin/qelectrotech", + "output": str(self.root / "brand_new.qet"), "title": "T"}) + + def test_operation_level_file_paths_are_checked(self): + """add_image/add_pdf_page carry their own path, one level down.""" + base = {"binary": "/usr/bin/qelectrotech", + "project": str(self.root / "ok.qet"), + "output": str(self.root / "out.qet")} + outside_png = str(self.outside / "anything.png") + for op in ({"op": "add_image", "folio": 0, "file": outside_png, "x": 0, "y": 0}, + {"op": "add_pdf_page", "folio": 0, "file": outside_png, + "page": 1, "dpi": 150, "x": 0, "y": 0}): + with self.subTest(op=op["op"]): + with self.assertRaisesRegex(ValueError, "outside the workspace"): + m.enforce_path_policy("qet_edit", dict(base, operations=[op])) + + def test_configuration_paths_are_exempt(self): + """binary and elements_dir are the operator's choice, not the model's. + + Both normally live in /usr or a build tree, so confining them would + reject the ordinary case while stopping nothing. + """ + m.enforce_path_policy("qet_query", { + "binary": "/usr/bin/qelectrotech", + "project": str(self.root / "ok.qet"), + "elements_dir": "/usr/share/qelectrotech/elements"}) + + def test_several_roots_may_be_allowed(self): + os.environ["QET_MCP_WORKSPACE"] = os.pathsep.join( + [str(self.root), str(self.outside)]) + m.enforce_path_policy("qet_project_info", {"path": str(self.outside / "secret.qet")}) + + def test_escape_hatch_disables_confinement(self): + os.environ["QET_MCP_ALLOW_ANY_PATH"] = "1" + self.assertEqual(m.workspace_roots(), []) + m.enforce_path_policy("qet_project_info", {"path": "/etc/passwd"}) + + def test_defaults_to_the_working_directory_not_to_everything(self): + os.environ.pop("QET_MCP_WORKSPACE", None) + roots = m.workspace_roots() + self.assertEqual(roots, [Path(os.getcwd()).resolve()], + "an unset workspace must still confine, not open up") + + def test_tools_without_data_paths_are_untouched(self): + m.enforce_path_policy("qet_diff", {}) + + def test_every_write_tool_offers_the_overwrite_flag(self): + """The policy and the schemas must not drift apart. + + A tool whose output the policy guards but whose schema never + mentions "overwrite" is a tool no client can ever replace a file + with; a tool that advertises the flag but is not in the policy is a + tool that silently clobbers. Both are one forgotten line away, so + the two lists are compared rather than trusted. + """ + guarded = {name for name, spec in m._DATA_PATHS.items() if spec.get("write")} + advertised = {t["name"] for t in m.TOOLS + if "overwrite" in t["inputSchema"].get("properties", {})} + self.assertEqual(guarded, advertised) + + def test_the_policy_names_only_real_tools_and_arguments(self): + by_name = {t["name"]: t for t in m.TOOLS} + for name, spec in m._DATA_PATHS.items(): + with self.subTest(tool=name): + self.assertIn(name, by_name, "policy guards a tool that does not exist") + props = by_name[name]["inputSchema"].get("properties", {}) + for arg in tuple(spec.get("read", ())) + tuple(spec.get("write", ())): + self.assertIn(arg, props, + f"{name} has no {arg!r} argument to guard") + + +class ScriptingDisabledHint(unittest.TestCase): + """QElectroTech may refuse to run scripts at all, and says so in French. + + Scripting is off by default from qelectrotech-source-mirror#984 on, so + every tool here that drives QElectroTech through --run comes back empty + until somebody sets QET_ENABLE_SCRIPTING=1 in the environment this + server was started in. The refusal has to arrive as an instruction the + caller can act on, not as exit code 3 and a paragraph of French, and + not as one of the older guesses ("is it a build with --run support?") + that happen to fit the same symptom. + + subprocess.run is faked, so these are about the reading of the result + and cost no launch. + """ + + REFUSAL = ("Les scripts sont désactivés.\n\nPour l'activer : Configurer " + "QElectroTech > Général > Projets, ou définir la variable " + "d'environnement QET_ENABLE_SCRIPTING=1 pour une exécution " + "sans interface (CI, traitement par lot).") + + def fake_run(self, returncode, stderr="", stdout=""): + def run(argv, **kwargs): + return subprocess.CompletedProcess(argv, returncode, stdout, stderr) + return run + + def call(self, returncode, stderr="", **kw): + saved = m.subprocess.run + m.subprocess.run = self.fake_run(returncode, stderr) + try: + # /bin/true only has to exist and be executable: it is copied + # into the sandbox and then never actually launched. + return m._run_qet("/bin/true", ["x.qet"], **kw) + finally: + m.subprocess.run = saved + + def test_a_refused_script_says_which_variable_to_set(self): + r = self.call(3, self.REFUSAL, script="qet.log('hi')") + self.assertFalse(r["ok"]) + self.assertIn("QET_ENABLE_SCRIPTING=1", r["hint"]) + self.assertIn("env", r["hint"]) + + def test_the_hint_survives_a_reworded_refusal(self): + # Keyed on the variable name first, exit 3 as the fallback, so a + # future build that words this differently still gets read right. + r = self.call(3, "scripting is disabled", script="qet.log('hi')") + self.assertIn("QET_ENABLE_SCRIPTING=1", r["hint"]) + + def test_a_launch_with_no_script_is_not_blamed_on_scripting(self): + # qet_export uses a plain CLI flag and keeps working with scripting + # off, so its failures must never be explained this way. + r = self.call(3, "Project not found: x.qet") + self.assertNotIn("hint", r) + + def test_a_successful_script_gets_no_hint(self): + r = self.call(0, "", script="qet.log('hi')") + self.assertTrue(r["ok"]) + self.assertNotIn("hint", r) + + def test_the_specific_hint_is_not_overwritten_by_a_generic_one(self): + """The two older guesses fit the same symptom and must yield to it. + + Scripting being off produces no capability report either, so + tool_edit() and tool_project_new() would otherwise answer "is it a + build with --run support?" -- sending the reader to check the one + thing that is fine. + """ + source = Path(m.__file__).read_text() + for marker in ("the binary never ran the script", + "QElectroTech did not write the project"): + with self.subTest(hint=marker[:30]): + idx = source.index(marker) + before = source[max(0, idx - 400):idx] + self.assertIn('setdefault("hint"', before, + "this hint would clobber a more specific one") + + def test_every_script_driven_tool_is_named_in_the_hint(self): + """The hint lists which tools need the variable; keep it true. + + A tool that starts driving QElectroTech through a script and is + missing from that list leaves its caller reading a message that + says the problem is somewhere else. + """ + source = Path(m.__file__).read_text() + # Tool handlers that pass script= to _run_qet. + driven = set(re.findall(r"^def (tool_\w+)", source, re.M)) + script_driven = set() + for name in driven: + body = source[source.index(f"def {name}("):] + body = body[:body.find("\ndef ") if "\ndef " in body else len(body)] + if "script=script" in body or "script=\"\\n\".join(script)" in body: + script_driven.add("qet_" + name[len("tool_"):]) + self.assertTrue(script_driven, "found no script-driven tools to check") + hint = source[source.index("QET_ENABLE_SCRIPTING=1 to the"):][:900] + for tool in sorted(script_driven): + with self.subTest(tool=tool): + self.assertIn(tool, hint) + + +class PathPolicyOverStdio(unittest.TestCase): + """Proves the policy is actually wired into the dispatcher. + + The checks above call enforce_path_policy() directly; this one goes + through a real server process, which is the only thing that shows a + tool call is gated rather than merely gate-able. + """ + + def rpc(self, message, env_extra): + env = dict(os.environ, **env_extra) + proc = subprocess.run([sys.executable, str(HERE / "qet_mcp.py")], + input=json.dumps(message) + "\n", + capture_output=True, text=True, timeout=30, env=env) + self.assertEqual(proc.returncode, 0, proc.stderr) + for line in proc.stdout.splitlines(): + if line.strip(): + obj = json.loads(line) + if obj.get("id") == message.get("id"): + return obj + self.fail("no reply for the request") + + def test_a_tool_call_reaching_outside_the_workspace_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "ws" + root.mkdir() + victim = Path(tmp) / "elsewhere.qet" + victim.write_text('') + + reply = self.rpc({"jsonrpc": "2.0", "id": 7, "method": "tools/call", + "params": {"name": "qet_project_info", + "arguments": {"path": str(victim)}}}, + {"QET_MCP_WORKSPACE": str(root)}) + result = reply["result"] + self.assertTrue(result.get("isError"), result) + self.assertIn("outside the workspace", result["content"][0]["text"]) + + def test_the_same_call_succeeds_inside_the_workspace(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "ws" + root.mkdir() + proj = root / "mine.qet" + proj.write_text('') + + reply = self.rpc({"jsonrpc": "2.0", "id": 8, "method": "tools/call", + "params": {"name": "qet_project_info", + "arguments": {"path": str(proj)}}}, + {"QET_MCP_WORKSPACE": str(root)}) + result = reply["result"] + self.assertFalse(result.get("isError"), result) + self.assertIn("mine", result["content"][0]["text"]) + + +class QueryGuard(unittest.TestCase): + def test_obvious_writes_are_refused_before_launch(self): + with tempfile.NamedTemporaryFile(suffix=".qet") as f: + for sql in ("DELETE FROM element", "DROP TABLE element", + "UPDATE element SET x=1", "PRAGMA table_info(element)", + "INSERT INTO element VALUES (1)"): + with self.subTest(sql=sql): + with self.assertRaisesRegex(ValueError, "read-only"): + m.tool_query("/nonexistent", f.name, sql) + + def test_missing_project(self): + with self.assertRaisesRegex(ValueError, "no such project"): + m.tool_query("/x", "/no/such.qet", "SELECT 1") + + +class ProjectNewValidation(unittest.TestCase): + def test_refuses_overwrite_and_bad_arguments(self): + with tempfile.TemporaryDirectory() as tmp: + existing = Path(tmp) / "a.qet" + existing.write_text("x") + with self.assertRaisesRegex(ValueError, "already exists"): + m.tool_project_new("/x", str(existing), "t") + new = str(Path(tmp) / "n.qet") + for kw in ({"folios": True}, {"folios": "2"}, {"folios": 500}, {"title": " "}): + with self.subTest(**kw): + with self.assertRaises(ValueError): + m.tool_project_new("/x", new, **({"title": "t"} | kw)) + self.assertFalse(Path(new).exists()) + + +class ReadTools(unittest.TestCase): + def test_project_info_and_scan_on_a_fixture(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text('' + '' + '' + '') + info = m.tool_project_info(str(p)) + self.assertEqual((info["title"], info["folio_count"]), ("T", 1)) + scan = m.tool_scan(tmp, "conductor", "cable") + self.assertEqual((scan["files"], scan["total"], scan["non_empty"]), (1, 1, 1)) + # (value, how many nodes carry it) + self.assertEqual([tuple(v) for v in scan["distinct_values"]], [("C1", 1)]) + + +# ========================================================================== +# protocol +# ========================================================================== + +class Protocol(unittest.TestCase): + def rpc(self, *messages, workspace=None): + # The server confines data paths to a workspace (see PathPolicy), so + # a test driving it over real stdio has to declare one, exactly as a + # real MCP host config does. + env = dict(os.environ) + if workspace is not None: + env["QET_MCP_WORKSPACE"] = str(workspace) + proc = subprocess.run([sys.executable, str(HERE / "qet_mcp.py")], + input="\n".join(json.dumps(x) for x in messages) + "\n", + capture_output=True, text=True, timeout=30, env=env) + self.assertEqual(proc.returncode, 0, proc.stderr) + out = [json.loads(line) for line in proc.stdout.splitlines() if line.strip()] + return {o["id"]: o for o in out if "id" in o}, proc + + def test_handshake_list_and_call_over_real_stdio(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text('') + replies, proc = self.rpc( + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + {"jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "qet_project_info", "arguments": {"path": str(p)}}}, + {"jsonrpc": "2.0", "id": 4, "method": "ping"}, + workspace=tmp) + self.assertEqual(replies[1]["result"]["serverInfo"]["name"], "qet-mcp") + self.assertEqual(len(replies[2]["result"]["tools"]), len(m.TOOLS)) + body = json.loads(replies[3]["result"]["content"][0]["text"]) + self.assertEqual(body["title"], "Via stdio") + self.assertEqual(replies[4]["result"], {}) + self.assertEqual(len(replies), 4, "a notification must not get a reply") + + def test_stdout_carries_only_protocol(self): + _, proc = self.rpc({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + for line in proc.stdout.splitlines(): + if line.strip(): + json.loads(line) + + def test_tool_errors_are_reported_not_thrown(self): + replies, _ = self.rpc({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "qet_project_info", "arguments": {"path": "/no/such.qet"}}}) + self.assertTrue(replies[1]["result"]["isError"]) + + def test_unknown_tool_and_method(self): + replies, _ = self.rpc( + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "nope"}}, + {"jsonrpc": "2.0", "id": 2, "method": "no/such"}) + self.assertEqual(replies[1]["error"]["code"], -32602) + self.assertEqual(replies[2]["error"]["code"], -32601) + + def test_list_flag(self): + out = subprocess.run([sys.executable, str(HERE / "qet_mcp.py"), "--list"], + capture_output=True, text=True, timeout=30).stdout + for t in m.TOOLS: + self.assertIn(t["name"], out) + + +# ========================================================================== +# integration +# ========================================================================== + +class Sandbox: + """A scratch directory plus the binary/collection arguments.""" + + def __init__(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = Path(self.tmp.name) + + def p(self, name): + return str(self.dir / name) + + def new(self, title="T", folios=1, **kw): + r = m.tool_project_new(BINARY, self.p(f"{title}.qet"), title=title, folios=folios, + overwrite=True, **kw) + assert r["ok"], r + return r["output"] + + def edit(self, project, ops, out="out.qet"): + return m.tool_edit(BINARY, project, ops, self.p(out), elements_dir=ELEMENTS) + + def close(self): + self.tmp.cleanup() + + +@needs_elements +class Integration(unittest.TestCase): + def setUp(self): + self.sb = Sandbox() + + def tearDown(self): + self.sb.close() + + def ok(self, r): + self.assertTrue(r["ok"], json.dumps({k: r.get(k) for k in ("hint", "operations")}, default=str)[:600]) + return r + + # ---- project_new ---- + + def test_new_project_reads_back(self): + r = m.tool_project_new(BINARY, self.sb.p("n.qet"), title='A "b" & ', + folios=["Power", "Control"], author="Me") + self.assertTrue(r["ok"]) + self.assertEqual(r["project"]["title"], 'A "b" & ') + self.assertEqual([f["title"] for f in r["project"]["folios"]], ["Power", "Control"]) + + def test_new_project_with_zero_folios(self): + r = m.tool_project_new(BINARY, self.sb.p("z.qet"), title="Z", folios=0) + self.assertTrue(r["ok"]) + self.assertEqual(r["project"]["folio_count"], 0) + + # ---- edit: geometry, wiring, labels ---- + + def test_place_wire_label_number_and_verify_by_diff(self): + base = self.sb.new() + ops = [ + {"op": "add_autonum", "kind": "conductor", "name": "W", "parts": ["string:W", "unit:1:1"]}, + {"op": "use_conductor_autonum", "folio": 0, "name": "W"}, + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_element", "id": "b", "folio": 0, "path": COIL, "x": 300, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", "from_terminal": 0, "to": "$b", "to_terminal": 0}, + {"op": "set_label", "folio": 0, "element": "$a", "label": "KM1"}, + {"op": "rotate_element", "folio": 0, "element": "$b", "angle": 90}, + ] + r = self.ok(self.sb.edit(base, ops)) + self.assertTrue(all(o["succeeded"] for o in r["operations"])) + d = r["diff"] + self.assertEqual(len(d["elements"]["added"]), 2) + self.assertEqual(len(d["conductors"]["added"]), 1) + self.assertEqual(d["conductors"]["removed"], []) + nums = [c["num"] for c in m.tool_conductors(r["output"])["conductors"]] + self.assertEqual(nums, ["W1"]) + + def test_noop_edit_has_no_conductor_churn(self): + """Re-saving renumbers the file's terminal ids; the diff must not + report the untouched conductors as removed and re-added.""" + if not have_examples: + self.skipTest("set QET_EXAMPLES") + src = shutil.copy(Path(EXAMPLES) / "ArduinoLCD.qet", self.sb.p("in.qet")) + r = self.ok(self.sb.edit(src, [{"op": "add_folio"}])) + c = r["diff"]["conductors"] + self.assertEqual((len(c["added"]), len(c["removed"])), (0, 0)) + self.assertEqual(c["before"], c["after"]) + + def test_failed_operation_stops_the_run_and_reports_it(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 0, "y": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 99, "to": "$a", "to_terminal": 0}, + {"op": "set_label", "folio": "$f", "element": "$a", "label": "NEVER"}, + ]) + self.assertFalse(r["ok"]) + self.assertTrue(r["stopped_early"]) + self.assertEqual(len(r["operations"]), 3) + self.assertIn("script", r, "the generated script is returned for debugging") + + def test_first_index_zero_is_not_a_failure(self): + """add_folio on an empty project and the first text both return 0.""" + base = m.tool_project_new(BINARY, self.sb.p("e.qet"), title="E", folios=0)["output"] + r = self.ok(self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_text", "id": "t", "folio": "$f", "text": "hi", "x": 5, "y": 5}])) + self.assertEqual([o["result"] for o in r["operations"]], [0, 0]) + + def test_two_potentials_are_refused_not_hung(self): + """ConductorCreator opens a modal dialog here, and headless nobody + can answer it: with the guard removed this call never returns.""" + base = self.sb.new() + r = m.tool_edit(BINARY, base, [ + {"op": "add_folio", "id": "f"}, + *[{"op": "add_element", "id": f"e{i}", "folio": "$f", "path": COIL, "x": 100 + i * 200, "y": 100} + for i in range(4)], + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$e2", "from_terminal": 0, "to": "$e3", "to_terminal": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0}, + ], self.sb.p("conflict.qet"), elements_dir=ELEMENTS, timeout=60) + self.assertFalse(r.get("timed_out"), "the call hung on a modal dialog") + self.assertFalse(r["ok"]) + self.assertEqual(r["operations"][-1]["result"], False) + + # ---- edit: conductor properties, potentials ---- + + def test_conductor_property_applies_to_the_whole_potential(self): + base = self.sb.new() + hub = [{"op": "add_folio", "id": "f"}, + *[{"op": "add_element", "id": f"e{i}", "folio": "$f", "path": COIL, "x": 100 + i * 200, "y": 100} + for i in range(3)], + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e1", "to_terminal": 0}, + {"op": "add_conductor", "folio": "$f", "from": "$e0", "from_terminal": 0, "to": "$e2", "to_terminal": 0}, + {"op": "set_conductor", "folio": "$f", "element": "$e1", "terminal": 0, "property": "num", "value": "W7"}, + {"op": "set_conductor", "folio": "$f", "element": "$e1", "terminal": 0, "property": "style", "value": "dashed"}] + r = self.ok(self.sb.edit(base, hub)) + rows = m.tool_conductors(r["output"])["conductors"] + self.assertEqual([c["num"] for c in rows], ["W7", "W7"]) + + # ---- conductor manual routing ---- + + def test_move_conductor_segment_reroutes_the_path(self): + base = self.sb.new() + ops = [{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 100, "y": 100}, + {"op": "add_element", "id": "b", "folio": "$f", "path": COIL, "x": 400, "y": 300}, + {"op": "add_conductor", "folio": "$f", "from": "$a", "from_terminal": 0, + "to": "$b", "to_terminal": 0}, + # the vertical segment between the two horizontal legs of the + # default L-route -- see test_two_terminals_default_route_is_l_shaped + # for how this index was established + {"op": "move_conductor_segment", "folio": "$f", "element": "$a", + "terminal": 0, "segment": 2, "dx": 50, "dy": 0}] + r = self.ok(self.sb.edit(base, ops)) + self.assertTrue(r["operations"][-1]["succeeded"]) + xml = Path(r["output"]).read_text(encoding="utf-8") + self.assertIn("b copies; b->c does not, since c was not copied") + self.assertEqual([f["elements"] for f in info["folios"]], [3, 2]) + + def test_duplicate_is_one_undo_step_and_refusals(self): + script = (f"var f=qet.addFolio(),g=qet.addFolio();var a=qet.addElement(f,'{COIL}',0,0),b=qet.addElement(f,'{COIL}',200,0);" + "qet.addConductor(f,a,0,b,0);qet.selectElement(b);var out={};" + "out.n=qet.duplicateElements(f,[a,b],g,50,50).length;" + "out.kept=qet.selectedElements(f).join()===b;" + "qet.undo();out.after_undo=[qet.elementCount(g),qet.conductorCount(g)];" + "out.bad=[qet.duplicateElements(f,['{nope}'],g,0,0).length," + "qet.duplicateElements(f,[a],9,0,0).length,qet.duplicateElements(f,[],g,0,0).length];" + "qet.log('DU '+JSON.stringify(out));") + out = m._run_qet(BINARY, [self.sb.new("dupu")], elements_dir=ELEMENTS, script=script) + line = [l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("DU ")][0] + r = json.loads(line[3:]) + self.assertEqual(r["n"], 2) + self.assertTrue(r["kept"], "copying works by selecting, so the selection must be given back") + self.assertEqual(r["after_undo"], [0, 0], "elements and conductor go in one step") + self.assertEqual(r["bad"], [0, 0, 0]) + + def test_a_failed_duplicate_stops_the_run(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "duplicate_elements", "folio": "$f", "elements": ["{not-an-element}"], + "to_folio": "$f", "x": 1, "y": 1}, + {"op": "add_element", "folio": "$f", "path": COIL, "x": 0, "y": 0}]) + self.assertFalse(r["ok"]) + self.assertTrue(r["stopped_early"]) + self.assertEqual(len(r["operations"]), 2) + + # ---- element auto-numbering ---- + + def test_element_numbering_labels_in_sequence_and_undoes_as_one_step(self): + """setUpFormula() writes the label straight into the element and only + the counter's advance is undoable, so for an element already placed + one undo rolled the counter back and left the label: c3 stayed 'K3' + while the counter went back to expecting K3.""" + script = (f"var f=qet.addFolio();qet.addAutoNum('element','EL',['elementprefix','unit:1:1']);" + "qet.useElementAutoNum('EL');var e=[];" + f"for(var i=0;i<3;i++)e.push(qet.addElement(f,'{COIL}',i*200,0));" + "e.forEach(function(u){qet.numberElement(f,u)});" + "var out={after:e.map(function(u){return qet.elementLabel(f,u)})};" + "qet.undo();out.undone=e.map(function(u){return qet.elementLabel(f,u)});" + "qet.numberElement(f,e[2]);out.again=qet.elementLabel(f,e[2]);" + "qet.log('EN '+JSON.stringify(out));") + out = m._run_qet(BINARY, [self.sb.new("num")], elements_dir=ELEMENTS, script=script) + line = [l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("EN ")][0] + r = json.loads(line[3:]) + self.assertEqual(r["after"], ["K1", "K2", "K3"]) + self.assertEqual(r["undone"], ["K1", "K2", ""], "the label must go back with the counter") + self.assertEqual(r["again"], "K3", "the counter must agree with the labels") + + def test_element_numbering_refusals(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": COIL, "x": 0, "y": 0}, + {"op": "number_element", "folio": "$f", "element": "$a"}]) + self.assertFalse(r["ok"], "no context selected: nothing to apply") + r = self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_autonum", "kind": "element", "name": "EL", "parts": ["elementprefix", "unit:1:1"]}, + {"op": "use_element_autonum", "name": "EL"}, + {"op": "add_element", "id": "s", "folio": "$f", "path": SLAVE, "x": 0, "y": 0}, + {"op": "number_element", "folio": "$f", "element": "$s"}]) + self.assertFalse(r["ok"], "a slave takes its label from its master") + + # ---- element text fields ---- + + def test_element_text_fields_follow_the_info_and_can_be_restyled(self): + base = self.sb.new() + first = self.ok(self.sb.edit(base, [ + {"op": "add_element", "id": "k", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "set_label", "folio": 0, "element": "$k", "label": "KM1"}])) + uuid = first["operations"][0]["result"] + r = self.ok(self.sb.edit(first["output"], [ + {"op": "set_element_text", "folio": 0, "element": uuid, "index": 2, "property": "x", "value": "60"}, + {"op": "set_element_text", "folio": 0, "element": uuid, "index": 2, "property": "size", "value": "14"}, + {"op": "set_element_text", "folio": 0, "element": uuid, "index": 2, "property": "frame", "value": "true"}, + {"op": "add_element_text", "id": "c", "folio": 0, "element": uuid, "source": "info", + "value": "comment", "x": 40, "y": 30}, + {"op": "set_info", "folio": 0, "element": uuid, "key": "comment", "value": "24VDC coil"}], "restyled.qet")) + d = r["diff"]["element_texts"] + self.assertEqual(len(d["added"]), 1) + self.assertEqual(d["changed"][0]["changed"], + {"x": ["30", "60"], "size": ["9", "14"], "frame": ["false", "true"]}) + # the added field is bound to the comment key, so it shows the value set afterwards + xml = Path(r["output"]).read_text(encoding="utf-8") + self.assertRegex(xml, r"24VDC coil\s*comment") + + def test_shows_follows_the_information_and_text_does_not(self): + """Two things are called text on an information-bound field. The + stored "text" property is an unused placeholder ('' or 'Texte'); what + is drawn -- "shows" -- follows the element's information immediately. + An earlier version of this test claimed the display was stale and + that a helper was needed; measuring showed toPlainText() matched the + information at every checkpoint, and it was the stored text that + looked wrong. Reading the stored text as the display is the mistake + this pins.""" + script = (f"var f=qet.addFolio();var e=qet.addElement(f,'{COIL}',0,0);" + "var out=[];function snap(t){var lab=-1,com=-1;" + "qet.elementTexts(f,e).forEach(function(l,i){if(l.indexOf(\"info='label'\")>=0)lab=i;" + "if(l.indexOf(\"info='comment'\")>=0)com=i;});" + "out.push({t:t,shows:qet.elementTextProperty(f,e,lab,'shows'),info:qet.elementInfo(f,e,'label')," + "stored:qet.elementTextProperty(f,e,lab,'text')," + "cshows:com>=0?qet.elementTextProperty(f,e,com,'shows'):null," + "cinfo:com>=0?qet.elementInfo(f,e,'comment'):null});}" + "snap('fresh');qet.setElementLabel(f,e,'KM1');snap('label');" + "qet.addElementText(f,e,'info','comment',1,1);qet.setElementInfo(f,e,'comment','hello');snap('comment');" + "qet.setElementLabel(f,e,'KM9');snap('relabel');" + "qet.log('TX '+JSON.stringify(out));") + out = m._run_qet(BINARY, [self.sb.new("live2")], elements_dir=ELEMENTS, script=script) + line = [l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("TX ")][0] + rows = json.loads(line[3:]) + for r in rows: + with self.subTest(step=r["t"]): + self.assertEqual(r["shows"], r["info"]) + if r["cinfo"] is not None: + self.assertEqual(r["cshows"], r["cinfo"]) + self.assertEqual([r["shows"] for r in rows], ["", "KM1", "KM1", "KM9"]) + # the stored property is not the display: it never became KM1/KM9 + self.assertEqual({r["stored"] for r in rows}, {""}) + + def test_bad_element_text_arguments_are_refused(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_element", "id": "k", "folio": 0, "path": COIL, "x": 0, "y": 0}, + {"op": "add_element_text", "folio": 0, "element": "$k", "source": "info", "value": "nonsense", + "x": 0, "y": 0}]) + self.assertFalse(r["ok"], "'nonsense' is not an element information key") + + # ---- text, shapes, images ---- + + def test_text_shape_and_image_round_trip_through_diff(self): + img = self.sb.dir / "pic.png" + png(img) + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_text", "id": "t", "folio": 0, "text": "note", "x": 50, "y": 50}, + {"op": "set_text_color", "folio": 0, "index": "$t", "color": "#ff0000"}, + {"op": "add_shape", "id": "s", "folio": 0, "shape": "rectangle", "x1": 10, "y1": 10, "x2": 200, "y2": 120}, + {"op": "set_shape", "folio": 0, "index": "$s", "property": "fill", "value": "#00ff00"}, + {"op": "add_image", "id": "i", "folio": 0, "file": str(img), "x": 60, "y": 200}, + {"op": "scale_image", "folio": 0, "index": "$i", "factor": 2}])) + d = r["diff"] + self.assertEqual([t["text"] for t in d["texts"]["added"]], ["note"]) + self.assertEqual(len(d["shapes"]["added"]), 1) + self.assertEqual(len(d["images"]["added"]), 1) + + # ---- polygon and path shapes ---- + + def test_add_polygon_with_more_than_two_points(self): + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_polygon", "folio": 0, + "points": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, + {"x": 0, "y": 100}, {"x": 50, "y": 150}], + "closed": True}])) + self.assertEqual(len(r["diff"]["shapes"]["added"]), 1) + xml = Path(r["output"]).read_text(encoding="utf-8") + self.assertEqual(xml.count("]*>([^<]+)", xml) + self.assertEqual(len(images), 2) + self.assertNotEqual(images[0], images[1], + "page 1 and page 2 must render different content") + + def test_add_pdf_page_dpi_controls_pixel_size(self): + doc = self.sb.dir / "one_page.pdf" + pdf(doc, page_colors=("1 0 0",)) + base = self.sb.new() + r72 = self.ok(self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(doc), "page": 1, "dpi": 72, + "x": 0, "y": 0}], out="dpi72.qet")) + r144 = self.ok(self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(doc), "page": 1, "dpi": 144, + "x": 0, "y": 0}], out="dpi144.qet")) + def png_width(qet_path): + import base64 + import struct + xml = Path(qet_path).read_text(encoding="utf-8") + b64 = re.search(r"]*>([^<]+)", xml).group(1) + png_bytes = base64.b64decode(b64) + return struct.unpack(">I", png_bytes[16:20])[0] # IHDR width + + w72 = png_width(r72["output"]) + w144 = png_width(r144["output"]) + self.assertAlmostEqual(w144 / w72, 2.0, places=1) + + def test_add_pdf_page_bad_page_number_is_refused(self): + doc = self.sb.dir / "two_pages.pdf" + pdf(doc) + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(doc), "page": 99, "dpi": 150, + "x": 0, "y": 0}]) + self.assertFalse(r["ok"]) + + def test_add_pdf_page_bad_file_is_refused(self): + base = self.sb.new() + r = self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(self.sb.dir / "missing.pdf"), + "page": 1, "dpi": 150, "x": 0, "y": 0}]) + self.assertFalse(r["ok"]) + + not_a_pdf = self.sb.dir / "notpdf.pdf" + not_a_pdf.write_bytes(b"not a pdf") + r = self.sb.edit(base, [ + {"op": "add_pdf_page", "folio": 0, "file": str(not_a_pdf), "page": 1, + "dpi": 150, "x": 0, "y": 0}]) + self.assertFalse(r["ok"]) + + def test_image_is_embedded_not_referenced(self): + img = self.sb.dir / "pic.png" + png(img) + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_image", "folio": 0, "file": str(img), "x": 0, "y": 0}])) + img.unlink() # the source is gone + again = m.tool_query(BINARY, r["output"], "SELECT COUNT(*) AS n FROM diagram") + self.assertTrue(again["ok"], "the project must open without the source file") + self.assertIn("= 2 and i["link_type"] in ("simple", "master", "slave", "terminal") + and all(i["terminal_names"]) and len(set(i["terminal_names"])) == i["terminals"] + and not i["terminal_order_ambiguous"]] + + def file_order(i): + return [t.get("name") or "" for t in ET.parse(i["file"]).getroot().iter("terminal")] + differ = [i for i in pool if file_order(i) != i["terminal_names"]] + self.assertGreater(len(differ), 50, "the property under test must actually vary") + pick = differ[:40] + [i for i in pool if file_order(i) == i["terminal_names"]][:10] + js = "var f=qet.addFolio();var out=[];\n" + "".join( + f"var u=qet.addElement(f,{json.dumps(i['path'])},{100 + (n % 10) * 80},{100 + (n // 10) * 80});" + f"out.push({{p:{json.dumps(i['path'])},t:u?qet.elementTerminals(f,u):[]}});\n" + for n, i in enumerate(pick)) + "qet.log('ROWS '+JSON.stringify(out));\n" + out = m._run_qet(BINARY, [self.sb.new("order")], elements_dir=ELEMENTS, script=js, tail=2_000_000) + rows = json.loads([l for l in (out["stdout"] + out["stderr"]).splitlines() if l.startswith("ROWS ")][0][5:]) + by = {r["p"]: r["t"] for r in rows} + wrong = [] + for i in pick: + actual = [re.match(r"\d+: (.*) \(\d+ conductor", t).group(1) for t in by[i["path"]]] + if actual != i["terminal_names"]: + wrong.append((i["path"], i["terminal_names"], actual)) + self.assertEqual(wrong, [], f"{len(wrong)} of {len(pick)} disagree") + + # ---- capability probe ---- + + def test_every_method_the_server_needs_exists_in_the_binary(self): + r = m.tool_edit(BINARY, self.sb.new(), [{"op": "add_folio"}], self.sb.p("p.qet"), + elements_dir=ELEMENTS) + self.assertEqual(r["missing_methods"], []) + + def test_a_binary_without_scripting_is_reported_not_run(self): + """Point the tool at a script-less 'binary' (any executable that is + not QElectroTech) and it must refuse rather than hang.""" + fake = self.sb.dir / "fake" + fake.write_text("#!/bin/sh\nexit 0\n") + fake.chmod(0o755) + r = m.tool_edit(str(fake), self.sb.new(), [{"op": "add_folio"}], self.sb.p("x.qet"), timeout=15) + self.assertFalse(r["ok"]) + + +@needs_binary +class PlcIntegration(unittest.TestCase): + """PLC IO table and PLC-slave linking, against the fixtures in + fixtures/ rather than the real QET_ELEMENTS collection (see + PLC_FIXTURES).""" + + def setUp(self): + self.sb = Sandbox() + + def tearDown(self): + self.sb.close() + + def ok(self, r): + self.assertTrue(r["ok"], json.dumps({k: r.get(k) for k in ("hint", "operations")}, default=str)[:600]) + return r + + def edit(self, project, ops, out="out.qet"): + return m.tool_edit(BINARY, project, ops, self.sb.p(out), elements_dir=PLC_FIXTURES) + + def test_add_set_and_remove_plc_io(self): + base = self.sb.new() + ops = [{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "m", "folio": "$f", "path": PLC_MASTER, "x": 100, "y": 100}, + {"op": "add_plc_io", "id": "io0", "folio": "$f", "element": "$m", + "type": "entree_digitale", "address": "1.0", "function": "Start", "comment": "c"}, + {"op": "add_plc_io", "id": "io1", "folio": "$f", "element": "$m", + "type": "sortie_digitale", "address": "1.1", "function": "Motor K1", "comment": ""}, + {"op": "set_plc_io", "folio": "$f", "element": "$m", "index": "$io0", + "property": "address", "value": "1.2"}, + {"op": "remove_plc_io", "folio": "$f", "element": "$m", "index": "$io1"}] + r = self.ok(self.edit(base, ops)) + self.assertEqual([o["result"] for o in r["operations"][2:4]], [0, 1]) + self.assertTrue(all(o["succeeded"] for o in r["operations"][4:])) + + def test_add_plc_io_on_non_master_is_refused(self): + base = self.sb.new() + r = self.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "s", "folio": "$f", "path": PLC_SLAVE, "x": 100, "y": 100}, + {"op": "add_plc_io", "folio": "$f", "element": "$s", + "type": "entree_digitale", "address": "1.0", "function": "f", "comment": "c"}]) + self.assertFalse(r["ok"]) + + def test_link_plc_io_targets_a_specific_row(self): + base = self.sb.new() + ops = [{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "m", "folio": "$f", "path": PLC_MASTER, "x": 100, "y": 100}, + {"op": "add_element", "id": "s", "folio": "$f", "path": PLC_SLAVE, "x": 300, "y": 100}, + {"op": "add_plc_io", "folio": "$f", "element": "$m", + "type": "entree_digitale", "address": "1.0", "function": "Start", "comment": ""}, + {"op": "add_plc_io", "folio": "$f", "element": "$m", + "type": "sortie_digitale", "address": "1.1", "function": "Motor K1", "comment": ""}, + {"op": "link_plc_io", "folio": "$f", "element": "$m", + "to_folio": "$f", "to": "$s", "io_index": 1}] + r = self.ok(self.edit(base, ops)) + self.assertTrue(r["operations"][-1]["succeeded"]) + xml = Path(r["output"]).read_text(encoding="utf-8") + self.assertIn("m_adjust_table_size_cb->isChecked(); } +/** + @brief AddTableDialog::setAdjustTableToFolio + @param set +*/ +void AddTableDialog::setAdjustTableToFolio(bool set) +{ + ui->m_adjust_table_size_cb->setChecked(set); +} + /** @brief AddTableDialog::addNewTableToNewDiagram @return @@ -81,6 +90,15 @@ bool AddTableDialog::addNewTableToNewDiagram() const return ui->m_add_table_and_folio->isChecked(); } +/** + @brief AddTableDialog::setAddNewTableToNewDiagram + @param set +*/ +void AddTableDialog::setAddNewTableToNewDiagram(bool set) +{ + ui->m_add_table_and_folio->setChecked(set); +} + /** @brief AddTableDialog::tableName @return @@ -90,6 +108,18 @@ QString AddTableDialog::tableName() const return ui->m_table_name_le->text(); } +/** + @brief AddTableDialog::setTableName + Set the name field directly, so a caller that builds this dialog to + read from (never shows or execs it -- the scripting API's addTable()) + does not need a name typed by a user who was never there to type one. + @param name +*/ +void AddTableDialog::setTableName(const QString &name) +{ + ui->m_table_name_le->setText(name); +} + /** @brief AddTableDialog::headerMargins @return diff --git a/sources/factory/ui/addtabledialog.h b/sources/factory/ui/addtabledialog.h index 9ac188d00..f95a985b3 100644 --- a/sources/factory/ui/addtabledialog.h +++ b/sources/factory/ui/addtabledialog.h @@ -44,9 +44,12 @@ class AddTableDialog : public QDialog void setQueryWidget(QWidget *widget); bool adjustTableToFolio() const; + void setAdjustTableToFolio(bool set); bool addNewTableToNewDiagram() const; + void setAddNewTableToNewDiagram(bool set); QString tableName() const; + void setTableName(const QString &name); QMargins headerMargins() const; Qt::Alignment headerAlignment() const; diff --git a/sources/qetgraphicsitem/conductor.cpp b/sources/qetgraphicsitem/conductor.cpp index 8b2d39666..f97fb97ec 100644 --- a/sources/qetgraphicsitem/conductor.cpp +++ b/sources/qetgraphicsitem/conductor.cpp @@ -1310,6 +1310,34 @@ const QList Conductor::segmentsList() const return(segments_vector); } +/** + @brief Conductor::moveSegment + Move one segment of this conductor by (dx, dy), the same primitive + handlerMouseMoveEvent()/handlerMouseReleaseEvent() apply on a manual + drag -- moveX()/moveY() each silently no-op on the wrong axis or a + static (terminal-anchored) segment, so both are always called and + whichever applies takes effect. Unlike a drag this commits the whole + move as a single undo step. + @param index a segmentsList() index + @param dx @param dy the movement, in the diagram's own coordinates + @return false if index is out of range +*/ +bool Conductor::moveSegment(int index, qreal dx, qreal dy) +{ + const QList segs = segmentsList(); + if (index < 0 || index >= segs.count()) return false; + + before_mov_text_pos_ = m_text_item->pos(); + ConductorSegment *seg = segs.at(index); + seg->moveX(dx); + seg->moveY(dy); + modified_path = true; + segmentsToPath(); + calculateTextItemPosition(); + saveProfile(); + return true; +} + /** @brief Conductor::length @return the length of this conductor diff --git a/sources/qetgraphicsitem/conductor.h b/sources/qetgraphicsitem/conductor.h index 63857e068..d1ecd6e43 100644 --- a/sources/qetgraphicsitem/conductor.h +++ b/sources/qetgraphicsitem/conductor.h @@ -114,6 +114,7 @@ class Conductor : public QGraphicsObject public: QVector handlerPoints() const; const QList segmentsList() const; + bool moveSegment(int index, qreal dx, qreal dy); void setPropertyToPotential( const ConductorProperties &property, diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 895f1a492..b31d16e1e 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -17,35 +17,79 @@ */ #include "qetscriptapi.h" +#include +#include + #include "../ElementsCollection/elementslocation.h" #include "../QPropertyUndoCommand/qpropertyundocommand.h" #include "../cli_export.h" #include "../diagram.h" +#include "../dataBase/ui/elementquerywidget.h" +#include "../dataBase/ui/summaryquerywidget.h" #include "../diagramcontent.h" #include "../diagramview.h" #include "../factory/elementfactory.h" +#include "../factory/qetgraphicstablefactory.h" +#include "../factory/ui/addtabledialog.h" #include "../qet.h" +#include "../qetgraphicsitem/ViewItem/qetgraphicstableitem.h" #include "../qetgraphicsitem/element.h" #include "../qetmessagebox.h" #include "../dataBase/projectdatabase.h" +#include "../qetapp.h" #include "../qetproject.h" #include "../qetresult.h" #include "../qetgraphicsitem/conductor.h" +#include "../conductorsegment.h" +#include "../qetgraphicsitem/diagramimageitem.h" + +// See diagrameventaddpdf.h: a missing QtPdf module (or Qt < 6.4) is not +// fatal at build time, so addPdfPage() is always declared -- a script +// asking qet.addPdfPage exists must never get "not a function" for a +// reason it has no way to discover -- and logs a clear refusal instead of +// failing to compile. +#ifdef QET_HAS_QTPDF +#include +#include +#endif +#include "../qetgraphicsitem/dynamicelementtextitem.h" #include "../qetgraphicsitem/independenttextitem.h" #include "../qetgraphicsitem/qetshapeitem.h" +#include "../undocommand/promoteshapecommand.h" +#include "../TerminalStrip/UndoCommand/addterminalstripcommand.h" +#include "../TerminalStrip/UndoCommand/addterminaltostripcommand.h" +#include "../TerminalStrip/UndoCommand/bridgeterminalscommand.h" +#include "../TerminalStrip/UndoCommand/groupterminalscommand.h" +#include "../TerminalStrip/UndoCommand/sortterminalstripcommand.h" +#include "../TerminalStrip/physicalterminal.h" +#include "../TerminalStrip/realterminal.h" +#include "../TerminalStrip/terminalstrip.h" +#include "../autoNum/assignvariables.h" +#include "../autoNum/numerotationcontext.h" +#include "../borderproperties.h" +#include "../titleblock/templatescollection.h" +#include "../diagramcommands.h" #include "../qetgraphicsitem/terminal.h" +#include "../qetgraphicsitem/terminalelement.h" #include "../qetinformation.h" #include "../titleblockproperties.h" #include "../undocommand/addgraphicsobjectcommand.h" +#include "../undocommand/addelementtextcommand.h" #include "../undocommand/changeelementinformationcommand.h" #include "../undocommand/changetitleblockcommand.h" #include "../undocommand/deleteqgraphicsitemcommand.h" #include "../undocommand/linkelementcommand.h" +#include "../undocommand/removediagramcommand.h" #include "../utils/conductorcreator.h" #include #include #include +#include +#include +#include +#include +#include #include #include @@ -351,6 +395,17 @@ QString conductorPropertyValue(const ConductorProperties &p, const QString &name if (name == QLatin1String("conductor_section")) return p.m_wire_section; if (name == QLatin1String("color")) return p.color.name(); if (name == QLatin1String("text_color")) return p.text_color.name(); + if (name == QLatin1String("color2")) return p.m_color_2.name(); + if (name == QLatin1String("bicolor")) return p.m_bicolor ? QStringLiteral("true") : QStringLiteral("false"); + if (name == QLatin1String("dash-size")) return QString::number(p.m_dash_size); + if (name == QLatin1String("condsize")) return QString::number(p.cond_size); + if (name == QLatin1String("numsize")) return QString::number(p.text_size); + if (name == QLatin1String("displaytext")) return p.m_show_text ? QStringLiteral("true") : QStringLiteral("false"); + if (name == QLatin1String("style")) { + if (p.style == Qt::DashLine) return QStringLiteral("dashed"); + if (p.style == Qt::DashDotLine) return QStringLiteral("dashdotted"); + return QStringLiteral("normal"); + } return QString(); } @@ -366,11 +421,47 @@ bool setConductorPropertyValue(ConductorProperties &p, const QString &name, cons if (name == QLatin1String("conductor_section")) { p.m_wire_section = value; return true; } // The two real colours are QColor, not free text: an unparseable name // would otherwise be stored as an invalid colour and drawn as black. - if (name == QLatin1String("color") || name == QLatin1String("text_color")) + if (name == QLatin1String("color") || name == QLatin1String("text_color") + || name == QLatin1String("color2")) { const QColor c(value); if (!c.isValid()) return false; - if (name == QLatin1String("color")) p.color = c; else p.text_color = c; + if (name == QLatin1String("color")) p.color = c; + else if (name == QLatin1String("color2")) p.m_color_2 = c; + else p.text_color = c; + return true; + } + if (name == QLatin1String("bicolor") || name == QLatin1String("displaytext")) + { + const QString v = value.toLower(); + if (v != QLatin1String("true") && v != QLatin1String("false")) return false; + (name == QLatin1String("bicolor") ? p.m_bicolor : p.m_show_text) = (v == QLatin1String("true")); + return true; + } + if (name == QLatin1String("dash-size") || name == QLatin1String("numsize")) + { + bool ok = false; + const int n = value.toInt(&ok); + if (!ok || n < 1) return false; + (name == QLatin1String("dash-size") ? p.m_dash_size : p.text_size) = n; + return true; + } + if (name == QLatin1String("condsize")) + { + bool ok = false; + const double d = value.toDouble(&ok); + if (!ok || d <= 0) return false; + p.cond_size = d; + return true; + } + if (name == QLatin1String("style")) + { + // The three the file format can express (ConductorProperties::readStyle); + // any other Qt pen style would be written back as a solid line. + if (value == QLatin1String("normal")) p.style = Qt::SolidLine; + else if (value == QLatin1String("dashed")) p.style = Qt::DashLine; + else if (value == QLatin1String("dashdotted")) p.style = Qt::DashDotLine; + else return false; return true; } return false; @@ -382,7 +473,9 @@ const QStringList &conductorPropertyNames() QStringLiteral("num"), QStringLiteral("formula"), QStringLiteral("function"), QStringLiteral("bus"), QStringLiteral("cable"), QStringLiteral("tension_protocol"), QStringLiteral("conductor_color"), QStringLiteral("conductor_section"), - QStringLiteral("color"), QStringLiteral("text_color")}; + QStringLiteral("color"), QStringLiteral("text_color"), QStringLiteral("color2"), + QStringLiteral("bicolor"), QStringLiteral("style"), QStringLiteral("dash-size"), + QStringLiteral("condsize"), QStringLiteral("numsize"), QStringLiteral("displaytext")}; return names; } @@ -619,9 +712,18 @@ QString QetScriptApi::elementName(int folioIndex, const QString &elementUuid) co base, and where it is not, every instance of that same element carries the same one -- two coils of one type placed side by side have byte-identical terminal uuids, which is plainly visible in the saved - file of any project written through this API. The order of - Element::terminals() also comes from the definition, but it is at least - unambiguous within the element the caller has already named by uuid. + file of any project written through this API. + + The index is the terminal's place in Element::terminals(), and that is + @b not the order the .elmt lists them in. Element::parseTerminal() + re-sorts the list on every insertion, top to bottom and then left to + right on each terminal's local position, so index 0 is the topmost + terminal. bobine_ka_a_remanence.elmt writes A2 (y=20) before A1 (y=-20) + and index 0 is A1. Of the 837 shipped elements whose terminals all have + distinct names, 619 list them in a different order than this. Two + terminals at the same point tie, and the sort is not stable, so which + of those is which is undefined -- read this listing rather than + assuming. */ QStringList QetScriptApi::elementTerminals(int folioIndex, const QString &elementUuid) const { @@ -827,6 +929,84 @@ bool QetScriptApi::setConductorProperty(int folioIndex, const QString &elementUu return true; } +/** + @brief QetScriptApi::conductorSegments + List the drawn path of the conductor on a terminal, one line per + segment: "index: (x1,y1)-(x2,y2) horizontal|vertical static|movable". + static marks a segment anchored to a terminal (moveConductorSegment() + on it is a no-op, the same as dragging its handle would be -- there is + no handle on it in the GUI). Points are in scene coordinates, matching + element_geometry(). +*/ +QStringList QetScriptApi::conductorSegments(int folioIndex, const QString &elementUuid, + int terminalIndex) const +{ + // const_cast: findConductor logs, and log() writes to stderr, which is + // not a const operation on this object. The lookup itself changes + // nothing. + auto *self = const_cast(this); + Conductor *conductor = self->findConductor(folioIndex, elementUuid, terminalIndex, + QStringLiteral("conductorSegments")); + if (!conductor) return {}; + + QStringList result; + const QList segs = conductor->segmentsList(); + for (int i = 0; i < segs.count(); ++i) { + ConductorSegment *seg = segs.at(i); + const QPointF p1 = conductor->mapToScene(seg->firstPoint()); + const QPointF p2 = conductor->mapToScene(seg->secondPoint()); + result << QStringLiteral("%1: (%2,%3)-(%4,%5) %6 %7") + .arg(i) + .arg(p1.x()).arg(p1.y()).arg(p2.x()).arg(p2.y()) + .arg(seg->isHorizontal() ? QStringLiteral("horizontal") : QStringLiteral("vertical"), + seg->isStatic() ? QStringLiteral("static") : QStringLiteral("movable")); + } + return result; +} + +/** + @brief QetScriptApi::moveConductorSegment + Move one segment of the conductor on a terminal by (dx, dy) and push + one undo step for the whole move -- Conductor::moveSegment(), the same + primitive a manual handle drag applies. A segment only moves + perpendicular to its own direction, the same as dragging its handle: + dx moves a vertical segment, dy moves a horizontal one, and the other + of the pair is ignored (ConductorSegment::moveX()/moveY() each silently + no-op on the wrong axis) -- check conductorSegments() for which one + applies before calling this. dx/dy are in scene coordinates; a + translation-only item (every conductor) makes a scene-space delta equal + to a local one, so no conversion is needed. A static segment or an + out-of-range index is refused. +*/ +bool QetScriptApi::moveConductorSegment(int folioIndex, const QString &elementUuid, + int terminalIndex, int segmentIndex, + double dx, double dy) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.moveConductorSegment: project is read-only")); + return false; + } + Conductor *conductor = findConductor(folioIndex, elementUuid, terminalIndex, + QStringLiteral("moveConductorSegment")); + if (!conductor) return false; + + const QList segs = conductor->segmentsList(); + if (segmentIndex < 0 || segmentIndex >= segs.count()) { + log(QStringLiteral("qet.moveConductorSegment: terminal %1 of %2 has %3 " + "segment(s), no index %4") + .arg(terminalIndex).arg(elementUuid).arg(segs.count()).arg(segmentIndex)); + return false; + } + if (segs.at(segmentIndex)->isStatic()) { + log(QStringLiteral("qet.moveConductorSegment: segment %1 is anchored to a " + "terminal and cannot be moved").arg(segmentIndex)); + return false; + } + + return conductor->moveSegment(segmentIndex, dx, dy); +} + QString QetScriptApi::elementLinkType(int folioIndex, const QString &elementUuid) const { Element *element = findElement(folioIndex, elementUuid); @@ -870,7 +1050,8 @@ QStringList QetScriptApi::linkedElements(int folioIndex, const QString &elementU GUI would refuse to make. */ bool QetScriptApi::linkElements(int folioIndexA, const QString &elementUuidA, - int folioIndexB, const QString &elementUuidB) + int folioIndexB, const QString &elementUuidB, + int groupIndex) { if (!m_project) return false; if (m_project->isReadOnly()) { @@ -895,13 +1076,70 @@ bool QetScriptApi::linkElements(int folioIndexA, const QString &elementUuidA, elementUuidB, elementLinkType(folioIndexB, elementUuidB))); return false; } + // Linking two report elements (next_report/previous_report) that each + // already carry a conductor with differing properties (colour, line + // style, text, function or tension protocol) makes + // LinkElementCommand::redo() open PotentialSelectorDialog -- a plain + // QDialog::exec(), not routed through QET::QetMessageBox, so headless + // --run has nobody to answer it and hangs until killed. Measured: + // confirmed hanging with the fix below NOT in place, confirmed clean + // (no hang, false returned) with it in place. Same reasoning and the + // same choice addConductor() already makes about ConductorCreator's + // own ambiguous-potential dialog. + if (LinkElementCommand::reportLinkNeedsPotentialChoice(a, b)) { + log(QStringLiteral("qet.linkElements: %1 and %2 already carry conductors whose " + "colour, style, text, function or tension protocol disagree -- " + "linking them would open a dialog asking which to keep, and " + "nobody is there to answer it under --run. Use " + "qet.checkContinuity() to see the mismatch, resolve it with " + "setConductorProperty() on one side, then link") + .arg(elementUuidA, elementUuidB)); + return false; + } + auto isPlcMaster = [](Element *e) { + return e->elementData().m_type == ElementData::Master + && e->elementData().m_master_type == ElementData::PLC; + }; + if (groupIndex >= 0 && !isPlcMaster(a) && !isPlcMaster(b)) { + log(QStringLiteral("qet.linkElements: groupIndex only applies to a PLC " + "master/slave pair -- neither %1 nor %2 is a PLC master") + .arg(elementUuidA, elementUuidB)); + return false; + } - auto *cmd = new LinkElementCommand(a); - cmd->setLink(b); + // LinkElementCommand only reads m_group_index when the command's OWN + // element is the Slave -- when it is the Master it looks in a + // per-slave m_group_indices map this call never populates, and + // setGroupIndex() is silently a no-op. Build the command from the + // slave's side instead, exactly as PlcLinkWidget does (m_element is + // always the slave being edited there). + Element *slave = (groupIndex >= 0 && b->elementData().m_type == ElementData::Slave) ? b : a; + Element *master = (slave == a) ? b : a; + + auto *cmd = new LinkElementCommand(slave); + cmd->setLink(master); + if (groupIndex >= 0) + cmd->setGroupIndex(groupIndex); m_project->undoStack()->push(cmd); return a->linkedElements().contains(b); } +/** + @brief QetScriptApi::elementLinkGroupIndex + The PLC IO row a linked slave is attached to -- the index passed as + linkElements()'s groupIndex when the link was made, or -1 if the pair + is not linked or the link carries no group index (an ordinary + master/slave or report pair, not a PLC one). +*/ +int QetScriptApi::elementLinkGroupIndex(int folioIndex, const QString &elementUuid, + int otherFolioIndex, const QString &otherElementUuid) const +{ + Element *element = findElement(folioIndex, elementUuid); + Element *other = findElement(otherFolioIndex, otherElementUuid); + if (!element || !other) return -1; + return element->groupIndexForElement(other); +} + bool QetScriptApi::unlinkElement(int folioIndex, const QString &elementUuid) { if (!m_project) return false; @@ -919,6 +1157,165 @@ bool QetScriptApi::unlinkElement(int folioIndex, const QString &elementUuid) return element->linkedElements().isEmpty(); } +namespace { +bool isPlcMaster(Element *element) +{ + return element + && element->elementData().m_type == ElementData::Master + && element->elementData().m_master_type == ElementData::PLC; +} +} + +/** + @brief QetScriptApi::plcIOs + List a PLC master's IO table, one line per row: "index: type address + 'functionText' 'comment' -> crossRef". crossRef is empty until a slave + is linked onto that row (linkElements()'s groupIndex). +*/ +QStringList QetScriptApi::plcIOs(int folioIndex, const QString &elementUuid) const +{ + Element *element = findElement(folioIndex, elementUuid); + if (!isPlcMaster(element)) return {}; + + QStringList result; + const auto ios = element->elementData().plcMasterData().ios; + for (int i = 0; i < ios.count(); ++i) { + const auto &io = ios.at(i); + result << QStringLiteral("%1: %2 %3 '%4' '%5' -> %6") + .arg(i) + .arg(ElementData::plcIOTypeToString(io.type), io.address, + io.functionText, io.comment, io.crossRef); + } + return result; +} + +/** + @brief QetScriptApi::addPlcIO + Append a row to a PLC master's IO table. type is one of + entree_digitale, sortie_digitale, entree_analogique, sortie_analogique, + entree_universelle, sortie_universelle. Returns the new row's index, or + -1 if elementUuid is not a PLC master. + + This edits ElementData directly through setElementData(), the same as + MasterPropertiesWidget's own PLC IO table -- which, like it, is NOT + undoable: MasterPropertiesWidget::associatedUndo() deliberately returns + nullptr for PLC masters (their linking is managed through the IO table, + not the link-tree widget it would otherwise build an unlink-all command + from), so qet.undo() cannot revert an addPlcIO/setPlcIO/removePlcIO call + any more than the GUI's own PLC IO editor can. +*/ +int QetScriptApi::addPlcIO(int folioIndex, const QString &elementUuid, const QString &type, + const QString &address, const QString &functionText, + const QString &comment) +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addPlcIO: project is read-only")); + return -1; + } + Element *element = findElement(folioIndex, elementUuid); + if (!isPlcMaster(element)) { + log(QStringLiteral("qet.addPlcIO: %1 is not a PLC master").arg(elementUuid)); + return -1; + } + + ElementData ed = element->elementData(); + ElementData::PlcMasterData plc_data = ed.plcMasterData(); + ElementData::PlcIO io; + io.type = ElementData::plcIOTypeFromString(type); + io.address = address; + io.functionText = functionText; + io.comment = comment; + plc_data.ios.append(io); + ed.setPlcMasterData(plc_data); + element->setElementData(ed); + if (element->scene()) element->update(); + + return plc_data.ios.count() - 1; +} + +/** + @brief QetScriptApi::setPlcIO + Change one field of a PLC master IO row: type, address, function + (functionText) or comment. See addPlcIO() for the undo caveat this + shares. +*/ +bool QetScriptApi::setPlcIO(int folioIndex, const QString &elementUuid, int ioIndex, + const QString &property, const QString &value) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setPlcIO: project is read-only")); + return false; + } + Element *element = findElement(folioIndex, elementUuid); + if (!isPlcMaster(element)) { + log(QStringLiteral("qet.setPlcIO: %1 is not a PLC master").arg(elementUuid)); + return false; + } + + ElementData ed = element->elementData(); + ElementData::PlcMasterData plc_data = ed.plcMasterData(); + if (ioIndex < 0 || ioIndex >= plc_data.ios.count()) { + log(QStringLiteral("qet.setPlcIO: %1 has %2 IO row(s), no index %3") + .arg(elementUuid).arg(plc_data.ios.count()).arg(ioIndex)); + return false; + } + + ElementData::PlcIO &io = plc_data.ios[ioIndex]; + if (property == QLatin1String("type")) { + io.type = ElementData::plcIOTypeFromString(value); + } else if (property == QLatin1String("address")) { + io.address = value; + } else if (property == QLatin1String("function")) { + io.functionText = value; + } else if (property == QLatin1String("comment")) { + io.comment = value; + } else { + log(QStringLiteral("qet.setPlcIO: unknown property '%1'; expected type, " + "address, function or comment").arg(property)); + return false; + } + + ed.setPlcMasterData(plc_data); + element->setElementData(ed); + if (element->scene()) element->update(); + return true; +} + +/** + @brief QetScriptApi::removePlcIO + Remove one row from a PLC master's IO table. See addPlcIO() for the + undo caveat this shares. Indexes shift afterwards. +*/ +bool QetScriptApi::removePlcIO(int folioIndex, const QString &elementUuid, int ioIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.removePlcIO: project is read-only")); + return false; + } + Element *element = findElement(folioIndex, elementUuid); + if (!isPlcMaster(element)) { + log(QStringLiteral("qet.removePlcIO: %1 is not a PLC master").arg(elementUuid)); + return false; + } + + ElementData ed = element->elementData(); + ElementData::PlcMasterData plc_data = ed.plcMasterData(); + if (ioIndex < 0 || ioIndex >= plc_data.ios.count()) { + log(QStringLiteral("qet.removePlcIO: %1 has %2 IO row(s), no index %3") + .arg(elementUuid).arg(plc_data.ios.count()).arg(ioIndex)); + return false; + } + + plc_data.ios.removeAt(ioIndex); + ed.setPlcMasterData(plc_data); + element->setElementData(ed); + if (element->scene()) element->update(); + return true; +} + namespace { /** @@ -934,9 +1331,9 @@ namespace { is for both kinds. */ template -QList sortedByPosition(const QSet &items) +QList sortedByPosition(const QList &items_in) { - QList list(items.cbegin(), items.cend()); + QList list = items_in; std::sort(list.begin(), list.end(), [](T *a, T *b) { const QPointF pa = a->sceneBoundingRect().topLeft(); const QPointF pb = b->sceneBoundingRect().topLeft(); @@ -949,6 +1346,16 @@ QList sortedByPosition(const QSet &items) return list; } +template +QList sortedByPosition(const QSet &items) +{ + // Qt 6 makes QVector an alias of QList, so this one overload also + // serves m_tables (a QVector) without a + // separate one -- adding one was a redefinition error, not a second + // overload, on this Qt version. + return sortedByPosition(QList(items.cbegin(), items.cend())); +} + } // namespace QList QetScriptApi::sortedTexts(int folioIndex) const @@ -1019,6 +1426,14 @@ int QetScriptApi::addText(int folioIndex, const QString &text, double x, double return sortedTexts(folioIndex).indexOf(item); } +QString QetScriptApi::textContent(int folioIndex, int textIndex) const +{ + auto *self = const_cast(this); + IndependentTextItem *item = self->findText(folioIndex, textIndex, QStringLiteral("textContent")); + if (!item) return QString(); + return item->toPlainText(); +} + bool QetScriptApi::setTextContent(int folioIndex, int textIndex, const QString &text) { if (!m_project) return false; @@ -1147,6 +1562,119 @@ int QetScriptApi::addShape(int folioIndex, const QString &type, return sortedShapes(folioIndex).indexOf(shape); } +namespace { +const QStringList &shapePropertyNames() +{ + static const QStringList n{QStringLiteral("color"), QStringLiteral("fill"), + QStringLiteral("width"), QStringLiteral("line-style"), QStringLiteral("rotation")}; + return n; +} +} // namespace + +QString QetScriptApi::shapeProperty(int folioIndex, int shapeIndex, const QString &property) const +{ + const QList list = sortedShapes(folioIndex); + if (shapeIndex < 0 || shapeIndex >= list.count()) return QString(); + QetShapeItem *shape = list.at(shapeIndex); + if (property == QLatin1String("color")) return shape->pen().color().name(); + if (property == QLatin1String("width")) return QString::number(shape->pen().widthF()); + if (property == QLatin1String("fill")) + return shape->brush().style() == Qt::NoBrush ? QStringLiteral("none") : shape->brush().color().name(); + if (property == QLatin1String("rotation")) return QString::number(shape->rotation()); + if (property == QLatin1String("line-style")) { + switch (shape->pen().style()) { + case Qt::DashLine: return QStringLiteral("dashed"); + case Qt::DotLine: return QStringLiteral("dotted"); + case Qt::DashDotLine: return QStringLiteral("dashdot"); + default: return QStringLiteral("solid"); + } + } + return QString(); +} + +/** + @brief QetScriptApi::setShapeProperty + Change one aspect of a shape's look through QPropertyUndoCommand on the + "pen", "brush" or "rotation" property, the properties the shape's own + style editor publishes. Values are validated and refused rather than + stored: a colour that does not parse, a non-positive width, a line + style outside solid/dashed/dotted/dashdot. +*/ +bool QetScriptApi::setShapeProperty(int folioIndex, int shapeIndex, + const QString &property, const QString &value) +{ + if (!m_project) return false; + const QString caller = QStringLiteral("setShapeProperty"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + if (!shapePropertyNames().contains(property)) { + log(QStringLiteral("qet.%1: unknown property '%2'; expected one of %3") + .arg(caller, property, shapePropertyNames().join(QStringLiteral(", ")))); + return false; + } + const QList list = sortedShapes(folioIndex); + if (shapeIndex < 0 || shapeIndex >= list.count()) { + log(QStringLiteral("qet.%1: folio %2 has %3 shape(s), no index %4") + .arg(caller).arg(folioIndex).arg(list.count()).arg(shapeIndex)); + return false; + } + QetShapeItem *shape = list.at(shapeIndex); + + QString what; + QVariant old_value, new_value; + const char *qt_property = nullptr; + if (property == QLatin1String("color") || property == QLatin1String("width") + || property == QLatin1String("line-style")) + { + QPen pen = shape->pen(); + if (property == QLatin1String("color")) { + const QColor c(value); + if (!c.isValid()) { log(QStringLiteral("qet.%1: '%2' is not a valid colour").arg(caller, value)); return false; } + pen.setColor(c); + } else if (property == QLatin1String("width")) { + bool ok = false; + const double w = value.toDouble(&ok); + if (!ok || w <= 0) { log(QStringLiteral("qet.%1: '%2' is not a positive width").arg(caller, value)); return false; } + pen.setWidthF(w); + } else { + if (value == QLatin1String("solid")) pen.setStyle(Qt::SolidLine); + else if (value == QLatin1String("dashed")) pen.setStyle(Qt::DashLine); + else if (value == QLatin1String("dotted")) pen.setStyle(Qt::DotLine); + else if (value == QLatin1String("dashdot")) pen.setStyle(Qt::DashDotLine); + else { log(QStringLiteral("qet.%1: unknown line-style '%2'").arg(caller, value)); return false; } + } + if (pen == shape->pen()) return true; + old_value = shape->pen(); new_value = pen; qt_property = "pen"; what = QObject::tr("Modifier le trait d'une forme"); + } + else if (property == QLatin1String("fill")) + { + QBrush brush = shape->brush(); + if (value == QLatin1String("none")) brush.setStyle(Qt::NoBrush); + else { + const QColor c(value); + if (!c.isValid()) { log(QStringLiteral("qet.%1: '%2' is not a valid colour").arg(caller, value)); return false; } + brush.setStyle(Qt::SolidPattern); brush.setColor(c); + } + if (brush == shape->brush()) return true; + old_value = shape->brush(); new_value = brush; qt_property = "brush"; what = QObject::tr("Modifier le remplissage d'une forme"); + } + else + { + bool ok = false; + const double angle = value.toDouble(&ok); + if (!ok) { log(QStringLiteral("qet.%1: '%2' is not an angle").arg(caller, value)); return false; } + if (angle == shape->rotation()) return true; + old_value = shape->rotation(); new_value = angle; qt_property = "rotation"; what = QObject::tr("Pivoter une forme"); + } + + auto *cmd = new QPropertyUndoCommand(shape, qt_property, old_value, new_value); + cmd->setText(what); + m_project->undoStack()->push(cmd); + return true; +} + bool QetScriptApi::deleteShape(int folioIndex, int shapeIndex) { if (!m_project) return false; @@ -1168,6 +1696,338 @@ bool QetScriptApi::deleteShape(int folioIndex, int shapeIndex) return true; } +namespace { + +bool variantToPointF(const QVariant &v, QPointF &out) +{ + const QVariantMap m = v.toMap(); + if (!m.contains(QStringLiteral("x")) || !m.contains(QStringLiteral("y"))) return false; + bool okx = false, oky = false; + const double x = m.value(QStringLiteral("x")).toDouble(&okx); + const double y = m.value(QStringLiteral("y")).toDouble(&oky); + if (!okx || !oky) return false; + out = QPointF(x, y); + return true; +} + +QPolygonF variantToPolygon(const QVariantList &points, bool *ok) +{ + QPolygonF poly; + for (const QVariant &v : points) { + QPointF p; + if (!variantToPointF(v, p)) { *ok = false; return {}; } + poly << p; + } + *ok = true; + return poly; +} + +QVariantMap pointFToVariant(const QPointF &p) +{ + QVariantMap m; + m.insert(QStringLiteral("x"), p.x()); + m.insert(QStringLiteral("y"), p.y()); + return m; +} + +QetShapeItem::NodeKind nodeKindFromString(const QString &s) +{ + if (s == QLatin1String("smooth")) return QetShapeItem::NodeKind::Smooth; + if (s == QLatin1String("symmetric")) return QetShapeItem::NodeKind::Symmetric; + return QetShapeItem::NodeKind::Corner; +} + +QString nodeKindToString(QetShapeItem::NodeKind k) +{ + switch (k) { + case QetShapeItem::NodeKind::Smooth: return QStringLiteral("smooth"); + case QetShapeItem::NodeKind::Symmetric: return QStringLiteral("symmetric"); + default: return QStringLiteral("corner"); + } +} + +// Node format: {x, y, kind: "corner"|"smooth"|"symmetric", inHandle: +// {x,y}, outHandle: {x,y}} -- inHandle/outHandle are omitted (not merely +// null) when a node has none, matching PathNode's std::optional. +bool variantToPathNode(const QVariant &v, QetShapeItem::PathNode &out) +{ + const QVariantMap m = v.toMap(); + QPointF anchor; + if (!variantToPointF(v, anchor)) return false; + out.anchor = anchor; + out.kind = nodeKindFromString(m.value(QStringLiteral("kind")).toString()); + if (m.contains(QStringLiteral("inHandle"))) { + QPointF h; + if (!variantToPointF(m.value(QStringLiteral("inHandle")), h)) return false; + out.inHandle = h; + } + if (m.contains(QStringLiteral("outHandle"))) { + QPointF h; + if (!variantToPointF(m.value(QStringLiteral("outHandle")), h)) return false; + out.outHandle = h; + } + return true; +} + +QVariantMap pathNodeToVariant(const QetShapeItem::PathNode &n) +{ + QVariantMap m = pointFToVariant(n.anchor); + m.insert(QStringLiteral("kind"), nodeKindToString(n.kind)); + if (n.inHandle) m.insert(QStringLiteral("inHandle"), pointFToVariant(*n.inHandle)); + if (n.outHandle) m.insert(QStringLiteral("outHandle"), pointFToVariant(*n.outHandle)); + return m; +} + +} // namespace + +/** + @brief QetScriptApi::addPolygon + Place a Polygon shape with as many points as given -- addShape()'s + "polygon" only ever produces the degenerate two-point form, since it + shares addShape()'s p1/p2 constructor and nothing else. Points are in + scene coordinates, as [{x,y}, ...]. Returns the new shape's index, or + -1 (at least 2 points are required). +*/ +int QetScriptApi::addPolygon(int folioIndex, const QVariantList &points, bool closed) +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addPolygon: project is read-only")); + return -1; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1; + if (points.count() < 2) { + log(QStringLiteral("qet.addPolygon: at least 2 points are required, got %1") + .arg(points.count())); + return -1; + } + bool ok = false; + const QPolygonF poly = variantToPolygon(points, &ok); + if (!ok) { + log(QStringLiteral("qet.addPolygon: every point must be an {x, y} object")); + return -1; + } + + Diagram *diagram = diagrams.at(folioIndex); + auto *shape = new QetShapeItem(poly.first(), poly.last(), QetShapeItem::Polygon); + shape->setPolygon(poly); + shape->setClosed(closed); + diagram->undoStack().push(new AddGraphicsObjectCommand(shape, diagram, QPointF(0, 0))); + return sortedShapes(folioIndex).indexOf(shape); +} + +/** + @brief QetScriptApi::shapePolygon + A Polygon shape's own points, in scene coordinates, as [{x,y}, ...]. + Empty for any other shape type or an out-of-range index. +*/ +QVariantList QetScriptApi::shapePolygon(int folioIndex, int shapeIndex) const +{ + const QList list = sortedShapes(folioIndex); + if (shapeIndex < 0 || shapeIndex >= list.count()) return {}; + QetShapeItem *shape = list.at(shapeIndex); + if (shape->shapeType() != QetShapeItem::Polygon) return {}; + QVariantList result; + for (const QPointF &p : shape->polygon()) + result << pointFToVariant(shape->mapToScene(p)); + return result; +} + +/** + @brief QetScriptApi::setShapePolygon + Replace a Polygon shape's points through QPropertyUndoCommand on its + "polygon" Q_PROPERTY, the same as dragging one of its point handles. +*/ +bool QetScriptApi::setShapePolygon(int folioIndex, int shapeIndex, const QVariantList &points) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setShapePolygon: project is read-only")); + return false; + } + const QList list = sortedShapes(folioIndex); + if (shapeIndex < 0 || shapeIndex >= list.count()) { + log(QStringLiteral("qet.setShapePolygon: folio %1 has %2 shape(s), no index %3") + .arg(folioIndex).arg(list.count()).arg(shapeIndex)); + return false; + } + QetShapeItem *shape = list.at(shapeIndex); + if (shape->shapeType() != QetShapeItem::Polygon) { + log(QStringLiteral("qet.setShapePolygon: shape %1 is not a polygon").arg(shapeIndex)); + return false; + } + if (points.count() < 2) { + log(QStringLiteral("qet.setShapePolygon: at least 2 points are required, got %1") + .arg(points.count())); + return false; + } + bool ok = false; + const QPolygonF poly = variantToPolygon(points, &ok); + if (!ok) { + log(QStringLiteral("qet.setShapePolygon: every point must be an {x, y} object")); + return false; + } + + const QVariant old_value = QVariant::fromValue(shape->polygon()); + const QVariant new_value = QVariant::fromValue(poly); + if (shape->polygon() == poly) return true; + auto *cmd = new QPropertyUndoCommand(shape, "polygon", old_value, new_value); + cmd->setText(QObject::tr("Modifier la forme d'%1").arg(shape->name())); + m_project->undoStack()->push(cmd); + return true; +} + +/** + @brief QetScriptApi::addPath + Place a Path shape -- a Polygon's points plus, per node, a kind + (corner/smooth/symmetric) and optional bezier in/out handles, the same + model the pen tool and node-edit mode build. See variantToPathNode() + for the node format. Returns the new shape's index, or -1 (at least 2 + nodes are required). +*/ +int QetScriptApi::addPath(int folioIndex, const QVariantList &nodes, bool closed) +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addPath: project is read-only")); + return -1; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1; + if (nodes.count() < 2) { + log(QStringLiteral("qet.addPath: at least 2 nodes are required, got %1").arg(nodes.count())); + return -1; + } + QVector path_nodes; + for (const QVariant &v : nodes) { + QetShapeItem::PathNode node; + if (!variantToPathNode(v, node)) { + log(QStringLiteral("qet.addPath: every node must be an {x, y} object, " + "optionally with kind/inHandle/outHandle")); + return -1; + } + path_nodes << node; + } + + Diagram *diagram = diagrams.at(folioIndex); + auto *shape = new QetShapeItem(path_nodes.first().anchor, path_nodes.last().anchor, + QetShapeItem::Path); + shape->setPathNodes(path_nodes); + shape->setClosed(closed); + diagram->undoStack().push(new AddGraphicsObjectCommand(shape, diagram, QPointF(0, 0))); + return sortedShapes(folioIndex).indexOf(shape); +} + +/** + @brief QetScriptApi::shapePathNodes + A Path shape's own nodes, in scene coordinates -- see + variantToPathNode()/pathNodeToVariant() for the format. Empty for any + other shape type or an out-of-range index. +*/ +QVariantList QetScriptApi::shapePathNodes(int folioIndex, int shapeIndex) const +{ + const QList list = sortedShapes(folioIndex); + if (shapeIndex < 0 || shapeIndex >= list.count()) return {}; + QetShapeItem *shape = list.at(shapeIndex); + if (shape->shapeType() != QetShapeItem::Path) return {}; + QVariantList result; + for (const QetShapeItem::PathNode &n : shape->pathNodes()) { + QetShapeItem::PathNode scene_node = n; + scene_node.anchor = shape->mapToScene(n.anchor); + if (n.inHandle) scene_node.inHandle = shape->mapToScene(*n.inHandle); + if (n.outHandle) scene_node.outHandle = shape->mapToScene(*n.outHandle); + result << pathNodeToVariant(scene_node); + } + return result; +} + +/** + @brief QetScriptApi::setShapePathNodes + Replace a Path shape's nodes. PathNode/QVector is not a + Q_PROPERTY-friendly type (it holds std::optional members), so + this reuses PromoteShapeCommand's generic before/after XML snapshot + mechanism instead -- the same one the node-edit handle drag itself + falls back to, and for the identical reason (see the PathAnchor case + in QetShapeItem::associatedUndoCommand()). +*/ +bool QetScriptApi::setShapePathNodes(int folioIndex, int shapeIndex, const QVariantList &nodes) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setShapePathNodes: project is read-only")); + return false; + } + const QList list = sortedShapes(folioIndex); + if (shapeIndex < 0 || shapeIndex >= list.count()) { + log(QStringLiteral("qet.setShapePathNodes: folio %1 has %2 shape(s), no index %3") + .arg(folioIndex).arg(list.count()).arg(shapeIndex)); + return false; + } + QetShapeItem *shape = list.at(shapeIndex); + if (shape->shapeType() != QetShapeItem::Path) { + log(QStringLiteral("qet.setShapePathNodes: shape %1 is not a path").arg(shapeIndex)); + return false; + } + if (nodes.count() < 2) { + log(QStringLiteral("qet.setShapePathNodes: at least 2 nodes are required, got %1") + .arg(nodes.count())); + return false; + } + QVector path_nodes; + for (const QVariant &v : nodes) { + QetShapeItem::PathNode node; + if (!variantToPathNode(v, node)) { + log(QStringLiteral("qet.setShapePathNodes: every node must be an {x, y} " + "object, optionally with kind/inHandle/outHandle")); + return false; + } + path_nodes << node; + } + if (path_nodes == shape->pathNodes()) return true; + + QDomDocument before_doc; + const QDomElement before = shape->toXml(before_doc); + before_doc.appendChild(before); + shape->setPathNodes(path_nodes); + QDomDocument after_doc; + const QDomElement after = shape->toXml(after_doc); + after_doc.appendChild(after); + + auto *cmd = new PromoteShapeCommand(shape, before, after); + cmd->setText(QObject::tr("Modifier la forme d'%1").arg(shape->name())); + m_project->undoStack()->push(cmd); + return true; +} + +/** + @brief QetScriptApi::setShapeClosed + Open or close a Polygon or Path shape through QPropertyUndoCommand on + its "close" Q_PROPERTY. A no-op (returns true) on any other shape type, + the same as QetShapeItem::setClosed() itself. +*/ +bool QetScriptApi::setShapeClosed(int folioIndex, int shapeIndex, bool closed) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setShapeClosed: project is read-only")); + return false; + } + const QList list = sortedShapes(folioIndex); + if (shapeIndex < 0 || shapeIndex >= list.count()) { + log(QStringLiteral("qet.setShapeClosed: folio %1 has %2 shape(s), no index %3") + .arg(folioIndex).arg(list.count()).arg(shapeIndex)); + return false; + } + QetShapeItem *shape = list.at(shapeIndex); + if (shape->isClosed() == closed) return true; + + auto *cmd = new QPropertyUndoCommand(shape, "close", shape->isClosed(), closed); + cmd->setText(QObject::tr("Fermer/Ouvrir %1").arg(shape->name())); + m_project->undoStack()->push(cmd); + return true; +} + /** @brief QetScriptApi::tables The tables and views the project database holds, as "name (type)". @@ -1276,6 +2136,1513 @@ QString QetScriptApi::queryError() const return m_query_error; } +/** + @brief QetScriptApi::deleteConductor + Delete the single conductor attached to a terminal (same addressing as + setConductorProperty()). Unlike a property change this removes only that + conductor: DeleteQGraphicsItemCommand itself rebuilds the remaining + conductors of the potential so it stays connected, exactly as when a user + selects one conductor and presses Delete. +*/ +bool QetScriptApi::deleteConductor(int folioIndex, const QString &elementUuid, int terminalIndex) +{ + if (!m_project) return false; + const QString caller = QStringLiteral("deleteConductor"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + Conductor *conductor = findConductor(folioIndex, elementUuid, terminalIndex, caller); + if (!conductor) return false; + Diagram *diagram = m_project->diagrams().at(folioIndex); + + DiagramContent content; + content.m_conductors_to_move << conductor; + diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, content)); + return true; +} + +/** + @brief QetScriptApi::removeFolio + Remove a folio through RemoveDiagramCommand, the command the GUI's + "delete folio" pushes (minus its confirmation box, which nobody could + answer headlessly). Undoable. Later folio indexes shift down by one. +*/ +bool QetScriptApi::removeFolio(int folioIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.removeFolio: project is read-only")); + return false; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return false; + m_project->undoStack()->push(new RemoveDiagramCommand(m_project, diagrams.at(folioIndex))); + return m_project->diagrams().count() == diagrams.count() - 1; +} + +namespace { +QString *titleBlockField(TitleBlockProperties &p, const QString &name) +{ + if (name == QLatin1String("title")) return &p.title; + if (name == QLatin1String("author")) return &p.author; + if (name == QLatin1String("filename")) return &p.filename; + if (name == QLatin1String("plant")) return &p.plant; + if (name == QLatin1String("locmach")) return &p.locmach; + if (name == QLatin1String("indexrev")) return &p.indexrev; + // Not "version": TitleBlockProperties::version is the file-format stamp + // QElectroTech writes on every save, so a value set here reports success + // and is overwritten -- measured: set "V9-USER", read back "0.200.1-dev". + if (name == QLatin1String("folio")) return &p.folio; + // Not "template" either: template_name resolves against the project's + // embedded collection, not free text, so it goes through + // setFolioProperty()'s own branch (embedTitleBlockTemplate() first) + // rather than this direct field lookup. + return nullptr; +} +const QStringList &titleBlockFieldNames() +{ + static const QStringList n{QStringLiteral("title"), QStringLiteral("author"), + QStringLiteral("filename"), QStringLiteral("plant"), QStringLiteral("locmach"), + QStringLiteral("indexrev"), QStringLiteral("folio"), QStringLiteral("template")}; + return n; +} +} // namespace + +QString QetScriptApi::folioProperty(int folioIndex, const QString &property) const +{ + if (!m_project) return QString(); + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return QString(); + Diagram *diagram = diagrams.at(folioIndex); + if (property == QLatin1String("template")) + return diagram->border_and_titleblock.titleBlockTemplateName(); + TitleBlockProperties p = diagram->border_and_titleblock.exportTitleBlock(); + QString *field = titleBlockField(p, property); + return field ? *field : QString(); +} + +/** + @brief QetScriptApi::setFolioProperty + Set one text field of a folio's title block (title, author, filename, + plant, locmach, indexrev, folio) via ChangeTitleBlockCommand, + like setFolioTitle() which this generalises. The date and the template are + not offered: the date has a use-current-date mode that a plain string + cannot express honestly. +*/ +bool QetScriptApi::setFolioProperty(int folioIndex, const QString &property, const QString &value) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setFolioProperty: project is read-only")); + return false; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return false; + Diagram *diagram = diagrams.at(folioIndex); + + // Not a TitleBlockProperties field like the others below: the template + // is named by whatever the project's embedded collection calls it, not + // by a value stored on this folio's own properties, so it has to be + // embedded (or already present) before Diagram::setTitleBlockTemplate() + // -- the same public slot BorderTitleBlock's own needTitleBlockTemplate + // signal calls -- can find it. + if (property == QLatin1String("template")) { + // BorderTitleBlock::titleBlockTemplateName() normalises a template + // literally named "default" back to "" -- indistinguishable, once + // set, from no override at all (a template named "default" ships + // in the common collection and is genuinely what "no override" + // renders with). Compare against that same normalised form, or a + // script setting "default" would see this report failure although + // the application applied it correctly -- measured: it did. + const QString normalised = (value == QLatin1String("default")) ? QString() : value; + if (diagram->border_and_titleblock.titleBlockTemplateName() == normalised) return true; + if (!embedTitleBlockTemplate(value)) return false; + diagram->setTitleBlockTemplate(value); + return diagram->border_and_titleblock.titleBlockTemplateName() == normalised; + } + + TitleBlockProperties old_p = diagram->border_and_titleblock.exportTitleBlock(); + TitleBlockProperties new_p = old_p; + QString *field = titleBlockField(new_p, property); + if (!field) { + log(QStringLiteral("qet.setFolioProperty: unknown property '%1'; expected one of %2") + .arg(property, titleBlockFieldNames().join(QStringLiteral(", ")))); + return false; + } + if (*field == value) return true; + *field = value; + m_project->undoStack()->push(new ChangeTitleBlockCommand(diagram, old_p, new_p)); + return true; +} + +QStringList QetScriptApi::terminalStrips() const +{ + QStringList list; + if (!m_project) return list; + const QVector strips = m_project->terminalStrip(); + for (int i = 0 ; i < strips.count() ; ++i) + { + TerminalStrip *t = strips.at(i); + list << QStringLiteral("%1: installation='%2' location='%3' name='%4' (%5 terminal(s))") + .arg(i).arg(t->installation(), t->location(), t->name()) + .arg(t->realTerminals().count()); + } + return list; +} + +/** + @brief QetScriptApi::addTerminalStrip + Create an empty terminal strip through AddTerminalStripCommand, as the + editor's creation dialog does after it is accepted. + @return its index in terminalStrips(), or -1 +*/ +int QetScriptApi::addTerminalStrip(const QString &installation, const QString &location, + const QString &name) +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addTerminalStrip: project is read-only")); + return -1; + } + auto *strip = new TerminalStrip(installation, location, name, m_project); + m_project->undoStack()->push(new AddTerminalStripCommand(strip, m_project)); + return m_project->terminalStrip().indexOf(strip); +} + +bool QetScriptApi::removeTerminalStrip(int stripIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.removeTerminalStrip: project is read-only")); + return false; + } + const QVector strips = m_project->terminalStrip(); + if (stripIndex < 0 || stripIndex >= strips.count()) { + log(QStringLiteral("qet.removeTerminalStrip: no strip at index %1").arg(stripIndex)); + return false; + } + m_project->undoStack()->push(new RemoveTerminalStripCommand(strips.at(stripIndex), m_project)); + return m_project->terminalStrip().count() == strips.count() - 1; +} + +bool QetScriptApi::addTerminalToStrip(int stripIndex, int folioIndex, const QString &elementUuid) +{ + if (!m_project) return false; + const QString caller = QStringLiteral("addTerminalToStrip"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + const QVector strips = m_project->terminalStrip(); + if (stripIndex < 0 || stripIndex >= strips.count()) { + log(QStringLiteral("qet.%1: no strip at index %2").arg(caller).arg(stripIndex)); + return false; + } + Element *element = findElement(folioIndex, elementUuid); + if (!element) return false; + auto *terminal_element = qobject_cast(element); + if (!terminal_element) { + log(QStringLiteral("qet.%1: %2 is not a terminal-type element").arg(caller, elementUuid)); + return false; + } + QSharedPointer real = terminal_element->realTerminal(); + if (!real) return false; + if (real->parentStrip()) { + log(QStringLiteral("qet.%1: %2 already belongs to a strip").arg(caller, elementUuid)); + return false; + } + m_project->undoStack()->push(new AddTerminalToStripCommand(real, strips.at(stripIndex))); + return real->parentStrip() == strips.at(stripIndex); +} + +/** + @brief QetScriptApi::stripRealTerminals + The strip's real terminals -- the wire-ends added by addTerminalToStrip() + -- one line per index: the owning element's uuid, the terminal's own + name, and which physical position (clamp) it currently sits on, by that + position's own index (so several real terminals reporting the same + physical index are already grouped together). +*/ +QStringList QetScriptApi::stripRealTerminals(int stripIndex) const +{ + QStringList list; + if (!m_project) return list; + const QVector strips = m_project->terminalStrip(); + if (stripIndex < 0 || stripIndex >= strips.count()) return list; + TerminalStrip *strip = strips.at(stripIndex); + + const QVector> physical = strip->physicalTerminal(); + const QVector> real = strip->realTerminals(); + for (int i = 0 ; i < real.count() ; ++i) + { + QSharedPointer rt = real.at(i); + QSharedPointer pt = rt->physicalTerminal(); + const int physical_index = pt ? physical.indexOf(pt) : -1; + list << QStringLiteral("%1: %2 terminal '%3', physical position %4 (%5 terminal(s) there)") + .arg(i) + .arg(rt->element() ? rt->element()->uuid().toString() : QStringLiteral("?")) + .arg(rt->label()) + .arg(physical_index) + .arg(pt ? pt->realTerminalCount() : 0); + } + return list; +} + +namespace { +/** + Resolve a list of indices into stripRealTerminals() to the RealTerminal + objects groupTerminals()/bridgeTerminals() need, or an empty (and + therefore refusable) list if any index is out of range or the list has + fewer than the two terminals either operation requires. +*/ +QVector> resolveRealTerminals( + TerminalStrip *strip, const QVariantList &indices, const QString &caller, + QetScriptApi *api) +{ + QVector> out; + const QVector> all = strip->realTerminals(); + if (indices.count() < 2) { + api->log(QStringLiteral("qet.%1: at least two real terminals are required").arg(caller)); + return {}; + } + for (const QVariant &v : indices) { + bool ok = false; + const int i = v.toInt(&ok); + if (!ok || i < 0 || i >= all.count()) { + api->log(QStringLiteral("qet.%1: %2 is not a valid real terminal index (strip has %3)") + .arg(caller, v.toString()).arg(all.count())); + return {}; + } + out << all.at(i); + } + return out; +} +} // namespace + +/** + @brief QetScriptApi::groupTerminals + Merge several real terminals onto one physical position, through + GroupTerminalsCommand exactly as the terminal strip editor's "group" + button does -- including which position receives the others: the one + among those named that already carries the most real terminals, the + same heuristic the editor applies, not necessarily the first one given. +*/ +bool QetScriptApi::groupTerminals(int stripIndex, const QVariantList &realTerminalIndices) +{ + if (!m_project) return false; + const QString caller = QStringLiteral("groupTerminals"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + const QVector strips = m_project->terminalStrip(); + if (stripIndex < 0 || stripIndex >= strips.count()) { + log(QStringLiteral("qet.%1: no strip at index %2").arg(caller).arg(stripIndex)); + return false; + } + TerminalStrip *strip = strips.at(stripIndex); + const QVector> chosen = resolveRealTerminals(strip, realTerminalIndices, caller, this); + if (chosen.isEmpty()) return false; + + QSharedPointer receiver = chosen.first()->physicalTerminal(); + int best_count = 0; + for (const QSharedPointer &rt : chosen) { + QSharedPointer pt = rt->physicalTerminal(); + const int count = pt ? pt->realTerminalCount() : 0; + if (count > 1 && count > best_count) { + best_count = count; + receiver = pt; + } + } + if (!receiver) { + log(QStringLiteral("qet.%1: no physical position to receive the group").arg(caller)); + return false; + } + + QVector> to_group = chosen; + for (const QSharedPointer &rt : receiver->realTerminals()) { + to_group.removeOne(rt); + } + if (to_group.isEmpty()) { + log(QStringLiteral("qet.%1: every named terminal is already on the receiving position").arg(caller)); + return true; + } + const int before = strip->physicalTerminalCount(); + m_project->undoStack()->push(new GroupTerminalsCommand(strip, receiver, to_group)); + return strip->physicalTerminalCount() < before; +} + +/** + @brief QetScriptApi::bridgeTerminals + Wire several real terminals together electrically, through + BridgeTerminalsCommand as the editor's "bridge" button does. Refused, + via TerminalStrip::isBridgeable(), when they are not all at the same + level -- the editor's own check, not a rule reimplemented here. +*/ +bool QetScriptApi::bridgeTerminals(int stripIndex, const QVariantList &realTerminalIndices) +{ + if (!m_project) return false; + const QString caller = QStringLiteral("bridgeTerminals"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + const QVector strips = m_project->terminalStrip(); + if (stripIndex < 0 || stripIndex >= strips.count()) { + log(QStringLiteral("qet.%1: no strip at index %2").arg(caller).arg(stripIndex)); + return false; + } + TerminalStrip *strip = strips.at(stripIndex); + const QVector> chosen = resolveRealTerminals(strip, realTerminalIndices, caller, this); + if (chosen.isEmpty()) return false; + + if (!strip->isBridgeable(chosen)) { + log(QStringLiteral("qet.%1: these terminals cannot be bridged -- they are not all at the same level") + .arg(caller)); + return false; + } + m_project->undoStack()->push(new BridgeTerminalsCommand(strip, chosen)); + return true; +} + +/** + @brief QetScriptApi::sortTerminalStrip + Reorder a strip's physical positions into the canonical order the + editor's own "sort" button computes, through SortTerminalStripCommand. +*/ +bool QetScriptApi::sortTerminalStrip(int stripIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.sortTerminalStrip: project is read-only")); + return false; + } + const QVector strips = m_project->terminalStrip(); + if (stripIndex < 0 || stripIndex >= strips.count()) { + log(QStringLiteral("qet.sortTerminalStrip: no strip at index %1").arg(stripIndex)); + return false; + } + m_project->undoStack()->push(new SortTerminalStripCommand(strips.at(stripIndex))); + return true; +} + +QList QetScriptApi::sortedTables(int folioIndex) const +{ + if (!m_project) return {}; + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return {}; + DiagramContent content(diagrams.at(folioIndex), false); + return sortedByPosition(content.m_tables); +} + +QStringList QetScriptApi::tables(int folioIndex) const +{ + QStringList list; + const QList all = sortedTables(folioIndex); + for (int i = 0 ; i < all.count() ; ++i) + { + QetGraphicsTableItem *t = all.at(i); + list << QStringLiteral("%1: '%2' at (%3, %4), %5 row(s)") + .arg(i).arg(t->tableName()).arg(t->pos().x()).arg(t->pos().y()) + .arg(t->model() ? t->model()->rowCount() : 0); + } + return list; +} + +/** + @brief QetScriptApi::addTable + Place a BOM/nomenclature or summary (table of contents) table, through + QetGraphicsTableFactory::create() -- the same factory call the "add + table" menu action makes, minus the modal AddTableDialog it collects + its settings from first. That dialog is built here too, off-screen and + never shown or exec'd: create() reads the table's name and the query + widget's identifier/query string from it rather than taking them as + plain arguments, so the dialog exists only to be read from, and its two + checkboxes -- "adjust to folio" and "add a new folio if the table + overflows" -- are forced off despite defaulting to checked in the .ui + file, so one call creates exactly the one table asked for. A script + that wants either behaviour can resize the result or add a folio itself. + + kind is "nomenclature" (an ElementQueryWidget, over placed elements) or + "summary" (a SummaryQueryWidget, over folios); query is required, since + both widgets otherwise build their own from a set of checkboxes that + default to none checked, and "SELECT with no columns" is not a useful + table -- query() against element_nomenclature_view or + project_summary_view is the way to find one that is. + + Not undoable: newTable(), which create() calls, calls + Diagram::addItem() directly, with no undo command of its own, in the + stock action as much as here. Which of the (possibly several) tables + create() left in the diagram is the new one is found by set difference + against the folio's table listing taken just before the call, since + create()'s return type is void and newTable() itself is private. + @return the table's index in tables(), or -1 +*/ +int QetScriptApi::addTable(int folioIndex, const QString &kind, const QString &name, + const QString &query) +{ + if (!m_project) return -1; + const QString caller = QStringLiteral("addTable"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return -1; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) { + log(QStringLiteral("qet.%1: no folio at index %2").arg(caller).arg(folioIndex)); + return -1; + } + if (query.isEmpty()) { + // Both widgets build their query from a set of checkboxes that + // default to none checked, so "no query" is not "the sensible + // default" here the way it might look -- it is SELECT with no + // columns. Measured: a table left to that default reports 0 rows + // against a folio that plainly has some. A real SELECT is required + // instead, over query() -- the same project database and the same + // two views this project already exposes, element_nomenclature_view + // for a nomenclature table and project_summary_view for a summary. + log(QStringLiteral("qet.%1: a query is required -- try qet.query() against " + "element_nomenclature_view or project_summary_view first " + "to find one that returns what is wanted").arg(caller)); + return -1; + } + + QWidget *content = nullptr; + if (kind == QLatin1String("nomenclature")) { + auto *w = new ElementQueryWidget(); + w->setQuery(query); + content = w; + } else if (kind == QLatin1String("summary")) { + auto *w = new SummaryQueryWidget(); + w->setQuery(query); + content = w; + } else { + log(QStringLiteral("qet.%1: unknown kind '%2'; expected nomenclature or summary").arg(caller, kind)); + return -1; + } + + AddTableDialog dialog(content); + dialog.setTableName(name); + // QetGraphicsTableFactory::newTable() is private -- only create() (its + // own class) may call it -- and create()'s own two checkboxes both + // default to checked in the .ui file: "adjust to folio" and "add a new + // folio if the table overflows". Forced off here rather than left at + // that default, since a script calling addTable() once should create + // exactly the one table it asked for, not possibly several across + // folios it never asked to add. + dialog.setAdjustTableToFolio(false); + dialog.setAddNewTableToNewDiagram(false); + + const QList before = sortedTables(folioIndex); + QetGraphicsTableFactory::create(diagrams.at(folioIndex), &dialog); + const QList after = sortedTables(folioIndex); + for (QetGraphicsTableItem *t : after) { + if (!before.contains(t)) return after.indexOf(t); + } + log(QStringLiteral("qet.%1: the table could not be created").arg(caller)); + return -1; +} + +bool QetScriptApi::deleteTable(int folioIndex, int tableIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.deleteTable: project is read-only")); + return false; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return false; + Diagram *diagram = diagrams.at(folioIndex); + + const QList all = sortedTables(folioIndex); + if (tableIndex < 0 || tableIndex >= all.count()) { + log(QStringLiteral("qet.deleteTable: folio %1 has %2 table(s), no index %3") + .arg(folioIndex).arg(all.count()).arg(tableIndex)); + return false; + } + DiagramContent to_remove; + to_remove.m_tables << all.at(tableIndex); + diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, to_remove)); + return true; +} + +/** + @brief QetScriptApi::setTablePosition + Move a table on its folio through QPropertyUndoCommand, the same + mechanism setElementPosition() uses -- QetGraphicsTableFactory::newTable() + places every new table at a fixed (50, 50), so a script adding more than + one table must reposition all but the first itself or they stack exactly + on top of each other. +*/ +bool QetScriptApi::setTablePosition(int folioIndex, int tableIndex, double x, double y) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setTablePosition: project is read-only")); + return false; + } + const QList all = sortedTables(folioIndex); + if (tableIndex < 0 || tableIndex >= all.count()) { + log(QStringLiteral("qet.setTablePosition: folio %1 has %2 table(s), no index %3") + .arg(folioIndex).arg(all.count()).arg(tableIndex)); + return false; + } + QetGraphicsTableItem *table = all.at(tableIndex); + + const QVariant old_value = table->pos(); + const QVariant new_value = QPointF(x, y); + if (old_value == new_value) return true; // already there; nothing to push + + auto *cmd = new QPropertyUndoCommand(table, "pos", old_value, new_value); + cmd->setText(QObject::tr("Déplacer %1").arg(table->tableName())); + m_project->undoStack()->push(cmd); + return true; +} + +namespace { +QHash autoNumTable(QETProject *project, const QString &kind, bool *ok) +{ + *ok = true; + if (kind == QLatin1String("conductor")) return project->conductorAutoNum(); + if (kind == QLatin1String("element")) return project->elementAutoNum(); + if (kind == QLatin1String("folio")) return project->folioAutoNum(); + *ok = false; + return {}; +} +} // namespace + +QStringList QetScriptApi::autoNums(const QString &kind) const +{ + QStringList list; + if (!m_project) return list; + bool ok; + const QHash table = autoNumTable(m_project, kind, &ok); + if (!ok) return list; + const QStringList names = table.keys(); + for (const QString &name : names) { + list << QStringLiteral("%1: formula='%2'") + .arg(name, autonum::numerotationContextToFormula(table.value(name))); + } + list.sort(); + return list; +} + +/** + @brief QetScriptApi::addAutoNum + Define (or replace) a named numbering context. Each part is + "type[:value[:increase]]"; a numeric type takes its starting value and + how much it advances by, a text type its text. Anything the + NumerotationContext rejects -- an unknown type, a non-numeric value for + a numeric type -- is refused rather than dropped. +*/ +bool QetScriptApi::addAutoNum(const QString &kind, const QString &name, const QStringList &parts) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addAutoNum: project is read-only")); + return false; + } + bool ok; + autoNumTable(m_project, kind, &ok); + if (!ok) { + log(QStringLiteral("qet.addAutoNum: unknown kind '%1'; expected conductor, element or folio").arg(kind)); + return false; + } + if (name.isEmpty() || parts.isEmpty()) { + log(QStringLiteral("qet.addAutoNum: a name and at least one part are required")); + return false; + } + + NumerotationContext context; + for (const QString &part : parts) + { + const QStringList f = part.split(QLatin1Char(':')); + const QString type = f.value(0); + const QVariant value = f.size() > 1 ? QVariant(f.at(1)) : QVariant(1); + bool inc_ok = true; + const int increase = f.size() > 2 ? f.at(2).toInt(&inc_ok) : 1; + if (!context.keyIsAcceptable(type) || !inc_ok || !context.addValue(type, value, increase)) { + log(QStringLiteral("qet.addAutoNum: cannot use part '%1'").arg(part)); + return false; + } + } + + if (kind == QLatin1String("conductor")) m_project->addConductorAutoNum(name, context); + else if (kind == QLatin1String("element")) m_project->addElementAutoNum(name, context); + else m_project->addFolioAutoNum(name, context); + return true; +} + +bool QetScriptApi::removeAutoNum(const QString &kind, const QString &name) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.removeAutoNum: project is read-only")); + return false; + } + bool ok; + const QHash table = autoNumTable(m_project, kind, &ok); + if (!ok || !table.contains(name)) { + log(QStringLiteral("qet.removeAutoNum: no %1 auto-numbering named '%2'").arg(kind, name)); + return false; + } + if (kind == QLatin1String("conductor")) m_project->removeConductorAutoNum(name); + else if (kind == QLatin1String("element")) m_project->removeElementAutoNum(name); + else m_project->removeFolioAutoNum(name); + return true; +} + +/** + @brief QetScriptApi::useConductorAutoNum + Make new conductors on a folio take their number from a named context. + Sets both what the folio reads and the project's current name, because + ConductorAutoNumerotation reads the context by the former and writes the + advanced counter back under the latter. +*/ +bool QetScriptApi::useConductorAutoNum(int folioIndex, const QString &name) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.useConductorAutoNum: project is read-only")); + return false; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return false; + if (!name.isEmpty() && !m_project->conductorAutoNum().contains(name)) { + log(QStringLiteral("qet.useConductorAutoNum: no conductor auto-numbering named '%1'").arg(name)); + return false; + } + diagrams.at(folioIndex)->setConductorsAutonumName(name); + m_project->setCurrentConductorAutoNum(name); + return true; +} + +QList QetScriptApi::sortedImages(int folioIndex) const +{ + if (!m_project) return {}; + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return {}; + DiagramContent content(diagrams.at(folioIndex), false); + return sortedByPosition(content.m_images); +} + +QStringList QetScriptApi::images(int folioIndex) const +{ + QStringList out; + const QList list = sortedImages(folioIndex); + for (int i = 0 ; i < list.count() ; ++i) + { + DiagramImageItem *item = list.at(i); + const QPixmap px = item->pixmap(); + const QPointF at = item->sceneBoundingRect().topLeft(); + out << QStringLiteral("%1: %2x%3 px at (%4, %5) scale=%6 rotation=%7") + .arg(i).arg(px.width()).arg(px.height()).arg(at.x()).arg(at.y()) + .arg(item->scaleFactorX()).arg(item->rotationAngle()); + } + return out; +} + +/** + @brief QetScriptApi::addImage + Place a picture from a file, as the "add image" tool does after its file + dialog. The pixels are copied into the project (DiagramImageItem::toXml + writes them inline), so the file need not exist afterwards. + @return the image's index in images(), or -1 +*/ +int QetScriptApi::addImage(int folioIndex, const QString &filePath, double x, double y) +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addImage: project is read-only")); + return -1; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1; + + const QFileInfo info(filePath); + if (!info.isFile()) { + log(QStringLiteral("qet.addImage: '%1' is not a file").arg(filePath)); + return -1; + } + constexpr qint64 max_bytes = 10LL * 1024 * 1024; + if (info.size() > max_bytes) { + log(QStringLiteral("qet.addImage: '%1' is %2 bytes; images are embedded in the project, " + "so files over 10 MB are refused").arg(filePath).arg(info.size())); + return -1; + } + const QImage image(filePath); + if (image.isNull()) { + log(QStringLiteral("qet.addImage: '%1' could not be read as an image").arg(filePath)); + return -1; + } + + Diagram *diagram = diagrams.at(folioIndex); + auto *item = new DiagramImageItem(QPixmap::fromImage(image)); + diagram->undoStack().push(new AddGraphicsObjectCommand(item, diagram, QPointF(x, y))); + return sortedImages(folioIndex).indexOf(item); +} + +bool QetScriptApi::setImageScale(int folioIndex, int imageIndex, double factor) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setImageScale: project is read-only")); + return false; + } + if (factor <= 0) { + log(QStringLiteral("qet.setImageScale: the factor must be positive")); + return false; + } + const QList list = sortedImages(folioIndex); + if (imageIndex < 0 || imageIndex >= list.count()) { + log(QStringLiteral("qet.setImageScale: folio %1 has %2 image(s), no index %3") + .arg(folioIndex).arg(list.count()).arg(imageIndex)); + return false; + } + DiagramImageItem *item = list.at(imageIndex); + if (item->scaleFactorX() == factor && item->scaleFactorY() == factor) return true; + + // Both axes, one undo step: a script that scales an image means the + // image, not one axis of it. + m_project->undoStack()->beginMacro(QObject::tr("Redimensionner une image")); + m_project->undoStack()->push(new QPropertyUndoCommand(item, "scaleFactorX", + QVariant(item->scaleFactorX()), QVariant(factor))); + m_project->undoStack()->push(new QPropertyUndoCommand(item, "scaleFactorY", + QVariant(item->scaleFactorY()), QVariant(factor))); + m_project->undoStack()->endMacro(); + return true; +} + +bool QetScriptApi::setImageRotation(int folioIndex, int imageIndex, double angle) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setImageRotation: project is read-only")); + return false; + } + const QList list = sortedImages(folioIndex); + if (imageIndex < 0 || imageIndex >= list.count()) { + log(QStringLiteral("qet.setImageRotation: folio %1 has %2 image(s), no index %3") + .arg(folioIndex).arg(list.count()).arg(imageIndex)); + return false; + } + DiagramImageItem *item = list.at(imageIndex); + if (item->rotationAngle() == angle) return true; + auto *cmd = new QPropertyUndoCommand(item, "rotationAngle", + QVariant(item->rotationAngle()), QVariant(angle)); + cmd->setText(QObject::tr("Pivoter une image")); + m_project->undoStack()->push(cmd); + return true; +} + +bool QetScriptApi::deleteImage(int folioIndex, int imageIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.deleteImage: project is read-only")); + return false; + } + const QList list = sortedImages(folioIndex); + if (imageIndex < 0 || imageIndex >= list.count()) { + log(QStringLiteral("qet.deleteImage: folio %1 has %2 image(s), no index %3") + .arg(folioIndex).arg(list.count()).arg(imageIndex)); + return false; + } + Diagram *diagram = m_project->diagrams().at(folioIndex); + DiagramContent content; + content.m_images << list.at(imageIndex); + diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, content)); + return true; +} + +/** + @brief QetScriptApi::addPdfPage + Render one page of a PDF file to an image and place it, as the "add + PDF" toolbar action does after its file and page-selection dialogs -- + same QPdfDocument::render() call, same white-background compositing + for a transparent PDF, same DiagramImageItem/AddGraphicsObjectCommand + underneath addImage() itself. Only reachable in a build with the + QtPdf module (Qt >= 6.4); refused with a clear reason otherwise, since + a missing module or a too-old Qt is a real possibility this project + ships around (see diagrameventaddpdf.h) rather than something a script + should read as "no such method". + @param pageNumber 1-based, as PdfPagesDialog shows it + @param dpi resolution to render at; the GUI dialog defaults to 150 + @return the new image's index in images(), or -1 +*/ +int QetScriptApi::addPdfPage(int folioIndex, const QString &pdfPath, int pageNumber, + int dpi, double x, double y) +{ +#ifndef QET_HAS_QTPDF + Q_UNUSED(folioIndex) Q_UNUSED(pdfPath) Q_UNUSED(pageNumber) + Q_UNUSED(dpi) Q_UNUSED(x) Q_UNUSED(y) + log(QStringLiteral("qet.addPdfPage: this build has no QtPdf module (or Qt < 6.4); " + "PDF page import is unavailable")); + return -1; +#else + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.addPdfPage: project is read-only")); + return -1; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1; + if (dpi <= 0) { + log(QStringLiteral("qet.addPdfPage: dpi must be positive, got %1").arg(dpi)); + return -1; + } + const QFileInfo info(pdfPath); + if (!info.isFile()) { + log(QStringLiteral("qet.addPdfPage: '%1' is not a file").arg(pdfPath)); + return -1; + } + + QPdfDocument document; + document.load(pdfPath); + if (document.status() != QPdfDocument::Status::Ready) { + log(QStringLiteral("qet.addPdfPage: '%1' could not be loaded as a PDF").arg(pdfPath)); + return -1; + } + const int pageCount = document.pageCount(); + if (pageNumber < 1 || pageNumber > pageCount) { + log(QStringLiteral("qet.addPdfPage: '%1' has %2 page(s), no page %3") + .arg(pdfPath).arg(pageCount).arg(pageNumber)); + return -1; + } + const int pageIndex = pageNumber - 1; + + // PDF point = 1/72 inch, same conversion PdfPagesDialog applies. + const QSizeF pageSize = document.pagePointSize(pageIndex); + const int pixelWidth = qRound((pageSize.width() / 72.0) * dpi); + const int pixelHeight = qRound((pageSize.height() / 72.0) * dpi); + if (pixelWidth <= 0 || pixelHeight <= 0) { + log(QStringLiteral("qet.addPdfPage: could not determine page %1's size").arg(pageNumber)); + return -1; + } + + const QImage rendered = document.render(pageIndex, QSize(pixelWidth, pixelHeight)); + if (rendered.isNull()) { + log(QStringLiteral("qet.addPdfPage: page %1 could not be rendered").arg(pageNumber)); + return -1; + } + + // A transparent PDF page would otherwise composite onto whatever is + // under it on the folio, unlike every other placed image. + QImage background(rendered.size(), QImage::Format_ARGB32_Premultiplied); + background.fill(Qt::white); + QPainter painter(&background); + painter.drawImage(0, 0, rendered); + painter.end(); + + Diagram *diagram = diagrams.at(folioIndex); + auto *item = new DiagramImageItem(QPixmap::fromImage(background)); + diagram->undoStack().push(new AddGraphicsObjectCommand(item, diagram, QPointF(x, y))); + return sortedImages(folioIndex).indexOf(item); +#endif +} + +namespace { +QString textSourceName(DynamicElementTextItem::TextFrom from) +{ + switch (from) { + case DynamicElementTextItem::ElementInfo: return QStringLiteral("info"); + case DynamicElementTextItem::CompositeText: return QStringLiteral("composite"); + default: return QStringLiteral("text"); + } +} + +const QStringList &elementTextPropertyNames() +{ + static const QStringList n{QStringLiteral("text"), QStringLiteral("source"), QStringLiteral("info"), + QStringLiteral("composite"), QStringLiteral("frame"), QStringLiteral("size"), + QStringLiteral("x"), QStringLiteral("y"), QStringLiteral("rotation"), QStringLiteral("width")}; + return n; +} +} // namespace + +DynamicElementTextItem *QetScriptApi::findElementText(int folioIndex, const QString &elementUuid, + int textIndex, const QString &caller) const +{ + Element *element = findElement(folioIndex, elementUuid); + if (!element) return nullptr; + const QList list = element->dynamicTextItems(); + if (textIndex < 0 || textIndex >= list.count()) { + const_cast(this)->log( + QStringLiteral("qet.%1: %2 has %3 text field(s), no index %4") + .arg(caller, element->name()).arg(list.count()).arg(textIndex)); + return nullptr; + } + return list.at(textIndex); +} + +QStringList QetScriptApi::elementTexts(int folioIndex, const QString &elementUuid) const +{ + QStringList out; + Element *element = findElement(folioIndex, elementUuid); + if (!element) return out; + const QList list = element->dynamicTextItems(); + for (int i = 0 ; i < list.count() ; ++i) + { + DynamicElementTextItem *t = list.at(i); + const QString source = textSourceName(t->textFrom()); + QString what; + if (t->textFrom() == DynamicElementTextItem::ElementInfo) what = QStringLiteral(" info='%1'").arg(t->infoName()); + else if (t->textFrom() == DynamicElementTextItem::CompositeText) what = QStringLiteral(" composite='%1'").arg(t->compositeText()); + out << QStringLiteral("%1: source=%2%3 shows='%4' at (%5, %6) size=%7") + .arg(i).arg(source, what, t->toPlainText()) + .arg(t->pos().x()).arg(t->pos().y()).arg(t->font().pointSizeF()); + } + return out; +} + +QString QetScriptApi::elementTextProperty(int folioIndex, const QString &elementUuid, + int textIndex, const QString &property) const +{ + DynamicElementTextItem *t = findElementText(folioIndex, elementUuid, textIndex, + QStringLiteral("elementTextProperty")); + if (!t) return QString(); + if (property == QLatin1String("text")) return t->text(); + if (property == QLatin1String("shows")) return t->toPlainText(); + if (property == QLatin1String("source")) return textSourceName(t->textFrom()); + if (property == QLatin1String("info")) return t->infoName(); + if (property == QLatin1String("composite")) return t->compositeText(); + if (property == QLatin1String("frame")) return t->frame() ? QStringLiteral("true") : QStringLiteral("false"); + if (property == QLatin1String("size")) return QString::number(t->font().pointSizeF()); + if (property == QLatin1String("x")) return QString::number(t->pos().x()); + if (property == QLatin1String("y")) return QString::number(t->pos().y()); + if (property == QLatin1String("rotation")) return QString::number(t->rotation()); + if (property == QLatin1String("width")) return QString::number(t->textWidth()); + return QString(); +} + +/** + @brief QetScriptApi::addElementText + Add a text field to a symbol, through AddElementTextCommand as the + element-texts editor does. + @param source "text" (value is the string shown), "info" (value is an + information key such as "label", and the field then follows that key) or + "composite" (value is a formula) + @return the field's index in elementTexts(), or -1 +*/ +int QetScriptApi::addElementText(int folioIndex, const QString &elementUuid, + const QString &source, const QString &value, + double x, double y) +{ + if (!m_project) return -1; + const QString caller = QStringLiteral("addElementText"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return -1; + } + Element *element = findElement(folioIndex, elementUuid); + if (!element) return -1; + + auto *item = new DynamicElementTextItem(element); + if (source == QLatin1String("text")) { + item->setTextFrom(DynamicElementTextItem::UserText); + item->setText(value); + } else if (source == QLatin1String("info")) { + if (!QETInformation::elementInfoKeys().contains(value)) { + log(QStringLiteral("qet.%1: '%2' is not an element information key").arg(caller, value)); + delete item; + return -1; + } + item->setTextFrom(DynamicElementTextItem::ElementInfo); + item->setInfoName(value); + } else if (source == QLatin1String("composite")) { + item->setTextFrom(DynamicElementTextItem::CompositeText); + item->setCompositeText(value); + } else { + log(QStringLiteral("qet.%1: unknown source '%2'; expected text, info or composite").arg(caller, source)); + delete item; + return -1; + } + item->setPos(x, y); + m_project->undoStack()->push(new AddElementTextCommand(element, item)); + return element->dynamicTextItems().indexOf(item); +} + +/** + @brief QetScriptApi::setElementTextProperty + Change one aspect of a symbol's text field through QPropertyUndoCommand on + the item's own properties, the way the element-texts editor does. + Properties: text, source (text|info|composite), info, composite, frame + (true|false), size (points), x, y (in the element's coordinates), + rotation, width. +*/ +bool QetScriptApi::setElementTextProperty(int folioIndex, const QString &elementUuid, + int textIndex, const QString &property, + const QString &value) +{ + if (!m_project) return false; + const QString caller = QStringLiteral("setElementTextProperty"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + if (!elementTextPropertyNames().contains(property)) { + log(QStringLiteral("qet.%1: unknown property '%2'; expected one of %3") + .arg(caller, property, elementTextPropertyNames().join(QStringLiteral(", ")))); + return false; + } + DynamicElementTextItem *t = findElementText(folioIndex, elementUuid, textIndex, caller); + if (!t) return false; + + auto number = [&](double &out, bool positive) { + bool ok = false; + out = value.toDouble(&ok); + if (!ok || (positive && out <= 0)) { + log(QStringLiteral("qet.%1: '%2' is not a valid %3").arg(caller, value, property)); + return false; + } + return true; + }; + + const char *qt_property = nullptr; + QVariant old_value, new_value; + double d = 0; + + if (property == QLatin1String("text")) { qt_property = "text"; old_value = t->text(); new_value = value; } + else if (property == QLatin1String("info")) { + if (!QETInformation::elementInfoKeys().contains(value)) { + log(QStringLiteral("qet.%1: '%2' is not an element information key").arg(caller, value)); + return false; + } + qt_property = "infoName"; old_value = t->infoName(); new_value = value; + } + else if (property == QLatin1String("composite")) { qt_property = "compositeText"; old_value = t->compositeText(); new_value = value; } + else if (property == QLatin1String("source")) { + DynamicElementTextItem::TextFrom from; + if (value == QLatin1String("text")) from = DynamicElementTextItem::UserText; + else if (value == QLatin1String("info")) from = DynamicElementTextItem::ElementInfo; + else if (value == QLatin1String("composite")) from = DynamicElementTextItem::CompositeText; + else { log(QStringLiteral("qet.%1: unknown source '%2'").arg(caller, value)); return false; } + qt_property = "textFrom"; old_value = QVariant::fromValue(t->textFrom()); new_value = QVariant::fromValue(from); + } + else if (property == QLatin1String("frame")) { + const QString v = value.toLower(); + if (v != QLatin1String("true") && v != QLatin1String("false")) { + log(QStringLiteral("qet.%1: frame is true or false, not '%2'").arg(caller, value)); + return false; + } + qt_property = "frame"; old_value = t->frame(); new_value = (v == QLatin1String("true")); + } + else if (property == QLatin1String("size")) { + if (!number(d, true)) return false; + QFont f = t->font(); f.setPointSizeF(d); + qt_property = "font"; old_value = t->font(); new_value = f; + } + else if (property == QLatin1String("x") || property == QLatin1String("y")) { + if (!number(d, false)) return false; + QPointF p = t->pos(); + (property == QLatin1String("x") ? p.rx() : p.ry()) = d; + qt_property = "pos"; old_value = t->pos(); new_value = p; + } + else if (property == QLatin1String("rotation")) { if (!number(d, false)) return false; qt_property = "rotation"; old_value = t->rotation(); new_value = d; } + else { if (!number(d, false)) return false; qt_property = "textWidth"; old_value = t->textWidth(); new_value = d; } + + if (old_value == new_value) return true; + auto *cmd = new QPropertyUndoCommand(t, qt_property, old_value, new_value); + cmd->setText(QObject::tr("Modifier un texte d'élément")); + m_project->undoStack()->push(cmd); + return true; +} + +bool QetScriptApi::deleteElementText(int folioIndex, const QString &elementUuid, int textIndex) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.deleteElementText: project is read-only")); + return false; + } + DynamicElementTextItem *t = findElementText(folioIndex, elementUuid, textIndex, + QStringLiteral("deleteElementText")); + if (!t) return false; + Diagram *diagram = m_project->diagrams().at(folioIndex); + DiagramContent content; + content.m_element_texts << t; + diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, content)); + return true; +} + +/** + @brief QetScriptApi::useElementAutoNum + Make an element numbering context the project's current one, as choosing + it in the auto-numbering panel does. An empty name clears the selection. +*/ +bool QetScriptApi::useElementAutoNum(const QString &name) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.useElementAutoNum: project is read-only")); + return false; + } + if (!name.isEmpty() && !m_project->elementAutoNum().contains(name)) { + log(QStringLiteral("qet.useElementAutoNum: no element auto-numbering named '%1'").arg(name)); + return false; + } + m_project->setCurrrentElementAutonum(name); + return true; +} + +/** + @brief QetScriptApi::numberElement + Give one element its label from the current element numbering context, + through Element::setUpFormula() -- the call the "add element" tool makes + right after placing one. + + Refused where setUpFormula() would do nothing, rather than reporting + success: a slave or a report takes its label from its master, and with no + current context there is no formula to apply. +*/ +bool QetScriptApi::numberElement(int folioIndex, const QString &elementUuid) +{ + if (!m_project) return false; + const QString caller = QStringLiteral("numberElement"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + Element *element = findElement(folioIndex, elementUuid); + if (!element) return false; + if (element->linkType() == Element::Slave || (element->linkType() & Element::AllReport)) { + log(QStringLiteral("qet.%1: a slave or a report takes its label from its master").arg(caller)); + return false; + } + if (m_project->elementAutoNumCurrentFormula().isEmpty()) { + log(QStringLiteral("qet.%1: no element auto-numbering is selected (see useElementAutoNum)").arg(caller)); + return false; + } + + // setUpFormula() writes the label straight into the element's + // information and pushes only the counter's advance onto the undo stack. + // For a new element that is fine -- undoing the placement removes it -- + // but for one already on the folio, one undo rolled the counter back and + // left the label behind (measured: c3 stayed "K3" while the counter went + // back to expecting K3), so the next numbering would repeat a label the + // counter had forgotten. So take the label it computed, put the + // information back, and push the change as a command of its own inside + // the same macro as the counter, making both one step. + const DiagramContext old_info = element->elementInformations(); + QUndoStack *stack = m_project->undoStack(); + stack->beginMacro(QObject::tr("Numéroter automatiquement un élément")); + element->setUpFormula(true); + const DiagramContext new_info = element->elementInformations(); + if (new_info.value(QETInformation::ELMT_LABEL) == old_info.value(QETInformation::ELMT_LABEL) + && new_info.value(QStringLiteral("formula")) == old_info.value(QStringLiteral("formula"))) { + stack->endMacro(); + return false; + } + element->setElementInformations(old_info); + stack->push(new ChangeElementInformationCommand(element, old_info, new_info)); + stack->endMacro(); + return true; +} + +/** + @brief QetScriptApi::duplicateElements + Copy elements, and the conductors between them, to another place. + + Mirrors DiagramView::copy() and DiagramView::paste(): the copy is + Diagram::toXml(false, true) of the diagram's @em selection, so the named + elements are selected for the moment and the previous selection restored + before returning; the paste is Diagram::fromXml() at the position, + followed by one PasteDiagramCommand so it is a single undo step. + @return the uuids of the new elements, or an empty list on failure +*/ +QStringList QetScriptApi::duplicateElements(int fromFolioIndex, const QStringList &elementUuids, + int toFolioIndex, double x, double y) +{ + QStringList created; + if (!m_project) return created; + const QString caller = QStringLiteral("duplicateElements"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return created; + } + const QList diagrams = m_project->diagrams(); + if (fromFolioIndex < 0 || fromFolioIndex >= diagrams.count() + || toFolioIndex < 0 || toFolioIndex >= diagrams.count()) { + log(QStringLiteral("qet.%1: folio index out of range").arg(caller)); + return created; + } + if (elementUuids.isEmpty()) { + log(QStringLiteral("qet.%1: no elements named").arg(caller)); + return created; + } + Diagram *source = diagrams.at(fromFolioIndex); + Diagram *target = diagrams.at(toFolioIndex); + + QList chosen; + for (const QString &uuid : elementUuids) { + Element *e = findElement(fromFolioIndex, uuid); + if (!e) { + log(QStringLiteral("qet.%1: no element %2 on folio %3").arg(caller, uuid).arg(fromFolioIndex)); + return created; + } + chosen << e; + } + + // Copying works on the selection, so borrow it and give it back. + const QList previous = source->selectedItems(); + source->clearSelection(); + for (Element *e : std::as_const(chosen)) e->setSelected(true); + const QDomDocument document = source->toXml(false, true); + source->clearSelection(); + for (QGraphicsItem *item : previous) item->setSelected(true); + + DiagramContent pasted; + QDomDocument copy = document; + target->fromXml(copy, QPointF(x, y), false, &pasted); + if (!pasted.count()) { + log(QStringLiteral("qet.%1: nothing was pasted").arg(caller)); + return created; + } + target->clearSelection(); + target->undoStack().push(new PasteDiagramCommand(target, pasted)); + + // The pasted list comes back in the scene's order, not the order the + // caller asked in: requesting the elements at x = 700, 100, 900 returned + // the copies of 100, 700, 900. A caller pairing copies with sources by + // index would be wired to the wrong ones with no error. A paste is a pure + // translation, so sorting sources and copies by position pairs them + // correctly, and the result can be returned in the request's order. + auto by_position = [](Element *a, Element *b) { + const QPointF pa = a->pos(), pb = b->pos(); + if (pa.y() != pb.y()) return pa.y() < pb.y(); + return pa.x() < pb.x(); + }; + QList sources_sorted = chosen; + std::stable_sort(sources_sorted.begin(), sources_sorted.end(), by_position); + QList copies_sorted = pasted.m_elements; + std::stable_sort(copies_sorted.begin(), copies_sorted.end(), by_position); + if (copies_sorted.count() != sources_sorted.count()) { + // A paste that produced a different number of elements than were + // copied cannot be paired, and a wrong pairing is worse than none. + log(QStringLiteral("qet.%1: %2 element(s) were copied but %3 pasted; " + "cannot say which copy is which").arg(caller) + .arg(sources_sorted.count()).arg(copies_sorted.count())); + for (Element *e : std::as_const(copies_sorted)) created << e->uuid().toString(); + return created; + } + for (Element *source_element : std::as_const(chosen)) { + created << copies_sorted.at(sources_sorted.indexOf(source_element))->uuid().toString(); + } + return created; +} + +bool QetScriptApi::setProjectTitle(const QString &title) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.setProjectTitle: project is read-only")); + return false; + } + m_project->setTitle(title); + return m_project->title() == title; +} + +namespace { +const QStringList &folioBorderNames() +{ + static const QStringList n{QStringLiteral("columns"), QStringLiteral("column-width"), + QStringLiteral("display-columns"), QStringLiteral("rows"), QStringLiteral("row-height"), + QStringLiteral("display-rows")}; + return n; +} +} // namespace + +QString QetScriptApi::folioBorder(int folioIndex, const QString &property) const +{ + if (!m_project) return QString(); + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return QString(); + const BorderProperties b = diagrams.at(folioIndex)->border_and_titleblock.exportBorder(); + if (property == QLatin1String("columns")) return QString::number(b.columns_count); + if (property == QLatin1String("column-width")) return QString::number(b.columns_width); + if (property == QLatin1String("display-columns")) return b.display_columns ? QStringLiteral("true") : QStringLiteral("false"); + if (property == QLatin1String("rows")) return QString::number(b.rows_count); + if (property == QLatin1String("row-height")) return QString::number(b.rows_height); + if (property == QLatin1String("display-rows")) return b.display_rows ? QStringLiteral("true") : QStringLiteral("false"); + return QString(); +} + +/** + @brief QetScriptApi::setFolioBorder + Change one field of a folio's frame through ChangeBorderCommand, as the + folio properties panel does. Counts are whole numbers from 1 to 99 and + sizes are from 1 to 1000. The panel's own upper limits are 99 and 1000; + its lower limit is 0, which is deliberately not offered -- a grid with + no columns, or columns of no width, has no use here and 0 was not + tested, so it is left refused rather than assumed safe. The extremes + that are offered (99 x 99 cells, widths from 1 to 1000) were exported to + PNG and did not hang or crash. +*/ +bool QetScriptApi::setFolioBorder(int folioIndex, const QString &property, const QString &value) +{ + if (!m_project) return false; + const QString caller = QStringLiteral("setFolioBorder"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return false; + } + if (!folioBorderNames().contains(property)) { + log(QStringLiteral("qet.%1: unknown property '%2'; expected one of %3") + .arg(caller, property, folioBorderNames().join(QStringLiteral(", ")))); + return false; + } + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return false; + Diagram *diagram = diagrams.at(folioIndex); + + const BorderProperties old_b = diagram->border_and_titleblock.exportBorder(); + BorderProperties new_b = old_b; + bool ok = false; + if (property == QLatin1String("columns") || property == QLatin1String("rows")) { + const int n = value.toInt(&ok); + if (!ok || n < 1 || n > 99) { + log(QStringLiteral("qet.%1: %2 must be a whole number from 1 to 99, not '%3'").arg(caller, property, value)); + return false; + } + (property == QLatin1String("columns") ? new_b.columns_count : new_b.rows_count) = n; + } else if (property == QLatin1String("column-width") || property == QLatin1String("row-height")) { + const double d = value.toDouble(&ok); + if (!ok || d < 1 || d > 1000) { + log(QStringLiteral("qet.%1: %2 must be a number from 1 to 1000, not '%3'").arg(caller, property, value)); + return false; + } + (property == QLatin1String("column-width") ? new_b.columns_width : new_b.rows_height) = d; + } else { + const QString v = value.toLower(); + if (v != QLatin1String("true") && v != QLatin1String("false")) { + log(QStringLiteral("qet.%1: %2 is true or false, not '%3'").arg(caller, property, value)); + return false; + } + (property == QLatin1String("display-columns") ? new_b.display_columns : new_b.display_rows) = (v == QLatin1String("true")); + } + if (new_b == old_b) return true; + m_project->undoStack()->push(new ChangeBorderCommand(diagram, old_b, new_b)); + return true; +} + +/** + @brief QetScriptApi::elementGeometry + Where an element is: x and y are its origin (what setElementPosition() + sets), rotation is in degrees, and left/top/right/bottom are the box it + occupies on the folio, its drawn extent rather than its origin -- which + differs from it by the hotspot and, for a rotated element, is the + rotated extent. Empty if the element is not found. +*/ +QVariantMap QetScriptApi::elementGeometry(int folioIndex, const QString &elementUuid) const +{ + QVariantMap g; + Element *element = findElement(folioIndex, elementUuid); + if (!element) return g; + const QRectF box = element->sceneBoundingRect(); + g.insert(QStringLiteral("x"), element->pos().x()); + g.insert(QStringLiteral("y"), element->pos().y()); + g.insert(QStringLiteral("rotation"), element->rotation()); + g.insert(QStringLiteral("left"), box.left()); + g.insert(QStringLiteral("top"), box.top()); + g.insert(QStringLiteral("right"), box.right()); + g.insert(QStringLiteral("bottom"), box.bottom()); + return g; +} + +/** + @brief QetScriptApi::insertFolio + Add a folio at a position (0 is first, folioCount() is last) through + QETProject::addNewDiagram(pos) -- undoable. The position is checked + here: QETProject::addDiagram() hands it straight to QList::insert(), + which is undefined past the end. + @return the new folio's index, or -1 +*/ +int QetScriptApi::insertFolio(int position) +{ + if (!m_project) return -1; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.insertFolio: project is read-only")); + return -1; + } + if (position < 0 || position > m_project->diagrams().count()) { + log(QStringLiteral("qet.insertFolio: position %1 is outside 0..%2") + .arg(position).arg(m_project->diagrams().count())); + return -1; + } + Diagram *diagram = m_project->addNewDiagram(position); + if (!diagram) return -1; + return m_project->diagrams().indexOf(diagram); +} + +/** + @brief QetScriptApi::titleBlockTemplates + Every title block template this project can use right now (embedded) + or could embed and then use (common, company, custom), each name + suffixed with which. A name can appear more than once, under different + sources -- embedding does not remove it from where it came from, and a + project can have its own embedded copy of a name the common collection + also has, which then shadows it (Diagram::setTitleBlockTemplate() only + ever looks in the embedded one). +*/ +QStringList QetScriptApi::titleBlockTemplates() const +{ + QStringList list; + if (!m_project) return list; + auto describe = [&list](TitleBlockTemplatesCollection *c, const QString &source) { + if (!c) return; + const QStringList names = c->templates(); + for (const QString &n : names) { + list << QStringLiteral("%1 (%2)").arg(n, source); + } + }; + describe(m_project->embeddedTitleBlockTemplatesCollection(), QStringLiteral("embedded")); + describe(QETApp::commonTitleBlockTemplatesCollection(), QStringLiteral("common")); + describe(QETApp::companyTitleBlockTemplatesCollection(), QStringLiteral("company")); + describe(QETApp::customTitleBlockTemplatesCollection(), QStringLiteral("custom")); + return list; +} + +/** + @brief QetScriptApi::embedTitleBlockTemplate + Copy a template's XML into the project's own embedded collection, from + the first of common/company/custom that has it -- the same + get/setTemplateXmlDescription() round trip the template editor itself + uses to save one, not scripting-specific code. A no-op, reporting + success, if the project already has an embedded copy of that name: the + embedded one is what Diagram::setTitleBlockTemplate() will use either + way, so re-embedding would only discard a project-specific edit to it + for no reason. +*/ +bool QetScriptApi::embedTitleBlockTemplate(const QString &name) +{ + if (!m_project) return false; + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.embedTitleBlockTemplate: project is read-only")); + return false; + } + if (name.isEmpty()) { + log(QStringLiteral("qet.embedTitleBlockTemplate: empty name")); + return false; + } + auto *embedded = m_project->embeddedTitleBlockTemplatesCollection(); + if (embedded->templates().contains(name)) return true; + + const QList sources{ + QETApp::commonTitleBlockTemplatesCollection(), + QETApp::companyTitleBlockTemplatesCollection(), + QETApp::customTitleBlockTemplatesCollection()}; + for (TitleBlockTemplatesCollection *source : sources) + { + if (!source || !source->templates().contains(name)) continue; + const QDomElement xml = source->getTemplateXmlDescription(name); + if (xml.isNull()) continue; + return embedded->setTemplateXmlDescription(name, xml); + } + log(QStringLiteral("qet.embedTitleBlockTemplate: no collection has a template named '%1'").arg(name)); + return false; +} + int QetScriptApi::addFolio() { if (!m_project) return -1; @@ -1336,6 +3703,374 @@ bool QetScriptApi::canRedo() const return m_project && m_project->undoStack()->canRedo(); } +/** + @brief QetScriptApi::searchAndReplace + Find and replace a pattern within one text field, across every folio, + as a single undo step -- the project-wide, single-action counterpart + to reading a value with elementInfo()/conductorProperty()/ + textContent(), computing a new one in the script itself, and writing + it back one item at a time (each of those pushes its own undo step; a + JS loop doing that across hundreds of items would leave hundreds of + entries on the undo stack instead of one). + + This is NOT QET's own "Search and replace" panel, whose replace model + is a batch overwrite-with-sentinel template (each field left empty + keeps the original, a magic string clears it, anything else replaces + it outright) built for picking items from a tree interactively -- a + poor fit for a script, which can already express "which items" + precisely without a GUI tree. This does what the name plainly says + instead: an actual substring or regex replace within the field's + current value, changing only the items where the pattern is found. + + @param kind "element_info" (field is an information key, e.g. + "label"), "conductor" (field is one of setConductorProperty()'s + property names -- replacing on one conductor of a potential updates + the whole potential, the same as setConductorProperty() always does), + or "text" (independent texts; field is ignored) + @param field the information key or conductor property; ignored for + "text" + @param pattern the text (or, if useRegex, the regular expression) to + search for; never matches an empty field, so nothing already blank + is ever touched + @param replacement the replacement text; with useRegex, \1 etc. in + it refer to pattern's capturing groups, the same as + QString::replace(QRegularExpression, QString) + @return the number of items actually changed, or -1 on a usage error + (nothing was touched) +*/ +int QetScriptApi::searchAndReplace(const QString &kind, const QString &field, + const QString &pattern, const QString &replacement, + bool useRegex, bool caseSensitive) +{ + if (!m_project) return -1; + const QString caller = QStringLiteral("searchAndReplace"); + if (m_project->isReadOnly()) { + log(QStringLiteral("qet.%1: project is read-only").arg(caller)); + return -1; + } + if (pattern.isEmpty()) { + log(QStringLiteral("qet.%1: pattern must not be empty").arg(caller)); + return -1; + } + if (kind != QLatin1String("element_info") && kind != QLatin1String("conductor") + && kind != QLatin1String("text")) + { + log(QStringLiteral("qet.%1: unknown kind '%2'; expected element_info, " + "conductor or text").arg(caller, kind)); + return -1; + } + if (kind == QLatin1String("element_info") && field.isEmpty()) { + log(QStringLiteral("qet.%1: element_info needs a field (information key)").arg(caller)); + return -1; + } + if (kind == QLatin1String("conductor") && !conductorPropertyNames().contains(field)) { + log(QStringLiteral("qet.%1: unknown conductor property '%2'; expected one of %3") + .arg(caller, field, conductorPropertyNames().join(QStringLiteral(", ")))); + return -1; + } + + QRegularExpression re; + if (useRegex) { + const auto opts = caseSensitive ? QRegularExpression::NoPatternOption + : QRegularExpression::CaseInsensitiveOption; + re = QRegularExpression(pattern, opts); + if (!re.isValid()) { + log(QStringLiteral("qet.%1: '%2' is not a valid regular expression: %3") + .arg(caller, pattern, re.errorString())); + return -1; + } + } + const Qt::CaseSensitivity cs = caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive; + + // nullopt when the pattern is not found -- distinguishes "no match" + // from "matched but happened to produce the same text". + auto replaced = [&](const QString ¤t) -> std::optional { + if (current.isEmpty()) return std::nullopt; + if (useRegex) { + if (!current.contains(re)) return std::nullopt; + QString updated = current; + updated.replace(re, replacement); + return updated; + } + if (!current.contains(pattern, cs)) return std::nullopt; + QString updated = current; + updated.replace(pattern, replacement, cs); + return updated; + }; + + const QList diagrams = m_project->diagrams(); + + // Applies the search across every folio, either just counting matches + // (dryRun) or actually writing them back. Run twice rather than + // tracked with a flag on every write call: QUndoStack::endMacro() + // still pushes an empty macro when nothing was added to it (it does + // not silently discard one the way an empty QUndoCommand child list + // might suggest), which would leave a no-op "Rechercher et remplacer" + // entry on the undo stack for a run that changed nothing -- easy to + // trigger (any search with zero matches) and confusing once there + // (undoing it visibly does nothing). Counting matches first, and + // never touching the undo stack at all when that count is zero, is + // simpler than adding an after-the-fact "was anything pushed" check. + auto apply = [&](bool dryRun) -> int { + int count = 0; + for (int f = 0; f < diagrams.count(); ++f) { + if (kind == QLatin1String("element_info")) { + DiagramContent content(diagrams.at(f), false); + for (Element *elmt : std::as_const(content.m_elements)) { + const QString current = elmt->elementInformations().value(field).toString(); + const auto updated = replaced(current); + if (!updated) continue; + if (dryRun || setInfoKey(f, elmt->uuid().toString(), field, *updated, caller)) + ++count; + } + } else if (kind == QLatin1String("conductor")) { + DiagramContent content(diagrams.at(f), false); + const QList all = content.conductors(DiagramContent::AnyConductor); + for (Conductor *c : all) { + const QString current = conductorPropertyValue(c->properties(), field); + const auto updated = replaced(current); + if (!updated) continue; + // setConductorProperty() applies to the whole potential, so a + // sibling conductor processed later in this same loop will + // already read the new value above and find no more match -- + // each potential is touched once, not once per conductor in + // it. On the dry run nothing is written, so this dedup + // does not happen there; the dry run's count is only ever + // used as a nonzero/zero test, not compared to the real one. + // Prefer whichever terminal carries exactly this one + // conductor: a hub terminal (several conductors meeting + // at one point, as in a star topology) is ambiguous -- + // findConductor() (via setConductorProperty()) refuses + // to address a conductor through it, the same as + // conductorProperty()/setConductorProperty() called + // directly would. Skip only if genuinely neither end is + // addressable; the rest of the potential is still + // covered by whichever other conductor in it has an + // unambiguous terminal. + Terminal *t = c->terminal1; + if (!t || t->conductors().count() != 1) t = c->terminal2; + if (!t || t->conductors().count() != 1 || !t->parentElement()) continue; + Element *elmt = t->parentElement(); + const int terminal_index = elmt->terminals().indexOf(t); + if (dryRun || setConductorProperty(f, elmt->uuid().toString(), + terminal_index, field, *updated)) + ++count; + } + } else { + const QList list = sortedTexts(f); + for (int i = 0; i < list.count(); ++i) { + const auto updated = replaced(list.at(i)->toPlainText()); + if (!updated) continue; + if (dryRun || setTextContent(f, i, *updated)) ++count; + } + } + } + return count; + }; + + if (apply(/*dryRun=*/true) == 0) return 0; + + m_project->undoStack()->beginMacro(QObject::tr("Rechercher et remplacer")); + const int changed = apply(/*dryRun=*/false); + m_project->undoStack()->endMacro(); + return changed; +} + +/** + @brief QetScriptApi::checkContinuity + Structural electrical checks against the live scene graph -- Terminal/ + Conductor/relatedPotentialConductors() -- rather than a heuristic read + of the saved XML the way qet_check's Python side works. Two checks: + + 1. unconnected_terminal (severity "info"): a terminal with no + conductor at all. Reported at low confidence deliberately -- an + unconnected terminal is routine (a spare relay contact, an unused + optional pin), not necessarily a mistake, so this is a prompt to + look, not a claim that something is wrong. + + 2. potential_mismatch (severity "error"): two conductors that + electrically belong to the same potential (connected transitively + through shared terminals, following bridged terminal strips and + linked report elements the same way setConductorProperty() does) + but disagree on num, conductor_color, conductor_section, function, + bus or cable. QElectroTech's own setConductorProperty() always + writes every member of a potential identically, so any divergence + found here did not come from this API or the GUI's equivalent + action -- it came from hand-edited XML, a legacy file, or an + external tool, and it is a real defect: two wire numbers on what + is electrically one node is exactly the kind of thing a human + reading the schematic would get wrong from. + + What this deliberately does NOT check, because QElectroTech's own + terminal data model does not carry the information a real check would + need: pin electrical direction/power conflicts (no terminal in this + model is marked input/output/power the way a KiCad pin is -- only + Generic/Inner/Outer/No/Nc/Common, which describe contact role within + one relay/switch, not signal direction), and short circuits between a + contact's No and Nc terminals sharing a Common (would need per- + contact-group semantics this does not attempt). Treat this as + continuity/consistency checking, not full ERC. + + @param folioIndex a single folio, or -1 for the whole project + @return a list of {kind, severity, folio, message, ...} objects; + kind-specific keys: unconnected_terminal has element/elementLabel/ + terminal/terminalName, potential_mismatch has property/values +*/ +QVariantList QetScriptApi::checkContinuity(int folioIndex) +{ + QVariantList findings; + if (!m_project) return findings; + const QList diagrams = m_project->diagrams(); + if (folioIndex >= diagrams.count()) { + log(QStringLiteral("qet.checkContinuity: folio %1 does not exist (%2 folio(s))") + .arg(folioIndex).arg(diagrams.count())); + return findings; + } + + for (int f = 0; f < diagrams.count(); ++f) { + if (folioIndex >= 0 && f != folioIndex) continue; + DiagramContent content(diagrams.at(f), false); + for (Element *elmt : std::as_const(content.m_elements)) { + const QList terminals = elmt->terminals(); + for (int ti = 0; ti < terminals.count(); ++ti) { + Terminal *t = terminals.at(ti); + if (!t->conductors().isEmpty()) continue; + QVariantMap finding; + finding.insert(QStringLiteral("kind"), QStringLiteral("unconnected_terminal")); + finding.insert(QStringLiteral("severity"), QStringLiteral("info")); + finding.insert(QStringLiteral("folio"), f); + finding.insert(QStringLiteral("element"), elmt->uuid().toString()); + finding.insert(QStringLiteral("elementLabel"), elmt->actualLabel()); + finding.insert(QStringLiteral("terminal"), ti); + finding.insert(QStringLiteral("terminalName"), t->name()); + finding.insert(QStringLiteral("message"), + QStringLiteral("terminal %1 ('%2') of %3 has no conductor") + .arg(ti).arg(t->name(), elmt->actualLabel())); + findings << finding; + } + } + } + + // "color"/"style" are the rendered pen -- what a person actually sees + // as "this wire is blue" -- distinct from "conductor_color" + // (ConductorProperties::m_wire_color), a separate free-text + // documentation field that is typically empty and says nothing about + // how the wire is drawn. A mismatch check that only covered the + // documentation field would miss exactly the visible kind of + // inconsistency a person would report (issue #974: the two halves of + // one folio-link conductor drawn in different colours). + static const QStringList checked_properties = { + QStringLiteral("num"), QStringLiteral("color"), QStringLiteral("style"), + QStringLiteral("conductor_color"), QStringLiteral("conductor_section"), + QStringLiteral("function"), QStringLiteral("bus"), QStringLiteral("cable")}; + + QSet visited; + for (int f = 0; f < diagrams.count(); ++f) { + if (folioIndex >= 0 && f != folioIndex) continue; + DiagramContent content(diagrams.at(f), false); + const QList all = content.conductors(DiagramContent::AnyConductor); + for (Conductor *c : all) { + if (visited.contains(c)) continue; + QSet potential = c->relatedPotentialConductors(true); + potential << c; + visited += potential; + if (potential.count() < 2) continue; + + QHash> distinct_values; + for (Conductor *pc : std::as_const(potential)) { + for (const QString &prop : checked_properties) { + distinct_values[prop].insert(conductorPropertyValue(pc->properties(), prop)); + } + } + for (const QString &prop : checked_properties) { + if (distinct_values.value(prop).count() <= 1) continue; + QStringList values(distinct_values.value(prop).begin(), + distinct_values.value(prop).end()); + values.sort(); + QVariantMap finding; + finding.insert(QStringLiteral("kind"), QStringLiteral("potential_mismatch")); + finding.insert(QStringLiteral("severity"), QStringLiteral("error")); + finding.insert(QStringLiteral("folio"), f); + finding.insert(QStringLiteral("property"), prop); + finding.insert(QStringLiteral("values"), values); + finding.insert(QStringLiteral("message"), + QStringLiteral("conductors on the same electrical potential disagree " + "on %1: %2").arg(prop, values.join(QStringLiteral(", ")))); + findings << finding; + } + } + } + + // report_link_mismatch (warning, not error): a next_report/ + // previous_report pair -- QElectroTech's folio-jump-arrow links, + // e.g. "Folio suivant"/"Folio précédent" -- is meant to represent one + // wire continuing across a folio boundary, but LinkElementCommand:: + // isLinkable() only ever checks type and freedom (see its own doc + // comment), never conductor properties. Nothing in QElectroTech + // copies one side's colour/style/num onto the other when the link is + // made, or keeps them in sync afterwards, so this is a real, + // unenforced gap rather than something a script or the GUI could + // have broken -- hence "warning", not "error" the way + // potential_mismatch is (which the app's own edits can never + // produce, so any occurrence there is definitely external tampering). + // Terminals are matched by index between the two linked elements: a + // report pair is authored as matching symbols carrying the same set + // of wires in the same declared order, the same convention terminal + // indexing already follows everywhere else in this API. + QSet visited_report; + for (int f = 0; f < diagrams.count(); ++f) { + if (folioIndex >= 0 && f != folioIndex) continue; + DiagramContent content(diagrams.at(f), false); + for (Element *elmt : std::as_const(content.m_elements)) { + if (elmt->linkType() != Element::NextReport + && elmt->linkType() != Element::PreviousReport) continue; + if (visited_report.contains(elmt)) continue; + + const QList linked = elmt->linkedElements(); + for (Element *other : linked) { + if (visited_report.contains(other)) continue; + visited_report << elmt << other; + + const QList ta = elmt->terminals(); + const QList tb = other->terminals(); + const int n = qMin(ta.count(), tb.count()); + auto *other_diagram = qobject_cast(other->scene()); + const int other_folio = other_diagram ? diagrams.indexOf(other_diagram) : -1; + + for (int i = 0; i < n; ++i) { + const QList ca = ta.at(i)->conductors(); + const QList cb = tb.at(i)->conductors(); + // ambiguous (>1) or unconnected (0) on either side: + // nothing to meaningfully compare + if (ca.count() != 1 || cb.count() != 1) continue; + + for (const QString &prop : checked_properties) { + const QString va = conductorPropertyValue(ca.first()->properties(), prop); + const QString vb = conductorPropertyValue(cb.first()->properties(), prop); + if (va == vb) continue; + QVariantMap finding; + finding.insert(QStringLiteral("kind"), QStringLiteral("report_link_mismatch")); + finding.insert(QStringLiteral("severity"), QStringLiteral("warning")); + finding.insert(QStringLiteral("folio"), f); + finding.insert(QStringLiteral("element"), elmt->uuid().toString()); + finding.insert(QStringLiteral("otherFolio"), other_folio); + finding.insert(QStringLiteral("otherElement"), other->uuid().toString()); + finding.insert(QStringLiteral("terminal"), i); + finding.insert(QStringLiteral("property"), prop); + finding.insert(QStringLiteral("values"), QStringList{va, vb}); + finding.insert(QStringLiteral("message"), + QStringLiteral("folio-link conductor disagrees on %1 across the " + "link: '%2' vs '%3'").arg(prop, va, vb)); + findings << finding; + } + } + } + } + } + + return findings; +} + bool QetScriptApi::selectElement(const QString &elementUuid) { if (!m_project) return false; @@ -1351,6 +4086,21 @@ bool QetScriptApi::selectElement(const QString &elementUuid) return false; } +QStringList QetScriptApi::selectedElements(int folioIndex) const +{ + QStringList list; + if (!m_project) return list; + const QList diagrams = m_project->diagrams(); + if (folioIndex < 0 || folioIndex >= diagrams.count()) return list; + for (QGraphicsItem *item : diagrams.at(folioIndex)->selectedItems()) { + if (item->type() == Element::Type) { + list << static_cast(item)->uuid().toString(); + } + } + list.sort(); + return list; +} + void QetScriptApi::deselectAll(int folioIndex) { if (!m_project) return; diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 2759b7636..5426275db 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -22,6 +22,7 @@ #include #include #include +#include class QETProject; class DiagramView; @@ -30,6 +31,9 @@ class Terminal; class Conductor; class IndependentTextItem; class QetShapeItem; +class DiagramImageItem; +class DynamicElementTextItem; +class QetGraphicsTableItem; /** @brief The QetScriptApi class @@ -93,13 +97,18 @@ class QetShapeItem; Terminals are addressed by their @b index in Element::terminals(), not by uuid, and elementTerminals() prints that indexing so a script - can see what it is about to wire. Terminal uuids look like the + can see what it is about to wire. The index is the terminal's place + in the element's own top-to-bottom, left-to-right ordering, not the + order its definition file lists them. Terminal uuids look like the obvious key and are not one: Terminal::uuid() is a property of the catalog .elmt definition, empty for most of the installed base and, where present, identical across every instance of that element -- so it does not distinguish one placed coil's A1 from another's. - @b Conductor properties and @b cross-references: set a conductor's - number, formula, colour or section, and link a master to a slave or + number, formula, colour or section (and its look: style normal/ + dashed/dashdotted, two-colour mode and second colour, dash size, + line width, text size, whether its number is shown -- all under the + names the .qet file uses for them), and link a master to a slave or one report to another. Both follow the application's own rules rather than writing the field: a conductor property is applied to every conductor of the same electrical potential, which is what the GUI and @@ -123,7 +132,11 @@ class QetShapeItem; its circuit -- a free-standing note, a line, a rectangle, an ellipse -- added with the same AddGraphicsObjectCommand the corresponding GUI tools use, and changed through the plainText/color/rotation - properties those items already publish. + properties those items already publish. A shape's look is set with + setShapeProperty(): color and fill (a colour name, or "none" for no + fill), width, line-style (solid, dashed, dotted, dashdot) and + rotation, through the pen/brush/rotation properties the shape's own + style editor changes. These are addressed by @b index into a listing sorted by position (top to bottom, then left to right), because unlike an element they @@ -150,6 +163,157 @@ class QetShapeItem; depend on. The underlying tables are how the cache happens to be arranged today, and a column may move. tables() lists both so a script can see what it is querying rather than guess. + - @b Terminal @b strips: create a strip, put terminal-type elements on + it, remove it. Strips are addressed by index into terminalStrips(), + which is the project's own order (unlike texts and shapes it is not + re-sorted) and does shift when one is removed. Only elements whose + link type is "terminal" can be added, the same restriction the + editor enforces by construction. + + stripRealTerminals() lists the strip's real terminals -- the actual + wire-ends added by addTerminalToStrip(), one per index -- which + physical position (clamp) each currently sits on and how many + neighbours share it, since that is what groupTerminals() and + bridgeTerminals() address by index into. + + groupTerminals() merges several real terminals onto one physical + position, choosing the receiving position the same way the terminal + strip editor's own "group" button does: the position among the ones + named that already carries the most real terminals, not necessarily + the first one given -- a script asking to group indices [0, 1] is not + guaranteed index 0's position is where they end up. bridgeTerminals() + wires several real terminals together electrically without merging + their positions, refused (TerminalStrip::isBridgeable()) when they + are not all at the same level -- the same check the editor's bridge + button applies, not a rule reimplemented here. sortTerminalStrip() + reorders the strip's physical positions into the canonical order the + editor's own sort button computes. + - @b Tables: a BOM/nomenclature or a summary (table of contents) placed + on a folio, through QetGraphicsTableFactory::create() -- the same + factory call the "add table" menu action makes, minus the modal + AddTableDialog it collects its settings from first. That dialog is + still built here, off-screen and never shown or exec'd: addTable() + calls setTableName() and the query widget's setQuery() on it, the + same as a user filling in the form, and forces its two checkboxes + ("adjust table to folio" and "add a new folio if the table overflows" + it) off regardless of their .ui-file default of checked -- a script + calling addTable() once should create exactly the one table it asked + for, not possibly several spread across folios it never asked to add. + A script that wants either behaviour can resize the result itself or + add its own folio. + + Neither creating nor deleting a table is undoable: + QetGraphicsTableFactory::newTable(), which create() calls, calls + Diagram::addItem() directly, with no undo command of its own, in the + stock "add table" action as much as here -- a pre-existing gap in the + application, not something introduced by this API. Tables are + addressed by index in a position-sorted listing, like texts, shapes + and images. + - @b Auto-numbering: define a named numbering context of kind + "conductor", "element" or "folio", built from parts written + "type[:value[:increase]]" -- types are the ones the auto-numbering + dialog offers (string, unit, ten, hundred, alpha, idfolio, folio, + plant, locmach, elementline, elementcolumn, elementprefix, wrap, + unitfolio, tenfolio, hundredfolio) -- and select which one a folio's + new conductors use. Defining or removing a context is not undoable, + because the application itself does it through direct project calls + and only the counter advance is on the undo stack; the numbering + actually applied to a conductor is. + + For elements, useElementAutoNum() selects the current context and + numberElement() applies it to one element, as the "add element" tool + does right after placing one. addElement() deliberately does not + number what it places: doing it silently would change what an existing + script produces the moment its project happens to have a context + selected, so it is a separate, explicit call. Folio auto-numbering is + not offered: in the application it spawns whole new folios from a + context, which is a different operation from labelling. + - @b Duplicating: copy elements, together with the conductors that run + between them, to a position on the same or another folio, through + Diagram::toXml() and fromXml() and PasteDiagramCommand -- what Ctrl+C + and Ctrl+V do, so a paste behaves as a paste does there: the copies + come without their labels and without their conductors' wire numbers, + which the application clears on paste (measured: '' on both). + The position is the top left of the pasted group's bounding rectangle, + so an element's own origin ends up offset from it by its hotspot + (measured: +20, +30 for a coil); (0, 0) is not a position but means + "keep the source coordinates", as Diagram::fromXml() treats it. The + result lists the copies in the order the elements were named -- + the application's own list is in scene order, and a caller pairing by + index would otherwise be wired to the wrong copies -- paired by + position, which a paste preserves, so two elements at the same point + cannot be told apart. A conductor is copied + only if both its ends are among the copied elements. The previous + selection is put back afterwards, since copying works by selecting. + - @b Project title and folio frame: setProjectTitle(), and the grid that + frames each folio -- columns and rows, their size, and whether the + headers show (columns, column-width, display-columns, rows, row-height, + display-rows) -- through ChangeBorderCommand. These are the six fields + the folio properties panel offers; the title block's header sizes, + which it does not, are left alone. Changing the project title is not + undoable: the application sets it directly too. + + A folio's title block @b template is a seventh, separate case: + Diagram::setTitleBlockTemplate() resolves a name only against + QETProject::embeddedTitleBlockTemplatesCollection() -- the same + copy-into-the-project step addElement() already does for elements, + and for the same reason (a project opened on another machine must not + depend on files only this one has). titleBlockTemplates() lists what + is embedded and what is available to embed from the common/company + /custom collections, each name suffixed with its source; + embedTitleBlockTemplate() does the copy (QDomElement in, unmodified, + via *TemplatesCollection::get/setTemplateXmlDescription() -- neither + side is scripting-specific code, both already exist for the template + editor to call). setFolioProperty(folio, "template", name) then + embeds it first if it is not already, refusing only if no collection + has that name at all. Embedding is not undoable, the same as defining + an auto-numbering context is not: the application does both through + direct collection/project calls with no undo command of their own. + A template literally named "default" reads back as folioProperty() + "" afterwards, not "default": BorderTitleBlock::titleBlockTemplateName() + treats the two as the same thing, since "no override" already renders + with the template named "default". + - @b Geometry and folio order: elementGeometry() reads where an element + is -- x, y (its origin), rotation, and the box it occupies on the folio + (left, top, right, bottom) -- so a script can lay one thing out relative + to another instead of only setting absolute coordinates, and can check + that a move landed. insertFolio() puts a new folio at a position + instead of at the end, which is what reordering is mostly for while + moving an existing folio still needs the application's project view. + - @b Images: place a picture from a file. The pixels are copied into + the project, which stores them inline in the .qet -- the saved file + does not refer to the original path, so it opens on another machine, + and it grows by roughly the size of the image, which is why files + over 10 MB are refused. Images are addressed by index in a + position-sorted listing, like texts and shapes -- by the on-screen + bounding box, so scaling or rotating an image, which turns about its + centre, can change where it sorts. Re-list after either. + - @b Element @b texts: the text fields drawn on a symbol -- its label, + the names beside its terminals, any value the definition placed there. + A symbol arrives with the fields its definition gives it; setElementLabel() + fills the value one of them shows, and these methods control the fields + themselves: where each sits, its size, whether it draws a frame, what it + shows, and adding or deleting one. Addressed by index in the element's + own list, which follows the definition's order and shifts when one is + deleted -- and undoing a deletion puts the field back at the end, so + list again after either. + + Two things called text, which differ for a field bound to an + information key: the @b "text" property is the field's stored string, + which for an information-bound field is an unused placeholder (empty, + or "Texte" once one has been added), and @b "shows" is what is drawn, + which follows the element's information straight away -- compared + against elementInfo() at seven points across relabel, rebinding, + setting and undo, with no difference. Read "shows". + + Consecutive setElementInfo()/setElementLabel() calls on one element + merge into a single undo step, as ChangeElementInformationCommand + does, so one undo can revert several. + + A field's @b source is "text" (a fixed string), "info" (the value of one + of the element's information keys, so it follows setElementInfo() and + setElementLabel()) or "composite" (a formula over several). Position is in + the element's own coordinates, not the folio's. - @b Navigating and @b messaging: select an element, zoom the active view, and show the user a message. Deliberately narrow: selection and messaging work with no view at all (headless `--run`); zoom is a no-op @@ -238,16 +402,38 @@ class QetScriptApi : public QObject int terminalIndex, const QString &property, const QString &value); + // -- a conductor's own drawn path, not the whole potential's + // properties above -- one conductor only, addressed the same way -- + Q_INVOKABLE QStringList conductorSegments(int folioIndex, const QString &elementUuid, + int terminalIndex) const; + Q_INVOKABLE bool moveConductorSegment(int folioIndex, const QString &elementUuid, + int terminalIndex, int segmentIndex, + double dx, double dy); + // -- cross-references: master/slave and report links -- Q_INVOKABLE QString elementLinkType(int folioIndex, const QString &elementUuid) const; Q_INVOKABLE QStringList linkedElements(int folioIndex, const QString &elementUuid) const; Q_INVOKABLE bool linkElements(int folioIndexA, const QString &elementUuidA, - int folioIndexB, const QString &elementUuidB); + int folioIndexB, const QString &elementUuidB, + int groupIndex = -1); Q_INVOKABLE bool unlinkElement(int folioIndex, const QString &elementUuid); + Q_INVOKABLE int elementLinkGroupIndex(int folioIndex, const QString &elementUuid, + int otherFolioIndex, const QString &otherElementUuid) const; + + // -- a PLC master's IO table: address/function/comment rows a PLC + // slave links onto via linkElements()'s groupIndex -- + Q_INVOKABLE QStringList plcIOs(int folioIndex, const QString &elementUuid) const; + Q_INVOKABLE int addPlcIO(int folioIndex, const QString &elementUuid, const QString &type, + const QString &address, const QString &functionText, + const QString &comment); + Q_INVOKABLE bool setPlcIO(int folioIndex, const QString &elementUuid, int ioIndex, + const QString &property, const QString &value); + Q_INVOKABLE bool removePlcIO(int folioIndex, const QString &elementUuid, int ioIndex); // -- independent text and drawing shapes -- Q_INVOKABLE QStringList texts(int folioIndex) const; Q_INVOKABLE int addText(int folioIndex, const QString &text, double x, double y); + Q_INVOKABLE QString textContent(int folioIndex, int textIndex) const; Q_INVOKABLE bool setTextContent(int folioIndex, int textIndex, const QString &text); Q_INVOKABLE bool setTextColor(int folioIndex, int textIndex, const QString &color); Q_INVOKABLE bool setTextRotation(int folioIndex, int textIndex, double angle); @@ -257,14 +443,97 @@ class QetScriptApi : public QObject Q_INVOKABLE int addShape(int folioIndex, const QString &type, double x1, double y1, double x2, double y2); Q_INVOKABLE bool deleteShape(int folioIndex, int shapeIndex); + Q_INVOKABLE QString shapeProperty(int folioIndex, int shapeIndex, const QString &property) const; + Q_INVOKABLE bool setShapeProperty(int folioIndex, int shapeIndex, + const QString &property, const QString &value); + + // -- polygon and path shapes: more than addShape()'s two-point box -- + Q_INVOKABLE int addPolygon(int folioIndex, const QVariantList &points, bool closed); + Q_INVOKABLE QVariantList shapePolygon(int folioIndex, int shapeIndex) const; + Q_INVOKABLE bool setShapePolygon(int folioIndex, int shapeIndex, const QVariantList &points); + Q_INVOKABLE int addPath(int folioIndex, const QVariantList &nodes, bool closed); + Q_INVOKABLE QVariantList shapePathNodes(int folioIndex, int shapeIndex) const; + Q_INVOKABLE bool setShapePathNodes(int folioIndex, int shapeIndex, const QVariantList &nodes); + Q_INVOKABLE bool setShapeClosed(int folioIndex, int shapeIndex, bool closed); // -- query the project database -- Q_INVOKABLE QStringList tables() const; Q_INVOKABLE QVariantList query(const QString &sql); Q_INVOKABLE QString queryError() const; + // -- removing a conductor or a folio; folio properties beyond the title -- + Q_INVOKABLE bool deleteConductor(int folioIndex, const QString &elementUuid, int terminalIndex); + Q_INVOKABLE bool removeFolio(int folioIndex); + Q_INVOKABLE bool setFolioProperty(int folioIndex, const QString &property, const QString &value); + Q_INVOKABLE QString folioProperty(int folioIndex, const QString &property) const; + + // -- terminal strips (borniers) -- + Q_INVOKABLE QStringList terminalStrips() const; + Q_INVOKABLE int addTerminalStrip(const QString &installation, const QString &location, + const QString &name); + Q_INVOKABLE bool removeTerminalStrip(int stripIndex); + Q_INVOKABLE bool addTerminalToStrip(int stripIndex, int folioIndex, + const QString &elementUuid); + Q_INVOKABLE QStringList stripRealTerminals(int stripIndex) const; + Q_INVOKABLE bool groupTerminals(int stripIndex, const QVariantList &realTerminalIndices); + Q_INVOKABLE bool bridgeTerminals(int stripIndex, const QVariantList &realTerminalIndices); + Q_INVOKABLE bool sortTerminalStrip(int stripIndex); + + // -- a BOM/nomenclature or summary table placed on a folio -- + Q_INVOKABLE QStringList tables(int folioIndex) const; + Q_INVOKABLE int addTable(int folioIndex, const QString &kind, const QString &name, + const QString &query); + Q_INVOKABLE bool deleteTable(int folioIndex, int tableIndex); + Q_INVOKABLE bool setTablePosition(int folioIndex, int tableIndex, double x, double y); + + // -- auto-numbering contexts (conductor, element, folio) -- + Q_INVOKABLE QStringList autoNums(const QString &kind) const; + Q_INVOKABLE bool addAutoNum(const QString &kind, const QString &name, const QStringList &parts); + Q_INVOKABLE bool removeAutoNum(const QString &kind, const QString &name); + Q_INVOKABLE bool useConductorAutoNum(int folioIndex, const QString &name); + Q_INVOKABLE bool useElementAutoNum(const QString &name); + Q_INVOKABLE bool numberElement(int folioIndex, const QString &elementUuid); + + // -- images, embedded in the project -- + Q_INVOKABLE QStringList images(int folioIndex) const; + Q_INVOKABLE int addImage(int folioIndex, const QString &filePath, double x, double y); + Q_INVOKABLE bool setImageScale(int folioIndex, int imageIndex, double factor); + Q_INVOKABLE bool setImageRotation(int folioIndex, int imageIndex, double angle); + Q_INVOKABLE bool deleteImage(int folioIndex, int imageIndex); + Q_INVOKABLE int addPdfPage(int folioIndex, const QString &pdfPath, int pageNumber, + int dpi, double x, double y); + + // -- the text fields shown on a symbol (label, terminal names, ...) -- + Q_INVOKABLE QStringList elementTexts(int folioIndex, const QString &elementUuid) const; + Q_INVOKABLE int addElementText(int folioIndex, const QString &elementUuid, + const QString &source, const QString &value, + double x, double y); + Q_INVOKABLE bool setElementTextProperty(int folioIndex, const QString &elementUuid, + int textIndex, const QString &property, + const QString &value); + 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); + + // -- copy elements (with the conductors between them) to a position -- + Q_INVOKABLE QStringList duplicateElements(int fromFolioIndex, const QStringList &elementUuids, + int toFolioIndex, double x, double y); + + // -- the project title, and each folio's frame (grid of columns and rows) -- + Q_INVOKABLE bool setProjectTitle(const QString &title); + Q_INVOKABLE QString folioBorder(int folioIndex, const QString &property) const; + Q_INVOKABLE bool setFolioBorder(int folioIndex, const QString &property, const QString &value); + + // -- title block templates: which exist, embedding one into the project -- + Q_INVOKABLE QStringList titleBlockTemplates() const; + Q_INVOKABLE bool embedTitleBlockTemplate(const QString &name); + + // -- read an element's geometry -- + Q_INVOKABLE QVariantMap elementGeometry(int folioIndex, const QString &elementUuid) const; + // -- folios -- Q_INVOKABLE int addFolio(); + Q_INVOKABLE int insertFolio(int position); Q_INVOKABLE bool setFolioTitle(int folioIndex, const QString &title); Q_INVOKABLE bool undo(); @@ -272,9 +541,21 @@ class QetScriptApi : public QObject Q_INVOKABLE bool canUndo() const; Q_INVOKABLE bool canRedo() const; + // -- project-wide text search & replace, one undo step for the + // whole run, in the same spirit as the "Search and replace" panel -- + Q_INVOKABLE int searchAndReplace(const QString &kind, const QString &field, + const QString &pattern, const QString &replacement, + bool useRegex, bool caseSensitive); + + // -- electrical continuity / ERC: read-only, structural checks + // against the live object graph rather than the XML -- see the + // .cpp doc comment for exactly what is and is not covered -- + Q_INVOKABLE QVariantList checkContinuity(int folioIndex); + // -- navigate and message -- Q_INVOKABLE bool selectElement(const QString &elementUuid); Q_INVOKABLE void deselectAll(int folioIndex); + Q_INVOKABLE QStringList selectedElements(int folioIndex) const; Q_INVOKABLE bool zoomFit(); Q_INVOKABLE bool zoomToContent(); Q_INVOKABLE bool zoomReset(); @@ -292,6 +573,10 @@ class QetScriptApi : public QObject const QString &caller); QList sortedTexts(int folioIndex) const; QList sortedShapes(int folioIndex) const; + QList sortedTables(int folioIndex) const; + QList sortedImages(int folioIndex) const; + DynamicElementTextItem *findElementText(int folioIndex, const QString &elementUuid, + int textIndex, const QString &caller) const; IndependentTextItem *findText(int folioIndex, int textIndex, const QString &caller); bool setInfoKey(int folioIndex, const QString &elementUuid, const QString &key, const QString &value, const QString &caller); diff --git a/sources/utils/conductorcreator.cpp b/sources/utils/conductorcreator.cpp index 4a0ce5a37..77f2009ec 100644 --- a/sources/utils/conductorcreator.cpp +++ b/sources/utils/conductorcreator.cpp @@ -18,7 +18,9 @@ #include "conductorcreator.h" #include "../conductorautonumerotation.h" +#include "../dataBase/projectdatabase.h" #include "../diagram.h" +#include "../qetproject.h" #include "../undocommand/addgraphicsobjectcommand.h" #include "../qetgraphicsitem/conductor.h" #include "../qetgraphicsitem/element.h" @@ -68,6 +70,15 @@ ConductorCreator::ConductorCreator(Diagram *d, QList terminals_list) for(Conductor *c : c_list) { c->refreshText(); + //refreshText() resolves an auto-numbering formula into + //properties.text without emitting propertiesChange, which is + //what the project database listens to. The row was inserted + //while text was still the raw formula ("W%sequ_1"), so without + //this the wiring list and BOM read the formula, not "W1", + //until something forces a full rebuild. + if (d->project() && d->project()->dataBase()) { + d->project()->dataBase()->updateConductor(c); + } } }