Live mode: print, save/close, changes, snapshot; house-style layout rules; saved symbol index

QElectroTech (LiveServer):
- print: folios to the default (or a named) printer with no print dialog,
  always after asking the user (paper cannot be undone); or to a new PDF
  file. Rendered as the PDF export draws a folio (no grid, no terminals).
- save_project (own file, or save as a new file, never over one) and
  close_project (only without unsaved changes: never discards work).
- changes: the undo history, each step marked assistant or user, with
  "since" to ask what changed after a point.
- snapshot: a copy of the open project as on screen, for a check to read,
  without saving it or changing its file.
- qet.elementTextGeometry(): where a symbol's text is drawn on the folio.

qet MCP server:
- qet_live_print, qet_live_save_project, qet_live_close_project,
  qet_live_changes, qet_live_layout_check (snapshot + qet_layout_check, no
  "run this script?" window); write paths never overwrite.
- Layout rules from the house style, for file and live checks alike:
  label_on_wire, four_way_junction, misaligned_branch.
- The element index is saved under ~/.cache/qet-mcp: a new server process
  loads 8839 symbols in 0.4 s instead of parsing them for 6.9 s; the
  change check is a third faster.
- qet_about lists the installation's standard symbols
  (standard-symbols.json in QElectroTech's data folder) when there are any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-05 19:36:34 +13:00
parent 4ceeeed302
commit 0df8ca5b3c
6 changed files with 806 additions and 10 deletions
+237
View File
@@ -23,6 +23,7 @@
#include "scriptlibrary.h"
#include "../diagram.h"
#include "../diagramview.h"
#include "../qet.h"
#include "../qetapp.h"
#include "../qetdiagrameditor.h"
#include "../qetproject.h"
@@ -51,6 +52,10 @@
#include <QDir>
#include <QFileInfo>
#include <QMdiSubWindow>
#include <QPageLayout>
#include <QPainter>
#include <QPrinterInfo>
#include <QPrinter>
#include <QDialogButtonBox>
#include <QLabel>
#include "../projectview.h"
@@ -260,6 +265,16 @@ void LiveServer::handle(const QJsonObject &request)
answer = openProject(request.value(QStringLiteral("path")).toString());
} else if (cmd == QLatin1String("switch_project")) {
answer = switchProject(request);
} else if (cmd == QLatin1String("save_project")) {
answer = saveProject(request);
} else if (cmd == QLatin1String("close_project")) {
answer = closeProject(request);
} else if (cmd == QLatin1String("print")) {
answer = print(request);
} else if (cmd == QLatin1String("snapshot")) {
answer = snapshot(request.value(QStringLiteral("path")).toString());
} else if (cmd == QLatin1String("changes")) {
answer = changes(request);
} else if (cmd == QLatin1String("show_folio")) {
answer = showFolio(request.value(QStringLiteral("folio")).toInt(-1));
} else if (cmd == QLatin1String("undo_last")) {
@@ -596,6 +611,228 @@ QJsonObject LiveServer::switchProject(const QJsonObject &request)
return status();
}
/**
@brief LiveServer::saveProject
Save the current project to its own file, or, with "path", to a new
file that becomes its file (Save As) -- never over an existing one.
*/
QJsonObject LiveServer::saveProject(const QJsonObject &request)
{
QETDiagramEditor *e = editor();
QETProject *project = e ? e->currentProject() : nullptr;
if (!project) return failure(QStringLiteral("no project is open in QElectroTech"));
if (project->isReadOnly()) return failure(QStringLiteral("the project is read-only"));
const QString path = request.value(QStringLiteral("path")).toString().trimmed();
if (path.isEmpty()) {
if (project->filePath().isEmpty())
return failure(QStringLiteral("the project has no file yet: give \"path\" to save it as one"));
} else {
const QFileInfo info(path);
if (info.isRelative()) return failure(QStringLiteral("path must be absolute: %1").arg(path));
if (info.exists())
return failure(QStringLiteral("%1 already exists; save as never replaces a file").arg(path));
if (!info.dir().exists())
return failure(QStringLiteral("the folder %1 does not exist").arg(info.absolutePath()));
project->setFilePath(info.absoluteFilePath());
}
const QETResult result = project->write();
if (!result.isOk()) return failure(QStringLiteral("not saved: %1").arg(result.errorMessage()));
QJsonObject answer = status();
answer.insert(QStringLiteral("saved"), project->filePath());
return answer;
}
/**
@brief LiveServer::closeProject
Close an open project (the current one, or "index"), only when it has
no unsaved changes: closing never discards the user's work, and never
asks them a question on the assistant's behalf.
*/
QJsonObject LiveServer::closeProject(const QJsonObject &request)
{
QETDiagramEditor *e = editor();
if (!e) return failure(QStringLiteral("no QElectroTech editor window is open"));
const QList<ProjectView *> views = e->openedProjects();
ProjectView *pv = nullptr;
if (request.contains(QStringLiteral("index"))) {
const int index = request.value(QStringLiteral("index")).toInt(-1);
if (index < 0 || index >= views.count())
return failure(QStringLiteral("no open project %1: %2 are open, counted from 0")
.arg(index).arg(views.count()));
pv = views.at(index);
} else {
pv = e->currentProjectView();
if (!pv) return failure(QStringLiteral("no project is open in QElectroTech"));
}
QETProject *project = pv->project();
if (project->projectWasModified())
return failure(QStringLiteral("\"%1\" has unsaved changes; save it first "
"(save_project) -- closing never discards them")
.arg(project->title()));
const QString title = project->title();
if (!e->closeProject(pv)) return failure(QStringLiteral("QElectroTech did not close \"%1\"").arg(title));
QJsonObject answer = status();
answer.insert(QStringLiteral("closed"), title);
return answer;
}
/**
@brief LiveServer::print
Print folios of the current project with no print dialog: "folios" is
"all" (default), "current" or a list of indexes; "printer" names one,
else the system's default printer. Paper cannot be taken back, so the
user is always asked first, whatever "always" says for scripts.
"output_file" prints to a new PDF file instead (no paper, so no
question; never over an existing file).
*/
QJsonObject LiveServer::print(const QJsonObject &request)
{
QETDiagramEditor *e = editor();
ProjectView *pv = e ? e->currentProjectView() : nullptr;
if (!pv) return failure(QStringLiteral("no project is open in QElectroTech"));
QETProject *project = pv->project();
const QList<Diagram *> all = project->diagrams();
QList<Diagram *> folios;
const QJsonValue which = request.value(QStringLiteral("folios"));
if (which.isUndefined() || which.toString() == QLatin1String("all")) {
folios = all;
} else if (which.toString() == QLatin1String("current")) {
if (DiagramView *dv = e->currentDiagramView()) folios << dv->diagram();
} else if (which.isArray()) {
for (const QJsonValue &v : which.toArray()) {
const int i = v.toInt(-1);
if (i < 0 || i >= all.count())
return failure(QStringLiteral("no folio %1: the project has %2, counted from 0")
.arg(v.toVariant().toString()).arg(all.count()));
folios << all.at(i);
}
} else {
return failure(QStringLiteral("folios must be \"all\", \"current\" or a list of indexes"));
}
if (folios.isEmpty()) return failure(QStringLiteral("no folio to print"));
const QString file = request.value(QStringLiteral("output_file")).toString().trimmed();
QPrinterInfo info;
if (file.isEmpty()) {
const QString name = request.value(QStringLiteral("printer")).toString().trimmed();
info = name.isEmpty() ? QPrinterInfo::defaultPrinter() : QPrinterInfo::printerInfo(name);
if (info.isNull())
return failure(name.isEmpty()
? QStringLiteral("this computer has no default printer")
: QStringLiteral("no printer called \"%1\" (printers: %2)")
.arg(name, QPrinterInfo::availablePrinterNames().join(QStringLiteral(", "))));
QMessageBox box(QMessageBox::Question, tr("Impression"),
tr("L'assistant veut imprimer %n folio(s) de « %1 » sur « %2 ».", "", int(folios.count()))
.arg(project->title(), info.printerName()),
QMessageBox::NoButton, e);
QPushButton *go = box.addButton(tr("&Imprimer"), QMessageBox::AcceptRole);
box.addButton(tr("&Annuler"), QMessageBox::RejectRole);
box.setDefaultButton(go);
box.exec();
if (box.clickedButton() != go) return failure(QStringLiteral("refused by the user"));
} else {
const QFileInfo fi(file);
if (fi.isRelative()) return failure(QStringLiteral("output_file must be absolute: %1").arg(file));
if (fi.exists()) return failure(QStringLiteral("%1 already exists; printing never replaces a file").arg(file));
}
QPrinter printer = file.isEmpty() ? QPrinter(info, QPrinter::HighResolution)
: QPrinter(QPrinter::HighResolution);
if (!file.isEmpty()) {
printer.setOutputFormat(QPrinter::PdfFormat);
printer.setOutputFileName(file);
}
printer.setDocName(project->title().isEmpty() ? QStringLiteral("QElectroTech") : project->title());
const auto folioRect = [](Diagram *d) {
QRectF r = d->border_and_titleblock.borderAndTitleBlockRect();
r.adjust(0, 0, 1, 1);
return r.toAlignedRect();
};
//One orientation for the job: some printer drivers ignore a change
//between pages. The first folio decides.
const QRect first = folioRect(folios.first());
printer.setPageOrientation(first.width() > first.height() ? QPageLayout::Landscape
: QPageLayout::Portrait);
QPainter painter;
if (!painter.begin(&printer)) return failure(QStringLiteral("the printer could not be opened"));
for (int i = 0; i < folios.count(); ++i) {
Diagram *d = folios.at(i);
if (i) printer.newPage();
//As the PDF export draws a folio: no grid, guides or terminals
const bool grid = d->displayGrid(), guides = d->displayGuides();
const bool terms = d->drawTerminals(), names = d->drawTerminalNames();
d->setDisplayGrid(false); d->setDisplayGuides(false);
d->setDrawTerminals(false); d->setDrawTerminalNames(false);
d->render(&painter, printer.pageLayout().paintRectPixels(printer.resolution()),
folioRect(d), Qt::KeepAspectRatio);
d->setDisplayGrid(grid); d->setDisplayGuides(guides);
d->setDrawTerminals(terms); d->setDrawTerminalNames(names);
}
painter.end();
return {{QStringLiteral("ok"), true}, {QStringLiteral("printed"), int(folios.count())},
{QStringLiteral("to"), file.isEmpty() ? info.printerName() : file}};
}
/**
@brief LiveServer::snapshot
Write a copy of the current project, as it is on screen (unsaved
changes included), to a new file -- for a check to read -- without
saving the project or changing its file. Never over an existing file.
*/
QJsonObject LiveServer::snapshot(const QString &raw_path)
{
QETDiagramEditor *e = editor();
QETProject *project = e ? e->currentProject() : nullptr;
if (!project) return failure(QStringLiteral("no project is open in QElectroTech"));
const QFileInfo info(raw_path.trimmed());
if (raw_path.trimmed().isEmpty() || info.isRelative())
return failure(QStringLiteral("path must be absolute"));
if (info.exists()) return failure(QStringLiteral("%1 already exists").arg(raw_path));
QDomDocument xml(project->toXml());
QString error;
if (!QET::writeXmlFile(xml, info.absoluteFilePath(), &error))
return failure(QStringLiteral("snapshot not written: %1").arg(error));
return {{QStringLiteral("ok"), true}, {QStringLiteral("path"), info.absoluteFilePath()},
{QStringLiteral("folio"), e->currentDiagramView()
? int(project->diagrams().indexOf(e->currentDiagramView()->diagram())) : -1}};
}
/**
@brief LiveServer::changes
The current project's undo history: each step's name, whether the
assistant made it, and whether it is undone. "since" (an index from an
earlier answer) keeps only the steps after it, so an assistant can say
what it changed since a point. Changes nothing.
*/
QJsonObject LiveServer::changes(const QJsonObject &request)
{
QETDiagramEditor *e = editor();
QETProject *project = e ? e->currentProject() : nullptr;
if (!project) return failure(QStringLiteral("no project is open in QElectroTech"));
QUndoStack *stack = project->undoStack();
const QString prefix = tr("Assistant : %1").arg(QString());
const int since = request.value(QStringLiteral("since")).toInt(-1);
QJsonArray steps;
int assistant = 0, user = 0;
for (int i = qMax(0, since + 1); i < stack->count(); ++i) {
const QString text = stack->text(i);
const bool mine = text.startsWith(prefix);
const bool undone = i >= stack->index();
if (!undone) (mine ? assistant : user)++;
steps.append(QJsonObject{{QStringLiteral("index"), i}, {QStringLiteral("step"), text},
{QStringLiteral("by"), mine ? QStringLiteral("assistant")
: QStringLiteral("user")},
{QStringLiteral("undone"), undone}});
}
return {{QStringLiteral("ok"), true}, {QStringLiteral("project"), project->title()},
{QStringLiteral("steps"), steps},
{QStringLiteral("now"), stack->index() - 1},
{QStringLiteral("saved_at"), stack->cleanIndex() - 1},
{QStringLiteral("done_by_assistant"), assistant}, {QStringLiteral("done_by_user"), user}};
}
/**
@brief LiveServer::undoLast
Undo the newest step, only if the assistant made it: what the user did
+5
View File
@@ -89,6 +89,11 @@ class LiveServer : public QObject
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);
+19
View File
@@ -3489,6 +3489,25 @@ bool QetScriptApi::deleteImage(int folioIndex, int imageIndex)
return true;
}
/**
@brief QetScriptApi::elementTextGeometry
Where one of a symbol's text fields is drawn on the folio, in folio
coordinates: left, top, right, bottom of the text as shown. A label
that sits on a wire is visible here, not from x/y alone (x/y are
relative to the symbol and say nothing about the text's size).
@return an empty map when the text does not exist
*/
QVariantMap QetScriptApi::elementTextGeometry(int folioIndex, const QString &elementUuid,
int textIndex) const
{
DynamicElementTextItem *t = findElementText(folioIndex, elementUuid, textIndex,
QStringLiteral("elementTextGeometry"));
if (!t) return {};
const QRectF r = t->sceneBoundingRect();
return {{QStringLiteral("left"), r.left()}, {QStringLiteral("top"), r.top()},
{QStringLiteral("right"), r.right()}, {QStringLiteral("bottom"), r.bottom()}};
}
/**
@brief QetScriptApi::addPdfPage
Render one page of a PDF file to an image and place it, as the "add
+2
View File
@@ -556,6 +556,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,