mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-11 06:34:14 +02:00
Merge remote-tracking branch 'upstream/master' into wiring-manager
Conflicts resolved in 24 files. Notable decisions: - ico/icon-themes.qrc: upstream's icon folder restructure kept; the 36 cable icon entries re-added under the new qet/qet-dark path scheme (XML valid, every referenced file exists). - Toolbar system: upstream's DiagramToolbarSettings now owns the toolbar filling. The cable tool is registered as diagrameditor.add_cable (generic group loop) and listed on the "diagram" toolbar right after the auto-break conductor action, separated by a divider, exactly like before the merge. - Paste: the cable lines of a copy are handed to PasteNumberingImport::push() as extra commands, so paste + cable lines + the numbering question stay ONE undo step on both paste paths (DiagramView::paste and DiagramEventAddPaste::commit). - PDF links: upstream's deterministic link sorting kept together with the cable cross-reference injector (injectLinkTo -> links list). - Element renumbering: upstream's ElementAutoNumSchemeCommand::renumber replaces the old formula-based body of QETProject::renumberElementsBySchemeTitle; the cable numbering rule API (cableAutoNum, axis priority, rule name) is kept in full. - Auto-numbering pages: upstream's scheme management kept, the Câbles tab added as index 4 (changeToCableTab), both entry points changeToCable() and changeToElement() kept side by side. - SelectAutonumW: dirty tracking and the contextEdited signal moved into upstream's insertPartRow() row API, so single-rule mode, the rule-removable button and "confirm without change leaves the project alone" keep working. - xrefpropertieswidget: upstream's offset clamping kept; the cable branch (Texte field, Police button, hidden display options) kept; the stack-overlapping checkbox is hidden for cables. Verified after the merge: build green (515/515, no errors), ctest 85/86 (only the pre-existing headless tst_menubarkeyboard failure), offscreen smoke exit 124.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "assistantinfo.h"
|
||||
|
||||
#include "qetscriptapi.h"
|
||||
#include "scriptlibrary.h"
|
||||
#include "macrorecorder.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetversion.h"
|
||||
#include "../utils/qetsettings.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
|
||||
namespace {
|
||||
QJsonObject s_live; ///< empty while live mode is closed
|
||||
bool s_running = true;
|
||||
bool s_watching = false; ///< this QElectroTech writes the file (an editor opened)
|
||||
}
|
||||
|
||||
namespace AssistantInfo {
|
||||
|
||||
QString path()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
|
||||
+ QStringLiteral("/qet-assistant.json");
|
||||
}
|
||||
|
||||
/**
|
||||
@brief watch
|
||||
Write the file now, again whenever the stored scripts change, and mark
|
||||
it stopped when QElectroTech quits. Called by every editor window as it
|
||||
opens; acts once.
|
||||
*/
|
||||
void watch()
|
||||
{
|
||||
if (s_watching) return;
|
||||
s_watching = true;
|
||||
QObject::connect(&ScriptLibrary::instance(), &ScriptLibrary::changed, &write);
|
||||
QObject::connect(qApp, &QCoreApplication::aboutToQuit, &markStopped);
|
||||
write();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief refresh
|
||||
Write the file again after a setting it carries changed, but only in a
|
||||
QElectroTech that writes it at all: a headless --run never opened an
|
||||
editor, and writing from it would replace the open QElectroTech's live
|
||||
channel with its own empty one.
|
||||
*/
|
||||
void refresh()
|
||||
{
|
||||
if (s_watching) write();
|
||||
}
|
||||
|
||||
void write()
|
||||
{
|
||||
QJsonArray scripts, refused;
|
||||
for (const ScriptLibrary::Script &s : ScriptLibrary::instance().scripts()) {
|
||||
const ScriptHeader &h = s.header;
|
||||
scripts.append(QJsonObject{
|
||||
{QStringLiteral("id"), h.id},
|
||||
{QStringLiteral("name"), h.name},
|
||||
{QStringLiteral("file"), s.path},
|
||||
{QStringLiteral("icon"), h.icon},
|
||||
{QStringLiteral("tooltip"), h.tooltip},
|
||||
{QStringLiteral("shortcut"), h.shortcut},
|
||||
{QStringLiteral("context"), h.context},
|
||||
{QStringLiteral("action_id"), ScriptLibrary::actionId(h.id)}});
|
||||
}
|
||||
for (const QString &e : ScriptLibrary::instance().errors()) refused.append(e);
|
||||
|
||||
const QJsonObject info{
|
||||
{QStringLiteral("format"), 1},
|
||||
{QStringLiteral("qelectrotech_version"), QetVersion::displayedVersion()},
|
||||
{QStringLiteral("program"), QCoreApplication::applicationFilePath()},
|
||||
{QStringLiteral("pid"), QCoreApplication::applicationPid()},
|
||||
{QStringLiteral("running"), s_running},
|
||||
{QStringLiteral("written"), QDateTime::currentDateTimeUtc().toString(Qt::ISODate)},
|
||||
{QStringLiteral("folders"), QJsonObject{
|
||||
{QStringLiteral("data"), QETApp::dataDir()},
|
||||
{QStringLiteral("config"), QETApp::configDir()},
|
||||
{QStringLiteral("scripts"), ScriptLibrary::folder()},
|
||||
{QStringLiteral("recordings"), MacroRecorder::folder()},
|
||||
{QStringLiteral("elements_common"), QETApp::commonElementsDir()},
|
||||
{QStringLiteral("elements_company"), QETApp::companyElementsDir()},
|
||||
{QStringLiteral("elements_custom"), QETApp::customElementsDir()},
|
||||
{QStringLiteral("titleblocks_common"), QETApp::commonTitleBlockTemplatesDir()},
|
||||
{QStringLiteral("titleblocks_company"), QETApp::companyTitleBlockTemplatesDir()},
|
||||
{QStringLiteral("titleblocks_custom"), QETApp::customTitleBlockTemplatesDir()}}},
|
||||
{QStringLiteral("features"), QJsonObject{
|
||||
#ifdef QET_HAS_SCRIPTING
|
||||
{QStringLiteral("scripting_available"), true},
|
||||
#else
|
||||
{QStringLiteral("scripting_available"), false},
|
||||
#endif
|
||||
{QStringLiteral("scripting_enabled"), QetSettings::scriptingEnabled()},
|
||||
{QStringLiteral("live_mode_setting"), QetSettings::liveAssistantEnabled()},
|
||||
{QStringLiteral("live_mode_open"), !s_live.isEmpty()},
|
||||
{QStringLiteral("macro_recording"), MacroRecorder::instance().isRecording()}}},
|
||||
{QStringLiteral("script_api"), QJsonArray::fromStringList(QetScriptApi::signatures())},
|
||||
{QStringLiteral("stored_scripts"), scripts},
|
||||
{QStringLiteral("refused_scripts"), refused},
|
||||
{QStringLiteral("recordings"), MacroRecorder::recordings()},
|
||||
{QStringLiteral("live"), s_live.isEmpty() ? QJsonValue() : QJsonValue(s_live)},
|
||||
// Free-text drawing conventions, set once by the user
|
||||
// (QetSettings::setHouseStyle(), qet.setHouseStyle()) and read
|
||||
// by any assistant that connects, live or headless, without it
|
||||
// having to ask the user or guess. Null, not "", when unset --
|
||||
// the two mean different things to a reader.
|
||||
{QStringLiteral("house_style"), QetSettings::houseStyle().isEmpty()
|
||||
? QJsonValue() : QJsonValue(QetSettings::houseStyle())}};
|
||||
|
||||
const QString file_path = path();
|
||||
QDir().mkpath(QFileInfo(file_path).path());
|
||||
QSaveFile file(file_path);
|
||||
if (!file.open(QIODevice::WriteOnly)) return;
|
||||
file.write(QJsonDocument(info).toJson(QJsonDocument::Indented));
|
||||
if (file.commit())
|
||||
QFile::setPermissions(file_path, QFile::ReadOwner | QFile::WriteOwner);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief setLive
|
||||
The live channel's details while it is open (socket, token), or an
|
||||
empty object once it closes.
|
||||
*/
|
||||
void setLive(const QJsonObject &live)
|
||||
{
|
||||
s_live = live;
|
||||
write();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief markStopped
|
||||
QElectroTech is quitting: the file stays, as a record of where things
|
||||
are, but says nothing is listening any more.
|
||||
*/
|
||||
void markStopped()
|
||||
{
|
||||
s_running = false;
|
||||
s_live = QJsonObject();
|
||||
write();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef ASSISTANTINFO_H
|
||||
#define ASSISTANTINFO_H
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
/**
|
||||
@brief qet-assistant.json: what an AI assistant (through the qet MCP
|
||||
server) needs to know about this QElectroTech, in one file.
|
||||
|
||||
Where its folders are -- data, settings, scripts, the element and title
|
||||
block collections --, which features are switched on, every call a
|
||||
script can make, the stored scripts, and, while live mode is open, how
|
||||
to reach it. QElectroTech rewrites it when it starts and whenever one of
|
||||
those changes, so the MCP server reads facts instead of guessing them
|
||||
per platform -- wrong as soon as QElectroTech is started with
|
||||
--data-dir or --config-dir.
|
||||
|
||||
It always lives in the standard data folder for the platform, even
|
||||
when --data-dir moves the data elsewhere: that is the one place the
|
||||
server can look without being told. Readable by the user only: while
|
||||
live mode is open it holds the channel's token.
|
||||
*/
|
||||
namespace AssistantInfo
|
||||
{
|
||||
QString path();
|
||||
void watch();
|
||||
void write();
|
||||
void refresh();
|
||||
void setLive(const QJsonObject &live);
|
||||
void markStopped();
|
||||
}
|
||||
|
||||
#endif // ASSISTANTINFO_H
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef LIVESERVER_H
|
||||
#define LIVESERVER_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
|
||||
class QLocalServer;
|
||||
class QLocalSocket;
|
||||
class QETDiagramEditor;
|
||||
class QWidget;
|
||||
|
||||
/**
|
||||
@brief The LiveServer class
|
||||
Live mode: an AI assistant, through the qet MCP server, runs scripts on
|
||||
the project open in this QElectroTech while the user watches.
|
||||
|
||||
Three things must all be true before anything can connect: scripting is
|
||||
allowed, the "mode direct" setting is on (off by default), and the user
|
||||
accepted the warning shown at this start (or once ticked "don't ask
|
||||
again", which lasts until the setting is switched off). With the setting off this
|
||||
class opens nothing and QElectroTech behaves as if it did not exist.
|
||||
|
||||
The channel is a QLocalServer only the user's own account can open,
|
||||
with a random name and token written to the "live" part of
|
||||
qet-assistant.json (AssistantInfo), which the MCP server reads, and
|
||||
cleared when the channel closes. One JSON request per connection, one JSON answer back, then
|
||||
the server closes the connection; every request carries the token.
|
||||
|
||||
Requests are never handled inside the socket's readyRead: each is
|
||||
queued to the event loop first. QETApp::receiveMessage() learnt why --
|
||||
a modal dialog opened from inside a socket handler crashed QElectroTech
|
||||
once the socket went away (PR #861).
|
||||
*/
|
||||
class LiveServer : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum State { Off, Waiting, Connected };
|
||||
|
||||
static LiveServer &instance();
|
||||
|
||||
void askAndStart(QWidget *parent);
|
||||
void stop();
|
||||
State state() const { return m_state; }
|
||||
bool askFirst() const { return m_ask_first; }
|
||||
void setAskFirst(bool ask);
|
||||
static QStringList allowedCommands();
|
||||
|
||||
signals:
|
||||
void stateChanged(LiveServer::State state);
|
||||
/// A request was handled: what was asked, and the answer sent
|
||||
void handled(const QJsonObject &request, const QJsonObject &answer);
|
||||
void askFirstChanged(bool ask);
|
||||
|
||||
private:
|
||||
LiveServer();
|
||||
~LiveServer() override;
|
||||
bool start();
|
||||
void setState(State state);
|
||||
void newConnection();
|
||||
void readClient();
|
||||
void handle(const QJsonObject &request, qint64 received_ns);
|
||||
void send(const QJsonObject &answer);
|
||||
QJsonObject status();
|
||||
QJsonObject runScript(const QString &name, const QString &source);
|
||||
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 saveProject(const QJsonObject &request);
|
||||
QJsonObject closeProject(const QJsonObject &request);
|
||||
QJsonObject print(const QJsonObject &request);
|
||||
QJsonObject changes(const QJsonObject &request);
|
||||
QJsonObject snapshot(const QString &path);
|
||||
QJsonObject undoLast();
|
||||
QJsonObject screenshot();
|
||||
bool confirm(const QString &name, const QString &source);
|
||||
QETDiagramEditor *editor() const;
|
||||
|
||||
QLocalServer *m_server = nullptr;
|
||||
QPointer<QLocalSocket> m_client;
|
||||
QByteArray m_buffer;
|
||||
QString m_token;
|
||||
State m_state = Off;
|
||||
bool m_asked = false;
|
||||
bool m_busy = false;
|
||||
bool m_ask_first = true; ///< saved: QetSettings::liveAskFirst()
|
||||
};
|
||||
|
||||
#endif // LIVESERVER_H
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "macrorecorder.h"
|
||||
|
||||
#include "assistantinfo.h"
|
||||
#include "../diagram.h"
|
||||
#include "../diagramview.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetdiagrameditor.h"
|
||||
#include "../qetgraphicsitem/element.h"
|
||||
#include "../qetproject.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QUndoStack>
|
||||
|
||||
namespace {
|
||||
bool writeFile(const QString &path, const QByteArray &data)
|
||||
{
|
||||
QSaveFile file(path);
|
||||
return file.open(QIODevice::WriteOnly) && file.write(data) >= 0 && file.commit();
|
||||
}
|
||||
}
|
||||
|
||||
MacroRecorder &MacroRecorder::instance()
|
||||
{
|
||||
static MacroRecorder recorder;
|
||||
return recorder;
|
||||
}
|
||||
|
||||
QString MacroRecorder::folder()
|
||||
{
|
||||
return QETApp::dataDir() + QStringLiteral("/recordings");
|
||||
}
|
||||
|
||||
/**
|
||||
@brief MacroRecorder::recordings
|
||||
What qet-assistant.json lists: every saved recording, newest first.
|
||||
*/
|
||||
QJsonArray MacroRecorder::recordings()
|
||||
{
|
||||
QJsonArray list;
|
||||
QDir dir(folder());
|
||||
const QStringList ids = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot,
|
||||
QDir::Name | QDir::Reversed);
|
||||
for (const QString &id : ids) {
|
||||
QFile file(dir.filePath(id + QStringLiteral("/recording.json")));
|
||||
if (!file.open(QIODevice::ReadOnly)) continue;
|
||||
const QJsonObject r = QJsonDocument::fromJson(file.readAll()).object();
|
||||
list.append(QJsonObject{
|
||||
{QStringLiteral("id"), id},
|
||||
{QStringLiteral("name"), r.value(QStringLiteral("name"))},
|
||||
{QStringLiteral("steps"), r.value(QStringLiteral("steps")).toArray().size()},
|
||||
{QStringLiteral("started"), r.value(QStringLiteral("started"))},
|
||||
{QStringLiteral("complete"), r.value(QStringLiteral("complete"))},
|
||||
{QStringLiteral("folder"), dir.filePath(id)}});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
bool MacroRecorder::start(QETDiagramEditor *editor)
|
||||
{
|
||||
QETProject *project = editor ? editor->currentProject() : nullptr;
|
||||
if (!project || isRecording()) return false;
|
||||
|
||||
m_id = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss"));
|
||||
m_dir = folder() + QLatin1Char('/') + m_id;
|
||||
if (!QDir().mkpath(m_dir + QStringLiteral("/steps"))) return false;
|
||||
if (!writeFile(m_dir + QStringLiteral("/before.qet"), project->toXml().toByteArray(4)))
|
||||
return false;
|
||||
|
||||
m_project = project;
|
||||
m_editor = editor;
|
||||
m_started = QDateTime::currentDateTime().toString(Qt::ISODate);
|
||||
m_project_title = project->title();
|
||||
m_project_path = project->filePath();
|
||||
m_steps = QJsonArray();
|
||||
m_start = where();
|
||||
m_last_index = project->undoStack()->index();
|
||||
connect(project->undoStack(), &QUndoStack::indexChanged,
|
||||
this, &MacroRecorder::undoIndexChanged);
|
||||
//Closed while recording: keep what was recorded
|
||||
connect(project, &QObject::destroyed, this, [this]() { finish(true); });
|
||||
|
||||
emit stateChanged(true, 0);
|
||||
AssistantInfo::write();
|
||||
return true;
|
||||
}
|
||||
|
||||
void MacroRecorder::undoIndexChanged(int index)
|
||||
{
|
||||
if (!m_project) return;
|
||||
QUndoStack *stack = m_project->undoStack();
|
||||
if (index > m_last_index) {
|
||||
for (int i = m_last_index; i < index; ++i) {
|
||||
const QUndoCommand *cmd = stack->command(i);
|
||||
if (!cmd) continue;
|
||||
QStringList parts;
|
||||
for (int c = 0; c < cmd->childCount(); ++c) parts << cmd->child(c)->text();
|
||||
addStep(QStringLiteral("do"), cmd->text(), parts);
|
||||
}
|
||||
} else if (index < m_last_index) {
|
||||
for (int i = m_last_index - 1; i >= index; --i) {
|
||||
const QUndoCommand *cmd = stack->command(i);
|
||||
addStep(QStringLiteral("undo"), cmd ? cmd->text() : QString(), {});
|
||||
}
|
||||
}
|
||||
m_last_index = index;
|
||||
}
|
||||
|
||||
void MacroRecorder::addStep(const QString &kind, const QString &text, const QStringList &parts)
|
||||
{
|
||||
const int n = int(m_steps.size()) + 1;
|
||||
QJsonObject step{
|
||||
{QStringLiteral("n"), n},
|
||||
{QStringLiteral("kind"), kind},
|
||||
{QStringLiteral("undo_text"), text},
|
||||
{QStringLiteral("parts"), QJsonArray::fromStringList(parts)}};
|
||||
|
||||
const QJsonObject here = where();
|
||||
for (auto it = here.begin(); it != here.end(); ++it) step.insert(it.key(), it.value());
|
||||
DiagramView *view = m_editor ? m_editor->currentDiagramView() : nullptr;
|
||||
Diagram *diagram = view ? view->diagram() : nullptr;
|
||||
if (diagram && diagram->project() == m_project) {
|
||||
const QString file = QStringLiteral("steps/%1.xml").arg(n, 3, 10, QLatin1Char('0'));
|
||||
if (writeFile(m_dir + QLatin1Char('/') + file, diagram->toXml(true).toByteArray(4)))
|
||||
step.insert(QStringLiteral("folio_file"), file);
|
||||
}
|
||||
m_steps.append(step);
|
||||
emit stateChanged(true, n);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief MacroRecorder::where
|
||||
The folio on screen and the elements selected on it: where the person
|
||||
was working, which a script that acts "on the selection" needs to be
|
||||
checked against.
|
||||
*/
|
||||
QJsonObject MacroRecorder::where() const
|
||||
{
|
||||
DiagramView *view = m_editor ? m_editor->currentDiagramView() : nullptr;
|
||||
Diagram *diagram = view ? view->diagram() : nullptr;
|
||||
if (!diagram || diagram->project() != m_project) return {};
|
||||
QJsonArray selected;
|
||||
for (QGraphicsItem *item : diagram->selectedItems()) {
|
||||
if (item->type() != Element::Type) continue;
|
||||
auto *element = static_cast<Element *>(item);
|
||||
selected.append(QJsonObject{
|
||||
{QStringLiteral("uuid"), element->uuid().toString()},
|
||||
{QStringLiteral("label"), element->actualLabel()},
|
||||
{QStringLiteral("x"), element->pos().x()},
|
||||
{QStringLiteral("y"), element->pos().y()}});
|
||||
}
|
||||
return {{QStringLiteral("folio"), int(m_project->diagrams().indexOf(diagram))},
|
||||
{QStringLiteral("folio_title"), diagram->title()},
|
||||
{QStringLiteral("selected_elements"), selected}};
|
||||
}
|
||||
|
||||
void MacroRecorder::stop()
|
||||
{
|
||||
if (isRecording()) finish(false);
|
||||
}
|
||||
|
||||
void MacroRecorder::finish(bool project_closed)
|
||||
{
|
||||
if (m_dir.isEmpty()) return;
|
||||
QETDiagramEditor *editor = m_editor;
|
||||
if (m_project) {
|
||||
disconnect(m_project->undoStack(), nullptr, this, nullptr);
|
||||
disconnect(m_project, nullptr, this, nullptr);
|
||||
}
|
||||
bool complete = false;
|
||||
if (!project_closed && m_project)
|
||||
complete = writeFile(m_dir + QStringLiteral("/after.qet"),
|
||||
m_project->toXml().toByteArray(4));
|
||||
|
||||
const QJsonObject recording{
|
||||
{QStringLiteral("format"), 1},
|
||||
{QStringLiteral("id"), m_id},
|
||||
{QStringLiteral("name"), tr("Macro du %1").arg(
|
||||
QDateTime::fromString(m_started, Qt::ISODate).toString(QStringLiteral("dd/MM/yyyy HH:mm")))},
|
||||
{QStringLiteral("project_title"), m_project_title},
|
||||
{QStringLiteral("project_path"), m_project_path},
|
||||
{QStringLiteral("started"), m_started},
|
||||
{QStringLiteral("stopped"), QDateTime::currentDateTime().toString(Qt::ISODate)},
|
||||
{QStringLiteral("complete"), complete},
|
||||
{QStringLiteral("note"), complete ? QString()
|
||||
: tr("projet fermé pendant l'enregistrement : pas de after.qet")},
|
||||
{QStringLiteral("before"), QStringLiteral("before.qet")},
|
||||
{QStringLiteral("after"), complete ? QJsonValue(QStringLiteral("after.qet")) : QJsonValue()},
|
||||
{QStringLiteral("folder"), m_dir},
|
||||
{QStringLiteral("start"), m_start},
|
||||
{QStringLiteral("steps"), m_steps}};
|
||||
writeFile(m_dir + QStringLiteral("/recording.json"),
|
||||
QJsonDocument(recording).toJson(QJsonDocument::Indented));
|
||||
|
||||
m_last_id = m_id;
|
||||
m_project = nullptr;
|
||||
m_editor = nullptr;
|
||||
m_dir.clear();
|
||||
emit stateChanged(false, int(m_steps.size()));
|
||||
AssistantInfo::write();
|
||||
emit finished(recording, editor);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief MacroRecorder::assistantRequest
|
||||
What "Copier la demande pour l'assistant" puts on the clipboard: the
|
||||
recording named, and the round the assistant should follow.
|
||||
*/
|
||||
QString MacroRecorder::assistantRequest(const QJsonObject &recording)
|
||||
{
|
||||
const int steps = int(recording.value(QStringLiteral("steps")).toArray().size());
|
||||
return tr("J'ai enregistré une macro dans QElectroTech : « %1 », %n étape(s) "
|
||||
"(identifiant %2, dossier %3).\n"
|
||||
"Avec le serveur MCP qet : lis-la avec qet_recording_read, écris un "
|
||||
"script qui fait la même chose de façon générale (par exemple sur les "
|
||||
"éléments sélectionnés plutôt que sur ceux-là précisément), vérifie-le "
|
||||
"avec qet_recording_check jusqu'à ce qu'il corresponde, puis propose-le "
|
||||
"comme bouton avec qet_script_install.", nullptr, steps)
|
||||
.arg(recording.value(QStringLiteral("name")).toString())
|
||||
.arg(recording.value(QStringLiteral("id")).toString(),
|
||||
QDir::toNativeSeparators(recording.value(QStringLiteral("folder")).toString()));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef MACRORECORDER_H
|
||||
#define MACRORECORDER_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
|
||||
class QETDiagramEditor;
|
||||
class QETProject;
|
||||
|
||||
/**
|
||||
@brief The MacroRecorder class
|
||||
Records what a person does by hand on one project, so an AI assistant
|
||||
can turn it into a script (see MACRO-RECORDER-SCOPE.md):
|
||||
|
||||
@code
|
||||
<data folder>/recordings/<id>/
|
||||
recording.json name, project, times, the steps
|
||||
before.qet the whole project when recording started
|
||||
after.qet the whole project when it stopped
|
||||
steps/003.xml the folio a step happened on, after the step
|
||||
@endcode
|
||||
|
||||
Steps come from the project's undo history -- each new entry is one
|
||||
step, with its name and the names of the commands inside it -- so no
|
||||
editing command has to be taught to the recorder. Before and after are
|
||||
exact whatever was done; the steps give the order. Nothing new walks
|
||||
the scene: the files are QElectroTech's own serialisation.
|
||||
|
||||
QElectroTech cannot send anything to an assistant; the assistant fetches
|
||||
recordings through the qet MCP server, and qet-assistant.json lists them.
|
||||
*/
|
||||
class MacroRecorder : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static MacroRecorder &instance();
|
||||
static QString folder();
|
||||
static QJsonArray recordings();
|
||||
|
||||
bool start(QETDiagramEditor *editor);
|
||||
void stop();
|
||||
bool isRecording() const { return !m_project.isNull(); }
|
||||
int stepCount() const { return int(m_steps.size()); }
|
||||
QString lastId() const { return m_last_id; }
|
||||
static QString assistantRequest(const QJsonObject &recording);
|
||||
|
||||
signals:
|
||||
void stateChanged(bool recording, int steps);
|
||||
/// A recording was saved; @a recording is its recording.json
|
||||
void finished(const QJsonObject &recording, QETDiagramEditor *editor);
|
||||
|
||||
private:
|
||||
MacroRecorder() = default;
|
||||
void undoIndexChanged(int index);
|
||||
void addStep(const QString &kind, const QString &text, const QStringList &parts);
|
||||
void finish(bool project_closed);
|
||||
QJsonObject where() const;
|
||||
|
||||
QPointer<QETProject> m_project;
|
||||
QPointer<QETDiagramEditor> m_editor;
|
||||
QString m_id;
|
||||
QString m_dir;
|
||||
QString m_started;
|
||||
QString m_project_title;
|
||||
QString m_project_path;
|
||||
QJsonArray m_steps;
|
||||
QJsonObject m_start; ///< folio and selection when recording started
|
||||
QString m_last_id;
|
||||
int m_last_index = 0;
|
||||
};
|
||||
|
||||
#endif // MACRORECORDER_H
|
||||
File diff suppressed because it is too large
Load Diff
@@ -259,7 +259,18 @@ class QetGraphicsTableItem;
|
||||
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:
|
||||
A seventh, write-only field, "preset", sizes the frame for a sheet of
|
||||
paper: "a4-landscape", "tabloid-portrait" and so on (folioPresets()
|
||||
lists them). It picks the column and row counts and whole-number
|
||||
sizes that fill the sheet best without going over it, keeping each
|
||||
size as near the current one as it can, and pushes them as one
|
||||
ChangeBorderCommand. Whole numbers because the folio properties panel
|
||||
edits these sizes in whole pixels: a fraction would be rounded off the
|
||||
first time someone opened it. Two read-only fields, "width" and
|
||||
"height", are the frame and title block together in scene units --
|
||||
what an export draws -- so a caller can check the result.
|
||||
|
||||
A folio's title block @b template is a separate case again:
|
||||
Diagram::setTitleBlockTemplate() resolves a name only against
|
||||
QETProject::embeddedTitleBlockTemplatesCollection() -- the same
|
||||
copy-into-the-project step addElement() already does for elements,
|
||||
@@ -283,7 +294,12 @@ class QetGraphicsTableItem;
|
||||
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
|
||||
that a move landed. terminalPosition() is where a wire docks on one
|
||||
terminal and which way it leaves, so a symbol can be placed with a
|
||||
terminal exactly in line with another one before any wire exists;
|
||||
conductorPath() is a wire's drawn path by its uuid, for any wire,
|
||||
where conductorSegments() needs a terminal carrying only that one.
|
||||
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
|
||||
@@ -359,6 +375,7 @@ class QetScriptApi : public QObject
|
||||
Q_INVOKABLE QString projectTitle() const;
|
||||
Q_INVOKABLE QString filePath() const;
|
||||
Q_INVOKABLE int folioCount() const;
|
||||
Q_INVOKABLE int currentFolio() const;
|
||||
Q_INVOKABLE QString folioTitle(int index) const;
|
||||
Q_INVOKABLE QString folioUuid(int index) const;
|
||||
Q_INVOKABLE int folioIndex(const QString &uuid) const;
|
||||
@@ -372,7 +389,8 @@ class QetScriptApi : public QObject
|
||||
Q_INVOKABLE bool exportDxf(const QString &outDir, bool showTerminals = false);
|
||||
Q_INVOKABLE bool exportCables(const QString &output);
|
||||
Q_INVOKABLE bool exportWires(const QString &output);
|
||||
Q_INVOKABLE bool exportBom(const QString &output);
|
||||
Q_INVOKABLE bool exportBom(const QString &output, bool noSlaves = false,
|
||||
bool noJunctions = false);
|
||||
Q_INVOKABLE bool exportCableList(const QString &output);
|
||||
Q_INVOKABLE bool exportWiring(const QString &output);
|
||||
Q_INVOKABLE bool exportNets(const QString &output);
|
||||
@@ -386,6 +404,8 @@ class QetScriptApi : public QObject
|
||||
Q_INVOKABLE bool setElementPosition(int folioIndex, const QString &elementUuid, double x, double y);
|
||||
Q_INVOKABLE bool moveElement(int folioIndex, const QString &elementUuid, double dx, double dy);
|
||||
Q_INVOKABLE bool rotateElement(int folioIndex, const QString &elementUuid, double angle);
|
||||
Q_INVOKABLE bool mirrorElement(int folioIndex, const QString &elementUuid, bool vertical = false);
|
||||
Q_INVOKABLE QString elementMirror(int folioIndex, const QString &elementUuid) const;
|
||||
Q_INVOKABLE bool deleteElement(int folioIndex, const QString &elementUuid);
|
||||
|
||||
// -- address what is already there --
|
||||
@@ -423,6 +443,11 @@ class QetScriptApi : public QObject
|
||||
Q_INVOKABLE bool moveConductorSegment(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex, int segmentIndex,
|
||||
double dx, double dy);
|
||||
Q_INVOKABLE QString routeConductor(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex);
|
||||
Q_INVOKABLE QString routeConductorBetween(int folioIndex,
|
||||
const QString &elementUuidA, int terminalIndexA,
|
||||
const QString &elementUuidB, int terminalIndexB);
|
||||
|
||||
// -- cross-references: master/slave and report links --
|
||||
Q_INVOKABLE QString elementLinkType(int folioIndex, const QString &elementUuid) const;
|
||||
@@ -507,9 +532,15 @@ class QetScriptApi : public QObject
|
||||
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 renameAutoNum(const QString &kind, const QString &name, const QString &newName);
|
||||
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);
|
||||
Q_INVOKABLE int renumberElementAutoNum(const QString &name);
|
||||
Q_INVOKABLE QVariantList freeElementNumbers(int folioIndex, const QString &elementUuid);
|
||||
Q_INVOKABLE bool assignElementNumber(int folioIndex, const QString &elementUuid, int number);
|
||||
Q_INVOKABLE bool assignElementAutoNum(const QString &name, int folioIndex,
|
||||
const QString &elementUuid, bool overwrite);
|
||||
|
||||
// -- images, embedded in the project --
|
||||
Q_INVOKABLE QStringList images(int folioIndex) const;
|
||||
@@ -517,6 +548,8 @@ class QetScriptApi : public QObject
|
||||
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 cropImage(int folioIndex, int imageIndex, int x, int y, int width, int height);
|
||||
Q_INVOKABLE QVariantMap imageCrop(int folioIndex, int imageIndex) const;
|
||||
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);
|
||||
@@ -534,6 +567,8 @@ class QetScriptApi : public QObject
|
||||
Q_INVOKABLE QString elementTextProperty(int folioIndex, const QString &elementUuid,
|
||||
int textIndex, const QString &property) const;
|
||||
Q_INVOKABLE bool deleteElementText(int folioIndex, const QString &elementUuid, int textIndex);
|
||||
Q_INVOKABLE QVariantMap elementTextGeometry(int folioIndex, const QString &elementUuid,
|
||||
int textIndex) const;
|
||||
|
||||
// -- copy elements (with the conductors between them) to a position --
|
||||
Q_INVOKABLE QStringList duplicateElements(int fromFolioIndex, const QStringList &elementUuids,
|
||||
@@ -543,6 +578,17 @@ class QetScriptApi : public QObject
|
||||
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);
|
||||
Q_INVOKABLE QStringList folioPresets() const;
|
||||
|
||||
// -- this installation's drawing conventions (grid, flow, routing, tagging, --
|
||||
// -- grouping, ...), free text, for every assistant that connects to read --
|
||||
Q_INVOKABLE QString houseStyle() const;
|
||||
Q_INVOKABLE bool setHouseStyle(const QString &text);
|
||||
|
||||
// -- the conductor defaults of a folio (Folio properties > Conductors),
|
||||
// or with folioIndex -1, the project's defaults for new folios --
|
||||
Q_INVOKABLE QString conductorDefault(int folioIndex, const QString &property) const;
|
||||
Q_INVOKABLE bool setConductorDefault(int folioIndex, const QString &property, const QString &value);
|
||||
|
||||
// -- title block templates: which exist, embedding one into the project --
|
||||
Q_INVOKABLE QStringList titleBlockTemplates() const;
|
||||
@@ -550,6 +596,9 @@ class QetScriptApi : public QObject
|
||||
|
||||
// -- read an element's geometry --
|
||||
Q_INVOKABLE QVariantMap elementGeometry(int folioIndex, const QString &elementUuid) const;
|
||||
Q_INVOKABLE QVariantMap terminalPosition(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex) const;
|
||||
Q_INVOKABLE QVariantList conductorPath(int folioIndex, const QString &conductorUuid) const;
|
||||
|
||||
// -- folios --
|
||||
Q_INVOKABLE int addFolio();
|
||||
@@ -584,11 +633,19 @@ class QetScriptApi : public QObject
|
||||
// -- logging: a script has no console of its own --
|
||||
Q_INVOKABLE void log(const QString &message);
|
||||
|
||||
// -- what a script can call, read from this class itself --
|
||||
Q_INVOKABLE QStringList apiSignatures() const;
|
||||
static QStringList signatures();
|
||||
|
||||
void setUndoGrouped(bool grouped);
|
||||
void setLive(QStringList *log);
|
||||
|
||||
private:
|
||||
bool runFlag(const QString &flag, const QStringList &args);
|
||||
Element *findElement(int folioIndex, const QString &elementUuid) const;
|
||||
Terminal *findTerminal(int folioIndex, const QString &elementUuid, int terminalIndex,
|
||||
const QString &caller);
|
||||
QString applyRoute(Conductor *conductor, const QString &caller);
|
||||
Conductor *findConductor(int folioIndex, const QString &elementUuid, int terminalIndex,
|
||||
const QString &caller);
|
||||
QList<IndependentTextItem *> sortedTexts(int folioIndex) const;
|
||||
@@ -603,6 +660,8 @@ class QetScriptApi : public QObject
|
||||
|
||||
QETProject *m_project;
|
||||
DiagramView *m_view;
|
||||
bool m_undo_grouped = false;
|
||||
QStringList *m_live_log = nullptr; ///< set for a live run: log() and showMessage() go here
|
||||
QString m_query_error;
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <QFileInfo>
|
||||
#include <QObject>
|
||||
#include <QTextStream>
|
||||
#include <QUndoStack>
|
||||
|
||||
#ifdef QET_HAS_SCRIPTING
|
||||
#include <QJSEngine>
|
||||
@@ -112,7 +113,24 @@ namespace {
|
||||
constexpr int kScriptTimeoutMs = 30000;
|
||||
}
|
||||
|
||||
bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view)
|
||||
bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view,
|
||||
const QString &title)
|
||||
{
|
||||
QFile file(scriptPath);
|
||||
if (QetSettings::scriptingEnabled()
|
||||
&& !file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
err << "Cannot open script: " << scriptPath << "\n";
|
||||
return false;
|
||||
}
|
||||
const QString source = QString::fromUtf8(file.readAll());
|
||||
file.close();
|
||||
return runSource(source, scriptPath,
|
||||
title.isEmpty() ? QFileInfo(scriptPath).completeBaseName() : title,
|
||||
project, view, nullptr);
|
||||
}
|
||||
|
||||
bool runSource(const QString &source, const QString &fileName, const QString &title,
|
||||
QETProject *project, DiagramView *view, LiveRun *live)
|
||||
{
|
||||
// Checked here as well as at each caller, deliberately: this is the
|
||||
// one function that actually evaluates JavaScript, so it is the one
|
||||
@@ -121,23 +139,18 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v
|
||||
// on the command line, an offer to switch the setting on in the editor.
|
||||
if (!QetSettings::scriptingEnabled()) {
|
||||
err << refusalMessage() << "\n";
|
||||
if (view) {
|
||||
if (live) {
|
||||
live->error = refusalMessage();
|
||||
} else if (view) {
|
||||
QET::QetMessageBox::warning(nullptr, QObject::tr("Script"),
|
||||
refusalMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QFile file(scriptPath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
err << "Cannot open script: " << scriptPath << "\n";
|
||||
return false;
|
||||
}
|
||||
const QString source = QString::fromUtf8(file.readAll());
|
||||
file.close();
|
||||
|
||||
QJSEngine engine;
|
||||
auto *api = new QetScriptApi(project, view, &engine);
|
||||
if (live) api->setLive(&live->log);
|
||||
QJSValue qet_value = engine.newQObject(api);
|
||||
// newQObject() takes ownership by default (QJSEngine::JavaScriptOwnership),
|
||||
// which would delete api as soon as the engine's GC decides to -- api's
|
||||
@@ -162,7 +175,22 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v
|
||||
}
|
||||
});
|
||||
|
||||
QJSValue result = engine.evaluate(source, scriptPath);
|
||||
// From the editor, a run is one undo step: a script that places
|
||||
// twenty items is undone with one Ctrl+Z, not twenty. Headless --run
|
||||
// keeps one step per call, which is what a script calling qet.undo()
|
||||
// itself relies on. QUndoStack keeps an empty macro as a blank entry
|
||||
// (and marks the project modified), so a script that changed nothing
|
||||
// has its macro taken off again: an obsolete command is deleted by
|
||||
// QUndoStack::undo() instead of being undone onto the redo side.
|
||||
QUndoStack *stack = (view && project) ? project->undoStack() : nullptr;
|
||||
const QString undo_text = live ? QObject::tr("Assistant : %1").arg(title)
|
||||
: QObject::tr("Script : %1").arg(title);
|
||||
if (stack) {
|
||||
stack->beginMacro(undo_text);
|
||||
api->setUndoGrouped(true);
|
||||
}
|
||||
|
||||
QJSValue result = engine.evaluate(source, fileName);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mtx);
|
||||
finished = true;
|
||||
@@ -170,9 +198,23 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v
|
||||
cv.notify_one();
|
||||
watchdog.join();
|
||||
|
||||
if (stack) {
|
||||
api->setUndoGrouped(false);
|
||||
stack->endMacro();
|
||||
// Always the top of the undo side once the macro is closed, even
|
||||
// when beginMacro() dropped a redo tail and count() went down.
|
||||
const QUndoCommand *macro = stack->command(stack->index() - 1);
|
||||
if (macro && macro->childCount() == 0) {
|
||||
const_cast<QUndoCommand *>(macro)->setObsolete(true);
|
||||
stack->undo();
|
||||
} else if (live) {
|
||||
live->undoText = undo_text;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isError()) {
|
||||
const QString message = QStringLiteral("Script error: %1:%2: %3")
|
||||
.arg(scriptPath)
|
||||
.arg(fileName)
|
||||
.arg(result.property(QStringLiteral("lineNumber")).toInt())
|
||||
.arg(result.toString());
|
||||
err << message << "\n";
|
||||
@@ -180,7 +222,11 @@ bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *v
|
||||
// launched the GUI normally (nowhere on Windows, easy to miss
|
||||
// everywhere else). Headless --run has no GUI to show this in, and
|
||||
// no session for it to block.
|
||||
if (view) {
|
||||
if (live) {
|
||||
// The assistant gets the error and the user sees it in the
|
||||
// log: a box here would wait on someone who did not ask.
|
||||
live->error = message;
|
||||
} else if (view) {
|
||||
QET::QetMessageBox::critical(nullptr, QObject::tr("Script"), message);
|
||||
}
|
||||
return false;
|
||||
@@ -202,7 +248,14 @@ int run(const QStringList &)
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool runOnProject(const QString &, QETProject *, DiagramView *)
|
||||
bool runSource(const QString &, const QString &, const QString &, QETProject *,
|
||||
DiagramView *, LiveRun *live)
|
||||
{
|
||||
if (live) live->error = QStringLiteral("built without Qt Qml: no scripting");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool runOnProject(const QString &, QETProject *, DiagramView *, const QString &)
|
||||
{
|
||||
err << "This build of QElectroTech was compiled without the Qt Qml "
|
||||
"module, so JavaScript scripting is not available.\n";
|
||||
|
||||
@@ -51,14 +51,40 @@ namespace QetScripting {
|
||||
|
||||
/**
|
||||
@brief Run @p scriptPath against an already-open @p project (the
|
||||
"Run Script..." GUI macro path). Errors go to stderr; there is no
|
||||
modal reporting in this first version.
|
||||
"Run Script..." GUI macro path). Errors go to stderr, and with a
|
||||
@p view also to a message box. With a @p view the whole run is one
|
||||
undo step; without one, each call is its own step, as on --run.
|
||||
@param view the active DiagramView, so the script's zoom methods
|
||||
have something to act on; nullptr from the headless entry point,
|
||||
where they become no-ops (see QetScriptApi).
|
||||
@param title what the undo step is called; the script's file name
|
||||
if empty.
|
||||
@return true if the script ran without throwing.
|
||||
*/
|
||||
bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view = nullptr);
|
||||
bool runOnProject(const QString &scriptPath, QETProject *project, DiagramView *view = nullptr,
|
||||
const QString &title = QString());
|
||||
|
||||
/**
|
||||
@brief What a live run (an assistant acting on the open project,
|
||||
see LiveServer) reports instead of showing: what the script
|
||||
logged, the error if it threw, and the undo step it left, empty
|
||||
if it changed nothing.
|
||||
*/
|
||||
struct LiveRun
|
||||
{
|
||||
QStringList log;
|
||||
QString error;
|
||||
QString undoText;
|
||||
};
|
||||
|
||||
/**
|
||||
@brief Run @p source as runOnProject() runs a file. @p fileName is
|
||||
what errors name; @p title names the undo step. With @p live, no
|
||||
box is shown: qet.log() and errors are collected there, and
|
||||
qet.showMessage() is logged rather than shown.
|
||||
*/
|
||||
bool runSource(const QString &source, const QString &fileName, const QString &title,
|
||||
QETProject *project, DiagramView *view, LiveRun *live);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef SCRIPTHEADER_H
|
||||
#define SCRIPTHEADER_H
|
||||
|
||||
#include <QRegularExpression>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
/**
|
||||
@brief The ScriptHeader struct
|
||||
How a stored script's button looks, read from a comment block at the top
|
||||
of the .js file itself, so one file is all there is to write by hand,
|
||||
to share, or for an assistant to create:
|
||||
|
||||
@code
|
||||
// ==QETScript==
|
||||
// @name Add revision note
|
||||
// @icon note.svg (a file next to the script, or builtin:<theme icon>)
|
||||
// @tooltip Puts a "Rev A" note on the folio on screen
|
||||
// @shortcut Ctrl+Alt+R
|
||||
// @context canvas (canvas, selection or conductor)
|
||||
// @api 1
|
||||
// ==/QETScript==
|
||||
@endcode
|
||||
|
||||
Only @name is required. An unknown key is an error rather than ignored:
|
||||
a misspelt "@shortcut" that silently did nothing would be harder to find
|
||||
than a script refused with the line that is wrong.
|
||||
|
||||
Header-only, with no QElectroTech dependency, so it is tested on its own.
|
||||
*/
|
||||
struct ScriptHeader
|
||||
{
|
||||
QString id; ///< file name without .js; the action id is diagrameditor.script.<id>
|
||||
QString name;
|
||||
QString icon;
|
||||
QString tooltip;
|
||||
QString shortcut; ///< as QKeySequence::fromString() reads it
|
||||
QString context = QStringLiteral("canvas");
|
||||
int api = 1;
|
||||
QString error; ///< empty if the header is usable
|
||||
|
||||
bool isValid() const { return error.isEmpty(); }
|
||||
|
||||
static QStringList contexts()
|
||||
{
|
||||
return {QStringLiteral("canvas"), QStringLiteral("selection"),
|
||||
QStringLiteral("conductor")};
|
||||
}
|
||||
|
||||
/**
|
||||
@brief parse
|
||||
@param text : the whole script
|
||||
@param id : the script's file name without its extension
|
||||
*/
|
||||
static ScriptHeader parse(const QString &text, const QString &id)
|
||||
{
|
||||
ScriptHeader h;
|
||||
h.id = id;
|
||||
static const QRegularExpression block(
|
||||
QStringLiteral("//\\s*==QETScript==\\s*\\n(.*?)//\\s*==/QETScript=="),
|
||||
QRegularExpression::DotMatchesEverythingOption);
|
||||
const QRegularExpressionMatch m = block.match(text);
|
||||
if (!m.hasMatch()) {
|
||||
h.error = QStringLiteral("no // ==QETScript== header");
|
||||
return h;
|
||||
}
|
||||
static const QRegularExpression line_re(
|
||||
QStringLiteral("^\\s*//\\s*@(\\w+)\\s+(.*?)\\s*$"));
|
||||
const QStringList lines = m.captured(1).split(QLatin1Char('\n'));
|
||||
for (const QString &line : lines) {
|
||||
const QRegularExpressionMatch lm = line_re.match(line);
|
||||
if (!lm.hasMatch()) {
|
||||
continue;
|
||||
}
|
||||
const QString key = lm.captured(1);
|
||||
const QString value = lm.captured(2);
|
||||
if (key == QLatin1String("name")) h.name = value;
|
||||
else if (key == QLatin1String("icon")) h.icon = value;
|
||||
else if (key == QLatin1String("tooltip")) h.tooltip = value;
|
||||
else if (key == QLatin1String("shortcut")) h.shortcut = value;
|
||||
else if (key == QLatin1String("context")) h.context = value;
|
||||
else if (key == QLatin1String("api")) h.api = value.toInt();
|
||||
else {
|
||||
h.error = QStringLiteral("unknown header key @%1").arg(key);
|
||||
return h;
|
||||
}
|
||||
}
|
||||
if (h.name.isEmpty()) {
|
||||
h.error = QStringLiteral("@name is required");
|
||||
} else if (!contexts().contains(h.context)) {
|
||||
h.error = QStringLiteral("@context must be one of: %1")
|
||||
.arg(contexts().join(QStringLiteral(", ")));
|
||||
} else if (h.api != 1) {
|
||||
h.error = QStringLiteral("@api %1 is not supported by this "
|
||||
"version (1 is)").arg(h.api);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief compose
|
||||
A whole script file: the header parse() reads, then @a body. Empty
|
||||
fields are left out, so a file saved by the script manager and a
|
||||
hand-written one look alike.
|
||||
*/
|
||||
static QString compose(const QString &name, const QString &icon,
|
||||
const QString &tooltip, const QString &shortcut,
|
||||
const QString &context, const QString &body)
|
||||
{
|
||||
QString text = QStringLiteral("// ==QETScript==\n");
|
||||
auto line = [&text](const QString &key, const QString &value) {
|
||||
const QString v = value.simplified();
|
||||
if (!v.isEmpty())
|
||||
text += QStringLiteral("// @%1 %2\n").arg(key.leftJustified(8), v);
|
||||
};
|
||||
line(QStringLiteral("name"), name);
|
||||
line(QStringLiteral("icon"), icon);
|
||||
line(QStringLiteral("tooltip"), tooltip);
|
||||
line(QStringLiteral("shortcut"), shortcut);
|
||||
if (context != QLatin1String("canvas")) line(QStringLiteral("context"), context);
|
||||
line(QStringLiteral("api"), QStringLiteral("1"));
|
||||
text += QStringLiteral("// ==/QETScript==\n") + body;
|
||||
if (!text.endsWith(QLatin1Char('\n'))) text += QLatin1Char('\n');
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief bodyOf
|
||||
Everything after the header, or the whole text if there is none.
|
||||
*/
|
||||
static QString bodyOf(const QString &text)
|
||||
{
|
||||
const int end = text.indexOf(QStringLiteral("// ==/QETScript=="));
|
||||
if (end < 0) return text;
|
||||
const int next = text.indexOf(QLatin1Char('\n'), end);
|
||||
return next < 0 ? QString() : text.mid(next + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief idFor
|
||||
A file name for a new script called @a name: lower-case letters,
|
||||
digits and dashes, accents dropped, not one of @a taken.
|
||||
*/
|
||||
static QString idFor(const QString &name, const QStringList &taken)
|
||||
{
|
||||
QString base = name.normalized(QString::NormalizationForm_KD).toLower();
|
||||
static const QRegularExpression not_kept(QStringLiteral("[^a-z0-9\\s_-]"));
|
||||
base.remove(not_kept);
|
||||
base = base.simplified().replace(QLatin1Char(' '), QLatin1Char('-')).left(48);
|
||||
if (base.isEmpty()) base = QStringLiteral("script");
|
||||
QString id = base;
|
||||
for (int i = 2; taken.contains(id); ++i)
|
||||
id = base + QLatin1Char('-') + QString::number(i);
|
||||
return id;
|
||||
}
|
||||
};
|
||||
|
||||
#endif // SCRIPTHEADER_H
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "scriptlibrary.h"
|
||||
|
||||
#include "../qetapp.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
|
||||
ScriptLibrary &ScriptLibrary::instance()
|
||||
{
|
||||
static ScriptLibrary library;
|
||||
return library;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptLibrary::folder
|
||||
Where stored scripts live: "scripts" in the user's data folder, next to
|
||||
the user's own element and title block collections.
|
||||
*/
|
||||
QString ScriptLibrary::folder()
|
||||
{
|
||||
return QETApp::dataDir() + QStringLiteral("/scripts");
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptLibrary::actionId
|
||||
The ShortcutManager id of a script's action. The "diagrameditor."
|
||||
prefix is what puts it among the commands the shortcut bar can show.
|
||||
*/
|
||||
QString ScriptLibrary::actionId(const QString &script_id)
|
||||
{
|
||||
return QStringLiteral("diagrameditor.script.") + script_id;
|
||||
}
|
||||
|
||||
ScriptLibrary::ScriptLibrary()
|
||||
{
|
||||
//An editor saving a file often writes it more than once, or
|
||||
//replaces it through a rename: wait for it to settle.
|
||||
m_rescan_timer.setSingleShot(true);
|
||||
m_rescan_timer.setInterval(250);
|
||||
connect(&m_rescan_timer, &QTimer::timeout, this, &ScriptLibrary::rescan);
|
||||
connect(&m_watcher, &QFileSystemWatcher::directoryChanged,
|
||||
&m_rescan_timer, qOverload<>(&QTimer::start));
|
||||
connect(&m_watcher, &QFileSystemWatcher::fileChanged,
|
||||
&m_rescan_timer, qOverload<>(&QTimer::start));
|
||||
rescan();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptLibrary::watch
|
||||
Watch the folder and each script in it, or, while the folder does not
|
||||
exist yet, the data folder it will be created in.
|
||||
*/
|
||||
void ScriptLibrary::watch()
|
||||
{
|
||||
if (!m_watcher.files().isEmpty()) m_watcher.removePaths(m_watcher.files());
|
||||
if (!m_watcher.directories().isEmpty()) m_watcher.removePaths(m_watcher.directories());
|
||||
|
||||
const QDir dir(folder());
|
||||
if (!dir.exists()) {
|
||||
if (QFileInfo::exists(QETApp::dataDir())) {
|
||||
m_watcher.addPath(QETApp::dataDir());
|
||||
}
|
||||
return;
|
||||
}
|
||||
m_watcher.addPath(dir.path());
|
||||
QStringList files;
|
||||
for (const Script &s : std::as_const(m_scripts)) files << s.path;
|
||||
for (const QString &e : std::as_const(m_errors)) files << dir.filePath(e.section(QLatin1Char(':'), 0, 0));
|
||||
if (!files.isEmpty()) m_watcher.addPaths(files);
|
||||
}
|
||||
|
||||
void ScriptLibrary::rescan()
|
||||
{
|
||||
QList<Script> scripts;
|
||||
QStringList errors;
|
||||
const QDir dir(folder());
|
||||
const QFileInfoList infos = dir.entryInfoList({QStringLiteral("*.js")},
|
||||
QDir::Files, QDir::Name);
|
||||
for (const QFileInfo &info : infos) {
|
||||
QFile file(info.filePath());
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) continue;
|
||||
const ScriptHeader header = ScriptHeader::parse(
|
||||
QString::fromUtf8(file.readAll()), info.completeBaseName());
|
||||
if (header.isValid()) {
|
||||
scripts << Script{header, info.filePath()};
|
||||
} else {
|
||||
errors << info.fileName() + QStringLiteral(": ") + header.error;
|
||||
}
|
||||
}
|
||||
|
||||
auto signature = [](const QList<Script> &list) {
|
||||
QStringList parts;
|
||||
for (const Script &s : list) {
|
||||
const ScriptHeader &h = s.header;
|
||||
parts << QStringList{s.path, h.name, h.icon, h.tooltip, h.shortcut,
|
||||
h.context}.join(QLatin1Char('\x1f'));
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
const bool same = signature(scripts) == signature(m_scripts) && errors == m_errors;
|
||||
m_scripts = scripts;
|
||||
m_errors = errors;
|
||||
watch();
|
||||
//The script's own text is read again on every run, so only a
|
||||
//change to what its button shows needs the actions rebuilt --
|
||||
//except an icon file edited in place, which this cannot see; the
|
||||
//next change to the folder picks it up.
|
||||
if (!same) emit changed();
|
||||
}
|
||||
|
||||
QList<ScriptLibrary::Script> ScriptLibrary::scripts() const
|
||||
{
|
||||
return m_scripts;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptLibrary::errors
|
||||
One "file.js: reason" per script whose header could not be used, so
|
||||
whoever wrote it can be told why its button is missing.
|
||||
*/
|
||||
QStringList ScriptLibrary::errors() const
|
||||
{
|
||||
return m_errors;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptLibrary::icon
|
||||
The icon a script's header names -- "builtin:<name>" from the icon
|
||||
theme, or an image file, relative to the script -- or, failing that, a
|
||||
tile with the script's initials, so every button can be told apart.
|
||||
*/
|
||||
QIcon ScriptLibrary::icon(const Script &script)
|
||||
{
|
||||
const QString spec = script.header.icon;
|
||||
if (spec.startsWith(QLatin1String("builtin:"))) {
|
||||
const QIcon themed = QIcon::fromTheme(spec.mid(8));
|
||||
if (!themed.isNull()) return themed;
|
||||
} else if (!spec.isEmpty()) {
|
||||
const QString path = QFileInfo(script.path).dir().absoluteFilePath(spec);
|
||||
if (QFileInfo::exists(path)) {
|
||||
const QIcon file_icon(path);
|
||||
if (!file_icon.availableSizes().isEmpty()
|
||||
|| !file_icon.pixmap(32).isNull()) {
|
||||
return file_icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString initials;
|
||||
for (const QString &word : script.header.name.split(QLatin1Char(' '), Qt::SkipEmptyParts)) {
|
||||
if (word.at(0).isLetterOrNumber()) initials += word.at(0).toUpper();
|
||||
if (initials.size() == 2) break;
|
||||
}
|
||||
if (initials.isEmpty()) initials = QStringLiteral("JS");
|
||||
|
||||
const QPalette palette = QGuiApplication::palette();
|
||||
QPixmap pixmap(64, 64);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing);
|
||||
painter.setPen(Qt::NoPen);
|
||||
painter.setBrush(palette.color(QPalette::Highlight));
|
||||
painter.drawRoundedRect(QRectF(4, 4, 56, 56), 10, 10);
|
||||
QFont font = painter.font();
|
||||
font.setBold(true);
|
||||
font.setPixelSize(initials.size() == 1 ? 34 : 26);
|
||||
painter.setFont(font);
|
||||
painter.setPen(palette.color(QPalette::HighlightedText));
|
||||
painter.drawText(QRectF(4, 4, 56, 56), Qt::AlignCenter, initials);
|
||||
painter.end();
|
||||
return QIcon(pixmap);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef SCRIPTLIBRARY_H
|
||||
#define SCRIPTLIBRARY_H
|
||||
|
||||
#include "scriptheader.h"
|
||||
|
||||
#include <QFileSystemWatcher>
|
||||
#include <QIcon>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
|
||||
/**
|
||||
@brief The ScriptLibrary class
|
||||
The user's stored scripts: every .js file with a ScriptHeader in
|
||||
folder(). The folder is the whole interface. A script written by hand
|
||||
in any editor, one saved by the script manager and one installed by an
|
||||
assistant through the qet MCP server all arrive the same way, as a file,
|
||||
and the library notices it with a QFileSystemWatcher and emits changed().
|
||||
|
||||
One instance for the application, shared by every editor window.
|
||||
*/
|
||||
class ScriptLibrary : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
struct Script
|
||||
{
|
||||
ScriptHeader header;
|
||||
QString path;
|
||||
};
|
||||
|
||||
static ScriptLibrary &instance();
|
||||
static QString folder();
|
||||
static QString actionId(const QString &script_id);
|
||||
static QIcon icon(const Script &script);
|
||||
|
||||
QList<Script> scripts() const;
|
||||
QStringList errors() const;
|
||||
|
||||
signals:
|
||||
void changed();
|
||||
|
||||
private:
|
||||
ScriptLibrary();
|
||||
void rescan();
|
||||
void watch();
|
||||
|
||||
QFileSystemWatcher m_watcher;
|
||||
QTimer m_rescan_timer;
|
||||
QList<Script> m_scripts;
|
||||
QStringList m_errors;
|
||||
};
|
||||
|
||||
#endif // SCRIPTLIBRARY_H
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "scriptmanagerdialog.h"
|
||||
|
||||
#include "scriptlibrary.h"
|
||||
#include "../qetmessagebox.h"
|
||||
|
||||
#include <QCloseEvent>
|
||||
#include <QComboBox>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QFontDatabase>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QKeySequenceEdit>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <QSaveFile>
|
||||
#include <QToolButton>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
ScriptManagerDialog::ScriptManagerDialog(Runner runner, QWidget *parent) :
|
||||
QDialog(parent),
|
||||
m_runner(std::move(runner))
|
||||
{
|
||||
setWindowTitle(tr("Gérer les scripts"));
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
m_list = new QListWidget(this);
|
||||
m_list->setIconSize(QSize(24, 24));
|
||||
m_list->setMinimumWidth(220);
|
||||
auto *new_button = new QPushButton(tr("&Nouveau"), this);
|
||||
m_delete = new QPushButton(tr("Supp&rimer"), this);
|
||||
auto *left_buttons = new QHBoxLayout();
|
||||
left_buttons->addWidget(new_button);
|
||||
left_buttons->addWidget(m_delete);
|
||||
auto *left = new QVBoxLayout();
|
||||
left->addWidget(m_list);
|
||||
left->addLayout(left_buttons);
|
||||
|
||||
m_name = new QLineEdit(this);
|
||||
m_name->setPlaceholderText(tr("Le texte du bouton"));
|
||||
m_icon = new QLineEdit(this);
|
||||
m_icon->setPlaceholderText(tr("vide : les initiales du nom ; builtin:<nom> : une icône du thème"));
|
||||
m_icon_preview = new QToolButton(this);
|
||||
m_icon_preview->setIconSize(QSize(24, 24));
|
||||
m_icon_preview->setToolTip(tr("Choisir une image (SVG ou PNG)…"));
|
||||
auto *icon_row = new QHBoxLayout();
|
||||
icon_row->addWidget(m_icon);
|
||||
icon_row->addWidget(m_icon_preview);
|
||||
m_tooltip = new QLineEdit(this);
|
||||
m_shortcut = new QKeySequenceEdit(this);
|
||||
m_context = new QComboBox(this);
|
||||
m_context->addItem(tr("Toujours"), QStringLiteral("canvas"));
|
||||
m_context->addItem(tr("Avec une sélection"), QStringLiteral("selection"));
|
||||
m_context->addItem(tr("Avec un conducteur sélectionné"), QStringLiteral("conductor"));
|
||||
|
||||
auto *form = new QFormLayout();
|
||||
form->addRow(tr("Nom :"), m_name);
|
||||
form->addRow(tr("Icône :"), icon_row);
|
||||
form->addRow(tr("Info-bulle :"), m_tooltip);
|
||||
form->addRow(tr("Raccourci :"), m_shortcut);
|
||||
form->addRow(tr("Actif :"), m_context);
|
||||
|
||||
m_body = new QPlainTextEdit(this);
|
||||
m_body->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
|
||||
m_body->setLineWrapMode(QPlainTextEdit::NoWrap);
|
||||
m_body->setMinimumSize(480, 260);
|
||||
|
||||
m_status = new QLabel(this);
|
||||
m_status->setWordWrap(true);
|
||||
m_status->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
|
||||
m_test = new QPushButton(tr("&Tester"), this);
|
||||
m_test->setToolTip(tr("Enregistre puis exécute le script sur le projet courant"
|
||||
" (Ctrl+Z annule l'exécution)"));
|
||||
m_save = new QPushButton(tr("&Enregistrer"), this);
|
||||
auto *folder_button = new QPushButton(tr("Ouvrir le &dossier"), this);
|
||||
auto *close_button = new QPushButton(tr("&Fermer"), this);
|
||||
//Enter in a field saves; in the script it is a new line
|
||||
m_save->setDefault(true);
|
||||
auto *buttons = new QHBoxLayout();
|
||||
buttons->addWidget(m_test);
|
||||
buttons->addWidget(m_save);
|
||||
buttons->addStretch();
|
||||
buttons->addWidget(folder_button);
|
||||
buttons->addWidget(close_button);
|
||||
|
||||
auto *right = new QVBoxLayout();
|
||||
right->addLayout(form);
|
||||
right->addWidget(new QLabel(tr("Script (l'objet qet ; qet.currentFolio() est le folio affiché) :"), this));
|
||||
right->addWidget(m_body, 1);
|
||||
right->addWidget(m_status);
|
||||
right->addLayout(buttons);
|
||||
|
||||
auto *layout = new QHBoxLayout(this);
|
||||
layout->addLayout(left);
|
||||
layout->addLayout(right, 1);
|
||||
|
||||
connect(new_button, &QPushButton::clicked, this, &ScriptManagerDialog::newScript);
|
||||
connect(m_delete, &QPushButton::clicked, this, &ScriptManagerDialog::deleteScript);
|
||||
connect(m_save, &QPushButton::clicked, this, &ScriptManagerDialog::save);
|
||||
connect(m_test, &QPushButton::clicked, this, &ScriptManagerDialog::testScript);
|
||||
connect(m_icon_preview, &QToolButton::clicked, this, &ScriptManagerDialog::chooseIcon);
|
||||
connect(close_button, &QPushButton::clicked, this, &QDialog::close);
|
||||
connect(folder_button, &QPushButton::clicked, this, []() {
|
||||
QDir().mkpath(ScriptLibrary::folder());
|
||||
QDesktopServices::openUrl(QUrl::fromLocalFile(ScriptLibrary::folder()));
|
||||
});
|
||||
|
||||
auto modified = [this]() { if (!m_loading) setModified(true); };
|
||||
connect(m_name, &QLineEdit::textEdited, this, modified);
|
||||
connect(m_icon, &QLineEdit::textEdited, this, modified);
|
||||
connect(m_icon, &QLineEdit::textChanged, this, &ScriptManagerDialog::updateIconPreview);
|
||||
connect(m_name, &QLineEdit::textChanged, this, &ScriptManagerDialog::updateIconPreview);
|
||||
connect(m_tooltip, &QLineEdit::textEdited, this, modified);
|
||||
connect(m_shortcut, &QKeySequenceEdit::keySequenceChanged, this, modified);
|
||||
connect(m_context, &QComboBox::currentIndexChanged, this, modified);
|
||||
connect(m_body, &QPlainTextEdit::textChanged, this, modified);
|
||||
|
||||
connect(m_list, &QListWidget::currentItemChanged, this,
|
||||
[this](QListWidgetItem *current, QListWidgetItem *previous) {
|
||||
if (m_loading || !current) return;
|
||||
if (!confirmDiscard()) {
|
||||
m_loading = true;
|
||||
m_list->setCurrentItem(previous);
|
||||
m_loading = false;
|
||||
return;
|
||||
}
|
||||
showScript(current->data(Qt::UserRole).toString());
|
||||
});
|
||||
|
||||
//A file written by hand or by an assistant shows up here too
|
||||
connect(&ScriptLibrary::instance(), &ScriptLibrary::changed,
|
||||
this, &ScriptManagerDialog::reload);
|
||||
|
||||
reload();
|
||||
if (m_list->count()) {
|
||||
m_list->setCurrentRow(0);
|
||||
} else {
|
||||
newScript();
|
||||
}
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::reload()
|
||||
{
|
||||
m_loading = true;
|
||||
const QString current = m_path;
|
||||
m_list->clear();
|
||||
const ScriptLibrary &library = ScriptLibrary::instance();
|
||||
for (const ScriptLibrary::Script &script : library.scripts()) {
|
||||
auto *item = new QListWidgetItem(ScriptLibrary::icon(script), script.header.name, m_list);
|
||||
item->setData(Qt::UserRole, script.path);
|
||||
item->setToolTip(QFileInfo(script.path).fileName());
|
||||
if (script.path == current) m_list->setCurrentItem(item);
|
||||
}
|
||||
//Listed so they can be opened and fixed here
|
||||
for (const QString &error : library.errors()) {
|
||||
const QString file = error.section(QStringLiteral(": "), 0, 0);
|
||||
const QString path = QDir(ScriptLibrary::folder()).filePath(file);
|
||||
auto *item = new QListWidgetItem(file, m_list);
|
||||
item->setData(Qt::UserRole, path);
|
||||
item->setForeground(palette().color(QPalette::PlaceholderText));
|
||||
item->setToolTip(tr("Pas de bouton : %1").arg(error.section(QStringLiteral(": "), 1)));
|
||||
if (path == current) m_list->setCurrentItem(item);
|
||||
}
|
||||
m_loading = false;
|
||||
//Changed on disk and not here: show what is on disk now
|
||||
if (!m_modified && !m_path.isEmpty()) {
|
||||
if (QFileInfo::exists(m_path)) showScript(m_path);
|
||||
else clearForm();
|
||||
}
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::showScript(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return;
|
||||
const QString text = QString::fromUtf8(file.readAll());
|
||||
const ScriptHeader h = ScriptHeader::parse(text, QFileInfo(path).completeBaseName());
|
||||
|
||||
m_loading = true;
|
||||
m_path = path;
|
||||
m_name->setText(h.name);
|
||||
m_icon->setText(h.icon);
|
||||
m_tooltip->setText(h.tooltip);
|
||||
m_shortcut->setKeySequence(QKeySequence::fromString(h.shortcut));
|
||||
m_context->setCurrentIndex(qMax(0, m_context->findData(h.context)));
|
||||
m_body->setPlainText(ScriptHeader::bodyOf(text));
|
||||
m_loading = false;
|
||||
setModified(false);
|
||||
m_status->setText(h.isValid()
|
||||
? QDir::toNativeSeparators(path)
|
||||
: tr("Pas de bouton pour ce fichier : %1").arg(h.error));
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::clearForm()
|
||||
{
|
||||
m_loading = true;
|
||||
m_path.clear();
|
||||
m_name->clear();
|
||||
m_icon->clear();
|
||||
m_tooltip->clear();
|
||||
m_shortcut->clear();
|
||||
m_context->setCurrentIndex(0);
|
||||
m_body->clear();
|
||||
m_loading = false;
|
||||
setModified(false);
|
||||
m_status->clear();
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::newScript()
|
||||
{
|
||||
if (!confirmDiscard()) return;
|
||||
m_loading = true;
|
||||
m_list->clearSelection();
|
||||
m_list->setCurrentItem(nullptr);
|
||||
m_loading = false;
|
||||
clearForm();
|
||||
m_loading = true;
|
||||
m_name->setText(tr("Nouveau script"));
|
||||
m_body->setPlainText(tr(
|
||||
"// qet.currentFolio() est le folio affiché ; un clic s'annule\n"
|
||||
"// d'un seul Ctrl+Z. Liste des appels : qet.apiSignatures()\n"
|
||||
"var f = qet.currentFolio();\n"
|
||||
"qet.addText(f, \"Texte\", 40, 40);\n"));
|
||||
m_loading = false;
|
||||
setModified(true);
|
||||
m_status->setText(tr("Pas encore enregistré"));
|
||||
m_name->setFocus();
|
||||
m_name->selectAll();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptManagerDialog::save
|
||||
Write the file; the library's watcher turns it into a button.
|
||||
@return true if written
|
||||
*/
|
||||
bool ScriptManagerDialog::save()
|
||||
{
|
||||
const QString name = m_name->text().simplified();
|
||||
if (name.isEmpty()) {
|
||||
m_status->setText(tr("Le script doit avoir un nom."));
|
||||
m_name->setFocus();
|
||||
return false;
|
||||
}
|
||||
QDir dir(ScriptLibrary::folder());
|
||||
dir.mkpath(QStringLiteral("."));
|
||||
QString path = m_path;
|
||||
if (path.isEmpty()) {
|
||||
QStringList taken;
|
||||
for (const QFileInfo &info : dir.entryInfoList({QStringLiteral("*.js")}, QDir::Files))
|
||||
taken << info.completeBaseName();
|
||||
path = dir.filePath(ScriptHeader::idFor(name, taken) + QStringLiteral(".js"));
|
||||
}
|
||||
|
||||
const QString text = ScriptHeader::compose(name, m_icon->text(), m_tooltip->text(),
|
||||
m_shortcut->keySequence().toString(QKeySequence::PortableText),
|
||||
m_context->currentData().toString(), m_body->toPlainText());
|
||||
const ScriptHeader h = ScriptHeader::parse(text, QFileInfo(path).completeBaseName());
|
||||
if (!h.isValid()) {
|
||||
m_status->setText(tr("Non enregistré : %1").arg(h.error));
|
||||
return false;
|
||||
}
|
||||
|
||||
//QSaveFile renames into place, so the watcher never reads half a file
|
||||
QSaveFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)
|
||||
|| file.write(text.toUtf8()) < 0 || !file.commit()) {
|
||||
m_status->setText(tr("Impossible d'écrire %1").arg(QDir::toNativeSeparators(path)));
|
||||
return false;
|
||||
}
|
||||
m_path = path;
|
||||
setModified(false);
|
||||
m_status->setText(QDir::toNativeSeparators(path));
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::deleteScript()
|
||||
{
|
||||
if (m_path.isEmpty()) {
|
||||
clearForm();
|
||||
return;
|
||||
}
|
||||
const auto answer = QET::QetMessageBox::question(
|
||||
this, tr("Supprimer le script"),
|
||||
tr("Supprimer « %1 » et son bouton ?").arg(m_name->text()),
|
||||
QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel);
|
||||
if (answer != QMessageBox::Yes) return;
|
||||
|
||||
const QString icon = m_icon->text();
|
||||
QFile::remove(m_path);
|
||||
//Its icon too, unless another script uses it
|
||||
if (!icon.isEmpty() && !icon.startsWith(QLatin1String("builtin:"))
|
||||
&& !icon.contains(QLatin1Char('/')) && !icon.contains(QLatin1Char('\\'))) {
|
||||
bool used = false;
|
||||
for (const ScriptLibrary::Script &s : ScriptLibrary::instance().scripts())
|
||||
if (s.path != m_path && s.header.icon == icon) used = true;
|
||||
if (!used) QFile::remove(QDir(ScriptLibrary::folder()).filePath(icon));
|
||||
}
|
||||
clearForm();
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::testScript()
|
||||
{
|
||||
if (m_modified || m_path.isEmpty()) {
|
||||
if (!save()) return;
|
||||
}
|
||||
if (m_runner) m_runner(m_path, m_name->text().simplified());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ScriptManagerDialog::chooseIcon
|
||||
Copy an image into the scripts folder, so the script and its icon
|
||||
travel together, and name it in the header.
|
||||
*/
|
||||
void ScriptManagerDialog::chooseIcon()
|
||||
{
|
||||
const QString source = QFileDialog::getOpenFileName(
|
||||
this, tr("Icône du script"), QString(),
|
||||
tr("Images (*.svg *.png)"));
|
||||
if (source.isEmpty()) return;
|
||||
QDir dir(ScriptLibrary::folder());
|
||||
dir.mkpath(QStringLiteral("."));
|
||||
const QString name = QFileInfo(source).fileName();
|
||||
const QString target = dir.filePath(name);
|
||||
if (QFileInfo(source).absoluteFilePath() != QFileInfo(target).absoluteFilePath()) {
|
||||
QFile::remove(target);
|
||||
if (!QFile::copy(source, target)) {
|
||||
m_status->setText(tr("Impossible de copier %1").arg(QDir::toNativeSeparators(source)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
m_icon->setText(name);
|
||||
setModified(true);
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::updateIconPreview()
|
||||
{
|
||||
ScriptLibrary::Script script;
|
||||
script.header.name = m_name->text();
|
||||
script.header.icon = m_icon->text().trimmed();
|
||||
script.path = m_path.isEmpty() ? QDir(ScriptLibrary::folder()).filePath(QStringLiteral("new.js"))
|
||||
: m_path;
|
||||
m_icon_preview->setIcon(ScriptLibrary::icon(script));
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::setModified(bool modified)
|
||||
{
|
||||
m_modified = modified;
|
||||
m_save->setEnabled(modified);
|
||||
m_delete->setEnabled(!m_path.isEmpty() || modified);
|
||||
setWindowTitle(tr("Gérer les scripts") + (modified ? QStringLiteral(" *") : QString()));
|
||||
}
|
||||
|
||||
bool ScriptManagerDialog::confirmDiscard()
|
||||
{
|
||||
if (!m_modified) return true;
|
||||
const auto answer = QET::QetMessageBox::question(
|
||||
this, tr("Gérer les scripts"),
|
||||
tr("Le script affiché n'est pas enregistré. Enregistrer ?"),
|
||||
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel,
|
||||
QMessageBox::Save);
|
||||
if (answer == QMessageBox::Cancel) return false;
|
||||
if (answer == QMessageBox::Save) return save();
|
||||
setModified(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScriptManagerDialog::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
if (confirmDiscard()) event->accept();
|
||||
else event->ignore();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef SCRIPTMANAGERDIALOG_H
|
||||
#define SCRIPTMANAGERDIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <functional>
|
||||
|
||||
class QComboBox;
|
||||
class QKeySequenceEdit;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QListWidget;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class QToolButton;
|
||||
|
||||
/**
|
||||
@brief The ScriptManagerDialog class
|
||||
Projet > Scripts > Gérer les scripts… : write a stored script, give its
|
||||
button a name, icon, tooltip, shortcut and context, try it, delete it.
|
||||
|
||||
It only ever reads and writes the files in ScriptLibrary::folder(),
|
||||
the same files a text editor or the qet MCP server would write: the
|
||||
library's watcher turns them into buttons. So the dialog, a hand-written
|
||||
file and an assistant can never disagree about what a script is.
|
||||
*/
|
||||
class ScriptManagerDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using Runner = std::function<void(const QString &path, const QString &name)>;
|
||||
ScriptManagerDialog(Runner runner, QWidget *parent = nullptr);
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private:
|
||||
void reload();
|
||||
void showScript(const QString &path);
|
||||
void clearForm();
|
||||
bool save();
|
||||
void newScript();
|
||||
void deleteScript();
|
||||
void testScript();
|
||||
void chooseIcon();
|
||||
void updateIconPreview();
|
||||
void setModified(bool modified);
|
||||
bool confirmDiscard();
|
||||
|
||||
Runner m_runner;
|
||||
QString m_path; ///< the file shown, empty for a new script not yet saved
|
||||
bool m_modified = false;
|
||||
bool m_loading = false;
|
||||
|
||||
QListWidget *m_list;
|
||||
QLineEdit *m_name;
|
||||
QLineEdit *m_icon;
|
||||
QToolButton *m_icon_preview;
|
||||
QLineEdit *m_tooltip;
|
||||
QKeySequenceEdit *m_shortcut;
|
||||
QComboBox *m_context;
|
||||
QPlainTextEdit *m_body;
|
||||
QLabel *m_status;
|
||||
QPushButton *m_save;
|
||||
QPushButton *m_delete;
|
||||
QPushButton *m_test;
|
||||
};
|
||||
|
||||
#endif // SCRIPTMANAGERDIALOG_H
|
||||
Reference in New Issue
Block a user