From 42b1e43e4ab19f201e88f4e377c07e6b3c7957b7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 18:41:58 +1300 Subject: [PATCH 1/4] Live mode: create a new project (new_project, qet_live_new_project) An assistant in live mode could only work on the project already open. new_project makes one as File > New does -- this installation's new-folio defaults, optional title and number of folios -- and makes it the current project, so the next request works on it. An optional absolute path saves it at once, never over an existing file: QElectroTech refuses, and the MCP server refuses before asking (a new never-overwrite class in its path policy, so an "overwrite" flag cannot get round it). Pages and links across pages already worked through scripts (qet.addFolio, setFolioTitle, linkElements across folios); the tool's description now says so. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 50 ++++++++++++++++++++++++++ misc/qet-mcp/test_qet_mcp.py | 40 +++++++++++++++++++-- sources/scripting/liveserver.cpp | 61 ++++++++++++++++++++++++++++++++ sources/scripting/liveserver.h | 1 + 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index df5c2a157..6ca809908 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -4085,6 +4085,22 @@ def tool_live_show_folio(folio: int) -> dict: return _live_call({"cmd": "show_folio", "folio": folio}) +def tool_live_new_project(title: str = "", folios: int = 1, path: str = "") -> dict: + _require_script_consent() + if not isinstance(folios, int) or isinstance(folios, bool) or not 1 <= folios <= 100: + raise ValueError("'folios' must be a whole number from 1 to 100") + if not isinstance(title, str) or not isinstance(path, str): + raise ValueError("'title' and 'path' must be text") + if path and not Path(path).expanduser().is_absolute(): + raise ValueError("'path' must be absolute, e.g. /home/me/projects/pump.qet") + request = {"cmd": "new_project", "folios": folios} + if title: + request["title"] = title + if path: + request["path"] = str(Path(path).expanduser()) + return _live_call(request) + + def tool_live_undo_last() -> dict: _require_script_consent() return _live_call({"cmd": "undo_last"}) @@ -5164,6 +5180,31 @@ TOOLS = [ }, "handler": lambda a: tool_live_show_folio(a["folio"]), }, + { + "name": "qet_live_new_project", + "description": "LIVE MODE. Create a new project in the QElectroTech the " + "user has open, as File > New does (this installation's " + "new-folio defaults), and make it the current project: every " + "following qet_live_* call works on it. Optional title, number " + "of empty folios (pages, 1-100) and an absolute path to save it " + "to at once -- never over an existing file. Then, in " + "qet_live_run_script: qet.addFolio() / qet.insertFolio(i) add " + "pages, qet.setFolioTitle(i, text) names them, and " + "qet.linkElements(folioA, a, folioB, b) links across pages -- " + "a going folio report arrow to a coming one, or a coil to its " + "contacts on another page.", + "inputSchema": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "the project's title"}, + "folios": {"type": "integer", "default": 1, "minimum": 1, "maximum": 100}, + "path": {"type": "string", + "description": "absolute .qet path to save to now; must not exist"}, + }, + }, + "handler": lambda a: tool_live_new_project(a.get("title", ""), a.get("folios", 1), + a.get("path", "")), + }, { "name": "qet_live_undo_last", "description": "LIVE MODE. Undo the newest step in the open project, only if " @@ -5298,6 +5339,9 @@ _DATA_PATHS = { "qet_check": {"read": ("project",)}, "qet_layout_check": {"read": ("project",)}, "qet_project_new": {"write": ("output",)}, + # QElectroTech writes this file, but where is the client's choice: + # held to the same workspace as every other file a tool writes. + "qet_live_new_project": {"write": ("path",)}, "qet_element_build": {"write": ("output",)}, # The scripts folder is chosen by scripts_dir(), never by the client, # so only the project a script is tried on is a data path here. @@ -5318,6 +5362,9 @@ _LAUNCHES_QET_WITH = {"qet_script_install": "test_project"} # Tools whose "overwrite" guards a file the server names itself (the # stored script, in scripts_dir()), not a client-chosen output path. _OVERWRITE_OWN_FILE = {"qet_script_install"} +# Tools that create a file and never replace one, whatever the client asks: +# no "overwrite" in their schema, and the flag is ignored if sent anyway. +_NEVER_OVERWRITE = {"qet_live_new_project"} # qet_edit operations that name a file of their own. _DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"} @@ -5517,6 +5564,9 @@ def enforce_path_policy(tool_name: str, arguments: dict) -> None: # 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 tool_name in _NEVER_OVERWRITE: + raise ValueError(f"{arg!r} already exists: {out}. This tool only " + "creates new files; choose another name.") if out.exists() and not arguments.get("overwrite"): raise ValueError( f"{arg!r} already exists: {out}. Pass \"overwrite\": true to " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index b4524d7ed..37c68e53b 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -184,7 +184,7 @@ class ToolRegistry(unittest.TestCase): "qet_live_run_stored", "qet_live_command", "qet_live_show_folio", "qet_live_undo_last", "qet_live_screenshot", "qet_about", "qet_recording_list", "qet_recording_read", "qet_recording_check", - "qet_recording_remove", "qet_layout_check"}) + "qet_recording_remove", "qet_layout_check", "qet_live_new_project"}) class EditValidation(unittest.TestCase): @@ -2829,7 +2829,20 @@ class PathPolicy(unittest.TestCase): 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 - m._OVERWRITE_OWN_FILE) + self.assertEqual(guarded - m._NEVER_OVERWRITE, advertised - m._OVERWRITE_OWN_FILE) + self.assertFalse(advertised & m._NEVER_OVERWRITE) + + def test_never_overwrite_tools_ignore_the_flag(self): + with tempfile.TemporaryDirectory() as d: + target = Path(d) / "exists.qet" + target.write_text("x") + with mock.patch.dict(os.environ, {"QET_MCP_WORKSPACE": d}): + for args in ({"path": str(target)}, {"path": str(target), "overwrite": True}): + with self.assertRaisesRegex(ValueError, "only creates new files"): + m.enforce_path_policy("qet_live_new_project", args) + m.enforce_path_policy("qet_live_new_project", {"path": str(Path(d) / "new.qet")}) + with self.assertRaises(ValueError): + m.enforce_path_policy("qet_live_new_project", {"path": "/etc/new.qet"}) def test_every_data_path_argument_is_guarded(self): """The other direction: a tool whose schema takes a data path must be @@ -3533,6 +3546,29 @@ class LiveClient(unittest.TestCase): with self.assertRaises(ValueError): m.tool_live_show_folio("2") + def test_new_project_sends_only_what_was_given(self): + self.session() + m.tool_live_new_project() + m.tool_live_new_project("Pump station", 3, "/tmp/x/pump.qet") + self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")} + for r in self.seen], + [{"cmd": "new_project", "folios": 1}, + {"cmd": "new_project", "folios": 3, "title": "Pump station", + "path": "/tmp/x/pump.qet"}]) + for bad in (0, 101, "2", True): + with self.assertRaises(ValueError): + m.tool_live_new_project(folios=bad) + with self.assertRaisesRegex(ValueError, "absolute"): + m.tool_live_new_project(path="pump.qet") + self.assertEqual(len(self.seen), 2) + + def test_new_project_needs_script_consent(self): + self.session() + with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}): + with self.assertRaises(ValueError): + m.tool_live_new_project() + self.assertEqual(self.seen, []) + def test_stale_session_file(self): self.info({"socket": self.sock_path + "-gone", "token": "T0K"}) with self.assertRaisesRegex(ValueError, "could not reach"): diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 6eb679c18..8a72cbdf8 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -48,6 +48,9 @@ #include #include #include +#include +#include +#include #include #include #include "../projectview.h" @@ -251,6 +254,8 @@ void LiveServer::handle(const QJsonObject &request) answer = runStored(request.value(QStringLiteral("script")).toString()); } else if (cmd == QLatin1String("command")) { answer = command(request.value(QStringLiteral("action")).toString()); + } else if (cmd == QLatin1String("new_project")) { + answer = newProject(request); } else if (cmd == QLatin1String("show_folio")) { answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1)); } else if (cmd == QLatin1String("undo_last")) { @@ -441,6 +446,62 @@ QJsonObject LiveServer::showFolio(int folio) return {{QStringLiteral("ok"), true}, {QStringLiteral("folio"), folio}}; } +/** + @brief LiveServer::newProject + A new project, as File > New makes it: the new-folio defaults of this + QElectroTech, opened and made the current project, so the next script + works on it. Optional: "title", "folios" (how many empty folios, 1 to + 100, default 1), and "path" to save it to at once -- never over an + existing file, so an assistant cannot replace the user's work. +*/ +QJsonObject LiveServer::newProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + + const int folios = request.value(QStringLiteral("folios")).toInt(1); + if (folios < 1 || folios > 100) + return failure(QStringLiteral("folios must be between 1 and 100, not %1").arg(folios)); + const QString path = request.value(QStringLiteral("path")).toString().trimmed(); + if (!path.isEmpty()) { + const QFileInfo info(path); + if (info.isRelative()) + return failure(QStringLiteral("path must be absolute: %1").arg(path)); + if (info.exists()) + return failure(QStringLiteral("%1 already exists; a new project is never " + "saved over a file").arg(path)); + if (!info.dir().exists()) + return failure(QStringLiteral("the folder %1 does not exist").arg(info.absolutePath())); + } + + auto project = new QETProject(e); + for (int i = 0; i < folios; ++i) project->addNewDiagram(); + const QString title = request.value(QStringLiteral("title")).toString().trimmed(); + if (!title.isEmpty()) project->setTitle(title); + if (!e->addProject(project)) return failure(QStringLiteral("QElectroTech refused the new project")); + + //Current at once, whether or not QElectroTech is the active + //application: the next request runs on it (see editor()) + for (ProjectView *pv : e->openedProjects()) { + if (pv->project() == project) { + for (QMdiSubWindow *w : e->m_workspace.subWindowList()) + if (w->widget() == pv) e->m_workspace.setActiveSubWindow(w); + } + } + + if (!path.isEmpty()) { + project->setFilePath(path); + const QETResult result = project->write(); + if (!result.isOk()) + return failure(QStringLiteral("the project was created but not saved to %1: %2") + .arg(path, result.errorMessage())); + } + + QJsonObject answer = status(); + answer.insert(QStringLiteral("created"), true); + return answer; +} + /** @brief LiveServer::undoLast Undo the newest step, only if the assistant made it: what the user did diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 2a8726a88..27ec6ac34 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -84,6 +84,7 @@ class LiveServer : public QObject QJsonObject runStored(const QString &id); QJsonObject command(const QString &id); QJsonObject showFolio(int folio); + QJsonObject newProject(const QJsonObject &request); QJsonObject undoLast(); QJsonObject screenshot(); bool confirm(const QString &name, const QString &source); From ad20eb94ba4a30dcef766231ff1bd2acf4345696 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 19:00:13 +1300 Subject: [PATCH 2/4] Live mode: open a saved project, switch between open projects open_project opens a .qet and makes it current, as File > Open does but with no file dialog and no error windows: a missing, unreadable or foreign file is refused in the answer. A project already open is only made current. switch_project makes another open project current, by its index or its file. status now lists every open project (index, title, file, folios, unsaved changes, current), so the assistant sees what it can switch to. MCP: qet_live_open_project, qet_live_switch_project; both paths go through the workspace policy. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 56 ++++++++++++++++++- misc/qet-mcp/test_qet_mcp.py | 22 +++++++- sources/scripting/liveserver.cpp | 94 ++++++++++++++++++++++++++++++++ sources/scripting/liveserver.h | 4 ++ 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 6ca809908..3a5b80075 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -4101,6 +4101,29 @@ def tool_live_new_project(title: str = "", folios: int = 1, path: str = "") -> d return _live_call(request) +def tool_live_open_project(path: str) -> dict: + if not isinstance(path, str) or not path.strip(): + raise ValueError("'path' must be the project's file, e.g. /home/me/projects/pump.qet") + p = Path(path).expanduser() + if not p.is_absolute(): + raise ValueError("'path' must be absolute, e.g. /home/me/projects/pump.qet") + return _live_call({"cmd": "open_project", "path": str(p)}) + + +def tool_live_switch_project(index: int | None = None, path: str = "") -> dict: + if (index is None) == (not path): + raise ValueError("give exactly one of 'index' (from qet_live_status's " + "\"projects\") or 'path'") + if index is not None: + if not isinstance(index, int) or isinstance(index, bool) or index < 0: + raise ValueError("'index' must be a whole number counted from 0") + return _live_call({"cmd": "switch_project", "index": index}) + p = Path(path).expanduser() + if not p.is_absolute(): + raise ValueError("'path' must be absolute") + return _live_call({"cmd": "switch_project", "path": str(p)}) + + def tool_live_undo_last() -> dict: _require_script_consent() return _live_call({"cmd": "undo_last"}) @@ -5107,7 +5130,9 @@ TOOLS = [ "name": "qet_live_status", "description": "LIVE MODE. Ask the QElectroTech the user has open what is on " "screen: the project, the folio shown (index and title), the " - "selected elements, the last undo step and the stored scripts. " + "selected elements, the last undo step, the stored scripts, and " + "\"projects\": every project open in the window (index, title, " + "file, folios, unsaved changes, which is current). " "Works only if the user switched live mode on in QElectroTech " "and accepted its warning at this start; the error says which " "step is missing. Changes nothing.", @@ -5205,6 +5230,33 @@ TOOLS = [ "handler": lambda a: tool_live_new_project(a.get("title", ""), a.get("folios", 1), a.get("path", "")), }, + { + "name": "qet_live_open_project", + "description": "LIVE MODE. Open a saved project (.qet, absolute path) in the " + "QElectroTech the user has open and make it current, as File > " + "Open does but with no dialog; errors come back as text. A " + "project already open is only made current. Every following " + "qet_live_* call works on it.", + "inputSchema": { + "type": "object", + "properties": {"path": {"type": "string", "description": "absolute .qet path"}}, + "required": ["path"], + }, + "handler": lambda a: tool_live_open_project(a["path"]), + }, + { + "name": "qet_live_switch_project", + "description": "LIVE MODE. Make another project that is already open the " + "current one, by its index in qet_live_status's \"projects\" or " + "by its file path. Every following qet_live_* call works on it. " + "Changes no project.", + "inputSchema": { + "type": "object", + "properties": {"index": {"type": "integer", "minimum": 0}, + "path": {"type": "string"}}, + }, + "handler": lambda a: tool_live_switch_project(a.get("index"), a.get("path", "")), + }, { "name": "qet_live_undo_last", "description": "LIVE MODE. Undo the newest step in the open project, only if " @@ -5342,6 +5394,8 @@ _DATA_PATHS = { # QElectroTech writes this file, but where is the client's choice: # held to the same workspace as every other file a tool writes. "qet_live_new_project": {"write": ("path",)}, + "qet_live_open_project": {"read": ("path",)}, + "qet_live_switch_project": {"read": ("path",)}, "qet_element_build": {"write": ("output",)}, # The scripts folder is chosen by scripts_dir(), never by the client, # so only the project a script is tried on is a data path here. diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 37c68e53b..e0ba86bdc 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -184,7 +184,8 @@ class ToolRegistry(unittest.TestCase): "qet_live_run_stored", "qet_live_command", "qet_live_show_folio", "qet_live_undo_last", "qet_live_screenshot", "qet_about", "qet_recording_list", "qet_recording_read", "qet_recording_check", - "qet_recording_remove", "qet_layout_check", "qet_live_new_project"}) + "qet_recording_remove", "qet_layout_check", "qet_live_new_project", + "qet_live_open_project", "qet_live_switch_project"}) class EditValidation(unittest.TestCase): @@ -3562,6 +3563,25 @@ class LiveClient(unittest.TestCase): m.tool_live_new_project(path="pump.qet") self.assertEqual(len(self.seen), 2) + def test_open_and_switch_send_only_what_was_given(self): + self.session() + m.tool_live_open_project("/tmp/x/pump.qet") + m.tool_live_switch_project(1) + m.tool_live_switch_project(path="/tmp/x/pump.qet") + self.assertEqual([{k: v for k, v in r.items() if k not in ("token", "id")} + for r in self.seen], + [{"cmd": "open_project", "path": "/tmp/x/pump.qet"}, + {"cmd": "switch_project", "index": 1}, + {"cmd": "switch_project", "path": "/tmp/x/pump.qet"}]) + for bad in ({}, {"index": 0, "path": "/a.qet"}, {"index": -1}, + {"index": True}, {"path": "rel.qet"}): + with self.assertRaises(ValueError): + m.tool_live_switch_project(**bad) + for bad in ("", "rel.qet"): + with self.assertRaises(ValueError): + m.tool_live_open_project(bad) + self.assertEqual(len(self.seen), 3) + def test_new_project_needs_script_consent(self): self.session() with mock.patch.dict(os.environ, {"QET_ENABLE_SCRIPTING": ""}): diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 8a72cbdf8..f1f87a659 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -256,6 +256,10 @@ void LiveServer::handle(const QJsonObject &request) answer = command(request.value(QStringLiteral("action")).toString()); } else if (cmd == QLatin1String("new_project")) { answer = newProject(request); + } else if (cmd == QLatin1String("open_project")) { + answer = openProject(request.value(QStringLiteral("path")).toString()); + } else if (cmd == QLatin1String("switch_project")) { + answer = switchProject(request); } else if (cmd == QLatin1String("show_folio")) { answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1)); } else if (cmd == QLatin1String("undo_last")) { @@ -318,6 +322,7 @@ QJsonObject LiveServer::status() {QStringLiteral("name"), s.header.name}}); answer.insert(QStringLiteral("stored_scripts"), scripts); //So an assistant can notice a recording the user just made + answer.insert(QStringLiteral("projects"), openProjects()); answer.insert(QStringLiteral("macro_recorder"), QJsonObject{ {QStringLiteral("recording"), MacroRecorder::instance().isRecording()}, {QStringLiteral("steps"), MacroRecorder::instance().stepCount()}, @@ -502,6 +507,95 @@ QJsonObject LiveServer::newProject(const QJsonObject &request) return answer; } +/** + @brief LiveServer::openProjects + The projects open in the editor window, in tab order: the index + switch_project takes, title, file, folios, unsaved changes, and which + one is current. +*/ +QJsonArray LiveServer::openProjects() const +{ + QJsonArray list; + QETDiagramEditor *e = editor(); + if (!e) return list; + const QETProject *current = e->currentProject(); + const QList views = e->openedProjects(); + for (int i = 0; i < views.count(); ++i) { + QETProject *p = views.at(i)->project(); + list.append(QJsonObject{ + {QStringLiteral("index"), i}, + {QStringLiteral("title"), p->title()}, + {QStringLiteral("path"), p->filePath()}, + {QStringLiteral("folios"), int(p->diagrams().count())}, + {QStringLiteral("modified"), p->projectWasModified()}, + {QStringLiteral("read_only"), p->isReadOnly()}, + {QStringLiteral("current"), p == current}}); + } + return list; +} + +/** + @brief LiveServer::openProject + Open a saved project and make it current, as File > Open does but with + no file dialog and no error windows: what is wrong comes back in the + answer. A project already open is only made current. +*/ +QJsonObject LiveServer::openProject(const QString &raw_path) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + const QString path = raw_path.trimmed(); + if (path.isEmpty()) return failure(QStringLiteral("no path given")); + const QFileInfo info(path); + if (info.isRelative()) return failure(QStringLiteral("path must be absolute: %1").arg(path)); + if (!info.isFile()) return failure(QStringLiteral("%1 does not exist").arg(path)); + if (!info.isReadable()) return failure(QStringLiteral("%1 cannot be read (permissions)").arg(path)); + + QJsonObject extra{{QStringLiteral("opened"), true}}; + if (ProjectView *pv = e->viewForFile(info.absoluteFilePath())) { + e->activateProject(pv); + extra = {{QStringLiteral("opened"), false}, {QStringLiteral("already_open"), true}}; + } else if (QETApp::diagramEditorForFile(info.absoluteFilePath())) { + return failure(QStringLiteral("%1 is open in another QElectroTech window; " + "switch to it there").arg(path)); + } else { + if (!e->openAndAddProject(info.absoluteFilePath(), false)) + return failure(QStringLiteral("QElectroTech could not open %1 (not a " + "QElectroTech project, or a newer format)").arg(path)); + if (ProjectView *pv = e->viewForFile(info.absoluteFilePath())) e->activateProject(pv); + } + QJsonObject answer = status(); + for (auto it = extra.begin(); it != extra.end(); ++it) answer.insert(it.key(), it.value()); + return answer; +} + +/** + @brief LiveServer::switchProject + Make another open project current, by its index in "projects" (status) + or by its file path. +*/ +QJsonObject LiveServer::switchProject(const QJsonObject &request) +{ + QETDiagramEditor *e = editor(); + if (!e) return failure(QStringLiteral("no QElectroTech editor window is open")); + const QList views = e->openedProjects(); + ProjectView *target = nullptr; + if (request.contains(QStringLiteral("index"))) { + const int index = request.value(QStringLiteral("index")).toInt(-1); + if (index < 0 || index >= views.count()) + return failure(QStringLiteral("no open project %1: %2 are open, counted from 0") + .arg(index).arg(views.count())); + target = views.at(index); + } else { + const QString path = request.value(QStringLiteral("path")).toString().trimmed(); + if (path.isEmpty()) return failure(QStringLiteral("give \"index\" (from \"projects\") or \"path\"")); + target = e->viewForFile(QFileInfo(path).absoluteFilePath()); + if (!target) return failure(QStringLiteral("%1 is not open; open_project opens it").arg(path)); + } + e->activateProject(target); + return status(); +} + /** @brief LiveServer::undoLast Undo the newest step, only if the assistant made it: what the user did diff --git a/sources/scripting/liveserver.h b/sources/scripting/liveserver.h index 27ec6ac34..93ba325fa 100644 --- a/sources/scripting/liveserver.h +++ b/sources/scripting/liveserver.h @@ -18,6 +18,7 @@ #ifndef LIVESERVER_H #define LIVESERVER_H +#include #include #include #include @@ -85,6 +86,9 @@ class LiveServer : public QObject QJsonObject command(const QString &id); QJsonObject showFolio(int folio); QJsonObject newProject(const QJsonObject &request); + QJsonObject openProject(const QString &path); + QJsonObject switchProject(const QJsonObject &request); + QJsonArray openProjects() const; QJsonObject undoLast(); QJsonObject screenshot(); bool confirm(const QString &name, const QString &source); From 2fb48e791b2d4b930956b473f31f10162ba6eff7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 19:15:19 +1300 Subject: [PATCH 3/4] Live mode: open_project never stops on the backup question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openAndAddProject() asked "Créer une copie de sauvegarde ?" even when called non-interactively, so open_project waited for an answer from the user in front of the window. Only live mode opens projects that way. Co-Authored-By: Claude Opus 5.5 --- sources/qetdiagrameditor.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 41c8e82e5..5c51490f2 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1856,8 +1856,10 @@ bool QETDiagramEditor::openAndAddProject( box.exec(); } + //Not when nobody is there to answer: an assistant opening a + //project in live mode would wait on a window it cannot see BackupDialog backup_dialog(this); - if (backup_dialog.exec() == QDialog::Accepted) + if (interactive && backup_dialog.exec() == QDialog::Accepted) { QString backup_path = filepath_info.absolutePath() + QDir::separator() + QDateTime::currentDateTime().toString("yyyy-MM-dd-hh-mm") + "_" + From 4ceeeed3022e1e6e85598d56931c8ff4479a8890 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 5 Oct 2026 20:34:14 +1300 Subject: [PATCH 4/4] qet-mcp README: the live project tools Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 0852d13a4..ad6e4c368 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -324,7 +324,7 @@ general (on the selected elements, say, not on these exact ones), ## Live mode: working in the QElectroTech you have open Every tool above works on files, with no QElectroTech window involved. The -three `qet_live_*` tools instead act on the project open in **your** +`qet_live_*` tools instead act on the project open in **your** QElectroTech, in front of you, so you can watch, stop or undo: | | | @@ -336,6 +336,15 @@ QElectroTech, in front of you, so you can watch, stop or undo: | `qet_live_show_folio` | show another folio | | `qet_live_undo_last` | undo the newest step, only if the assistant made it | | `qet_live_screenshot` | a picture of the folio on screen, as an MCP image, cropped to the folio | +| `qet_live_new_project` | a new project, as File > New makes it, made current; optional title, number of folios and a file to save it to (never over an existing one) | +| `qet_live_open_project` | open a saved project and make it current, with no dialog; one already open is only made current | +| `qet_live_switch_project` | make another open project current, by its index in `qet_live_status`'s `projects` or its file | + +Every `qet_live_*` call works on the current project. Pages and links +between pages need no tool of their own: in `qet_live_run_script`, +`qet.addFolio()` and `qet.setFolioTitle()` add and name folios, and +`qet.linkElements()` links a folio report arrow, or a coil and its +contacts, across folios. A script the assistant writes on the spot is shown to you first, with *Run*, *Decline* or *Always this session*; the Assistant