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 diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index df5c2a157..3a5b80075 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -4085,6 +4085,45 @@ 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_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"}) @@ -5091,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.", @@ -5164,6 +5205,58 @@ 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_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 " @@ -5298,6 +5391,11 @@ _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_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. @@ -5318,6 +5416,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 +5618,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..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_recording_remove", "qet_layout_check", "qet_live_new_project", + "qet_live_open_project", "qet_live_switch_project"}) class EditValidation(unittest.TestCase): @@ -2829,7 +2830,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 +3547,48 @@ 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_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": ""}): + 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/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") + "_" + diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index 6eb679c18..f1f87a659 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,12 @@ 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("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")) { @@ -313,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()}, @@ -441,6 +451,151 @@ 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::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 2a8726a88..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 @@ -84,6 +85,10 @@ class LiveServer : public QObject QJsonObject runStored(const QString &id); 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);