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:
Kellermorph
2026-10-09 14:09:08 +02:00
1767 changed files with 194544 additions and 72846 deletions
+167
View File
@@ -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();
}
}
+51
View File
@@ -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
+113
View File
@@ -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
+242
View File
@@ -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()));
}
+92
View File
@@ -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
+62 -3
View File
@@ -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;
};
+67 -14
View File
@@ -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";
+29 -3
View File
@@ -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);
}
+175
View File
@@ -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
+194
View File
@@ -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);
}
+72
View File
@@ -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
+396
View File
@@ -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();
}
+86
View File
@@ -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