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);